mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
c02ef189bb
起因是一条没人看得懂的报错:
工具 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 步花在这上面,最后只剩一步真查数据
181 lines
6.8 KiB
Python
181 lines
6.8 KiB
Python
"""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"]
|