Files
AgentLab/backend/tests/test_agent_tool_loop.py
T
ThearchyHelios c02ef189bb 工具调用失败能自救;agent 改成一步一个工具
起因是一条没人看得懂的报错:

    工具 db_query__shop 执行失败:
    _make_query_tool.<locals>._run() got an unexpected keyword argument 'query'

参数名写成了 query(它叫 sql),而 call_tool 对动态工具是裸 **args 展开的,
跳过了 args_schema —— pydantic 那句"缺少 sql 字段"漏成了只剩内部闭包名的
TypeError,既指不到是哪个工具,也说不出该填什么。同一个函数里内置工具那条
分支本来是校验的,两条路径不一致。

registry 里补上 prepare_args / describe_args,两条路径共用:

- 候选唯一时(多一个不认识的、少一个必填的)改名重试,并发一条 warn 说破
  —— 配置里那个错名还在,替它跑通一次却不吭声,下次照样踩
- 有歧义就不猜,报错改成"参数不对:query 不是它的参数;缺少必填参数 sql。
  它接受:sql(必填,字符串)— 要执行的 SQL…",模型照着这句就能改对。
  这才是失败之后真正能自愈的重试
- 顺带堵了一类静默失败:pydantic 默认把认不出的键悄悄丢掉,全可选 schema 上
  {"tabel": "orders"} 会"成功"执行一次什么都没查的调用

agent 循环默认改成串行:一轮只调一个工具,看到结果再想下一步。并行更快,但
一批调用中间没有任何新的思考——第二个是照着"还没看到第一个结果"时的判断发出
去的。节点上留了「并行执行工具」开关。两个必须守住的点:没执行的调用绝不发
tool.start(前端靠 call_id 配对,发了没结尾的 start 会永远转圈),但必须各有
一条配对的 tool_result(少一条下次调用直接 400)。

交错思考跟着补上。4.6 起 adaptive thinking 自带交错,不需要也不该挂 beta
header(Opus 4.6 上手动模式压根没有交错);4/4.5 那一代要手动开 thinking +
interleaved-thinking beta,光挂 header 是一条什么都不做的空设置。开了 thinking
就不能再传 temperature,这一代本来是收的,漏了就是 400。

还有两处是串行带出来的,一并治了:

- 步数用完时收尾取的是 messages[-1],而那往往是给模型看的 ToolMessage。
  真漏到用户面前当过答案(run dd9927e6):一句"本轮只执行了第一个工具"冒充
  结论。改成只取模型自己说过的话,并把"步数用完了、去调大最大步数"附在后面
- db_schema 一次能问多张表。一次调用就是一步,逐张问 6 张表就是 6 步——
  实测 8 步的运行里 7 步花在这上面,最后只剩一步真查数据
2026-09-18 17:57:20 +08:00

181 lines
6.8 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 的工具循环,跑真图验收。
单测能保证 split_tool_calls 分得对、prepare_args 纠得对,但保证不了它们在
run_agent 里被接对了。这里从 run_manager.start 进去、从落库的 RunEvent 出来,
验的是用户真正会看到的那条事件流。
两件必须守住的事:
- 每个 tool_use 都要有配对的 tool_result。少一条,下一次模型调用直接 400。
- 没执行的调用绝不能发 tool.start。前端靠 call_id 配对 start/end
(frontend/src/run/decode.ts),一条没有结尾的 start 就是界面上一个永远转圈的卡片。
"""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.runner import run_manager
@pytest.fixture
async def runner():
"""真图要跑起来就得有 checkpointer —— 平时是 FastAPI 的 lifespan 建的。"""
await run_manager.setup()
yield run_manager
await run_manager.shutdown()
@pytest.fixture
async def shop(tmp_path):
"""一个真实的小库 + 一条 DataSource 记录,工具要能真的查到数。"""
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL NOT NULL);")
db.executemany("INSERT INTO orders(id,amount) VALUES(?,?)", [(i, i * 2.0) for i in range(1, 8)])
db.commit()
db.close()
name = f"shop_{tmp_path.name[-6:].replace('-', '_')}"
async with SessionLocal() as session:
session.add(DataSource(
name=name, kind="sqlite", database=str(path), readonly=True,
description="测试库", options={}, schema_cache={}, enabled=True,
))
await session.commit()
return name, str(path)
def _graph(tool_name: str) -> dict:
return {
"nodes": [
{"id": "in", "type": "input", "data": {"config": {}}},
{"id": "bot", "type": "agent", "data": {"config": {
"prompt": "查一下订单总额",
"tools": [tool_name],
"approval": "never", # 审批会挂起,这里要一路跑到底
"max_steps": 3,
}}},
{"id": "out", "type": "output", "data": {"config": {}}},
],
"edges": [
{"source": "in", "target": "bot"},
{"source": "bot", "target": "out"},
],
}
async def _run_to_completion(graph: dict) -> list[RunEvent]:
run = await run_manager.start(graph=graph, input_payload={"question": "订单总额"})
for _ in range(300):
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("运行没有在 15 秒内结束")
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
async def test_serial_loop_runs_one_tool_and_answers_every_call(runner, shop, monkeypatch) -> None:
"""模型一轮发两个工具调用:只跑第一个,但两个都要有回音。
顺带把第一个的参数名写错成 query —— 这正是那条
"_run() got an unexpected keyword argument 'query'" 的来源。
"""
name, _ = shop
tool_name = f"db_query__{name}"
from app.providers import mock_model
turns = {"n": 0}
def _decide(self, messages):
turns["n"] += 1
if turns["n"] == 1:
return AIMessage(
content="先查一下。",
tool_calls=[
# 参数名故意写错:能被纠正成 sql 才算过
{"name": tool_name, "args": {"query": "SELECT SUM(amount) AS total FROM orders"},
"id": "call_a"},
{"name": tool_name, "args": {"sql": "SELECT COUNT(*) FROM orders"},
"id": "call_b"},
],
)
return AIMessage(content="订单总额是 56。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
events, run = await _run_to_completion(_graph(tool_name))
kinds = [(e.type, e.data) for e in events]
assert run.status == "succeeded", [k for k, _ in kinds]
# 一轮只跑了一个工具
starts = [d for t, d in kinds if t == "tool.start"]
assert len(starts) == 1, f"应该只有一次 tool.start,实际 {len(starts)} 次"
assert starts[0]["call_id"] == "call_a"
# 发出去的是纠正后的参数,不是模型原样那份
assert "sql" in starts[0]["args"] and "query" not in starts[0]["args"]
# 每个 tool.start 都有配对的结尾,界面上不会留下转圈的卡片
ends = [d for t, d in kinds if t in ("tool.end", "tool.error")]
assert {d["call_id"] for d in ends} == {"call_a"}
# 纠正这件事说出来了,没有安静地把事办了
warns = [d["message"] for t, d in kinds if t == "log" and d.get("level") == "warn"]
assert any("query" in w and "sql" in w for w in warns), warns
# 工具真的查到了数
assert "56" in str([d.get("preview") for t, d in kinds if t == "tool.end"])
async def test_bad_args_feed_the_schema_back_instead_of_dying(runner, shop, monkeypatch) -> None:
"""参数全不认识时不执行,把"它接受什么"喂回去——模型下一步才改得对。"""
name, _ = shop
tool_name = f"db_query__{name}"
from app.providers import mock_model
seen: list[str] = []
turns = {"n": 0}
def _decide(self, messages):
turns["n"] += 1
if turns["n"] == 1:
return AIMessage(
content="查一下。",
tool_calls=[{"name": tool_name, "args": {"foo": 1, "bar": 2}, "id": "call_x"}],
)
# 第二轮:把上一轮喂回来的内容记下来,这就是模型能看到的全部依据
seen.extend(m.content for m in messages if isinstance(m, ToolMessage))
return AIMessage(content="好的。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
events, run = await _run_to_completion(_graph(tool_name))
assert run.status == "succeeded"
assert seen, "参数错了也必须给模型一条 tool_result,否则下一轮直接 400"
text = seen[0]
assert "sql" in text and "必填" in text # 说清楚该填什么
assert "unexpected keyword argument" not in text # 不再是那句天书
# 没执行的调用不发 tool.start,界面上不会留下转不完的卡片
assert not [e for e in events if e.type == "tool.start"]
assert [e for e in events if e.type == "tool.error"]