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:
tzt
2026-09-01 23:12:26 +08:00
parent 7d11ae2644
commit ca8add02e2
16 changed files with 487 additions and 21 deletions
+21
View File
@@ -414,6 +414,7 @@ class ToolLoop:
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
result_preview_chars: int = MAX_RESULT_CHARS,
emit_final: bool = True,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
):
self.tools = tools
self.chat_fn = chat_fn
@@ -422,6 +423,8 @@ class ToolLoop:
self.on_event = on_event
self.result_preview_chars = result_preview_chars
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
self.approval_hook = approval_hook
def _emit(self, ev: Dict[str, Any]) -> None:
if ev.get("type") == "final" and not self.emit_final:
@@ -500,6 +503,24 @@ class ToolLoop:
for c in calls:
self._emit({"type": "tool_call", "round": round_no,
"name": c["name"], "arguments": c["arguments"]})
# 审批门卫(D9):按策略挂起等待用户裁决;拒绝/超时折叠为失败结果回喂
if self.approval_hook is not None:
allowed = False
try:
allowed = await self.approval_hook(c["name"], c["arguments"])
except Exception as e:
self._emit({"type": "approval_decided", "round": round_no,
"id": "", "allowed": False,
"note": f"审批流程异常: {type(e).__name__}"})
if not allowed:
denied = {"ok": False,
"error": "用户拒绝执行该操作(如需执行请调整审批策略或换一种做法)"}
preview = json.dumps(denied, ensure_ascii=False)
self._emit({"type": "tool_result", "round": round_no,
"name": c["name"], "ok": False, "preview": preview})
messages.append({"role": "tool", "tool_call_id": c["id"],
"content": preview})
continue
result = self.tools.execute(c["name"], c["arguments"])
preview = json.dumps(result, ensure_ascii=False)
if len(preview) > self.result_preview_chars: