Files
projectAIpopular/tests/test_tools.py
T
tzt ca8add02e2 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
2026-09-01 23:12:26 +08:00

317 lines
12 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.
"""工具内核测试:路径关押、文件工具往返、ToolLoop 循环编排(假 chat_fn,零外部依赖)。"""
import asyncio
import json
import pytest
def asyncio_run(coro):
"""与 test_pipeline.py 相同的事件循环包装(不依赖 pytest-asyncio 插件模式)。"""
return asyncio.run(coro)
from router_system.tools import (
ToolError,
ToolLoop,
WorkspaceTools,
parse_tool_calls,
)
@pytest.fixture()
def ws(tmp_path):
return WorkspaceTools(tmp_path / "ws")
# ---------------- WorkspaceTools ----------------
def test_jail_blocks_traversal(ws):
with pytest.raises(ToolError):
ws.resolve("../outside.txt")
with pytest.raises(ToolError):
ws.resolve("../../etc/passwd")
with pytest.raises(ToolError):
ws.resolve("a/../../b.txt")
# 绝对路径逃逸
with pytest.raises(ToolError):
ws.resolve(str(ws.root.parent / "secret.txt"))
def test_write_read_roundtrip(ws):
r = ws.write_file("dir/a.txt", "你好,工作区")
assert r["ok"] is True
got = ws.read_file("dir/a.txt")
assert got["ok"] is True
assert got["content"] == "你好,工作区"
assert got["truncated"] is False
# 越界写入被折叠为 ok=False(不抛出)
bad = ws.execute("write_file", {"path": "../evil.txt", "content": "x"})
assert bad["ok"] is False
assert not (ws.root.parent / "evil.txt").exists()
def test_list_dir(ws):
ws.write_file("b.txt", "x" * 10)
ws.write_file("sub/c.txt", "y")
r = ws.list_dir("")
names = [e["name"] for e in r["entries"]]
assert "b.txt" in names and "sub/" in names
r2 = ws.list_dir("sub")
assert r2["entries"][0]["name"] == "c.txt"
# 不存在的目录
assert ws.list_dir("nope")["ok"] is False
def test_read_truncation(ws):
ws.write_file("big.txt", "字" * 20000)
got = ws.read_file("big.txt")
assert got["truncated"] is True
assert len(got["content"]) == 8000
def test_unknown_tool_and_missing_file(ws):
assert ws.execute("rm_rf", {"path": "."})["ok"] is False
assert ws.execute("read_file", {"path": "ghost.txt"})["ok"] is False
# ---------------- parse_tool_calls ----------------
def test_parse_tool_calls():
msg = {"tool_calls": [
{"id": "c1", "function": {"name": "read_file", "arguments": '{"path": "a.txt"}'}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{broken json"}},
]}
calls = parse_tool_calls(msg)
assert calls[0]["name"] == "read_file"
assert calls[0]["arguments"] == {"path": "a.txt"}
assert calls[1]["arguments"] == {} # 非法 JSON 容错
assert parse_tool_calls({}) == []
# ---------------- ToolLoop ----------------
def _mk_chat(script):
"""script: 依次弹出的响应列表。记录每次收到的 messages。"""
calls = []
async def chat_fn(messages, tools_spec):
calls.append([dict(m) for m in messages])
return script.pop(0)
chat_fn.calls = calls
return chat_fn
def test_toolloop_answer_direct(ws):
chat = _mk_chat([{"content": "最终答案", "tool_calls": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}}])
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
result = asyncio_run(loop.run("任务"))
assert result["response"] == "最终答案"
assert result["reason"] == "answer"
assert result["prompt_tokens"] == 10 and result["completion_tokens"] == 5
assert events[0]["type"] == "round"
assert events[-1]["type"] == "final" and events[-1]["reason"] == "answer"
def test_toolloop_write_then_answer(ws):
"""第一轮调 write_file,第二轮给最终答复;工具结果应回喂。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "hello.txt", "content": "hi"}}],
"usage": {"prompt_tokens": 20, "completion_tokens": 4}},
{"content": "已写入 hello.txt", "tool_calls": [],
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
])
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
result = asyncio_run(loop.run("写个文件"))
assert result["reason"] == "answer"
assert (ws.root / "hello.txt").read_text(encoding="utf-8") == "hi"
kinds = [e["type"] for e in events]
assert kinds.count("tool_call") == 1 and kinds.count("tool_result") == 1
# 第二轮 messages 应包含 assistant(tool_calls) + tool 结果
second = chat.calls[1]
assert second[1]["role"] == "assistant"
assert json.loads(second[1]["tool_calls"][0]["function"]["arguments"])["path"] == "hello.txt"
assert second[2]["role"] == "tool"
assert second[2]["tool_call_id"] == "c1"
def test_toolloop_max_rounds_forces_summary(ws):
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "list_dir", "arguments": {}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1}},
] * 3 + [
{"content": "强制总结", "tool_calls": [], "usage": {"prompt_tokens": 5, "completion_tokens": 2}},
])
loop = ToolLoop(ws, chat, max_rounds=3)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "max_rounds"
assert result["response"] == "强制总结"
assert result["error"]
def test_toolloop_token_cap(ws):
chat = _mk_chat([
{"content": "x", "tool_calls": [],
"usage": {"prompt_tokens": 100, "completion_tokens": 100}},
] * 5)
loop = ToolLoop(ws, chat, token_cap=150)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "token_cap"
assert "熔断" in result["error"]
assert len(chat.calls) == 1 # 触顶后不再继续调用
def test_toolloop_chat_error(ws):
async def bad_chat(messages, tools_spec):
raise RuntimeError("网络断了")
loop = ToolLoop(ws, bad_chat)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "error"
assert "RuntimeError" in result["error"]
# ---------------- 扩展工具(T22):edit_file / search_files / run_command ----------------
def test_edit_file_unique_replace(ws):
ws.write_file("app.py", "def main():\n print('v1')\n return 0\n")
r = ws.execute("edit_file", {"path": "app.py", "old_string": "print('v1')",
"new_string": "print('v2 — 已修复')"})
assert r["ok"] is True and r["replaced"] == 1
assert "print('v2 — 已修复')" in ws.read_file("app.py")["content"]
def test_edit_file_rejects_ambiguous_and_missing(ws):
ws.write_file("dup.txt", "abc-abc")
r1 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "abc", "new_string": "x"})
assert r1["ok"] is False and "2 次" in r1["error"]
r2 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "zzz", "new_string": "x"})
assert r2["ok"] is False and "未在文件中找到" in r2["error"]
assert ws.read_file("dup.txt")["content"] == "abc-abc" # 原文未被破坏
def test_search_files(ws):
ws.write_file("a.py", "DEFAULT_PORT = 8000\n")
ws.write_file("docs/note.md", "端口 8000 是默认值\n")
ws.write_file("node_modules/pkg/index.js", "port 8000\n") # 应被跳过
r = ws.execute("search_files", {"query": "8000"})
assert r["ok"] is True
files = {m["file"] for m in r["matches"]}
assert files == {"a.py", "docs/note.md"}
assert all("node_modules" not in f for f in files)
# 空查询
assert ws.execute("search_files", {"query": ""})["ok"] is False
def test_run_command_disabled_by_default(ws):
r = ws.execute("run_command", {"command": "echo hi"})
assert r["ok"] is False and "allow_shell" in r["error"]
def test_run_command_enabled(tmp_path):
import sys
ws2 = WorkspaceTools(tmp_path / "ws2", allow_shell=True, shell_timeout_s=15)
r = ws2.execute("run_command", {"command": f'"{sys.executable}" -c "print(40+2)"'})
assert r["ok"] is True and r["exit_code"] == 0
assert "42" in r["output"]
def test_run_command_timeout(tmp_path):
import sys
ws2 = WorkspaceTools(tmp_path / "ws3", allow_shell=True, shell_timeout_s=2)
r = ws2.execute("run_command",
{"command": f'"{sys.executable}" -c "import time; time.sleep(30)"'})
assert r["ok"] is False and "超时" in r["error"]
def test_browse_directories(tmp_path):
from router_system.tools import browse_directories
(tmp_path / "sub").mkdir()
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
r = browse_directories(str(tmp_path))
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
assert r["parent"] # 可以上级
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
def test_toolloop_history_injected(ws):
"""history 应出现在 system 之后、任务之前(会话式多轮上下文)。"""
hist = [{"role": "user", "content": "上一个任务"},
{"role": "assistant", "content": "上一个结果"}]
chat = _mk_chat([{"content": "好", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}])
loop = ToolLoop(ws, chat)
asyncio_run(loop.run("新任务", system="SYS", history=hist))
msgs = chat.calls[0]
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)