跳到主内容
创建时间:最后更新:作者:白川

工具参数与失败夹具

用参数校验失败、执行期抛错与取消三类夹具覆盖工具失败路径,确认失败结果始终带着 toolCallId 回到原始调用,并处理 terminate 与整批一致的边界。

工具参数与失败夹具

真实仓库里跑起来的模型,有一半的调用会以失败告终——路径拼错、oldText 不唯一、命令超时、用户中途按了取消。

失败处理的质量决定智能体能不能自己爬起来。一次设计良好的失败,能让模型下一轮就改对;一次设计拙劣的失败,会让模型在同一个错误上反复撞墙。

这篇做一件事:给三类失败各写一个可直接用于单元测试的夹具,并断言失败结果能回到正确的工具调用。读完你能回答:失败该抛异常还是返回错误文本?失败信息里该放什么?terminate: true 和失败回填是什么关系?

读它需要会用 Vitest 或同类测试框架,并且对 Agent 循环有一个基本印象——模型发起调用,程序校验参数后执行,再把结果回填。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

最终要跑通什么

  1. 一个能稳定复现三类失败的夹具集合,不需要真实模型参与。
  2. 断言失败结果的 toolCallId 与原始调用一一对应。
  3. 断言事件序列符合契约:失败也走 tool_execution_end,不是静默消失。
  4. 确认 terminate: true 不会因为一批里混了失败就被误触发。

三类失败的准确边界

先纠正一个常见的误解。源码里工具失败不是三种互斥情况,而是三个不同阶段各自可能失败:

失败类型发生位置是否进入 execute()失败结果形态
参数校验失败
validateToolArguments() 抛错,被 prepareToolCall() 捕获
否
isError: true 的工具结果,内容是校验错误文本
工具不存在
prepareToolCall() 找不到同名工具
否
isError: true,内容是 Tool <name> not found
被 beforeToolCall 拦截
钩子返回 { block: true, reason }
否
isError: true,内容是 reason(缺省是 Tool execution was blocked)
执行期抛错
execute() 抛异常,被 executePreparedToolCall() 捕获
是
isError: true,内容是异常的 message
执行期返回错误结果
execute() 返回 { isError: true, ... }
是
保留 details / structuredContent,模型看到错误
取消
signal.aborted 触发
视时机
isError: true,内容通常是 Operation aborted

关键结论:参数校验失败同样会产生 tool_execution_end。

prepareToolCall() 把校验失败包成 kind: "immediate" 的结果,这个结果照样走 emitToolExecutionEnd()。所以事件序列在三类失败下是一致的:tool_execution_start → tool_execution_end(isError: true)→ toolResult 消息。唯一区别是失败发生得太早,execute() 根本没被调用,durationMs 也就缺席。

durationMs 缺席是个好用的信号:源码里它只在 execute() 真正跑过时才存在。有 durationMs 说明进过工具体,没有说明在校验或拦截阶段就被挡下了。

失败结果如何回到原始调用

这是本篇的核心验收点。回填靠的是 toolCallId,它在三个地方保持一致:

text
助手消息里的 toolCall.id
        │
        ├─→ tool_execution_start.toolCallId
        ├─→ tool_execution_end.toolCallId
        └─→ toolResult 消息的 toolCallId

createToolResultMessage() 直接把 finalized.toolCall.id 写进消息,工具实现无法篡改它。所以宿主只要在 tool_execution_end 事件里按 toolCallId 建索引,就能把"哪个工具、哪次调用、什么错误"三者串起来。

tool_execution_end 事件带着这些字段:

字段含义失败时的值
toolCallId
调用 ID,用于回溯到助手消息
原样透传
toolName
工具名
原样透传
result
工具结果对象
content 是错误文本,details 可能是 undefined
isError
是否失败
true
durationMs
执行耗时(单调时钟)
校验失败时缺席,执行期失败时存在

抛异常,不是返回错误文本

Agent Core 的工具契约写得很直接:抛错表示失败,把错误当普通内容返回不算失败。

typescript
// 失败必须抛异常:返回错误文本会被当成一次成功调用
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 上也看不出区别。

两者的差别在下游很大:

行为抛异常返回错误文本
tool_execution_end.isError
true
false
toolResult 消息的 isError
true
false
模型是否看到"这是失败"
是
否,它看到一段普通文本
details / structuredContent
丢失(异常路径不保留结构化数据)
保留

官方扩展文档补了一条例外,这是唯一该返回 isError: true 的场景:

To report a failure that still carries data, return the result with isError: true instead of throwing: the model sees an error, and scripts still receive structuredContent.

也就是说:需要同时给模型看错误文本、给程序留结构化数据时,才返回 isError: true;否则一律抛错。

让失败信息能自我纠正

参数错误是三类失败里最需要设计的。模型看到什么,决定它下一轮会不会改对。

源码里 validateToolArguments() 生成的错误消息长这样:

text
Validation failed for tool "read":
  - path: must be string

Received arguments:
{
  "path": 42
}

三部分:哪个工具、哪个字段、期望什么,加上回显模型实际传了什么。最后这段是关键——没有它,模型只能盲猜;有了它,它能直接看出是类型错了还是字段名错了。

对自定义工具,这条同样适用。给错误信息加上"同目录下最接近的文件"就是这个道理:把「事实 + 下一步」一起放进错误消息,模型的纠错轮次会明显下降。

对比一下两种写法:

typescript
// 差:模型只知道失败了,得自己再探一次
throw new Error("File not found");

// 好:模型知道哪个文件错了、目录下有什么、下一步该做什么
// 原则是把「事实 + 下一步」一起放进错误消息,纠错轮次会明显下降
throw new Error(
  `文件不存在:${params.path}。` +
    `同目录下相近的文件:${siblings.join("、") || "(无)"}。` +
    `请先用 ws_ls 列出目录内容,确认准确路径后重试。`
);

写一组可用的失败夹具

下面的夹具不依赖真实模型。它直接用 runToolCall()——这是 Agent Core 导出的低层入口,走的是和模型发起调用完全相同的流程:参数准备 → schema 校验 → beforeToolCall → 执行 → afterToolCall。

源码注释把它的契约写得很明确:永不 reject。未知工具、校验失败、被拦截、抛出的异常,全都变成 isError: true 的返回值。

typescript
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()。事件序列是宿主界面和遥测的契约,必须能测。

typescript
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 是工具给运行时的提示:这一批工具都跑完了,不用再自动发起下一次模型请求。

源码里判定规则只有一条:

typescript
// 整批一致:批里每一个最终结果都必须带 terminate: true,有一个不带就整批不终止
function shouldTerminateToolBatch(finalizedCalls) {
	return finalizedCalls.length > 0 && finalizedCalls.every((f) => f.result.terminate === true);
}

整批一致——批里每一个最终结果都必须带 terminate: true,有一个不带就整批不终止。

这一点和失败回填的关系,是最容易踩坑的地方:

场景结果
工具抛异常
异常路径生成的错误结果没有 terminate 字段 → 整批不终止
工具返回 isError: true
该结果自带 terminate 才会参与整批一致判定
beforeToolCall 拦截并带 terminate: true
拦截结果会被写入 terminate: true,参与整批判定
一批三个工具,两个成功带 terminate、一个抛错
抛错那个不带 terminate → 整批不终止,循环继续

失败天然不终止,这是符合直觉的:一个批次里有一个工具失败了,通常正需要模型再跑一轮看看怎么办。

但要注意一个反向陷阱:如果你在 afterToolCall 里统一给所有结果加 terminate: true,失败的工具也会被算进去,于是"失败后不再问模型"——模型永远不知道它失败了。这正是官方那句警告的含义:terminate 只在每个最终结果都同意时才生效。

在真实会话里观察失败

上面的夹具用的是低层入口。想在真实会话里确认"失败也会回到模型",注册一个必定失败的工具,然后看事件和会话内容:

typescript
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 是有意义的用法。政策上禁止某个工具时,你不希望模型换个姿势再试一次,于是让拦截直接终止整批。

谁负责什么

职责由谁承担说明
决定失败是抛错还是返回 isError
工具实现
除非需要保留 structuredContent,否则一律抛错
错误消息的内容与可纠错性
工具实现
点名工具、字段、期望值,回显实际输入
参数 schema 与校验消息
工具定义
TypeBox schema 决定校验错误文本的形态
把失败转成 isError: true 的结果
Agent Core
executePreparedToolCall() 捕获异常并转换
保持 toolCallId 一致
Agent Core
createToolResultMessage() 强制写入,工具无法篡改
发出失败终态事件
Agent Core
校验失败也会发 tool_execution_end
拦截与放行
宿主(beforeToolCall / tool_call 钩子)
政策判断在这里,不在提示词里
决定是否提前收尾
宿主
terminate 需整批一致,失败默认不终止

三种场景的可观察结果

场景触发条件模型看到什么宿主观察到什么
参数校验失败
模型传的参数不符 schema
校验错误文本,含字段名与实际入参
tool_execution_end.isError: true,无 durationMs
执行期失败
工具抛错或返回 isError: true
错误文本或带错误标记的结果内容
tool_execution_end.isError: true,有 durationMs
取消
signal.aborted
Operation aborted 之类的文本
tool_execution_end.isError: true,工具侧资源已释放

区分第一行和第二行只需要看 durationMs 是否存在。这比读日志文本可靠得多。

你现在能做什么

  • 把上面这组夹具直接放进项目跑一遍,确认 runToolCall 在你的版本里也是"永不 reject"——如果它抛了异常,说明接口变了。
  • 给你的每个自定义工具补一个"参数非法"用例,断言错误文本里同时包含工具名、字段名和实际入参三样东西。
  • 在你的事件处理里按 toolCallId 建一张索引表,跑一次带失败的任务,确认这张表里既有成功项也有失败项,且能和会话里的 toolResult 消息一一对上。
  • 写一个反向测试:让 afterToolCall 给所有结果加 terminate: true,然后断言失败的那个工具没有阻止整批终止——用这个测试锁住"失败后模型还能再试一次"这条语义。

排障速查

现象检查点处理
模型把失败当成功,重复读同一个不存在的文件
工具是抛错还是返回文本
抛错,或返回时必须带 isError: true
失败没有 tool_execution_end 事件
是不是在 execute() 之前就失败了
校验失败和拦截同样会发终态事件;真没发说明事件订阅漏了
界面卡在"执行中"
tool_execution_end 的 toolCallId 是否对得上
用 toolCallId 建索引,注意 pendingToolCalls 集合的增删
失败信息太长把上下文撑爆
错误文本里有没有塞整个文件内容
只放事实与下一步,别贴大段原文
模型在同一处反复失败
错误消息是否可自我纠正
必须点名工具、字段、期望值,并回显实际入参
工具体根本没跑但有耗时
是否误读了 durationMs
校验失败时 durationMs 缺席,这是正常的
一批工具全部失败后不再问模型
是否有失败结果也带了 terminate: true
terminate 是整批一致判定,别在 afterToolCall 里无差别添加
runToolCall 抛了异常
是否用了官方入口
runToolCall 永不 reject;直接调 execute() 才会
取消后进程还在跑
工具是否处理了 signal
子进程要杀进程组,参考命令执行那一篇

参考资料