mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:01:59 +00:00
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 并注明理由
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
|