工具参数与失败夹具
用参数校验失败、执行期抛错与取消三类夹具覆盖工具失败路径,确认失败结果始终带着 toolCallId 回到原始调用,并处理 terminate 与整批一致的边界。
工具参数与失败夹具
真实仓库里跑起来的模型,有一半的调用会以失败告终——路径拼错、oldText 不唯一、命令超时、用户中途按了取消。
失败处理的质量决定智能体能不能自己爬起来。一次设计良好的失败,能让模型下一轮就改对;一次设计拙劣的失败,会让模型在同一个错误上反复撞墙。
这篇做一件事:给三类失败各写一个可直接用于单元测试的夹具,并断言失败结果能回到正确的工具调用。读完你能回答:失败该抛异常还是返回错误文本?失败信息里该放什么?terminate: true 和失败回填是什么关系?
读它需要会用 Vitest 或同类测试框架,并且对 Agent 循环有一个基本印象——模型发起调用,程序校验参数后执行,再把结果回填。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 一个能稳定复现三类失败的夹具集合,不需要真实模型参与。
- 断言失败结果的
toolCallId与原始调用一一对应。 - 断言事件序列符合契约:失败也走
tool_execution_end,不是静默消失。 - 确认
terminate: true不会因为一批里混了失败就被误触发。
三类失败的准确边界
先纠正一个常见的误解。源码里工具失败不是三种互斥情况,而是三个不同阶段各自可能失败:
关键结论:参数校验失败同样会产生 tool_execution_end。
prepareToolCall() 把校验失败包成 kind: "immediate" 的结果,这个结果照样走 emitToolExecutionEnd()。所以事件序列在三类失败下是一致的:tool_execution_start → tool_execution_end(isError: true)→ toolResult 消息。唯一区别是失败发生得太早,execute() 根本没被调用,durationMs 也就缺席。
durationMs 缺席是个好用的信号:源码里它只在 execute() 真正跑过时才存在。有 durationMs 说明进过工具体,没有说明在校验或拦截阶段就被挡下了。
失败结果如何回到原始调用
这是本篇的核心验收点。回填靠的是 toolCallId,它在三个地方保持一致:
助手消息里的 toolCall.id
│
├─→ tool_execution_start.toolCallId
├─→ tool_execution_end.toolCallId
└─→ toolResult 消息的 toolCallIdcreateToolResultMessage() 直接把 finalized.toolCall.id 写进消息,工具实现无法篡改它。所以宿主只要在 tool_execution_end 事件里按 toolCallId 建索引,就能把"哪个工具、哪次调用、什么错误"三者串起来。
tool_execution_end 事件带着这些字段:
抛异常,不是返回错误文本
Agent Core 的工具契约写得很直接:抛错表示失败,把错误当普通内容返回不算失败。
// 失败必须抛异常:返回错误文本会被当成一次成功调用
execute: async (toolCallId, params, signal, onUpdate) => {
if (!fs.existsSync(params.path)) {
throw new Error(`File not found: ${params.path}`);
}
return { content: [{ type: "text", text: "..." }] };
}源码里 executePreparedToolCall() 的 catch 分支把抛出的异常转成 createErrorToolResult(error.message),并置 isError: true。而如果工具返回 { content: [{ text: "文件不存在" }] },那是一个成功结果——模型会以为读到了内容,宿主在 isError 上也看不出区别。
两者的差别在下游很大:
官方扩展文档补了一条例外,这是唯一该返回 isError: true 的场景:
To report a failure that still carries data, return the result with
isError: trueinstead of throwing: the model sees an error, and scripts still receivestructuredContent.
也就是说:需要同时给模型看错误文本、给程序留结构化数据时,才返回 isError: true;否则一律抛错。
让失败信息能自我纠正
参数错误是三类失败里最需要设计的。模型看到什么,决定它下一轮会不会改对。
源码里 validateToolArguments() 生成的错误消息长这样:
Validation failed for tool "read":
- path: must be string
Received arguments:
{
"path": 42
}三部分:哪个工具、哪个字段、期望什么,加上回显模型实际传了什么。最后这段是关键——没有它,模型只能盲猜;有了它,它能直接看出是类型错了还是字段名错了。
对自定义工具,这条同样适用。给错误信息加上"同目录下最接近的文件"就是这个道理:把「事实 + 下一步」一起放进错误消息,模型的纠错轮次会明显下降。
对比一下两种写法:
// 差:模型只知道失败了,得自己再探一次
throw new Error("File not found");
// 好:模型知道哪个文件错了、目录下有什么、下一步该做什么
// 原则是把「事实 + 下一步」一起放进错误消息,纠错轮次会明显下降
throw new Error(
`文件不存在:${params.path}。` +
`同目录下相近的文件:${siblings.join("、") || "(无)"}。` +
`请先用 ws_ls 列出目录内容,确认准确路径后重试。`
);写一组可用的失败夹具
下面的夹具不依赖真实模型。它直接用 runToolCall()——这是 Agent Core 导出的低层入口,走的是和模型发起调用完全相同的流程:参数准备 → schema 校验 → beforeToolCall → 执行 → afterToolCall。
源码注释把它的契约写得很明确:永不 reject。未知工具、校验失败、被拦截、抛出的异常,全都变成 isError: true 的返回值。
import { describe, expect, it } from "vitest";
import { runToolCall } from "@earendil-works/pi-agent-core";
import { Type } from "typebox";
import type { AgentTool, AgentToolCall } from "@earendil-works/pi-agent-core";
/** 一个最小助手消息:夹具只需要它提供调用来源。 */
const assistantMessage = {
role: "assistant" as const,
content: [],
api: "openai-responses" as const,
provider: "openai" as const,
model: "mock",
usage: {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse" as const,
timestamp: Date.now(),
};
/** 构造一个工具调用块。id 就是回填时的锚点。 */
function call(id: string, name: string, args: Record<string, unknown>): AgentToolCall {
return { type: "toolCall", id, name, arguments: args };
}
const readSchema = Type.Object({ path: Type.String() });
/** 夹具一:参数校验失败。path 传了对象,schema 要求字符串。 */
const schemaViolation: AgentTool = {
name: "read",
label: "read",
description: "读取文件",
parameters: readSchema,
async execute() {
throw new Error("不应该走到这里:参数校验就该挡住了");
},
};
/** 夹具二:执行期抛错。 */
const throwing: AgentTool = {
name: "read",
label: "read",
description: "读取文件",
parameters: readSchema,
async execute() {
throw new Error("ENOENT: no such file or directory, access '/repo/missing.ts'");
},
};
/** 夹具三:取消。signal 已 aborted 时工具应当立即失败。 */
const cancellable: AgentTool = {
name: "read",
label: "read",
description: "读取文件",
parameters: readSchema,
async execute(_toolCallId, _params, signal) {
if (signal?.aborted) throw new Error("Operation aborted");
return { content: [{ type: "text", text: "不该执行到这" }], details: {} };
},
};
const options = (tools: AgentTool[]) => ({
tools,
assistantMessage,
context: { messages: [] },
});
describe("工具失败夹具", () => {
it("参数校验失败:不进 execute,回填 isError 且带 toolCallId", async () => {
const outcome = await runToolCall(call("call-schema", "read", { path: { bad: 1 } }), options([schemaViolation]));
expect(outcome.toolCall.id).toBe("call-schema");
expect(outcome.isError).toBe(true);
// 校验失败发生在 prepare 阶段,工具体从未执行,因此没有耗时
expect(outcome.durationMs).toBeUndefined();
// 错误文本必须点名工具与字段,并回显模型实际传了什么
const text = outcome.result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
expect(text).toContain('Validation failed for tool "read"');
expect(text).toContain("Received arguments");
});
it("执行期抛错:错误文本原样回填,耗时存在", async () => {
const outcome = await runToolCall(call("call-throw", "read", { path: "/repo/missing.ts" }), options([throwing]));
expect(outcome.toolCall.id).toBe("call-throw");
expect(outcome.isError).toBe(true);
expect(outcome.durationMs).toBeGreaterThanOrEqual(0);
const text = outcome.result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
expect(text).toContain("ENOENT");
});
it("取消:signal aborted 时工具失败而不是成功", async () => {
const controller = new AbortController();
controller.abort();
const outcome = await runToolCall(call("call-abort", "read", { path: "/repo/a.ts" }), {
...options([cancellable]),
signal: controller.signal,
});
expect(outcome.toolCall.id).toBe("call-abort");
expect(outcome.isError).toBe(true);
const text = outcome.result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
expect(text).toContain("Operation aborted");
});
it("未知工具:回填 Tool <name> not found,不抛异常", async () => {
const outcome = await runToolCall(call("call-unknown", "search", { query: "x" }), options([schemaViolation]));
expect(outcome.isError).toBe(true);
const text = outcome.result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
expect(text).toBe("Tool search not found");
});
});这组夹具可以直接放进你的测试目录跑。它不需要凭据、不发网络请求、每次几毫秒。
断言事件序列
上面的夹具断言的是返回值。要断言事件序列,得跑完整的 agentLoop()。事件序列是宿主界面和遥测的契约,必须能测。
import { describe, expect, it } from "vitest";
import { agentLoop } from "@earendil-works/pi-agent-core";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
const failingTool: AgentTool = {
name: "read",
label: "read",
description: "读取文件",
parameters: Type.Object({ path: Type.String() }),
async execute() {
throw new Error("boom");
},
};
describe("失败的事件序列", () => {
it("失败也走完整的 tool_execution_start / end / toolResult 三段", async () => {
const context: AgentContext = { messages: [], tools: [failingTool] };
const config: AgentLoopConfig = {
model: {
id: "mock", name: "mock", api: "openai-responses", provider: "openai",
baseUrl: "https://example.invalid", reasoning: false, input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192, maxTokens: 2048,
},
convertToLlm: (messages) => messages as never,
};
const userMessage: AgentMessage = {
role: "user", content: "读一下", timestamp: Date.now(),
};
let callIndex = 0;
const stream = agentLoop([userMessage], context, config, undefined, () => {
const mock = createAssistantMessageEventStream();
queueMicrotask(() => {
if (callIndex === 0) {
mock.push({
type: "done",
reason: "toolUse",
message: {
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "a.ts" } }],
api: "openai-responses", provider: "openai", model: "mock",
usage: {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
});
} else {
mock.push({
type: "done",
reason: "stop",
message: {
role: "assistant", content: [{ type: "text", text: "done" }],
api: "openai-responses", provider: "openai", model: "mock",
usage: {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
});
}
callIndex++;
});
return mock;
});
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// 1) 起始事件一定发出
const start = events.find((e) => e.type === "tool_execution_start");
expect(start).toBeDefined();
// 2) 失败同样有终态事件,且标记为错误
const end = events.find((e) => e.type === "tool_execution_end");
expect(end).toBeDefined();
expect(end).toMatchObject({ toolCallId: "call-1", isError: true });
// 3) 失败结果作为 toolResult 消息回填,且带着同一个 toolCallId
const resultMessage = events.find(
(e) => e.type === "message_end" && e.message.role === "toolResult",
);
expect(resultMessage).toMatchObject({
message: { toolCallId: "call-1", toolName: "read", isError: true },
});
// 4) 本轮结束时会带上这个失败结果
const turnEnd = events.find((e) => e.type === "turn_end");
expect(turnEnd).toBeDefined();
});
});toolCallId 在三处一致(tool_execution_start / tool_execution_end / toolResult 消息),这就是"失败结果能回到正确工具调用"的可验证形式。
terminate 与失败回填的交互
terminate: true 是工具给运行时的提示:这一批工具都跑完了,不用再自动发起下一次模型请求。
源码里判定规则只有一条:
// 整批一致:批里每一个最终结果都必须带 terminate: true,有一个不带就整批不终止
function shouldTerminateToolBatch(finalizedCalls) {
return finalizedCalls.length > 0 && finalizedCalls.every((f) => f.result.terminate === true);
}整批一致——批里每一个最终结果都必须带 terminate: true,有一个不带就整批不终止。
这一点和失败回填的关系,是最容易踩坑的地方:
失败天然不终止,这是符合直觉的:一个批次里有一个工具失败了,通常正需要模型再跑一轮看看怎么办。
但要注意一个反向陷阱:如果你在 afterToolCall 里统一给所有结果加 terminate: true,失败的工具也会被算进去,于是"失败后不再问模型"——模型永远不知道它失败了。这正是官方那句警告的含义:terminate 只在每个最终结果都同意时才生效。
在真实会话里观察失败
上面的夹具用的是低层入口。想在真实会话里确认"失败也会回到模型",注册一个必定失败的工具,然后看事件和会话内容:
import { Type } from "@earendil-works/pi-ai";
import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
customTools: [
defineTool({
name: "always_fails",
label: "always_fails",
description: "必定失败的探针工具,用来验证失败回填",
parameters: Type.Object({ reason: Type.String() }),
async execute() {
// 抛错 = 失败。这条错误会作为工具结果回到模型。
throw new Error("探针失败:这是预期的错误");
},
}),
],
});
try {
const events: string[] = [];
const unsubscribe = session.subscribe((event) => {
if (event.type === "tool_execution_start") {
events.push(`start:${event.toolName}`);
}
if (event.type === "tool_execution_end") {
// isError 和 durationMs 是判断失败形态的关键字段
events.push(`end:${event.toolName}:isError=${event.isError}:hasDuration=${event.durationMs !== undefined}`);
}
});
await session.prompt("调用 always_fails 看看会发生什么");
console.log(events);
// 预期能看到 start -> end(isError=true) 两段,
// 而且 hasDuration=true(因为 execute 真的跑过了)
console.log("模型看到的最后回复:", session.getLastAssistantText());
} finally {
// 释放:dispose 会中止在跑的工作并清空监听器
session.dispose();
}这段代码验证两件事:失败会产生完整的 tool_execution_start / tool_execution_end 两段事件(不会静默消失),并且失败结果会作为工具结果回到模型上下文里。
durationMs 是否存在,是区分"失败在参数校验阶段"和"失败在执行阶段"的可靠信号。
还有一点:拦截结果上带 terminate: true 是有意义的用法。政策上禁止某个工具时,你不希望模型换个姿势再试一次,于是让拦截直接终止整批。
谁负责什么
三种场景的可观察结果
区分第一行和第二行只需要看 durationMs 是否存在。这比读日志文本可靠得多。
你现在能做什么
- 把上面这组夹具直接放进项目跑一遍,确认
runToolCall在你的版本里也是"永不 reject"——如果它抛了异常,说明接口变了。 - 给你的每个自定义工具补一个"参数非法"用例,断言错误文本里同时包含工具名、字段名和实际入参三样东西。
- 在你的事件处理里按
toolCallId建一张索引表,跑一次带失败的任务,确认这张表里既有成功项也有失败项,且能和会话里的toolResult消息一一对上。 - 写一个反向测试:让
afterToolCall给所有结果加terminate: true,然后断言失败的那个工具没有阻止整批终止——用这个测试锁住"失败后模型还能再试一次"这条语义。