✨ feat: WP-06 工具域——注册表、四维裁决、审批与 RunBudget

- 迁移 10:tool(注册表:风险级/审批声明/输入 Schema)、tool_call(六态含 pending_approval、幂等唯一约束)、run.max_tool_calls(RunBudget 硬上限)
- 内置工具三件:get-current-time/calculate(AST 白名单四则,只读循律)、record-note(真实副作用:写入记忆候选,必须请命)
- 四维裁决(能力/数据/后果/情境→autonomy_level):read_only 循律直接执行;side_effect/high_impact 进入 pending_approval 请命;禁用工具 forbidden
- 审批流(朱批):approve → 执行并落审计;deny → 拒绝且不可逆(重复审批 409);副作用工具产物走既有记忆审核通道
- 工具入参按不可信模型输出处理:JSON Schema 子集校验(required/type/未知字段拒绝);RunBudget 超限 422 不静默放行
- 幂等:同 (run, tool, Idempotency-Key) 重放返回 200 原调用
- 测试 67 项全绿(+7):循律即执行、请命→朱批→真实候选产物、拒绝不可逆与不执行、Schema 三类拒绝、未注册 404、幂等重放、预算硬上限
This commit is contained in:
chenxin committed 2026-09-11 11:29:43 +08:00
1 parent e333dc5ea5
commit dfce60f7db
7 files changed
+669

No files matched your search

+135
View File
@@ -881,3 +881,138 @@ class TestBigCycle:
assert version.status == "pending" # 候选先于写入
assert version.payload["source"] == "big_cycle"
assert version.payload["proposal"] # 模型真实输出
@pytest.mark.usefixtures("client")
class TestTools:
async def _make_run(self, client, headers) -> str:
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, f"idem-tool-{_uuid4().split(chr(45))[0]}")
await _wait_run_terminal_headers(client, headers)
return resp.json()["run"]["id"]
async def test_rule_bound_tool_executes_immediately(self, client: AsyncClient) -> None:
headers = await login(client, "tool1@example.com")
await client.get("/v1/tools", headers=headers) # 触发内置登记
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": "(1+2)*3"}},
headers={**headers, "Idempotency-Key": "tc-1"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "succeeded"
assert body["output"]["result"] == 9.0
async def test_side_effect_tool_requires_approval(self, client: AsyncClient) -> None:
headers = await login(client, "tool2@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "重要笔记"}},
headers=headers,
)
assert created.json()["status"] == "pending_approval"
call_id = created.json()["id"]
# 待审批列表可见
pending = await client.get("/v1/tool-calls", headers=headers)
assert any(c["id"] == call_id for c in pending.json()["data"])
# 朱批 → 执行 → 真实副作用(记忆候选创建)
approved = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert approved.json()["status"] == "succeeded"
assert approved.json()["output"]["memory_candidate_id"]
async def test_denied_tool_never_executes(self, client: AsyncClient) -> None:
headers = await login(client, "tool3@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "被拒绝的笔记"}},
headers=headers,
)
call_id = created.json()["id"]
denied = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": False}, headers=headers
)
assert denied.json()["status"] == "denied"
assert denied.json()["output"] is None # 未执行
again = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert again.status_code == 409 # 拒绝不可逆
async def test_schema_validation_rejects_bad_input(self, client) -> None:
headers = await login(client, "tool4@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
missing = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {}}, headers=headers,
)
assert missing.status_code == 422
wrong_type = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": 42}}, headers=headers,
)
assert wrong_type.status_code == 422
async def test_unknown_tool_404(self, client) -> None:
headers = await login(client, "tool5@example.com")
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "rm-rf", "input": {}}, headers=headers,
)
assert resp.status_code == 404
async def test_idempotent_replay_returns_original(self, client) -> None:
from fastapi.responses import JSONResponse # noqa: F401
headers = await login(client, "tool6@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
first = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert first.status_code == 201
second = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert second.status_code == 200
assert second.json()["id"] == first.json()["id"]
async def test_run_budget_hard_limit(self, client) -> None:
headers = await login(client, "tool7@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
# max_tool_calls 默认 10:连续调用到上限后第 11 次被拒
last_status = None
for i in range(11):
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": f"tc-budget-{i}"},
)
last_status = resp.status_code
assert last_status == 422
def _uuid4() -> str:
from uuid import uuid4
return str(uuid4())
async def _wait_run_terminal_headers(client, headers, limit_s: float = 10.0) -> None:
import asyncio
await asyncio.sleep(0)