mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:51:59 +00:00
62aac187d8
MCP / 自定义工具的审批三档(tools/trust.py) - 平台看不出这两类工具做什么,以前审批关卡一律当它们安全、照跑不误。现在默认每次调用都等审批; 工具页可以逐个改成「始终允许 · 门控把关」或「始终允许」,审批卡上的「始终允许」等于前者 - 门控把关:每次调用先由门控模型审一遍工具说明、参数和节点任务;判可疑、出错、超时、答不成格式, 一律交还给人,不会变成放行。判定包进 checkpoint,审批恢复重放时不再问一遍 - agent 节点、工具节点、协作团队三处关卡都接上;团队成员停不下来等人,门控没放行的调用不执行 - 每次运行在发起时快照档位(runs.tool_trust),恢复和接着跑沿用同一份;正式运行不看档位,一律等审批; 升级前发起的运行照旧不问——它们可能正停在某个审批上,多问一次答复就会对错号 - 接口:GET /api/tools 带 trust / trust_key,PUT /api/tools/trust;设置新增门控模型;新事件 tool.gated - 界面:工具页三选一、审批卡「始终允许」、设置页门控模型、运行记录解码 tool.gated; 顺手修了 MCP 工具详情 404、工具审批被驳回却显示成放行 智能体护栏(engine/guards.py),取代固定的「最多 12 步」 - 步数只留兜底:默认 100,设置里可改;以前画布自动写进节点的 12 按「没填」处理 - 同一个工具、同样的参数第二次不再执行,把上次的结果交还给它;连续 3 步拿不到新信息就收尾 - 每个节点的令牌预算(默认 200 万)和金额预算(默认不限),都可以在设置里改成不限 - 上下文用到窗口的 60% 先压缩早期的工具结果,到 85% 就收尾;临近任何一个上限先提醒模型一次 - 下游结构化校验失败时写明是上游哪个 agent 提前收了尾;复核信号带上收尾原因, 问数据页只在真是步数用满时才给「放宽步数重跑」 - 上限在发起时快照(runs.agent_limits);升级前发起的运行一字不改走旧逻辑 检查脚本 - check-all 默认 4 道并行:量时间最多的两项互不重叠;并行时没过的最后单独重跑一遍,重跑才过的 单列成「偶发」。16 项全量从约 21 分钟降到约 7 分钟(加上一次重跑约 10 分钟);--lanes 1 回到串行 顺带修的 - 设置接口里的 limits 改为只读:库里存着旧版设置页写进去的一份(max_agent_steps: 25),从不生效, 却盖住了真实上限,问数据页照着错的数许诺 - test_kb 里嵌入模型那条测试不再真连 api.openai.com(代理慢时一条要等近 4 分钟) - check-stream 读滚动容器前先等它挂上,并行跑时不再偶发 验证(沙箱):tsc 干净,生产构建通过,pytest 1655 passed(两遍),check-all 16 项检查、3562 项全过; 新增后端测试 53 条,关键逻辑做了 22 处变异验证、全部抓到。
220 lines
8.5 KiB
Python
220 lines
8.5 KiB
Python
"""步数用尽时,agent 要收口,而不是把半截过程交出去。
|
||
|
||
走到步数上限一定意味着最后那步**要求了工具**(不要工具的话循环早 break 了),
|
||
也就是说模型从来没拿到过"说结论"的那一轮——它只是被掐断在半路。交出去的
|
||
是它上一句中间过程的话,末尾补一句"步数不够"。
|
||
|
||
所以补上那一轮:不给工具,让它基于已经查到的收口。这里守三件事——收尾轮
|
||
真的发生了、它**发不出**工具调用、以及它没有变成每次都多烧的一次调用。
|
||
|
||
顺带补回 agent 循环的集成测试:原来那个文件在脱敏那轮被整个删掉了。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from langchain_core.messages import AIMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run, RunEvent
|
||
from app.engine.runner import run_manager
|
||
|
||
#: 收尾轮该说的话。挑一句和"中间过程"截然不同的,好分辨答案到底取自哪一轮
|
||
SETTLED = "已经查到的部分:门店 8 家。其余未查到,这个数是下限。"
|
||
MIDWAY = "中间过程:还得再查一张表。"
|
||
|
||
MAX_STEPS = 3
|
||
|
||
|
||
@pytest.fixture
|
||
async def runner():
|
||
await run_manager.setup()
|
||
yield run_manager
|
||
await run_manager.shutdown()
|
||
|
||
|
||
def _graph(*, max_steps: int = MAX_STEPS) -> dict:
|
||
return {
|
||
"nodes": [
|
||
{"id": "in", "type": "input", "data": {"config": {}}},
|
||
{"id": "work", "type": "agent", "data": {"config": {
|
||
"prompt": "{{ input.question }}",
|
||
"tools": ["calculator"],
|
||
"max_steps": max_steps,
|
||
"approval": "never",
|
||
}}},
|
||
{"id": "out", "type": "output", "data": {"config": {}}},
|
||
],
|
||
"edges": [{"source": "in", "target": "work"}, {"source": "work", "target": "out"}],
|
||
}
|
||
|
||
|
||
def never_concludes(monkeypatch, *, settle_raises: bool = False) -> None:
|
||
"""绑了工具就永远再要一次工具;没绑工具才说话。
|
||
|
||
`self.tools` 是 bind_tools 之后才有的。收尾轮走的是**没绑过工具**的
|
||
base_model,所以它必然落到下面那个分支——这正是"收尾轮发不出工具调用"
|
||
那条断言的着力点:真要是绑着工具过来的,拿到的就会是 MIDWAY。
|
||
"""
|
||
from app.providers import mock_model
|
||
|
||
def _decide(self, messages):
|
||
if self.tools:
|
||
return AIMessage(
|
||
content=MIDWAY,
|
||
# 每步参数不同:同样的调用第二次不会再执行(engine/guards.py),这里要的是真把步数用完
|
||
tool_calls=[{"name": self.tools[0]["name"],
|
||
"args": {"expression": f"1+{len(messages)}"},
|
||
"id": f"call_{len(messages)}"}],
|
||
)
|
||
if settle_raises:
|
||
raise RuntimeError("收尾轮炸了")
|
||
return AIMessage(content=SETTLED)
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
|
||
|
||
|
||
async def _run(graph: dict, question: str = "有几家门店"):
|
||
run = await run_manager.start(graph=graph, input_payload={"question": question})
|
||
for _ in range(200):
|
||
await asyncio.sleep(0.05)
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(Run).where(Run.id == run.id))).scalar_one()
|
||
if row.status in ("succeeded", "failed", "cancelled"):
|
||
break
|
||
else:
|
||
pytest.fail("运行没有在 10 秒内结束")
|
||
async with SessionLocal() as session:
|
||
events = list((await session.execute(
|
||
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
|
||
)).scalars())
|
||
return events, row
|
||
|
||
|
||
def codes(events) -> list[str]:
|
||
return [e.data.get("code") for e in events
|
||
if e.type == "log" and (e.data or {}).get("level") == "warn"]
|
||
|
||
|
||
def answer_of(row) -> str:
|
||
from app.engine import review as rv
|
||
return rv.answer_text(row.output)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_step_limit_ends_with_a_conclusion_not_a_stump(runner, monkeypatch) -> None:
|
||
"""这块改动的全部意义:交出去的是结论,不是中间过程加一句抱怨。"""
|
||
never_concludes(monkeypatch)
|
||
events, row = await _run(_graph())
|
||
assert row.status == "succeeded", row.error
|
||
|
||
text = answer_of(row)
|
||
assert SETTLED in text, text
|
||
assert MIDWAY not in text, f"上一句中间过程冒充了答案:{text}"
|
||
assert "step_limit_settled" in codes(events), codes(events)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_the_settle_round_has_no_tools_bound(runner, monkeypatch) -> None:
|
||
"""收尾轮必须在**结构上**发不出工具调用,而不是靠提示词请它别调。
|
||
|
||
mock 的判据就是"有没有绑工具":真要是把绑过工具的 model 传进了收尾轮,
|
||
它会照常再要一次工具,答案里出现的就是 MIDWAY。
|
||
"""
|
||
never_concludes(monkeypatch)
|
||
events, row = await _run(_graph())
|
||
|
||
assert SETTLED in answer_of(row)
|
||
# 工具调用次数正好等于步数——收尾轮一次都没多调
|
||
assert len([e for e in events if e.type == "tool.start"]) == MAX_STEPS
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_the_settle_round_costs_exactly_one_extra_call(runner, monkeypatch) -> None:
|
||
"""多花的必须是可数的一次,不是一个说不清的尾巴。"""
|
||
never_concludes(monkeypatch)
|
||
events, _ = await _run(_graph())
|
||
assert len([e for e in events if e.type == "llm.start"]) == MAX_STEPS + 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_normal_agent_does_not_pay_for_a_settle_round(runner, monkeypatch) -> None:
|
||
"""反向断言:正常收口的 agent 一分钱都不该多花。
|
||
|
||
没有这条,"每次都多跑一轮"这种退化会悄无声息地通过全部其他测试。
|
||
"""
|
||
from app.providers import mock_model
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide",
|
||
lambda self, messages: AIMessage(content="直接就有结论了。"))
|
||
events, row = await _run(_graph())
|
||
|
||
assert "直接就有结论了。" in answer_of(row)
|
||
assert len([e for e in events if e.type == "llm.start"]) == 1
|
||
assert not [c for c in codes(events) if c and c.startswith("step_limit")]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_failed_settle_round_falls_back_and_keeps_what_was_found(
|
||
runner, monkeypatch
|
||
) -> None:
|
||
"""收尾轮自己挂了,不能把已经查到的一起赔进去。"""
|
||
never_concludes(monkeypatch, settle_raises=True)
|
||
events, row = await _run(_graph())
|
||
assert row.status == "succeeded", row.error
|
||
|
||
text = answer_of(row)
|
||
assert "settle_failed" in codes(events), codes(events)
|
||
# 退回原来的行为:老的 step_limit(BROKEN),而不是 settled 那个
|
||
assert "step_limit" in codes(events)
|
||
assert "step_limit_settled" not in codes(events)
|
||
# 已有成果还在,末尾挂着说明
|
||
assert MIDWAY in text and "用满了" in text, text
|
||
|
||
|
||
def test_max_steps_is_clamped_by_the_hard_cap() -> None:
|
||
"""节点上写多大都过不去硬顶——否则一张图就能把预算烧穿。"""
|
||
from app.core.config import settings
|
||
|
||
assert min(200, settings.max_agent_steps) == settings.max_agent_steps
|
||
assert settings.max_agent_steps >= 25, "抬高上限不该反而把原来能配的值挡回去"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 复核层怎么看这两种收场
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _warn(message: str, code: str) -> dict:
|
||
return {"type": "log", "node_id": "work",
|
||
"data": {"level": "warn", "message": message, "code": code}}
|
||
|
||
|
||
OK_OUTPUT = {"text": SETTLED}
|
||
|
||
|
||
def test_a_settled_run_is_a_gap_not_a_break() -> None:
|
||
"""收口过的答案是完整一段话,只是覆盖面不全——那是缺口不是断裂。"""
|
||
from app.engine import review as rv
|
||
|
||
signals = rv.scan([_warn("用满了 3 步,基于已查到的部分给出", "step_limit_settled")],
|
||
OK_OUTPUT)
|
||
assert [s.severity for s in signals] == [rv.DEGRADED]
|
||
# 连带效果正是想要的:不再触发自动重跑
|
||
assert not rv.should_retry(signals)
|
||
|
||
|
||
def test_a_hard_truncation_is_still_broken_and_still_retries() -> None:
|
||
"""收尾轮没能收上来,判定一点没变——这条是防止改动把老路一起改坏。"""
|
||
from app.engine import review as rv
|
||
|
||
signals = rv.scan([_warn("用满了 3 步还没给出结论", "step_limit")], OK_OUTPUT)
|
||
assert rv.worst(signals) == rv.BROKEN
|
||
assert rv.should_retry(signals)
|