Files
AgentLab/backend/tests/test_tool_args.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

376 lines
15 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 一轮只跑一个工具。
起因是一条谁也看不懂的报错:
工具 db_query__shop 执行失败:
_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'
参数名写成了 query(它叫 sql),而动态工具那条调用路径当时是裸 `**args` 展开的,
于是 pydantic 的"缺少 sql 字段"漏成了一个只剩内部闭包名的 TypeError —— 既指不到
是哪个工具,也说不出该填什么,人和模型都只能接着猜。
"""
from __future__ import annotations
import sqlite3
from types import SimpleNamespace
import pytest
from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
from app.engine.nodes.llm import split_tool_calls
from app.tools.datasource import _make_query_tool, _make_schema_tool
from app.tools.registry import (
ToolArgsError,
ToolContext,
args_model_of,
describe_args,
prepare_args,
)
class _QueryLike(BaseModel):
"""和 datasource 的查询参数同形:一个必填 + 一个可选。"""
sql: str = Field(description="要执行的 SQL,一次一条")
limit: int | None = Field(default=None, description="最多返回多少行,默认 1000")
class _AllOptional(BaseModel):
table: str | None = Field(default=None, description="表名;留空则列出所有表")
@pytest.fixture
def source(tmp_path):
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 * 1.5) for i in range(1, 6)])
db.commit()
db.close()
return SimpleNamespace(
# id 必须和别的测试文件不同:engine 按 source.id 缓存连接池
# (app/data/engine.py:121),撞了 id 就会连到人家那个库上——
# 默认的文件顺序下碰巧不冲突,单跑一个文件或换个顺序就串了
id="tool-args-src", name="shop", kind="sqlite", database=str(path),
host=None, port=None, username=None, password=None, options={},
readonly=True, description="测试库", schema_cache={},
)
# --------------------------------------------------------------------------
# prepare_args
# --------------------------------------------------------------------------
def test_unique_candidate_is_corrected() -> None:
"""多出来一个 query、少了一个必填的 sql —— 候选唯一,改名就是了。"""
payload, note = prepare_args(_QueryLike, {"query": "SELECT 1"})
assert payload["sql"] == "SELECT 1"
assert note and "query" in note and "sql" in note
def test_correct_args_pass_through_without_a_note() -> None:
"""本来就对的不该被说成"纠正过",否则轨迹里全是没用的黄条。"""
payload, note = prepare_args(_QueryLike, {"sql": "SELECT 1", "limit": 10})
assert (payload["sql"], payload["limit"]) == ("SELECT 1", 10)
assert note is None
def test_ambiguous_names_are_not_guessed() -> None:
"""两个都不认识时改名就是碰运气,不如把参数表摊开。"""
with pytest.raises(ToolArgsError) as e:
prepare_args(_QueryLike, {"foo": "SELECT 1", "bar": 3})
text = str(e.value)
assert "sql" in text and "limit" in text
assert "foo" in text and "bar" in text
def test_typo_on_all_optional_schema_is_not_silently_dropped() -> None:
"""全可选的 schema 上,pydantic 默认会把认不出的键悄悄丢掉。
真丢了的话 {"tabel": "orders"} 会"成功"执行一次什么都没查的调用 ——
这种静默失败比报错难查得多。这里必须要么纠正、要么报错。
"""
payload, note = prepare_args(_AllOptional, {"tabel": "orders"})
assert payload["table"] == "orders"
assert note is not None
def test_wrong_type_names_the_field_and_the_expected_type() -> None:
"""名字对但类型不对,不是"写错名字",别顺手改名把错误盖掉。"""
with pytest.raises(ToolArgsError) as e:
prepare_args(_QueryLike, {"sql": "SELECT 1", "limit": "很多"})
assert "limit 的值不合法,应为整数" in str(e.value)
def test_missing_required_says_what_is_required() -> None:
with pytest.raises(ToolArgsError) as e:
prepare_args(_QueryLike, {})
assert "缺少必填参数 sql" in str(e.value)
def test_every_problem_is_reported_at_once() -> None:
"""改对了名字再回来撞一次类型错误,等于让人分两趟改。"""
with pytest.raises(ToolArgsError) as e:
prepare_args(_QueryLike, {"query": "SELECT 1", "limit": "很多"})
text = str(e.value)
assert "query 不是它的参数" in text
assert "limit 的值不合法" in text
def test_the_message_is_a_whole_sentence() -> None:
"""调用方会把它直接拼在工具名后面("工具 X:…"),不能缺主语。"""
for args in ({}, {"foo": 1, "bar": 2}, {"sql": "SELECT 1", "limit": "很多"}):
with pytest.raises(ToolArgsError) as e:
prepare_args(_QueryLike, args)
assert str(e.value).startswith("参数不对:")
# --------------------------------------------------------------------------
# describe_args —— 这段话是模型下一步改对的唯一依据
# --------------------------------------------------------------------------
def test_description_carries_the_field_help_text() -> None:
text = describe_args(_QueryLike, {"query": "SELECT 1"})
assert "要执行的 SQL" in text # Field(description=...) 要搬进来
assert "必填" in text and "可选" in text
assert "字符串" in text and "整数" in text # 可选字段是 anyOf[int, null],要摊开
assert "query" in text # 说清楚"你传的是什么"
# --------------------------------------------------------------------------
# 真实的数据源工具:复现开头那条报错
# --------------------------------------------------------------------------
async def test_the_original_query_typo_now_runs(source) -> None:
tool = _make_query_tool(source, ToolContext())
schema = args_model_of(tool)
assert schema is not None
payload, note = prepare_args(schema, {"query": "SELECT COUNT(*) AS c FROM orders"})
assert note is not None
result = await tool.coroutine(**payload)
assert '"c"' in result or "c" in result
async def test_raw_expansion_is_what_used_to_explode(source) -> None:
"""记录一下被修掉的是什么:绕开校验直接展开,抛的就是那句天书。"""
tool = _make_query_tool(source, ToolContext())
with pytest.raises(TypeError) as e:
await tool.coroutine(query="SELECT 1")
assert "unexpected keyword argument" in str(e.value)
async def test_schema_tool_args_are_validated_too(source) -> None:
tool = _make_schema_tool(source)
schema = args_model_of(tool)
payload, note = prepare_args(schema, {"tabel": "orders"})
assert payload["table"] == "orders" and note is not None
async def test_call_tool_end_to_end_on_the_path_that_broke(source) -> None:
"""走 call_tool 这个真实入口(工具节点和设置页的"试一下"都走它)。
以前这条路径对动态工具是裸 `**args` 展开的,报错止步于 TypeError;
现在参数名能被纠正、纠正这件事能通过 on_fix 说出去。
"""
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.tools.registry import call_tool
async with SessionLocal() as session:
session.add(DataSource(
name="shop", kind="sqlite", database=source.database,
readonly=True, description="测试库", options={}, schema_cache={}, enabled=True,
))
await session.commit()
notes: list[str] = []
async with SessionLocal() as session:
out = await call_tool(
"db_query__shop", {"query": "SELECT COUNT(*) AS c FROM orders"},
ToolContext(), session=session, on_fix=notes.append,
)
assert "5" in str(out)
# 跑通了,但配置里那个错的参数名还在,必须留下痕迹而不是安静地把事办了
assert len(notes) == 1 and "sql" in notes[0]
async with SessionLocal() as session:
with pytest.raises(ToolArgsError) as e:
await call_tool("db_query__shop", {"foo": 1, "bar": 2}, ToolContext(), session=session)
assert "sql" in str(e.value)
assert "unexpected keyword argument" not in str(e.value)
def test_tools_without_a_pydantic_schema_are_let_through() -> None:
"""MCP 这类工具的 args_schema 可能是一份 JSON Schema dict,校验不了就放行。"""
assert args_model_of(SimpleNamespace(args_schema={"type": "object"})) is None
assert args_model_of(SimpleNamespace(args_schema=None)) is None
# --------------------------------------------------------------------------
# agent 一轮只跑一个工具
# --------------------------------------------------------------------------
def _calls() -> list[dict]:
return [
{"name": "db_schema__shop", "args": {}, "id": "c1"},
{"name": "db_query__shop", "args": {"sql": "SELECT 1"}, "id": "c2"},
{"name": "db_query__shop", "args": {"sql": "SELECT 2"}, "id": "c3"},
]
def test_serial_runs_only_the_first() -> None:
run_calls, deferred = split_tool_calls(_calls(), parallel=False)
assert [c["id"] for c in run_calls] == ["c1"]
assert [c["id"] for c, _ in deferred] == ["c2", "c3"]
def test_every_deferred_call_still_gets_a_result() -> None:
"""两家 API 都要求每个 tool_use 有一条配对的 tool_result,少一条下次调用就是 400。"""
calls = _calls()
run_calls, deferred = split_tool_calls(calls, parallel=False)
answered = {c["id"] for c in run_calls} | {m.tool_call_id for _, m in deferred}
assert answered == {c["id"] for c in calls}
assert all(isinstance(m, ToolMessage) and m.content for _, m in deferred)
def test_parallel_keeps_every_call() -> None:
run_calls, deferred = split_tool_calls(_calls(), parallel=True)
assert len(run_calls) == 3 and deferred == []
def test_single_call_is_never_deferred() -> None:
run_calls, deferred = split_tool_calls(_calls()[:1], parallel=False)
assert len(run_calls) == 1 and deferred == []
# --------------------------------------------------------------------------
# 串行也要告诉模型,省得它白发一堆调用
# --------------------------------------------------------------------------
def _one_tool():
from app.tools.registry import all_specs, build_tool
return [build_tool(next(iter(all_specs().values())), ToolContext())]
def test_anthropic_gets_disable_parallel_tool_use() -> None:
"""ChatAnthropic 把它翻成 tool_choice,不是原样的 parallel_tool_calls。
type 必须是 auto:强制某个工具(any/tool)和 thinking 不能并存,
而 agent 节点默认是开着 thinking 的。
"""
from langchain_anthropic import ChatAnthropic
from app.providers.factory import bind_tools_safely
model = ChatAnthropic(model="claude-opus-5", api_key="x",
thinking={"type": "adaptive", "display": "summarized"})
choice = bind_tools_safely(model, _one_tool(), parallel=False).kwargs["tool_choice"]
assert choice == {"type": "auto", "disable_parallel_tool_use": True}
assert "tool_choice" not in bind_tools_safely(model, _one_tool(), parallel=True).kwargs
def test_openai_gets_parallel_tool_calls() -> None:
from langchain_openai import ChatOpenAI
from app.providers.factory import bind_tools_safely
model = ChatOpenAI(model="gpt-4o", api_key="x")
assert bind_tools_safely(model, _one_tool(), parallel=False).kwargs["parallel_tool_calls"] is False
assert "parallel_tool_calls" not in bind_tools_safely(model, _one_tool(), parallel=True).kwargs
def test_binding_survives_models_that_ignore_the_flag() -> None:
"""mock 和各种兼容网关不认这个参数也不能炸——串行本来就有编排层兜底。"""
from app.providers.factory import bind_tools_safely
from app.providers.mock_model import MockChatModel
model = MockChatModel(model_name="mock-fast")
assert bind_tools_safely(model, _one_tool(), parallel=False) is not None
# 没有工具时原样返回,不该凭空包一层
assert bind_tools_safely(model, [], parallel=False) is model
# --------------------------------------------------------------------------
# 步数用完时的收尾
# --------------------------------------------------------------------------
def test_the_skip_note_never_becomes_the_answer() -> None:
"""真出过事:run dd9927e6 交给用户的"答案"是一句内部管道文案。
本轮只执行了第一个工具。看到它的结果之后,再决定这一个还要不要调…
步数用完时,收尾取的是 messages[-1]——而串行模式下那很可能是一条给模型看的
ToolMessage。用户看到的不是"没跑完",是一句冒充结论的管道说明。
"""
from langchain_core.messages import AIMessage, HumanMessage
from app.engine.nodes.llm import SKIPPED_NOTE
messages = [
HumanMessage(content="Shop 有几个管理员?"),
AIMessage(content="我先看看表结构。"),
ToolMessage(content="表 user …", tool_call_id="c1"),
ToolMessage(content=SKIPPED_NOTE, tool_call_id="c2"),
]
# 收尾该取的是模型自己说过的最后一句,不是消息数组的最后一条
picked = next(
(t for m in reversed(messages)
if isinstance(m, AIMessage) and (t := m.content.strip())),
"",
)
assert picked == "我先看看表结构。"
assert SKIPPED_NOTE not in picked
# --------------------------------------------------------------------------
# 查表结构可以一次问好几张
# --------------------------------------------------------------------------
async def test_schema_tool_takes_several_tables_at_once(source) -> None:
"""一次调用就是一步。逐张问 6 张表就是 6 步,一次 8 步的预算直接见底。"""
from app.data import introspect
source.schema_cache = await introspect.introspect(source)
tool = _make_schema_tool(source)
one = await tool.coroutine(table="orders")
assert "orders" in one
many = await tool.coroutine(table=["orders", "orders"])
assert many.count("表 orders") == 2 # 两张都描述了,不是只认第一张
async def test_schema_tool_accepts_a_comma_separated_string(source) -> None:
"""模型两种写法都会用。为此拒掉一次调用,白白浪费一步。"""
from app.data import introspect
source.schema_cache = await introspect.introspect(source)
out = await _make_schema_tool(source).coroutine(table="orders, orders")
assert out.count("表 orders") == 2
async def test_schema_tool_still_lists_everything_when_asked_for_nothing(source) -> None:
from app.data import introspect
source.schema_cache = await introspect.introspect(source)
out = await _make_schema_tool(source).coroutine()
assert "张表" in out and "orders" in out
def test_copilot_is_told_not_to_pin_max_steps() -> None:
"""Copilot 把 max_steps: 8 写进了生成的图,后端默认值根本轮不上。"""
from app.api.copilot import NODE_REFERENCE
assert "不要写 max_steps" in NODE_REFERENCE