feat(v3): T28 操作审批流(dsh 式 allow-once/deny,fail-closed)
- ToolLoop 增 approval_hook:工具执行前挂起等待用户裁决,拒绝/异常折叠为
失败结果回喂模型(可改道),fail-closed
- 策略 agent.approval_policy:off | dangerous(写/编辑/命令询问,只读放行,默认)| all;
approval_timeout_s 超时自动拒绝(轮询实现,规避 portal 循环下 wait_for 定时器不可靠)
- POST /agent/{id}/approve 裁决端点;approval_request/decided 事件对进 SSE 与审计
- 前端:运行中审批卡(工具名+参数预览+拒绝/允许一次),composer 审批策略 chip
- 测试 +8(策略矩阵/拒绝回喂/允许执行/fail-closed/超时/端点分支),全量 289 passed
This commit is contained in:
+81
-6
@@ -20,7 +20,7 @@ import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from router_system.tools import ToolLoop, WorkspaceTools
|
||||
|
||||
@@ -234,7 +234,9 @@ class AgentService:
|
||||
allow_shell: bool = False, shell_timeout_s: int = 20,
|
||||
executor_chat: Any = None,
|
||||
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
|
||||
session: Optional["AgentSession"] = None) -> None:
|
||||
session: Optional["AgentSession"] = None,
|
||||
approval_policy: str = "dangerous",
|
||||
approval_timeout_s: int = 120) -> None:
|
||||
"""执行智能体任务(由调用方包成后台协程)。
|
||||
|
||||
executor_chat 为空 = 单模型模式(chat 全程包办);
|
||||
@@ -242,6 +244,36 @@ class AgentService:
|
||||
session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。
|
||||
"""
|
||||
history = self._history_from_session(session)
|
||||
approval_mgr = ApprovalManager()
|
||||
info._approval_manager = approval_mgr # 供 /approve 端点裁决(瞬态属性)
|
||||
|
||||
async def approval_hook(name: str, args: Dict[str, Any]) -> bool:
|
||||
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
|
||||
if not needs_approval(approval_policy, name):
|
||||
return True
|
||||
aid = "ap" + uuid.uuid4().hex[:8]
|
||||
ev = approval_mgr.open(aid)
|
||||
self._append_event(info, {"type": "approval_request", "id": aid,
|
||||
"name": name, "arguments": args,
|
||||
"policy": approval_policy})
|
||||
# 轮询等待(0.1s 步进):不用 wait_for——portal 循环下其定时器不可靠
|
||||
allowed = False
|
||||
note = ""
|
||||
deadline = time.time() + max(1, approval_timeout_s)
|
||||
while time.time() < deadline:
|
||||
if ev.is_set():
|
||||
allowed = approval_mgr._pending.get(aid, {}).get("allowed", False)
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
note = f"超时({approval_timeout_s}s)未响应,自动拒绝"
|
||||
if ev.is_set() and not allowed:
|
||||
note = note or "用户拒绝"
|
||||
approval_mgr.close(aid)
|
||||
self._append_event(info, {"type": "approval_decided", "id": aid,
|
||||
"name": name, "allowed": allowed,
|
||||
**({"note": note} if note else {})})
|
||||
return allowed
|
||||
|
||||
try:
|
||||
if executor_chat is not None:
|
||||
@@ -249,12 +281,14 @@ class AgentService:
|
||||
info, chat, executor_chat, workspace_dir,
|
||||
max_rounds=max_rounds, token_cap=token_cap,
|
||||
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
|
||||
max_handoffs=max_handoffs)
|
||||
max_handoffs=max_handoffs,
|
||||
approval_hook=approval_hook)
|
||||
else:
|
||||
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
|
||||
shell_timeout_s=shell_timeout_s)
|
||||
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
|
||||
on_event=self._make_event_writer(info))
|
||||
on_event=self._make_event_writer(info),
|
||||
approval_hook=approval_hook)
|
||||
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
|
||||
history=history)
|
||||
self._apply_result(info, result)
|
||||
@@ -315,7 +349,9 @@ class AgentService:
|
||||
workspace_dir: str | Path, max_rounds: int = 8,
|
||||
token_cap: int = 0, allow_shell: bool = False,
|
||||
shell_timeout_s: int = 20,
|
||||
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> Dict[str, Any]:
|
||||
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
|
||||
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
|
||||
info.mode = "dual"
|
||||
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
|
||||
@@ -387,7 +423,8 @@ class AgentService:
|
||||
loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds,
|
||||
token_cap=max(1, _remaining_cap()),
|
||||
on_event=self._make_event_writer(info),
|
||||
emit_final=False)
|
||||
emit_final=False,
|
||||
approval_hook=approval_hook)
|
||||
exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT)
|
||||
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
|
||||
"completion_tokens": exec_result.get("completion_tokens", 0)})
|
||||
@@ -547,6 +584,44 @@ def new_request_id() -> str:
|
||||
return "ag" + uuid.uuid4().hex[:10]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 审批流(D9):dsh 式 allow-once / deny,fail-closed
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
READ_ONLY_TOOLS = {"list_dir", "read_file", "search_files"}
|
||||
|
||||
|
||||
def needs_approval(policy: str, tool_name: str) -> bool:
|
||||
"""审批策略判定:off=全放行;all=全询问;dangerous=写/编辑/命令询问,只读放行。"""
|
||||
if policy == "all":
|
||||
return True
|
||||
if policy == "dangerous":
|
||||
return tool_name not in READ_ONLY_TOOLS
|
||||
return False
|
||||
|
||||
|
||||
class ApprovalManager:
|
||||
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
|
||||
|
||||
def __init__(self):
|
||||
self._pending: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def open(self, approval_id: str) -> asyncio.Event:
|
||||
ev = asyncio.Event()
|
||||
self._pending[approval_id] = {"event": ev, "allowed": False}
|
||||
return ev
|
||||
|
||||
def decide(self, approval_id: str, allowed: bool) -> bool:
|
||||
p = self._pending.get(approval_id)
|
||||
if p is None:
|
||||
return False
|
||||
p["allowed"] = allowed
|
||||
p["event"].set()
|
||||
return True
|
||||
|
||||
def close(self, approval_id: str) -> None:
|
||||
self._pending.pop(approval_id, None)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 会话(dsh 式:工作区内多轮对话,持久化到磁盘)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user