Files
AgentLab/backend/tests/test_agent_steps.py
T
ThearchyHelios 62aac187d8 工具审批三档、智能体护栏取代固定 12 步、检查脚本分道并行
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 处变异验证、全部抓到。
2026-09-28 11:47:45 +08:00

220 lines
8.5 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.
"""步数用尽时,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)