Files
AgentLab/backend/tests/test_tool_trust.py
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

497 lines
20 KiB
Python

"""MCP 和自定义工具的信任三档:等审批 / 始终允许 · 门控把关 / 始终允许。
以前这两类工具在任何审批模式下都不问人:审批关卡只认内置工具的 dangerous 和数据源的
dangerous_if,它们两样都没有。现在默认每次等人批;「始终允许」可以交给门控模型逐次把关。
要守住的几件事:
- 默认等审批,审批请求带 trust_key,审批卡才有「始终允许」;
- 门控出任何意外(没答上来、答不成格式)都按交给人工处理,不能变成放行;
- 点了「始终允许」:写进设置,本节点后面的同一个工具改由门控把关,不再问人;
- 一次运行用发起时的快照;正式运行不看三档;升级前的运行(快照为 None)照旧不问。
"""
from __future__ import annotations
import asyncio
import json
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
from langchain_core.tools import StructuredTool
from pydantic import BaseModel
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Approval, CustomTool, Run, RunEvent, Setting
from app.engine.runner import run_manager
from app.tools import custom as custom_tools
from app.tools.trust import (
FORMAL_MARK, GATE_SYSTEM, SETTING_KEY, asks_trustable, call_policy, load_trust, parse_verdict,
set_trust,
)
TOOL = "crm_lookup"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
async def clean_trust():
async with SessionLocal() as session:
row = await session.get(Setting, SETTING_KEY)
if row:
await session.delete(row)
await session.commit()
yield
@pytest.fixture
async def crm(monkeypatch):
"""一个自定义 http 工具。真正的请求换成记账:调了几次、参数是什么。"""
calls: list[dict] = []
async def fake_http(row, args):
calls.append(dict(args))
return {"customer": args.get("name"), "level": "gold"}
monkeypatch.setattr(custom_tools, "_run_http", fake_http)
async with SessionLocal() as session:
session.add(CustomTool(name=TOOL, kind="http", description="按名字查客户等级",
parameters={"type": "object", "properties": {"name": {"type": "string"}},
"required": ["name"]},
config={"url": "https://example.com/crm"}))
await session.commit()
yield calls
async with SessionLocal() as session:
for row in (await session.execute(select(CustomTool).where(CustomTool.name == TOOL))).scalars():
await session.delete(row)
await session.commit()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes),
"edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def agent_graph(**extra):
return chain(
node("start", "input", fields=[{"name": "question"}]),
node("bot", "agent", prompt="查两位客户的等级", tools=[TOOL], max_steps=5, **extra),
node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.bot.text }}"}]),
)
def tool_graph(**extra):
return chain(
node("start", "input", fields=[{"name": "question"}]),
node("t", "tool", tool=TOOL, args={"name": "甲公司"}, **extra),
node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.t.result }}"}]),
)
def script(monkeypatch, *, gate=None, names=("甲公司", "乙公司"), team=False):
"""agent 依次查 names 里的客户;gate 是门控模型的回复(字符串、可调用,或 None 表示不该被问到)。"""
from app.providers import mock_model
gate_calls: list[str] = []
turns = {"route": 0}
def _decide(self, messages):
if messages and isinstance(messages[0], SystemMessage) and messages[0].content == GATE_SYSTEM:
gate_calls.append(str(messages[-1].content))
if gate is None:
raise AssertionError("不该问门控")
return AIMessage(content=gate(messages) if callable(gate) else gate)
if team and self.response_format:
turns["route"] += 1
plan = ({"assignments": [{"agent": "sales", "instruction": "查甲公司"}]}
if turns["route"] == 1 else {"assignments": [], "done": True})
return AIMessage(content=json.dumps(plan, ensure_ascii=False))
if self.response_format:
return AIMessage(content="{}")
if not self.tools:
return AIMessage(content="收尾")
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(names):
return AIMessage(content="查一下。", tool_calls=[
{"name": TOOL, "args": {"name": names[done]}, "id": f"call_{done}"}])
return AIMessage(content="都查完了。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return gate_calls
ALLOW = '{"verdict": "allow", "reason": "只读查询,和任务一致"}'
ESCALATE = '{"verdict": "escalate", "reason": "拿不准"}'
async def wait(run_id: str, statuses=("interrupted", "failed", "succeeded"), timeout=20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"等超时了:{run_id}")
async def events(run_id: str, kind: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
rows = list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq))).scalars())
return [e for e in rows if kind is None or e.type == kind]
async def pending(run_id: str) -> list[Approval]:
async with SessionLocal() as session:
return list((await session.execute(select(Approval).where(
Approval.run_id == run_id, Approval.status == "pending"))).scalars())
async def start(graph, **kw) -> str:
return (await run_manager.start(graph=graph, input_payload={"question": "查"}, **kw)).id
# ---------------------------------------------------------------- 判定规则
class _NoArgs(BaseModel):
pass
def _tool(key: str | None, name: str = "x") -> StructuredTool:
async def run(**_): return ""
return StructuredTool(name=name, description="", coroutine=run, func=None, args_schema=_NoArgs,
metadata={"trust_key": key} if key else {})
def test_policy_for_trustable_tools():
t = _tool("mcp:demo/search", "search")
assert call_policy(t, "search", {}, {}) == "ask"
assert call_policy(t, "search", {}, {"mcp:demo/search": "gated"}) == "gate"
assert call_policy(t, "search", {}, {"mcp:demo/search": "always"}) == "safe"
# 正式运行:档位一律不看
assert call_policy(t, "search", {}, {FORMAL_MARK: "1", "mcp:demo/search": "always"}) == "ask"
# 升级前发起的运行:照旧不问(它们可能正停在别的审批上,多问一次答复就对错号了)
assert call_policy(t, "search", {}, None) == "safe"
def test_policy_leaves_builtin_tools_alone():
"""内置工具不看三档:calculator 安全,shell_exec 危险,和以前一样。"""
assert call_policy(_tool(None, "calculator"), "calculator", {}, {}) == "safe"
assert call_policy(_tool(None, "shell_exec"), "shell_exec", {}, {"shell_exec": "always"}) == "ask"
def test_always_button_only_where_it_takes_effect():
t = _tool("crm_lookup")
assert asks_trustable(t, {}) == "crm_lookup"
assert asks_trustable(t, {FORMAL_MARK: "1"}) is None
assert asks_trustable(t, None) is None
assert asks_trustable(_tool(None), {}) is None
@pytest.mark.parametrize("text, expected", [
('{"verdict": "allow", "reason": "只读"}', (True, "只读")),
('好的:{"verdict":"escalate","reason":"要删数据"}', (False, "要删数据")),
('{"verdict": "allow"}', (True, "(门控未说明理由)")),
('{"verdict": "yes"}', None),
("放行", None),
("", None),
('{"verdict": "allow", ', None),
])
def test_parse_verdict(text, expected):
assert parse_verdict(text) == expected
# ---------------------------------------------------------------- 接口
@pytest.fixture
async def client():
# 不走 TestClient:它会跑应用的启动流程、按环境变量种 provider,后面的运行就不再用 mock
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def _row(client) -> dict:
return next(t for t in (await client.get("/api/tools")).json() if t["id"] == TOOL)
async def test_api_lists_and_sets_trust(crm, client):
row = await _row(client)
assert (row["trust"], row["trust_key"], row["runtime_approval"]) == ("ask", TOOL, True)
assert (await client.put("/api/tools/trust", json={"key": TOOL, "trust": "always"})).json() \
== {"key": TOOL, "trust": "always"}
row = await _row(client)
assert (row["trust"], row["runtime_approval"]) == ("always", False)
await client.put("/api/tools/trust", json={"key": TOOL, "trust": "gated"})
row = await _row(client)
assert (row["trust"], row["runtime_approval"]) == ("gated", True)
# 改回等审批:从表里删掉,不留一条 ask
await client.put("/api/tools/trust", json={"key": TOOL, "trust": "ask"})
stored = (await client.get("/api/settings")).json().get(SETTING_KEY) or {}
assert TOOL not in stored.get("tools", {})
assert (await client.put("/api/tools/trust", json={"key": TOOL, "trust": "yes"})).status_code == 422
assert (await client.put("/api/tools/trust", json={"key": "", "trust": "ask"})).status_code == 422
async def test_settings_page_cannot_overwrite_trust(crm, client):
"""设置页整组回写时带着一份旧的三档,不能把工具页刚改的冲掉。"""
await client.put("/api/tools/trust", json={"key": TOOL, "trust": "always"})
await client.put("/api/settings", json={"values": {SETTING_KEY: {"tools": {}}, "run": {"stream_tokens": True}}})
assert (await _row(client))["trust"] == "always"
async def test_gate_model_settings_have_defaults(client):
run = (await client.get("/api/settings")).json()["run"]
assert run["tool_gate_provider"] is None and run["tool_gate_model"] is None
# ---------------------------------------------------------------- agent 节点
async def test_custom_tool_waits_for_approval_by_default(crm, monkeypatch):
script(monkeypatch, names=("甲公司",))
run_id = await start(agent_graph())
run = await wait(run_id)
assert run.status == "interrupted", f"自定义工具没等审批就执行了:{run.status} {run.error}"
assert crm == []
[approval] = await pending(run_id)
assert approval.payload["trust_key"] == TOOL
[asked] = await events(run_id, "human.requested")
assert asked.data["trust_key"] == TOOL
await run_manager.resume(run_id, {"approved": True})
run = await wait(run_id, ("failed", "succeeded"))
assert run.status == "succeeded", run.error
assert crm == [{"name": "甲公司"}]
async def test_always_runs_without_asking(crm, monkeypatch):
await _set(TOOL, "always")
script(monkeypatch, gate=None)
run = await wait(await start(agent_graph()))
assert run.status == "succeeded", run.error
assert [c["name"] for c in crm] == ["甲公司", "乙公司"]
assert not await events(run.id, "human.requested")
async def test_gated_and_allowed_runs_without_asking(crm, monkeypatch):
await _set(TOOL, "gated")
asked = script(monkeypatch, gate=ALLOW)
run = await wait(await start(agent_graph()))
assert run.status == "succeeded", run.error
assert len(asked) == 2 and "甲公司" in asked[0] and "查两位客户的等级" in asked[0]
gated = await events(run.id, "tool.gated")
assert [(e.data["verdict"], e.data["reason"]) for e in gated] == [("allow", "只读查询,和任务一致")] * 2
assert not await events(run.id, "human.requested")
assert len(crm) == 2
@pytest.mark.parametrize("reply", [ESCALATE, "我觉得可以", '{"verdict": "maybe"}'])
async def test_gate_that_does_not_clearly_allow_hands_over_to_a_person(crm, monkeypatch, reply):
await _set(TOOL, "gated")
script(monkeypatch, gate=reply, names=("甲公司",))
run = await wait(await start(agent_graph()))
assert run.status == "interrupted", "门控没放行,却没交给人"
assert crm == []
[gated] = await events(run.id, "tool.gated")
assert gated.data["verdict"] == "escalate"
async def test_gate_that_crashes_hands_over_to_a_person(crm, monkeypatch):
await _set(TOOL, "gated")
def boom(_):
raise RuntimeError("网关断开")
script(monkeypatch, gate=boom, names=("甲公司",))
run = await wait(await start(agent_graph()))
assert run.status == "interrupted"
[gated] = await events(run.id, "tool.gated")
assert gated.data["verdict"] == "escalate" and "门控模型未给出判断" in gated.data["reason"]
async def test_gate_verdict_is_not_asked_again_when_the_node_replays(crm, monkeypatch):
"""门控判了交给人 → 批准 → 节点重放。重放时不能再问门控:这次可能判放行,interrupt 就错号了。"""
await _set(TOOL, "gated")
replies = iter([ESCALATE, ALLOW, ALLOW])
asked = script(monkeypatch, gate=lambda _: next(replies))
run_id = await start(agent_graph())
await wait(run_id)
await run_manager.resume(run_id, {"approved": True})
run = await wait(run_id, ("failed", "succeeded", "interrupted"))
assert run.status == "succeeded", run.error
assert len(asked) == 2, f"门控被问了 {len(asked)} 次,重放时又问了一遍"
assert [c["name"] for c in crm] == ["甲公司", "乙公司"]
async def test_always_button_persists_and_covers_the_rest_of_the_node(crm, monkeypatch):
asked = script(monkeypatch, gate=ALLOW)
run_id = await start(agent_graph())
await wait(run_id)
await run_manager.resume(run_id, {"approved": True, "always": True})
run = await wait(run_id, ("failed", "succeeded", "interrupted"))
assert run.status == "succeeded", f"点了始终允许,第二次调用还在等审批:{run.status}"
assert len(await events(run_id, "human.requested")) == 1
# 第一次是人批的,第二次改由门控把关
assert len(asked) == 1 and "乙公司" in asked[0]
resolved = (await events(run_id, "human.resolved"))[0]
assert resolved.data.get("always") is True
async with SessionLocal() as session:
assert (await load_trust(session)) == {TOOL: "gated"}
async def test_always_is_ignored_where_there_is_no_button(monkeypatch):
"""内置工具的审批带了 always 也不理:没有可改的档位。"""
graph = chain(
node("start", "input", fields=[{"name": "question"}]),
node("t", "tool", tool="calculator", args={"expression": "1+1"}, approval="always"),
node("out", "output", fields=[]),
)
run_id = await start(graph)
await wait(run_id)
[approval] = await pending(run_id)
assert "trust_key" not in approval.payload
await run_manager.resume(run_id, {"approved": True, "always": True})
await wait(run_id, ("failed", "succeeded"))
async with SessionLocal() as session:
assert await load_trust(session) == {}
async def test_a_run_keeps_the_trust_it_started_with(crm, monkeypatch):
"""运行中途在工具页改了档位,这次运行不受影响:审批恢复时的判定必须和第一次一样。"""
script(monkeypatch, gate=None)
run_id = await start(agent_graph())
await wait(run_id)
await _set(TOOL, "always")
await run_manager.resume(run_id, {"approved": True})
run = await wait(run_id, ("failed", "succeeded", "interrupted"))
assert run.status == "interrupted", "快照被中途的改动顶掉了:第二次调用没再问"
async with SessionLocal() as session:
assert (await session.get(Run, run_id)).tool_trust == {}
async def test_formal_runs_ignore_trust(crm, monkeypatch):
await _set(TOOL, "always")
script(monkeypatch, gate=None, names=("甲公司",))
run_id = await start(agent_graph(), run_class="formal")
run = await wait(run_id)
assert run.status == "interrupted", "正式运行吃了用户的「始终允许」"
[approval] = await pending(run_id)
assert "trust_key" not in approval.payload
started = (await events(run_id, "run.started"))[0]
assert started.data["tool_trust"] == "formal"
async def test_run_started_records_the_snapshot(crm, monkeypatch):
await _set(TOOL, "always")
await _set("mcp:demo/search", "gated")
script(monkeypatch, gate=None)
run = await wait(await start(agent_graph()))
started = (await events(run.id, "run.started"))[0]
assert started.data["tool_trust"] == {TOOL: "always", "mcp:demo/search": "gated"}
async def test_node_approval_never_skips_the_gate(crm, monkeypatch):
await _set(TOOL, "gated")
script(monkeypatch, gate=None)
run = await wait(await start(agent_graph(approval="never")))
assert run.status == "succeeded", run.error
assert not await events(run.id, "tool.gated")
# ---------------------------------------------------------------- 工具节点
async def test_tool_node_gated_allow_runs(crm, monkeypatch):
await _set(TOOL, "gated")
script(monkeypatch, gate=ALLOW)
run = await wait(await start(tool_graph()))
assert run.status == "succeeded", run.error
assert crm == [{"name": "甲公司"}]
assert [e.data["verdict"] for e in await events(run.id, "tool.gated")] == ["allow"]
async def test_tool_node_escalation_asks_with_the_always_button(crm, monkeypatch):
await _set(TOOL, "gated")
asked = script(monkeypatch, gate=ESCALATE)
run_id = await start(tool_graph())
assert (await wait(run_id)).status == "interrupted"
[approval] = await pending(run_id)
assert approval.payload["trust_key"] == TOOL
await run_manager.resume(run_id, {"approved": True, "always": True})
run = await wait(run_id, ("failed", "succeeded"))
assert run.status == "succeeded", run.error
assert len(asked) == 1, "重放时又问了一遍门控"
assert len(await events(run_id, "tool.gated")) == 1
assert crm == [{"name": "甲公司"}]
async def test_tool_node_default_waits(crm, monkeypatch):
script(monkeypatch, gate=None)
run = await wait(await start(tool_graph()))
assert run.status == "interrupted" and crm == []
# ---------------------------------------------------------------- 协作团队
def team_graph():
return chain(
node("start", "input", fields=[{"name": "question"}]),
node("team", "supervisor", goal="查客户", max_rounds=3,
agents=[{"name": "sales", "system": "你是销售助理", "tools": [TOOL], "max_steps": 3}]),
node("out", "output", fields=[]),
)
async def test_team_member_does_not_run_an_unapproved_custom_tool(crm, monkeypatch):
script(monkeypatch, gate=None, names=("甲公司",), team=True)
run = await wait(await start(team_graph()))
assert run.status == "succeeded", run.error
assert crm == []
assert "tool_needs_approval" in [e.data.get("code") for e in await events(run.id, "log")]
async def test_team_member_gated(crm, monkeypatch):
await _set(TOOL, "gated")
script(monkeypatch, gate=ALLOW, names=("甲公司",), team=True)
run = await wait(await start(team_graph()))
assert run.status == "succeeded", run.error
assert crm == [{"name": "甲公司"}]
[gated] = await events(run.id, "tool.gated")
assert gated.data["agent"] == "sales" and gated.data["verdict"] == "allow"
async def test_team_member_gate_escalation_is_not_executed(crm, monkeypatch):
await _set(TOOL, "gated")
script(monkeypatch, gate=ESCALATE, names=("甲公司",), team=True)
run = await wait(await start(team_graph()))
assert run.status == "succeeded", run.error
assert crm == []
ends = [e.data.get("preview", "") for e in await events(run.id, "tool.end")]
assert any("门控模型未批准(拿不准)" in p for p in ends), ends
async def _set(key: str, level: str) -> None:
async with SessionLocal() as session:
await set_trust(session, key, level)