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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user