工具调用失败能自救;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 步花在这上面,最后只剩一步真查数据
This commit is contained in:
ThearchyHelios
2026-09-18 17:57:20 +08:00
parent fa6a8a2ffe
commit c02ef189bb
12 changed files with 1060 additions and 36 deletions
+3 -1
View File
@@ -111,7 +111,9 @@ NODE_REFERENCE = """\
- input:入口。config.fields = [{name, required, default, description}]
- output:出口,收集最终成果。config.fields = [{name, value}],value 里写模板引用
- llm:单次模型调用。config: {system, prompt, model, temperature, max_tokens, assign_to, output_schema}
- agent:带工具循环的 agent。config: {system, prompt, tools:[工具名], max_steps, approval, assign_to}
- agent:带工具循环的 agent。config: {system, prompt, tools:[工具名], approval, assign_to}
**不要写 max_steps**。平台默认值是按「一步只调一个工具」校准过的;写死一个
小数字(见过 8)会让它查完表结构就没额度回答了,用户拿到的是半截结论
- supervisor:多 agent 协作。config: {goal, agents:[{name, description, system, tools, model}], max_rounds}
- tool:直接调一个工具。config: {tool: 工具名, args: {...}, assign_to}
- code:沙箱里跑代码。config: {language: python|bash|node, code, timeout, network, assign_to}
+96 -10
View File
@@ -17,8 +17,21 @@ from app.db.models import Skill
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context, thinking_text
from app.providers import catalog
from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model
from app.tools.registry import ToolContext, build_tools, get_spec
from app.providers.factory import (
ModelSpec,
ProviderNotConfigured,
bind_tools_safely,
get_chat_model,
)
from app.tools.registry import (
ToolArgsError,
ToolContext,
args_model_of,
build_tools,
describe_args,
get_spec,
prepare_args,
)
# --------------------------------------------------------------------------
# 公共部分
@@ -270,6 +283,33 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
return await build_tools(names, tool_ctx, session=session)
SKIPPED_NOTE = (
"本轮只执行了第一个工具。看到它的结果之后,再决定这一个还要不要调、要用什么参数调。"
)
def split_tool_calls(
tool_calls: list[dict[str, Any]], *, parallel: bool
) -> tuple[list[dict[str, Any]], list[tuple[dict[str, Any], ToolMessage]]]:
"""把一轮里的工具调用分成"这次跑"和"这次不跑"。
串行时只跑第一个。剩下的不能就这么丢掉:两家 API 都要求每个 tool_use 在
下一轮有一条配对的 tool_result,少一条下次调用直接 400。所以这里给每个
未执行的调用配一条说明性的 ToolMessage 一起交出去。
它们也**绝不能**发 tool.start —— 前端靠 call_id 配对 start/end
(frontend/src/run/decode.ts),发了一条没有结尾的 start,那个工具卡片就会
在界面上永远转圈。所以这个函数不碰事件,只产消息。
"""
if parallel or len(tool_calls) <= 1:
return list(tool_calls), []
deferred = [
(call, ToolMessage(content=SKIPPED_NOTE, tool_call_id=call.get("id") or f"skipped-{i}"))
for i, call in enumerate(tool_calls[1:])
]
return tool_calls[:1], deferred
def _needs_approval(ctx: NodeContext, tool_name: str) -> bool:
mode = ctx.cfg("approval", "dangerous") # never | dangerous | always
if mode == "always":
@@ -295,10 +335,13 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
tools = await _resolve_tools(ctx, state)
tool_map = {t.name: t for t in tools}
model = base_model.bind_tools(tools) if tools else base_model
# 默认串行:一轮只调一个工具,拿到结果再想下一步。并行更快,但一批工具
# 中间没有任何新的思考——第二个调用是照着"还没看到第一个结果"时的判断发出来的
parallel_tools = bool(ctx.cfg("parallel_tools", False))
model = bind_tools_safely(base_model, tools, parallel=parallel_tools)
messages = await _build_messages(state, ctx)
max_steps = min(int(ctx.cfg("max_steps", 8) or 8), settings.max_agent_steps)
max_steps = min(int(ctx.cfg("max_steps", 12) or 12), settings.max_agent_steps)
total_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0}
transcript: list[dict[str, Any]] = []
@@ -328,7 +371,9 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
final_text = message_text(response)
break
for call in tool_calls:
run_calls, deferred = split_tool_calls(tool_calls, parallel=parallel_tools)
for call in run_calls:
name = call.get("name", "")
args = call.get("args", {}) or {}
call_id = call.get("id") or f"{ctx.node.id}-{step}-{name}"
@@ -339,6 +384,24 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
)
continue
# 参数先过 schema 再决定要不要打扰人审批:参数就不对的调用没必要问人,
# 而纠正过的参数必须是人在审批框里看到的那一份
schema = args_model_of(tool_map[name])
if schema is not None:
try:
args, fix_note = prepare_args(schema, args)
except ToolArgsError as e:
# 不执行,把"它接受什么"原样喂回去。模型下一步照着改就行,
# 这才是失败之后真正能自愈的重试——以前喂回去的是一句
# TypeError,里面既没有正确参数名也没有字段说明
ctx.emit(EventType.TOOL_ERROR, tool=name, call_id=call_id, error=str(e))
messages.append(ToolMessage(content=str(e), tool_call_id=call_id))
transcript.append({"tool": name, "args": args, "ok": False, "result": str(e)})
continue
if fix_note:
ctx.emit(EventType.LOG, level="warn",
message=f"工具 {name}:{fix_note}")
if _needs_approval(ctx, name):
ctx.emit(
EventType.HUMAN_REQUESTED,
@@ -377,6 +440,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
ok = True
except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错
content = f"工具执行失败:{type(e).__name__}: {e}"
if isinstance(e, TypeError) and schema is not None:
# 签名对不上还能走到这儿,说明 schema 和函数本身不一致(工具的 bug)。
# 模型改不了这个,但把参数表摊开至少让它别在同一个地方反复试
content += f"\n{describe_args(schema, args)}"
ok = False
elapsed = int((time.perf_counter() - started) * 1000)
snapshot_id: str | None = None
@@ -402,13 +469,32 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
{"tool": name, "args": args, "ok": ok, "duration_ms": elapsed, "result": content[:4000]}
)
messages.append(ToolMessage(content=content, tool_call_id=call_id))
# 没跑的那些排在执行过的后面,保持模型原本的调用顺序
for call, message in deferred:
messages.append(message)
transcript.append(
{"tool": call.get("name", ""), "args": call.get("args", {}) or {}, "skipped": True}
)
else:
final_text = message_text(messages[-1]) if messages else ""
ctx.emit(
EventType.LOG,
level="warn",
message=f"agent 达到最大步数 {max_steps},提前收尾",
# 步数用完了。成果只能取模型自己说过的话——messages[-1] 很可能是一条
# ToolMessage:工具的原始返回,或者串行模式下那句"本轮只执行了第一个
# 工具"。后者真的漏到用户面前当过答案(run dd9927e6),一句内部管道
# 文案冒充结论,比明说"没跑完"糟糕得多。
final_text = next(
(text for m in reversed(messages)
if isinstance(m, AIMessage) and (text := message_text(m).strip())),
"",
)
hint = (
f"agent 用满了 {max_steps} 步还没给出结论。"
"把节点上的「最大步数」调大;如果它大部分步数花在逐张表查结构上,"
"也可以在提示词里点明该查哪几张表。"
)
# 一步一工具时步数消耗得比并行快得多,这里不说清楚,用户只会看到
# 一个没头没尾的答案,而不知道是被步数掐断的
final_text = f"{final_text}\n\n({hint})".strip() if final_text else f"({hint})"
ctx.emit(EventType.LOG, level="warn", message=hint)
total_usage["total_tokens"] = total_usage["input_tokens"] + total_usage["output_tokens"]
result = {
+32 -9
View File
@@ -14,8 +14,14 @@ from app.db.models import Workflow
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context
from app.providers import catalog
from app.providers.factory import ModelSpec, get_chat_model
from app.tools.registry import ToolContext, build_tools
from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
from app.tools.registry import (
ToolArgsError,
ToolContext,
args_model_of,
build_tools,
prepare_args,
)
_MAX_DEPTH = 3
@@ -122,7 +128,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
)
tools = await build_tools(cfg.get("tools", []) or [], tool_ctx, session=session)
bound = model.bind_tools(tools) if tools else model
bound = bind_tools_safely(model, tools, parallel=False)
tool_map = {t.name: t for t in tools}
messages: list[Any] = [
SystemMessage(content=cfg.get("system") or f"你是 {name}。"),
@@ -140,15 +146,32 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
tname = call.get("name", "")
targs = call.get("args", {}) or {}
cid = call.get("id") or f"{name}-{tname}"
ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid)
if tname not in tool_map:
ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid)
content = f"错误:没有名为 {tname} 的工具"
else:
try:
raw = await tool_map[tname].ainvoke(targs)
content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str)
except Exception as e: # noqa: BLE001
content = f"工具失败:{type(e).__name__}: {e}"
schema = args_model_of(tool_map[tname])
fix_note = None
args_error = None
if schema is not None:
try:
targs, fix_note = prepare_args(schema, targs)
except ToolArgsError as e:
args_error = str(e)
# 纠正后的参数才是真正要执行的那份,tool.start 要发它
ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid)
if fix_note:
ctx.emit(EventType.LOG, level="warn",
message=f"工具 {tname}:{fix_note}")
if args_error:
# 参数就不对,没必要真调一次。把"它接受什么"喂回去让它改
content = args_error
else:
try:
raw = await tool_map[tname].ainvoke(targs)
content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str)
except Exception as e: # noqa: BLE001
content = f"工具失败:{type(e).__name__}: {e}"
ctx.emit(EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500])
calls.append({"tool": tname, "args": targs, "result": content[:2000]})
messages.append(ToolMessage(content=content, tool_call_id=cid))
+16 -2
View File
@@ -12,7 +12,7 @@ from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState
from app.sandbox.base import SandboxLimits
from app.sandbox.manager import sandbox_manager
from app.tools.registry import ToolContext, call_tool, get_spec
from app.tools.registry import ToolArgsError, ToolContext, call_tool, get_spec
def _tool_ctx(ctx: NodeContext) -> ToolContext:
@@ -55,11 +55,25 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
ctx.emit(EventType.TOOL_START, tool=name, args=args)
started = time.perf_counter()
def _fixed(note: str) -> None:
# 替它跑通了这一次,但节点配置里那个错的参数名原封不动,下次还会踩。
# 所以纠正必须留一条看得见的痕迹,而不是安静地把事办了
ctx.emit(EventType.LOG, level="warn",
message=f"工具 {name}:{note}。请到节点里改正。")
try:
async with SessionLocal() as session:
result = await call_tool(name, args, _tool_ctx(ctx), session=session)
result = await call_tool(name, args, _tool_ctx(ctx), session=session, on_fix=_fixed)
except KeyError as e:
raise NodeError(ctx.node.id, str(e)) from e
except ToolArgsError as e:
# 参数对不上且没有唯一候选可纠。报错里已经写清楚该填什么,
# 不要再套一层 "执行失败:" 把它推远
ctx.emit(EventType.TOOL_ERROR, tool=name, error=str(e))
if ctx.cfg("fail_fast", True):
raise NodeError(ctx.node.id, f"工具 {name}:{e}") from e
result = {"error": str(e)}
except Exception as e: # noqa: BLE001
ctx.emit(EventType.TOOL_ERROR, tool=name, error=f"{type(e).__name__}: {e}")
if ctx.cfg("fail_fast", True):
+19
View File
@@ -167,6 +167,25 @@ _ADAPTIVE_THINKING_PREFIXES = _NO_SAMPLING_PREFIXES
# 支持 output_config.effort
_EFFORT_PREFIXES = _NO_SAMPLING_PREFIXES + ("claude-opus-4-5",)
# 交错思考(工具结果回来之后再想一段,而不是一轮开头想完就只剩执行)。
#
# 4.6 及以后走 adaptive thinking,交错是自带的,不需要也不该再挂 beta header
# ——在 Opus 4.6 上手动模式压根没有交错思考,只有 adaptive 有。上面那张
# _ADAPTIVE_THINKING_PREFIXES 覆盖的都属于这一档。
#
# 需要显式开的是 Claude 4 / 4.5 这一代。目录里没有列它们,但模型名是放行的
# (用户可以手填),所以这条路径要留着。Haiku 4.5 不支持,前缀也就不包含它。
_INTERLEAVED_BETA_PREFIXES = ("claude-opus-4", "claude-sonnet-4")
INTERLEAVED_BETA = "interleaved-thinking-2025-05-14"
def needs_interleaved_beta(model: str) -> bool:
"""这个模型要不要靠 beta header 才会在工具之间思考。"""
name = _norm(model)
if supports_adaptive_thinking(name):
return False # adaptive 自带,多挂一个 header 只会是噪音
return name.startswith(_INTERLEAVED_BETA_PREFIXES)
def _norm(model: str) -> str:
"""去掉 openrouter 之类的前缀,只看模型名本身。"""
+57 -1
View File
@@ -138,17 +138,49 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel:
# 会出现"思考完就没额度写答案",返回一条空正文。
kwargs.setdefault("max_tokens", 8192)
# 交错思考 —— 工具结果回来之后模型还能再想一段,而不是一轮开头想完
# 就只剩埋头执行。串行跑工具时这一条最要紧:每一步之间本来就该重新
# 判断一次"看到这个结果之后下一步该干嘛"。
#
# 两代模型的开法完全不同,这也是为什么这里要分叉:
# 4.6 起 adaptive thinking **自带**交错,不需要 beta header。
# (Opus 4.6 上手动模式压根没有交错,只有 adaptive 有,
# 所以这一档绝不能退回手动。)
# 4 / 4.5 手动开 thinking + 挂 interleaved-thinking beta header。
# 目录里没列这一代,但模型名是放行的,用户可以手填。
model_kwargs: dict[str, Any] = {}
thinking_on = False
mode = spec.thinking or "summarized"
if catalog.supports_adaptive_thinking(model):
mode = spec.thinking or "summarized"
if mode in ("adaptive", "summarized"):
# summarized 会把思考摘要一起返回,画布上就能展示"它在想什么"
kwargs["thinking"] = {
"type": "adaptive",
"display": "summarized" if mode == "summarized" else "omitted",
}
thinking_on = True
elif mode == "off":
kwargs["thinking"] = {"type": "disabled"}
elif mode != "off" and catalog.needs_interleaved_beta(model):
# 光挂 header 是不够的:这一代默认根本没开 thinking,
# 没有思考可交错,header 就是一条什么都不做的空设置
kwargs["thinking"] = {
"type": "enabled",
"budget_tokens": max(1024, int(kwargs["max_tokens"]) // 2),
}
thinking_on = True
# betas 会把调用改道 client.beta.messages。自建网关可能不认它,
# 而那是请求时才炸的,所以留一个关得掉的口子
if extra.get("interleaved_thinking") is not False:
kwargs["betas"] = [catalog.INTERLEAVED_BETA]
# 开了 thinking 就不能再传 temperature(要求是 1)。4.6 起的模型压根
# 不收采样参数,上面 supports_sampling 已经挡住了;漏的是 4/4.5 这一代
# ——它们本来收 temperature,一旦开了 thinking 再传就是 400
if thinking_on:
kwargs.pop("temperature", None)
if spec.effort and catalog.supports_effort(model):
model_kwargs["output_config"] = {"effort": spec.effort}
if model_kwargs:
@@ -228,6 +260,30 @@ class _LazyReasoningChatOpenAI:
_ReasoningAwareChatOpenAI = _LazyReasoningChatOpenAI()
def bind_tools_safely(model: BaseChatModel, tools: list[Any], *, parallel: bool) -> Any:
"""绑定工具,并在需要时让模型一次只发一个工具调用。
`parallel_tool_calls=False` 两家都认,只是落点不同:ChatAnthropic 把它翻成
`tool_choice={"type": "auto", "disable_parallel_tool_use": True}`(type 是 auto
而不是强制某个工具,所以和 thinking 并存没问题),ChatOpenAI 原样透传。
但 provider 可以是任意 OpenAI 兼容网关(Ollama、vLLM、OpenRouter…),个别
网关可能在**请求时**才拒绝这个字段——那种情况这里的 try 拦不住。真撞上了,
把节点上的"并行执行工具"打开即可,绑定就退回原样。
无论如何这都只是省 token 的优化:模型仍然多发了几个调用时,编排层一轮只执行
第一个,串行语义不依赖这里能不能传成功。
"""
if not tools:
return model
if parallel:
return model.bind_tools(tools)
try:
return model.bind_tools(tools, parallel_tool_calls=False)
except (TypeError, ValueError, NotImplementedError):
return model.bind_tools(tools)
async def get_chat_model(session: AsyncSession, spec: ModelSpec) -> tuple[BaseChatModel, str]:
"""返回 (模型, 实际使用的模型 id)。找不到任何 provider 时回退到 mock,
保证一张图在没配 key 的机器上也能跑完。"""
+15 -5
View File
@@ -35,7 +35,12 @@ class _QueryArgs(BaseModel):
class _SchemaArgs(BaseModel):
table: str | None = Field(default=None, description="表名;留空则列出所有表")
# 一次能问好几张。串行跑工具时一次调用就是一步,逐张问 6 张表就是 6 步——
# 实测一次 8 步的运行里 7 步花在这上面,最后只剩一步来真查数据(run dd9927e6)
table: str | list[str] | None = Field(
default=None,
description='表名。可以一次给多张:"user" 或 ["user","roles"];留空则列出所有表',
)
def tool_names(source: DataSource) -> list[str]:
@@ -125,9 +130,13 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
def _make_schema_tool(source: DataSource) -> StructuredTool:
async def _run(table: str | None = None) -> str:
if table:
return introspect.describe_table(source, table)
async def _run(table: str | list[str] | None = None) -> str:
wanted = [table] if isinstance(table, str) else list(table or [])
# 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
if wanted:
return "\n\n".join(introspect.describe_table(source, t) for t in wanted)
tables = introspect.table_names(source)
if not tables:
return (
@@ -142,7 +151,8 @@ def _make_schema_tool(source: DataSource) -> StructuredTool:
name=f"{SCHEMA_PREFIX}{source.name}",
description=(
f"查看数据源「{source.name}」的表结构。"
"传 table 看某张表的字段;不传则列出所有表。写 SQL 前先用它确认字段名。"
"写 SQL 前先用它确认字段名。不传 table 则列出所有表;"
"**要看多张表就一次全传进来**(table 可以是数组),不要一张一张问。"
),
args_schema=_SchemaArgs,
coroutine=_run,
+161 -7
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
ToolFunc = Callable[..., Awaitable[Any]]
@@ -151,12 +151,157 @@ async def build_tools(
return tools
async def call_tool(name: str, args: dict[str, Any], ctx: ToolContext, session: Any = None) -> Any:
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。"""
class ToolArgsError(ValueError):
"""工具参数对不上 schema。
message 是直接给人和模型看的:报错里必须说清楚"收到了什么"和"它接受什么",
因为看到这句话的下一步动作就是照着改参数名。不要再往外包一层。
"""
_TYPE_LABELS = {
"string": "字符串", "integer": "整数", "number": "数字",
"boolean": "布尔", "array": "数组", "object": "对象",
}
def _type_label(prop: dict[str, Any]) -> str:
"""JSON Schema 的类型写成人话。可选字段是 anyOf[T, null],要摊开看。"""
names: list[str] = []
for node in [prop, *(prop.get("anyOf") or [])]:
kind = node.get("type")
if isinstance(kind, str) and kind != "null":
names.append(_TYPE_LABELS.get(kind, kind))
return "/".join(dict.fromkeys(names)) or "任意"
def args_model_of(tool: BaseTool) -> type[BaseModel] | None:
"""工具的参数模型。
MCP 这类动态工具的 args_schema 可能直接是一份 JSON Schema dict,拿不到
pydantic 模型——那种只能原样放行,不能因为校验不了就拒绝调用。
"""
schema = getattr(tool, "args_schema", None)
if isinstance(schema, type) and issubclass(schema, BaseModel):
return schema
return None
def describe_args(
schema: type[BaseModel],
got: dict[str, Any] | None = None,
error: ValidationError | None = None,
) -> str:
"""把"哪里不对、它到底要什么"写成一句能照着改的话。
字段说明取 Field(description=...),各工具定义里本来就写了,这里只是搬到报错里
——模型拿到这句话就能在下一步自己改对,不用人工介入。
返回的永远是一句完整的话("参数不对:…。它接受:…"),调用方直接拼在
工具名后面即可,不用猜这次会不会缺个主语。
"""
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
fields: list[str] = []
for key, prop in props.items():
tag = "必填" if key in required else "可选"
desc = str(prop.get("description") or "").strip()
fields.append(f"{key}({tag},{_type_label(prop)})" + (f" — {desc}" if desc else ""))
tail = "它接受:" + ";".join(fields) if fields else "它不接受任何参数。"
got = dict(got or {})
unknown = [k for k in got if k not in props]
missing = [k for k in required if k not in got]
problems: list[str] = []
if unknown:
problems.append(f"{'、'.join(unknown)} 不是它的参数")
if missing:
problems.append(f"缺少必填参数 {'、'.join(missing)}")
for err in error.errors() if error else ():
loc = ".".join(str(p) for p in (err.get("loc") or ()))
if not loc or loc in unknown or loc in missing:
continue # 上面两条已经说过了,不重复
# 用 schema 里的类型说,不要把 pydantic 那句英文原样甩出去
problems.append(
f"{loc} 的值不合法,应为{_type_label(props[loc])}"
if loc in props
else f"{loc} 的值不合法({err.get('msg', '')})"
)
head = "参数不对:" + ";".join(problems) + "。" if problems else "参数不对。"
return f"{head}{tail}"
def prepare_args(
schema: type[BaseModel], args: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""校验工具参数,必要时纠正一个明显写错的参数名。
返回 (可直接展开给函数的 payload, 纠正说明或 None)。纠正说明不为空时调用方
**必须**把它说出去——参数还错在配置里/还错在模型脑子里,替它跑通一次却不吭声,
下一次照样踩。
多余的参数名在这里是硬错误,哪怕校验能过:pydantic 默认把认不出的键悄悄丢掉,
于是全可选字段的 schema 上 {"tabel": "orders"} 会"成功"执行一次什么都没查的调用。
这种静默失败比报错难查得多。
纠正只在候选唯一时发生。多于一个就有排列组合,猜对了也是运气,不如把 schema
摊开让人或模型自己改。
"""
args = dict(args or {})
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
unknown = [k for k in args if k not in props]
if not unknown:
try:
return schema(**args).model_dump(), None
except ValidationError as e:
raise ToolArgsError(describe_args(schema, args, e)) from e
# 少了哪个必填的,就是多出来那个想写的;一个必填都不缺时,退而看还空着哪个可选字段
missing = [k for k in required if k not in args] or [k for k in props if k not in args]
residual: ValidationError | None = None
if len(unknown) == 1 and len(missing) == 1:
fixed = dict(args)
fixed[missing[0]] = fixed.pop(unknown[0])
try:
payload = schema(**fixed).model_dump()
except ValidationError as e:
# 改完名还是不合法。把这些毛病一并说出来,省得对方改对了名字
# 再回来撞一次类型——describe_args 会跳过已经报过的那几个字段
residual = e
else:
return payload, f"参数名 {unknown[0]} 不存在,已按唯一候选 {missing[0]} 执行"
raise ToolArgsError(describe_args(schema, args, residual))
async def call_tool(
name: str,
args: dict[str, Any],
ctx: ToolContext,
session: Any = None,
*,
on_fix: Callable[[str], None] | None = None,
) -> Any:
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。
两条分支都先过 args_schema。动态工具那条以前是裸 `**args` 展开的,参数名写错
时抛的是 TypeError,报错里只剩内部闭包名——`_make_query_tool.<locals>._run() got
an unexpected keyword argument 'query'` 既指不到是哪个工具,也说不出该填什么。
on_fix 在参数名被纠正时回调一次。call_tool 不该知道事件系统长什么样,
所以只把这件事交出去,由调用方决定说给谁听。
"""
spec = get_spec(name)
if spec:
validated = spec.args_schema(**args)
payload = validated.model_dump()
payload, note = prepare_args(spec.args_schema, args)
if note and on_fix:
on_fix(note)
if spec.needs_context:
return await spec.func(ctx, **payload)
return await spec.func(**payload)
@@ -165,9 +310,18 @@ async def call_tool(name: str, args: dict[str, Any], ctx: ToolContext, session:
if not tools:
raise KeyError(f"找不到工具 {name!r}")
tool = tools[0]
model = args_model_of(tool)
if model is None:
payload = dict(args)
else:
payload, note = prepare_args(model, args)
if note and on_fix:
on_fix(note)
if tool.coroutine:
return await tool.coroutine(**args)
result = tool.invoke(args)
return await tool.coroutine(**payload)
result = tool.invoke(payload)
if inspect.isawaitable(result):
return await result
return result
+180
View File
@@ -0,0 +1,180 @@
"""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"]
+100
View File
@@ -52,3 +52,103 @@ def test_plain_message_has_no_thinking() -> None:
def test_empty_reasoning_is_not_reported() -> None:
"""空串不算思考,否则界面上会出现一行什么都没有的"思考"。"""
assert thinking_text(AIMessageChunk(content="", additional_kwargs={"reasoning_content": ""})) == ""
# --------------------------------------------------------------------------
# 交错思考:工具结果回来之后还能再想一段
# --------------------------------------------------------------------------
#
# 串行跑工具时这一条最要紧——每一步之间本来就该重新判断一次"看到这个结果
# 之后下一步该干嘛"。没有它,模型只在一轮开头想一次,后面全是埋头执行。
def _anthropic(model: str, **spec_kw):
import os
from app.db.models import Provider
from app.providers.factory import ModelSpec, build_chat_model
os.environ.setdefault("ANTHROPIC_API_KEY", "test-only")
provider = Provider(name="t", kind="anthropic", api_key=None, models=[],
extra=spec_kw.pop("extra", {}) or {})
return build_chat_model(provider, ModelSpec(model=model, **spec_kw))
def test_adaptive_models_need_no_beta_header() -> None:
"""4.6 起 adaptive thinking 自带交错。
这里不是"忘了加"——在 Opus 4.6 上手动模式压根没有交错思考,只有 adaptive
有,所以多挂一个 header 不但没用,还会把调用改道 client.beta.messages。
"""
for model in ("claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-sonnet-4-6"):
chat = _anthropic(model)
assert chat.betas is None, model
assert chat.thinking == {"type": "adaptive", "display": "summarized"}, model
def test_the_previous_generation_gets_the_beta_header() -> None:
"""Claude 4 / 4.5 要显式开。目录里没列它们,但模型名是放行的(可以手填)。"""
for model in ("claude-opus-4-5", "claude-sonnet-4-5", "claude-opus-4"):
assert _anthropic(model).betas == ["interleaved-thinking-2025-05-14"], model
# openrouter 那种带前缀的写法也要认得出来
assert _anthropic("openrouter/anthropic/claude-sonnet-4-5").betas == [
"interleaved-thinking-2025-05-14"
]
def test_the_beta_header_comes_with_thinking_actually_enabled() -> None:
"""光挂 header 是一条什么都不做的空设置。
这一代默认根本没开 thinking——没有思考可交错,header 挂了也白挂。
第一版就是这么写的:betas 有了,thinking 是 None。
"""
chat = _anthropic("claude-sonnet-4-5")
assert chat.thinking == {"type": "enabled", "budget_tokens": 4096}
assert chat.betas == ["interleaved-thinking-2025-05-14"]
def test_thinking_and_temperature_never_go_out_together() -> None:
"""开了 thinking 再传 temperature 就是 400。
4.6 起的模型压根不收采样参数,supports_sampling 已经挡住了;漏的正是
4/4.5 这一代——它们本来是收 temperature 的。
"""
assert _anthropic("claude-sonnet-4-5", temperature=0.7).temperature is None
# 没开 thinking 的还照常收
assert _anthropic("claude-haiku-4-5", temperature=0.7).temperature == 0.7
assert _anthropic("claude-sonnet-4-5", thinking="off", temperature=0.7).temperature == 0.7
def test_models_without_the_feature_are_left_alone() -> None:
"""Haiku 4.5 不支持交错思考,给它挂 header 只是噪音。"""
assert _anthropic("claude-haiku-4-5").betas is None
def test_thinking_off_means_no_interleaving_either() -> None:
"""都不让它想了,就别再开"想得更碎"的开关。"""
assert _anthropic("claude-sonnet-4-5", thinking="off").betas is None
def test_a_gateway_that_rejects_the_beta_can_opt_out() -> None:
"""自建网关可能不认这个 beta,而它是在请求时才炸的——留一个关得掉的口子。
关掉的只是交错,思考本身要留着:想要"别想了"的人写的是 thinking=off。
"""
chat = _anthropic("claude-sonnet-4-5", extra={"interleaved_thinking": False})
assert chat.betas is None
assert chat.thinking == {"type": "enabled", "budget_tokens": 4096}
def test_serial_tool_choice_stays_compatible_with_interleaving() -> None:
"""交错思考要求 tool_choice 是 auto 或 none。
串行模式给的是 {"type": "auto", "disable_parallel_tool_use": true} —— auto
不是强制调用,两者并存没问题。这条盯的是以后有人把它改成 any/tool:
那样会同时踩坏交错思考和 thinking 本身。
"""
from app.providers.factory import bind_tools_safely
from app.tools.registry import ToolContext, all_specs, build_tool
tools = [build_tool(next(iter(all_specs().values())), ToolContext())]
bound = bind_tools_safely(_anthropic("claude-sonnet-4-5"), tools, parallel=False)
assert bound.kwargs["tool_choice"]["type"] in ("auto", "none")
+375
View File
@@ -0,0 +1,375 @@
"""工具参数的校验、纠错,以及 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
+6 -1
View File
@@ -143,10 +143,15 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
],
help: '需要确认时运行会暂停,等你在审批面板放行',
},
{
key: 'parallel_tools', label: '并行执行工具', type: 'switch', advanced: true,
help: '默认关闭:一轮只调一个工具,看到结果再想下一步。开启后一轮可发多个,更快,'
+ '但这一批中间没有新的思考',
},
...MODEL_FIELDS,
...COMMON_TAIL,
],
defaults: { max_steps: 8, approval: 'dangerous', tools: [] },
defaults: { max_steps: 12, approval: 'dangerous', tools: [], parallel_tools: false },
},
supervisor: {
type: 'supervisor', label: '多 Agent 协作', category: '模型', icon: Users,