Files
aiagent/backend/app/services/approval_manager.py
renjianbo f3cb35c460 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>
2026-05-04 23:17:59 +08:00

95 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工具级人工审批管理器 — 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()