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
+63
View File
@@ -251,3 +251,66 @@ def test_toolloop_history_injected(ws):
assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1:3] == hist
assert msgs[3] == {"role": "user", "content": "新任务"}
# ---------------- 审批门卫(T28 ----------------
def test_approval_denied_feeds_result_back(ws):
"""审批拒绝:工具不执行,拒绝结果回喂模型。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "x.txt", "content": "hi"}}],
"usage": {}},
{"content": "了解,不写了。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}},
])
async def deny_hook(name, args):
return False
loop = ToolLoop(ws, chat, approval_hook=deny_hook)
result = asyncio_run(loop.run("写文件"))
assert result["reason"] == "answer"
assert not (ws.root / "x.txt").exists() # 未执行
# 第二轮模型消息里应包含拒绝结果
tool_msg = chat.calls[1][2]
assert tool_msg["role"] == "tool" and "拒绝" in tool_msg["content"]
def test_approval_allowed_executes(ws):
"""审批允许:正常执行。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "y.txt", "content": "ok"}}],
"usage": {}},
{"content": "完成。", "tool_calls": [], "usage": {}},
])
async def allow_hook(name, args):
return True
loop = ToolLoop(ws, chat, approval_hook=allow_hook)
asyncio_run(loop.run("写文件"))
assert (ws.root / "y.txt").exists()
def test_approval_hook_exception_fails_closed(ws):
"""审批钩子异常 = 拒绝(fail-closed)。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "read_file",
"arguments": {"path": "z.txt"}}],
"usage": {}},
{"content": "收到。", "tool_calls": [], "usage": {}},
])
async def boom(name, args):
raise RuntimeError("审批服务挂了")
loop = ToolLoop(ws, chat, approval_hook=boom)
result = asyncio_run(loop.run("读文件"))
assert result["reason"] == "answer"
msgs = chat.calls[1]
assert any("拒绝" in str(m.get("content", "")) for m in msgs)