mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
c8393c3c6b
这个仓库是公开的,而历史里带着一份客户 BI 仓的逐字转储:ANALYTICS(原 schema 名)下 53 张视图名、实测行数、字段结构表,外加一句写明取数方是谁 的注脚。还有一处把真人姓名和工作单位写进了检查脚本的断言里。这些都不是 化名,是能直接拿去对库的东西。 按"含敏感词的测试直接删"处理,共 21 个用例: test_agent_tool_loop.py 整个文件(2 个,agent 工具循环的真图验收) test_conversations.py 9 个(会话 CRUD、历史文本、上下文进 prompt) test_datasource.py 5 个(探查失败的实情、候选 schema 提示) test_copilot_context.py 3 个(数据源摘要的限定名) test_review.py 1 个(干净运行无信号) test_tool_args.py 1 个(call_tool 端到端) 辅助函数和产品源码不在"测试"之列,改为脱敏——否则删一个 _seeded 会连带 带走 8 个没被点名的用例。标识符一律换成中性名:shop→shop、 ANALYTICS→ANALYTICS、prod_shop→prod_shop、真人姓名→张三。 markdown 样本里那两条不是改标识符能解决的:行数、字段结构、业务域描述 整段都是客户的。换成同构的合成内容——领域换了,markdown 构造逐项对齐 (标题层级、有序/无序列表、三列表、带 inline code 的单元格、引用块), 渲染器的覆盖面没有变窄。check-stream 里那句"用的是真实输出"跟着改成实话。 286 → 265 passed,差值正好是删掉的 21 个。tsc 干净,decode / stream / ui 三个 check 不回归。 历史里的同一批内容另行用 git filter-repo 处理。
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""工具参数的校验、纠错,以及 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
|