Files
AgentLab/backend/tests/test_tool_args.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

342 lines
14 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
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