feat: Phase 2 - Orchestrator workflow node + tool-level human approval
2.1 Orchestrator in workflow:
- New run_orchestrator_node() in workflow_integration.py loads agents from DB,
supports route/sequential/debate/pipeline modes
- New 'orchestrator' node type in workflow_engine.py execute_node dispatch
2.2 Tool-level human approval:
- AgentToolConfig extended with require_approval, approval_timeout_ms,
approval_default fields
- New ApprovalManager (approval_manager.py) with asyncio.Event-based
create/wait_for_decision/resolve pattern
- AgentRuntime run() and run_stream() intercept tool execution,
wait for approval decision before executing
- New POST /api/v1/approval/{id}/resolve REST endpoint
- Frontend: approval_required SSE event handling, approval dialog UI
with approve/deny/skip buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
94
backend/app/services/approval_manager.py
Normal file
94
backend/app/services/approval_manager.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""工具级人工审批管理器 — asyncio.Event 驱动的异步审批等待/唤醒"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
approval_id: str
|
||||
tool_name: str
|
||||
args: Dict[str, Any]
|
||||
event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
decision: str = "deny" # approved | denied | skip
|
||||
|
||||
|
||||
class ApprovalManager:
|
||||
"""全局单例 — 管理工具执行前的审批等待/唤醒。"""
|
||||
|
||||
_pending: Dict[str, ApprovalRequest] = {}
|
||||
|
||||
def create(self, tool_name: str, args: Dict[str, Any]) -> ApprovalRequest:
|
||||
"""创建审批请求(不等待),返回 ApprovalRequest(含 approval_id)。
|
||||
|
||||
用于流式场景:先 create → yield SSE 事件(带 approval_id)→ wait_for_decision。
|
||||
"""
|
||||
approval_id = str(uuid.uuid4())[:8]
|
||||
req = ApprovalRequest(
|
||||
approval_id=approval_id,
|
||||
tool_name=tool_name,
|
||||
args=args,
|
||||
)
|
||||
self._pending[approval_id] = req
|
||||
logger.info("审批请求已创建: id=%s tool=%s", approval_id, tool_name)
|
||||
return req
|
||||
|
||||
async def wait_for_decision(self, approval_id: str, timeout_ms: int = 60000) -> str:
|
||||
"""等待审批决定(带超时)。返回 "approved" | "denied" | "skip" """
|
||||
req = self._pending.get(approval_id)
|
||||
if not req:
|
||||
return "deny"
|
||||
try:
|
||||
await asyncio.wait_for(req.event.wait(), timeout=timeout_ms / 1000.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("审批请求超时: id=%s", approval_id)
|
||||
req.decision = "deny"
|
||||
self._pending.pop(approval_id, None)
|
||||
return req.decision
|
||||
|
||||
async def submit(self, tool_name: str, args: Dict[str, Any], timeout_ms: int = 60000) -> ApprovalRequest:
|
||||
"""提交审批请求并等待决策(带超时)— 非流式场景一步完成。
|
||||
|
||||
Returns:
|
||||
ApprovalRequest(含 decision 字段,调用方读取即可)
|
||||
"""
|
||||
req = self.create(tool_name, args)
|
||||
try:
|
||||
await asyncio.wait_for(req.event.wait(), timeout=timeout_ms / 1000.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("审批请求超时: id=%s tool=%s", req.approval_id, tool_name)
|
||||
req.decision = "deny"
|
||||
self._pending.pop(req.approval_id, None)
|
||||
return req
|
||||
|
||||
def resolve(self, approval_id: str, decision: str) -> bool:
|
||||
"""外部(API)调用,写入审批决定并唤醒等待方。
|
||||
|
||||
Returns:
|
||||
True 表示成功唤醒,False 表示审批 ID 无效或已完成。
|
||||
"""
|
||||
req = self._pending.get(approval_id)
|
||||
if not req:
|
||||
logger.warning("审批 ID 无效或已完成: %s", approval_id)
|
||||
return False
|
||||
if decision not in ("approved", "denied", "skip"):
|
||||
decision = "deny"
|
||||
req.decision = decision
|
||||
req.event.set()
|
||||
logger.info("审批已解决: id=%s decision=%s", approval_id, decision)
|
||||
return True
|
||||
|
||||
def get_pending(self, approval_id: str) -> Optional[ApprovalRequest]:
|
||||
"""查询待审批请求(用于前端展示详情)。"""
|
||||
return self._pending.get(approval_id)
|
||||
|
||||
|
||||
# 全局单例
|
||||
approval_manager = ApprovalManager()
|
||||
Reference in New Issue
Block a user