第 3 章讲 Agent Loop 时,我们追踪了”模型决定调用 read 工具”到”工具结果回到模型面前”这段旅程。但当时把它当黑盒跳过了——只说了”Loop 执行工具”,没说具体怎么执行的。
这一章就来打开这个黑盒。
校对口径:本章对应 Pi v0.80.2 的
agent-loop.ts与 coding-agent 内置工具。文中“错误变成消息”只指工具准备、执行和后处理这条边界;事件监听器、宿主回调或框架自身的异常仍可能向外传播。
当模型的回复里出现了这样一条指令:
{ "type": "toolCall", "id": "call_abc123", "name": "read", "arguments": { "path": "src/main.ts" } }
从这条指令到文件内容回到模型面前,中间经历了什么?
你的第一反应可能是:找到 read 工具,读文件,把内容塞进消息,完事。但现实中没这么简单——模型可能漏掉必填参数(根本没传 path),模型可能要求执行危险命令(rm -rf /),工具执行时也可能抛异常(文件不存在)。
Pi 用一条五步管道处理参数与执行边界:参数预处理 → Schema 验证 → 前置钩子 → 工具执行 → 后置钩子。管道内的失败会尽量转换成工具结果;前置钩子给宿主留下权限、确认或其他策略入口,但它本身不是一套默认启用的权限系统。
但在讲管道之前,得先搞清楚一个更基础的问题:工具到底是怎么定义的? 为什么 Pi 要设计三层类型来描述”一个工具”?
一、三层类型:为什么”一个工具”要分三层来定义?
第一层:Tool——一张”名片”
打开 packages/ai/src/types.ts,你会看到工具的最底层定义:
// packages/ai/src/types.ts:427-431
export interface Tool<TParameters extends TSchema = TSchema> {
name: string; // 工具名,如 "read"、"bash"
description: string; // 给 LLM 看的工具描述
parameters: TParameters; // 参数的 JSON Schema(用 TypeBox 定义)
}
三个字段。工具就是一个有名字、有描述、有参数 Schema 的东西。
这个接口住在 pi-ai 层——纯模型适配层。它唯一关心的事情是:怎么把工具的信息告诉模型。 name 和 description 会出现在发给模型的 API 请求里,parameters 告诉模型”你可以传哪些参数”。
在这个层面,工具只是一张名片。能描述自己,但不能执行任何操作。
第二层:AgentTool——加上了”执行能力”
Agent Loop 要执行工具调用,光有名片不够。它需要知道怎么执行这个工具、这个工具能不能并行执行、参数格式要不要预处理。
于是 pi-agent-core 层在 Tool 基础上扩展了 AgentTool:
// packages/agent/src/types.ts:371-394
export interface AgentTool<TParameters, TDetails>
extends Tool<TParameters> // 继承 Tool 的三个字段
{
label: string; // 给人看的标签(不同于给 LLM 的 description)
prepareArguments?: (args: unknown) => Static<TParameters>; // 兼容性垫片
execute: ( // 执行函数
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
executionMode?: "sequential" | "parallel"; // 执行模式
}
从 Tool 到 AgentTool,新增了 4 个字段。每个都有明确用途:
label:模型看到的是name(“read”),UI 看到的是label(“读取文件”)prepareArguments:兼容层,处理不同模型输出的参数怪癖(后面详讲)execute:真正干活的函数——模型说”读文件”,这个函数去读executionMode:标记这个工具能否和其他工具并行执行
第三层:ToolDefinition——产品层再加东西
到了 pi-coding-agent 层(产品运行层),工具还需要更多能力:自定义渲染(read 工具在终端里怎么显示?edit 工具怎么展示 diff?)、提示词注入(有些工具需要在系统提示词里加一段使用指南)。
于是出现了第三层 ToolDefinition。它的 execute 函数比 AgentTool 多了一个参数——ctx: ExtensionContext,让工具执行时可以访问当前会话状态:
AgentTool.execute(
toolCallId, params, signal, onUpdate
)
ToolDefinition.execute(
toolCallId, params, signal, onUpdate, ctx
)
└─ ExtensionContext
ToolDefinition 还新增了 promptSnippet(系统提示词片段)、renderCall(调用时渲染)、renderResult(结果时渲染)等 UI 相关字段。
桥接两层:wrapToolDefinition
Agent Loop 只认识 AgentTool,但产品层的工具都是 ToolDefinition。谁来把 ToolDefinition 变成 AgentTool?
答案是一个只有十几行的包装器函数:
// packages/coding-agent/src/core/tools/tool-definition-wrapper.ts
export function wrapToolDefinition(definition, ctxFactory?) {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
// 关键:重写 execute,通过闭包注入 ExtensionContext
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.()),
};
}
注意最后一行。AgentTool 的 execute 只有 4 个参数,但 ToolDefinition 的 execute 有 5 个。包装器通过闭包捕获了 ctxFactory,在调用时动态创建 ExtensionContext 并注入第 5 个参数。Agent Loop 永远不知道 ExtensionContext 的存在。
为什么非要分三层?
把所有字段塞进一个 Tool 接口,加几个可选字段不就行了?
不行。原因是每层有独立的依赖范围。pi-ai 层的 Tool 接口只依赖 TypeBox 的 TSchema。如果在这个接口里加了 renderCall(返回终端 UI 组件),pi-ai 就得依赖终端 UI 渲染库。但 pi-ai 是纯模型适配层——它的工作只是”把工具信息格式化成 API 请求”,不应该知道终端 UI 长什么样。
三层递进的本质是:每一层只加自己这个层级需要的能力,不越界。 Tool 管”我能描述自己”,AgentTool 管”我能被执行”,ToolDefinition 管”我能被展示和扩展”。
二、五步管道:工具调用不是”调个函数就完了”
类型定义搞清楚了,现在看工具调用的实际执行过程。
配图说明:从 ToolCall 到 ToolResultMessage 的五步垂直管道——prepareArguments→validate→beforeToolCall→execute→afterToolCall。图中的错误汇聚只描述这五步的工具边界,不涵盖事件消费者或宿主代码自身的故障。
为什么不能直接调函数?
最简单的处理方式:找到 read 工具 → 读文件 → 把内容塞进 ToolResultMessage → 完事。一行函数调用,很直觉。
但模型的输出并不总是规规矩矩的:
- 参数格式不对:Edit 工具期望
edits是一个数组,但某些模型会把数组序列化成字符串"[{...}]"传过来 - 参数类型错误:Read 工具的
path参数是 string,但模型可能传个数字12345 - 危险操作:模型要求执行
rm -rf /;若宿主没有配置自己的检查策略,通用管道不会自动判断它危险
这些问题意味着”直接调函数”是不够的。你需要在执行前加几道关卡。
Pi 的答案:五步管道
LLM 输出 ToolCall
│
├─ 1. prepareArguments
│ 参数预处理,例如解析字符串化的数组
│
├─ 2. validateToolArguments
│ 通用值转换后,再用 TypeBox Schema 验证
│
├─ 3. beforeToolCall
│ 可选的宿主策略钩子,可阻止执行
│
├─ 4. tool.execute
│ 执行工具,并通过 onUpdate 报告进度
│
├─ 5. afterToolCall
│ 产品层后处理,可替换 content、details、isError
│
▼
ToolResultMessage
每一步都有明确的职责和退出机制。前 3 步是”准备工作”——任何一步失败都不会执行工具。第 4 步是”真正干活”。第 5 步是”收尾”。我们逐步展开。
第 1 步:prepareArguments——兼容性垫片
不同模型的 API 在序列化工具参数时有微妙的差异。prepareArguments 就是为这些差异准备的兼容层。
比如 Edit 工具期望 edits 是数组:
// 模型实际传来的(某些模型把 JSON 数组序列化成了字符串)
{ edits: "[{\"oldText\":\"hello\",\"newText\":\"world\"}]" }
// 经过 prepareArguments 处理后
{ edits: [{ oldText: "hello", newText: "world" }] }
如果工具没定义 prepareArguments,参数直接透传。这一步的代码很简单——有就用,没有就跳过。
为什么还需要 Schema 验证? 两层处理的粒度不同。prepareArguments 是工具自定义的兼容层——修复某种已知结构怪癖;validateToolArguments 是所有工具共享的通用层:先用 TypeBox Value.Convert(普通 JSON Schema 还有相应的 coercion)尝试转换基础值,再检查必填项、类型和约束。它可能把 path: 12345 转成 "12345",所以不能把“输入类型与 Schema 字面不同”直接等同于失败。
第 2 步:validateToolArguments——Schema 验证
经过预处理后,参数还要走“转换 + 检查”。例如 Read 工具要求 path,但模型完全没传:
Before:{}
Convert:没有可补出的 path
Check: 验证失败 → 报错 → 不执行工具
转换后仍不满足 Schema 的错误会被 prepareToolCall 的 try-catch 捕获,生成一个错误 ToolResultMessage。通过 Agent Loop 这条标准调用路径时,未通过 Schema 的参数不会进入 tool.execute();通过的则可能是转换后的副本。 如果宿主绕过 Agent Loop 直接调用工具,则需要自行承担参数转换与校验。
第 3 步:beforeToolCall——前置钩子(可阻止执行)
参数验证通过后,在执行之前,宿主还有一次可选的策略入口。beforeToolCall 是一个通用回调;coding-agent 的扩展可以用它检查命令、请求确认或实施其他策略,但 Agent Core 本身不会内置判断“哪条命令危险”:
| 返回值 | 效果 |
|---|---|
undefined | 放行,继续执行工具 |
{ block: true, reason: "危险命令" } | 阻止执行,生成错误 ToolResultMessage |
注意:配置了钩子且工具被阻止时,结果仍然是一条 ToolResultMessage,只是 isError: true。模型会看到这条错误消息,知道命令被拒绝了,然后决定下一步怎么做(换一个命令,或者跟用户解释为什么不能执行)。这个 block 分支不会把拒绝原因作为异常抛出;事件监听器或宿主回调自身抛错仍属于本章开头声明的边界外故障。
第 4 步:tool.execute——实际执行
前 3 步都通过后,工具的 execute 函数被真正调用。回头看一下它的签名:
execute: (toolCallId, params, signal, onUpdate) => Promise<AgentToolResult>
四个参数——toolCallId 是这次调用的 ID,params 是验证过的参数,signal 是用于取消的 AbortSignal(用户按 Ctrl+C 时触发)。第四个 onUpdate 是什么?
它解决的是”长任务的进度感知”问题。 假设 Bash 工具要跑一个 30 秒的命令——如果只有”开始执行”和”执行完成”两个时刻能向外界报告,用户在这 30 秒里只能盯着加载动画。onUpdate 让工具能边执行边向外推消息,这些推送会被包装成 tool_execution_update 事件,最终流向 UI。v0.80.2 的内置工具中,Bash 会使用这个回调报告流式输出;read / write / edit / grep / find / ls 虽然共享同一接口,但没有发送增量更新。
简单说:没有 onUpdate,工具执行就是黑盒;有了它,工具执行是”可观察的”。 这是工具能向用户实时汇报进度的关键机制。
但有个边角问题需要处理。工具的 execute 是异步函数,它 return 之后,内部可能还有没结束的异步操作——比如 Bash 工具的子进程在主命令返回后还在异步打印最后几行日志。如果这些延迟回调还往 onUpdate 推数据,就会污染一个已经结束的工具调用,让 UI 上下文错乱。Pi 用一个 acceptingUpdates 标志位解决:execute 一旦返回(或抛异常),立即把标志位关掉,之后所有 onUpdate 调用一律静默丢弃。这是工程上的防御性细节,不复杂,但必须要有。
onUpdate 推出的消息最终流向哪里?这个问题很重要——它是下一章”消息系统”的核心议题,那里会展开。这里只需要记住:工具执行不是黑盒,进度可观察。
如果 tool.execute() 抛出异常怎么办?别担心,§四 会详细讲这是怎么处理的——剧透一句:异常会被翻译成一条 isError: true 的消息发给模型。
第 5 步:afterToolCall——后置钩子(可修改结果)
工具执行完毕后,产品层还有一次修改结果的机会。afterToolCall 可以做这些事情:
| 场景 | 做什么 | 怎么做 |
|---|---|---|
| 脱敏 | 把工具返回的敏感信息替换掉 | 返回 { content: [{type:"text", text:"[已脱敏]"}] } |
| 审计 | 记录工具调用的详细信息 | 读取 result,写日志,返回 undefined(不改结果) |
| 修错 | 把工具的错误结果修正为正常结果 | 返回 { isError: false, content: [...] } |
| 早停投票 | 标记本次工具结果希望终止当前工具链 | 返回 { terminate: true };只有整批结果都为 true,批次才终止 |
合并语义是字段级覆盖——提供了就替换,没提供就保留原值。这里的 terminate 是整批 unanimous(every)判断,不是任意一个 after hook 返回 true 就让 Agent 停止;即使当前工具链终止,steering / follow-up 队列仍可能让同一 Trace 继续。
管道的终点:ToolResultMessage
在工具准备、执行和后处理边界内,成功结果以及被捕获的失败最终都会形成一条 ToolResultMessage:
{
role: "toolResult",
toolCallId: "call_abc123", // 关联到原始 ToolCall
toolName: "read",
content: [{ type: "text", text: "1│ import { Agent }..." }],
details: { language: "typescript" }, // 给 UI 的元数据
isError: false, // 是否为错误结果
timestamp: 1700000000000,
}
这条消息会被追加到对话历史中,在下一轮循环里作为上下文发给模型。模型看到”文件内容是这样的”,然后决定下一步——可能要编辑,可能要再读别的文件,可能直接回答用户。
在工具调用边界内,参数校验、前置钩子、execute 与后置钩子的失败都会被转换成一条 isError: true 的 ToolResultMessage。 模型看到错误消息后可以决定下一步。模型流错误或 Agent 生命周期监听器错误走的是另外的失败路径,§四 会把边界讲清楚。
三、并行 vs 串行:一个批次的工具不是”一起跑就完了”
配图说明:顶部是串行决策——全局 config.toolExecution 强制串行,或批内任一工具声明 sequential,都会让整批串行。左侧绿色三阶段(顺序准备→并行执行与收尾→按调用顺序发送结果消息),右侧黑色瀑布式串行。底部说明准备顺序和串行模式的适用场景。
模型经常一次调用多个工具
Agent Loop 的内层循环中,模型的一次回复可能包含多个 ToolCall:
assistantMessage.content = [
{ type: "text", text: "我来查一下文件" },
{
type: "toolCall", id: "call_1", name: "read",
arguments: { path: "a.ts" }
},
{
type: "toolCall", id: "call_2", name: "grep",
arguments: { pattern: "TODO" }
},
{
type: "toolCall", id: "call_3", name: "find",
arguments: { pattern: "*.test.ts" }
},
]
三个 ToolCall,都是只读操作。直觉告诉我们应该并行执行——用 Promise.all 一起跑,省时间。
但并行不是无脑 Promise.all
如果自定义的变更工具并发修改同一资源而自身没有协调机制,就可能互相覆盖:
ToolCall 1: edit {
path: "app.ts", oldText: "v1", newText: "v2"
}
ToolCall 2: edit {
path: "app.ts", oldText: "v3", newText: "v4"
}
└─ 同一个文件;若工具不加锁,结果取决于并发时序
所以 Pi 需要一种机制来判断”哪些工具能并行,哪些必须串行”。
Pi 的调度策略:全局开关 + 一票否决
Pi 的策略很简单——全局配置要求串行,或只要有一个工具标记为 sequential,整个批次就串行执行:
// 检查是否有串行工具
const hasSequentialToolCall = toolCalls.some(
(tc) => tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
// 有串行工具 → 整批串行;没有 → 并行
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(...);
}
return executeToolCallsParallel(...);
为什么一票否决而不是只串行冲突的工具? 因为调度器只有工具声明和调用参数,没有通用的资源冲突模型。全局配置可以直接关闭批内并行;没有全局强制时,只要一个工具声明 sequential,整批串行就是一条简单、可预测的规则。它能减少并发干扰,但不会自动证明整批操作安全。v0.80.2 的内置 edit / write 还会在工具内部按文件路径排队,后文会区分这两层机制。
并行执行的三阶段设计
当判定可以并行时,Pi 不是简单地 Promise.all 跑完就完——它把执行分成了三个阶段:
阶段 1 - 准备(顺序执行):
ToolCall 1: emit_start → prepareArguments → validate → beforeToolCall
ToolCall 2: emit_start → prepareArguments → validate → beforeToolCall
ToolCall 3: emit_start → prepareArguments → validate → beforeToolCall
// 实现按调用顺序 await;即时失败会在这里直接 emit_end
阶段 2 - 执行与收尾(并行):
ToolCall 1: execute → afterToolCall → emit_end ─┐
ToolCall 2: execute → afterToolCall → emit_end ─┤ Promise.all
ToolCall 3: execute → afterToolCall → emit_end ─┘
// 每项在自己的并发任务中发 end,因此通常按完成先后出现
阶段 3 - 结果消息(有序):
ToolCall 1: emit_result
ToolCall 2: emit_result
ToolCall 3: emit_result
// ToolResultMessage 固定按原始调用顺序发送
实现按 ToolCall 顺序等待 prepareArguments、校验和 beforeToolCall,因此这些钩子不会相互重叠;某一项被 block 后,后续项仍会继续准备,除非 signal 已终止。准备成功的调用随后通过 Promise.all 并发执行,afterToolCall 和 tool_execution_end 也在各自并发任务里完成。Promise.all 的返回数组保持输入顺序,最后再按原始调用顺序发送 ToolResultMessage,避免按完成快慢重排对话历史。
还有一个细节:v0.80.2 的 7 个内置工具(read/write/edit/bash/grep/find/ls)都没有显式声明
executionMode,默认全部"parallel"(ToolExecutionMode类型见agent/src/types.ts:41,运行时只在agent-loop.ts:382判断是否"sequential",未显式声明即按并行处理)。那 Edit / Write 怎么降低同一文件上的并发冲突?答案是工具内部的withFileMutationQueue(文件变更队列,file-mutation-queue.ts:32-61)——Edit 在edit.ts:312、Write 在write.ts:203调用它,按文件路径把变更操作串行化。这是工具自己做的第二道防线,不等于所有文件操作都自动安全。扩展工具如果需要整批串行,可以显式声明executionMode: "sequential"。
四、工具边界内:把执行错误变成消息
前面 §二 的五步管道里,每一步出错都被编码成了 isError: true 的 ToolResultMessage。看起来错误已经被处理了。
但你可能会问:万一 tool.execute() 内部抛了一个未捕获的异常呢?工具开发者写代码时什么情况都可能发生——文件不存在、权限拒绝、命令超时、JSON 解析失败。这些异常如果不处理,就会一路穿透管道,打断 Agent Loop。
这一节回答:工具执行出错时,Pi 怎么把失败留在对话协议里?这种选择有什么收益和边界?
错误的统一出口:6 种错误,1 种产物
回看整个五步管道,工具调用的每一步都可能出错。但你会发现一个惊人的规律:不管哪一步出错,最终产物都是同一种东西——一条 isError: true 的 ToolResultMessage。
| 哪一步出错 | 怎么处理 | 最终产物 |
|---|---|---|
| 工具未找到 | 直接返回错误结果,不进入管道 | ToolResultMessage { isError: true, content: "Tool xxx not found" } |
| prepareArguments 抛异常 | 被 try-catch 捕获 | ToolResultMessage { isError: true, content: 异常信息 } |
| Schema 验证失败 | 被 try-catch 捕获 | ToolResultMessage { isError: true, content: 验证错误描述 } |
| beforeToolCall 阻止 | 返回阻止结果 | ToolResultMessage { isError: true, content: 阻止原因 } |
| tool.execute 抛异常 | 被 executePreparedToolCall 的 try-catch 捕获 | ToolResultMessage { isError: true, content: 异常信息 } |
| afterToolCall 抛异常 | 被 finalizeExecutedToolCall 的 try-catch 捕获 | ToolResultMessage { isError: true, content: 异常信息 } |
注意表格的右列——这些工具阶段的失败最终都被整理为 ToolResultMessage。若 emit() 的监听器、配置回调或工具边界之外的框架逻辑抛错,这张表不提供兜底。
关键代码:tool.execute 的双重防护
其中最关键的一层在 executePreparedToolCall()——它包住了 tool.execute() 这个最容易出错的环节:
// packages/agent/src/agent-loop.ts:628-669
async function executePreparedToolCall(prepared, signal, emit) {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true; // 工具 Promise settle 后关闭
try {
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args,
signal,
(partialResult) => {
if (!acceptingUpdates) return; // settle 后的孤儿回调直接忽略
updateEvents.push(/* ... 发 tool_execution_update ... */);
},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
// 关键:先等所有进度事件发完,再把异常编码成消息
await Promise.all(updateEvents);
return {
result: createErrorToolResult(
error instanceof Error ? error.message : String(error)
),
isError: true,
};
} finally {
acceptingUpdates = false; // 兜底:无论如何都关闭闸门
}
}
这段代码做了三件事,每一件都对应一个工程上的关键决策:
1. tool.execute() 的异常被 catch。文件不存在、权限拒绝、超时或解析失败,只要是执行 Promise 的拒绝,都会先在这里转成结果。
2. 异常被”翻译”成正常结果。catch 块里调用 createErrorToolResult(error.message),把异常对象转成一个 AgentToolResult——长得跟正常结果一模一样,只是 content 里装的是错误描述文本。从这一刻起,它就不再是”异常”,而是”一条带错误标记的消息”。
3. 在事件发送成功的前提下,先等进度事件收口。catch 块里的 await Promise.all(updateEvents) 会等待已经发出的 tool_execution_update Promise,再把执行异常编码成结果,避免正常监听器下的乱序。若某个事件监听器自己的 Promise 拒绝,Promise.all 仍可能向外抛;这正是本章开头所说的边界。
异常 → 消息:编码前后对比
下面这个对比能让你看清”异常被翻译成消息”的本质:
catch 之前:工具抛出原始异常
└─ Error: ENOENT: no such file or directory
├─ 离开工具调用边界
├─ 外层 run 进入失败生命周期
└─ 模型拿不到这次工具的专用结果
catch 之后:异常被编码为 ToolResultMessage
└─ {
role: "toolResult",
toolCallId: "call_abc",
toolName: "read",
content: [{
type: "text",
text: "ENOENT: no such file or directory"
}],
isError: true
}
├─ 追加到对话历史
├─ 下一轮发给模型
└─ 模型看到后自行决定下一步
异常和消息的区别不在于”内容是什么”——两者描述的是同一件事——而在于接收者是谁。异常的接收者是调用栈(外层框架),它会打断循环;消息的接收者是模型,它会消化错误然后继续。Pi 选择了把异常翻译成消息,让”工具出错”成为模型可见的、可处理的正常信息流。
为什么把错误编码成消息?
你可能会想:异常抛出去给外层统一处理不也行吗?为什么要费劲翻译成一条”长得像正常结果”的消息?
答案的核心是:让模型自己决定下一步,比框架替它决定更好。
考虑这几种真实的工具错误场景:
| 错误场景 | 模型看到错误消息后的合理反应 |
|---|---|
read("/path/a.ts") 报”文件不存在” | 模型可能先 ls 看看目录里有什么,找到正确文件名再读 |
edit 报”oldText 在文件中找不到匹配” | 模型可能先 read 文件查看实际内容,调整 oldText 后重试 |
bash("npm run build") 报”模块未找到” | 模型可能 npm install 后再 build |
bash("rm -rf /") 被 beforeToolCall 阻止 | 模型看到阻止原因,换一种安全的写法或向用户解释 |
每种场景下,合理的下一步动作都不同。模型掌握当前对话与调用意图,通常比通用工具调度器更适合决定是重试、换工具还是向用户解释;宿主仍可以通过钩子、重试策略或人工确认施加额外规则。
如果把预期中的工具失败直接抛到宿主,模型就看不到失败原因;编码成消息后,模型有机会调整参数或改用别的工具。代价是错误不会自动升级为进程级告警,所以宿主仍应通过事件和日志监控高频失败。
所以,Pi 的工具错误处理哲学可以总结成一句:错误信息是给模型的反馈,不是给框架的终止信号。
关键细节:错误描述越具体,模型纠错能力越强
到这里你可能会产生一个误解——“反正框架会把异常编码成消息,那我工具内部随便 throw 个 Error("failed") 不就行了?”
绝对不行。 错误消息的内容直接决定模型能不能纠错。比较下面两种情况:
模糊错误(不可取)
└─ {
content: [{ text: "Read failed" }],
isError: true
}
具体 error.message(推荐)
└─ {
content: [{
text: "Offset 200 is beyond end of file (100 lines total)"
}],
isError: true
}
模型看到 “Read failed”,只能盲目重试或放弃;看到 “Offset 200 is beyond end of file (100 lines total)“,能立刻明白”哦,文件只有 100 行,我 offset 给错了”,下次直接给 offset: 50 就成了。具体的错误描述等于给模型一份”怎么改才对”的提示。
Pi 的真实做法:两层错误处理,分层负责
回源码看 Pi 自己的工具,可以看到不少已知失败会先在工具内部改写成更具体的 Error;无论是这种重新包装的错误,还是工具没识别出的异常,最终都会继续抛给框架边界编码:
Read 工具(read.ts:284-287)——越界时附上文件总行数:
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
Edit 工具(edit.ts:328-330)——附上文件路径;若异常带 code,优先写入 code,否则使用异常文本:
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
Bash 工具(bash.ts:380-407)——区分“执行 Promise 抛错”和“命令正常返回非零码”:
try {
let exitCode: number | null;
try {
const result = await ops.exec(/* ... */);
exitCode = result.exitCode;
} catch (err) {
const snapshot = await finishOutput();
const { text } = formatOutput(snapshot, "");
if (err instanceof Error && err.message === "aborted") {
throw new Error(appendStatus(text, "Command aborted"));
}
if (err instanceof Error && err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));
}
throw err;
}
const snapshot = await finishOutput();
const { text: outputText, details } = formatOutput(snapshot);
if (exitCode !== 0 && exitCode !== null) {
throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}
return { content: [{ type: "text", text: outputText }], details };
} finally {
clearUpdateTimer();
}
注意 Bash 的策略:内层 catch 只处理 ops.exec() 抛出的中止和超时,并把此前输出附进新 Error;无法识别的异常原样抛出。非零退出码不是这个 catch 的分支——ops.exec() 正常返回后,外层再检查 exitCode,同样把输出和状态包装成 Error。
这就是 Pi 的真实设计:两层错误处理,分层负责。
第一层(工具内部,主动):识别已知错误类型,包装成具体可读的描述
└── Read/Edit/Bash 都是这样——Bash 甚至把"已输出的内容"附在错误里
└── 目的:给模型提供"为什么失败、怎么改才对"的具体线索
第二层(框架兜底,被动):executePreparedToolCall 的 catch
└── 接住 tool.execute 最终抛出的所有异常,包括工具主动重新包装的已知错误
└── 不创造新的错误描述,只把 error.message 原样透传给模型
└── 目的:兜住工具 execute 与后处理边界内的异常
executePreparedToolCall 里的兜底 catch 用的就是工具自己抛的 error.message:
} catch (error) {
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
// ^^^^^^^^^^^^^^^^
// 工具内部包装好的具体描述,框架不动它,只搬运
isError: true,
};
}
createErrorToolResult 函数体只有三行(agent-loop.ts:716-721),它不做任何”统一描述”——工具写的 message 是什么,模型就看到什么。所以工具内部包装得越具体,模型看到的错误信息就越有用。
写自定义工具时的建议
借鉴 Bash 工具的写法,自定义工具的 execute 应该长这样:
execute: async (id, params, signal, onUpdate) => {
try {
// ... 业务逻辑
return { content: [...], details: {...} };
} catch (err) {
// 第 1 步:识别已知错误类型,重新包装成具体描述
if (err instanceof MyKnownErrorA) {
throw new Error(`具体的描述A:${err.message}。建议的修复方法...`);
}
if (err instanceof MyKnownErrorB) {
throw new Error(`具体的描述B:${err.message}。可能的原因...`);
}
// 第 2 步:实在识别不了的异常,原样抛出,让框架兜底
throw err;
}
}
两个关键原则:
- 能识别的错误一定要包装:附上”是什么错、为什么、怎么办”的线索。比如”文件不存在”比”操作失败”更具体;“文件 /a.ts 不存在,目录下有 [b.ts, c.ts]“又提供了可操作的下一步。
- 识别不了的不要硬编码描述:直接
throw err,让框架兜底 catch 把err.message透传出去。不要写throw new Error("操作失败")这种笼统描述——那等于把所有未知错误都涂成同一种颜色,模型无法区分。
这里的“未知错误也变成消息”仍有边界:它指 tool.execute 抛出的未知值会由框架 catch 转换,不代表 Agent 进程里的任意异常都被吞掉。错误描述仍应尽量具体;无从识别时再透传 err.message。
一句话总结
在工具执行边界内,Pi 把失败编码成 isError: true 的 ToolResultMessage。工具实现负责尽量给出具体描述,框架负责把 tool.execute 最终抛出的值转成统一结果,模型再决定重试、换路径或解释。这个边界提高了工具失败后的可恢复性,但不替代宿主层的异常处理。
五、【进阶】Operations 抽象:把可替换的系统能力做成接口
这一节属于软件工程的实现技巧,和 Agent 本身关系不大。如果你只关心 Agent 的运行机制,可以跳过。
问题:工具代码写死了系统调用
Read 工具要读文件,最直接的写法:
const content = fs.readFileSync(path, "utf-8");
但如果你想在测试中 Mock 文件系统呢?如果你想让工具通过 SSH 读远程文件呢?如果你想让工具在 Docker 容器里执行呢?
fs.readFileSync 是写死的——它只认本地文件系统。想换执行环境,就得改工具代码。
解法:需要可替换的能力通过接口注入
Pi 的部分内置工具把关键系统能力定义成最小接口:Read / Write / Edit / Ls 的文件操作,以及 Bash 的命令执行,都可以通过 Operations 注入。但这不是 Agent Core 强制的安全沙箱,也不是所有工具的统一限制:Grep / Find 是混合实现,扩展工具更可以自行调用系统 API。
以 Read 工具为例:
export interface ReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
}
ops 在创建 Read 工具时选定并被闭包捕获;后续每次 execute 都复用同一个对象,文件操作通过它调用:
export function createReadToolDefinition(cwd: string, options?: ReadToolOptions) {
const ops = options?.operations ?? defaultReadOperations;
return {
// ...
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
await ops.access(absolutePath); // 通过接口检查权限
const buffer = await ops.readFile(absolutePath); // 通过接口读文件
// ...
},
};
}
关键区别:
直接调用 fs(硬编码) | 通过 Operations 接口(可替换) |
|---|---|
fs.readFile(path) | ops.readFile(path) |
| 只能读取本地文件 | 可注入本地、SSH 或 Mock 实现 |
| 测试需要创建真实文件 | 测试可注入内存实现 |
Operations 在工具创建时被闭包捕获。后续每次执行都用同一套实现。不同环境注入不同的 Operations 实现,工具代码一行不用改:
// 本地执行(默认)
const localTool = createReadToolDefinition(cwd); // 用 defaultReadOperations
// 单元测试(Mock)
const mockTool = createReadToolDefinition(cwd, {
operations: {
readFile: async () => Buffer.from("mock file content"), // 不需要创建真实文件
access: async () => {}, // resolve 就表示可访问
}
});
// 远程执行(假设 ssh 客户端已经提供匹配 ReadOperations 的 Promise 接口)
declare const ssh: {
readFile(path: string): Promise<Buffer>;
access(path: string): Promise<void>;
};
const remoteTool = createReadToolDefinition(cwd, {
operations: {
readFile: (path) => ssh.readFile(path),
access: (path) => ssh.access(path),
}
});
每个工具定义自己需要的最小接口
一个有趣的细节:接口是按工具需求裁剪的,不是大一统的。
| 工具 | 接口 | 方法 |
|---|---|---|
| Read | ReadOperations | readFile, access(另含可选 detectImageMimeType) |
| Write | WriteOperations | writeFile, mkdir |
| Edit | EditOperations | readFile, writeFile, access |
| Bash | BashOperations | exec |
| Grep | GrepOperations | isDirectory, readFile;搜索进程仍由工具直接启动 |
| Find | FindOperations | exists, glob;默认查找路径仍由工具直接启动进程 |
| Ls | LsOperations | exists, stat, readdir |
Read 工具不需要写文件,所以 ReadOperations 没有 writeFile。这些接口都只描述各工具选择开放给宿主替换的能力;它们不一定覆盖该工具的全部系统交互。例如 Grep 的 Operations 负责路径判断与上下文读取,默认搜索本身仍直接启动 rg。
代码来源:
read.ts:43-50/write.ts:25-30/edit.ts:74-81/bash.ts:40-58/grep.ts:51-56/find.ts:41-46/ls.ts:32-39
六、方法论提炼
回顾整个工具系统,有四个设计模式值得在自己的 Agent 项目中复用:
1. 分层接口递进法:基础层只管”能描述”(Tool),运行时层加”能执行”(AgentTool),产品层加”能展示和扩展”(ToolDefinition)。通过包装器桥接层间差异。
2. 管道+钩子模式:核心流程是一条管道(prepare → validate → execute),执行前后各有一个可选钩子(before/after)。工具边界会把这些阶段中捕获的失败编码成结果消息;事件监听器和宿主回调仍可能向外抛。
3. 工具错误即消息原则:准备、执行和后处理阶段的失败统一编码成 isError: true 的 ToolResultMessage,让模型能基于错误信息继续决策。这个原则不覆盖事件监听器和宿主回调。
4. Operations 抽象法:把需要替换的系统能力收进最小 Operations 接口,测试可以 Mock,远程实现也可注入。它是工具级可替换 seam,不自动构成权限边界;未被接口覆盖的代码仍可能直接访问系统 API。
七、收尾
回到开场的问题:“当模型说’读取这个文件’,到底发生了什么?”
现在你有完整答案了:
模型输出 ToolCall { name: "read", arguments: { path: "src/main.ts" } }
│
├── 第 1 步:prepareArguments 处理模型怪癖
├── 第 2 步:validateToolArguments 做 Schema 验证
├── 第 3 步:beforeToolCall(若宿主配置)执行策略检查
├── 第 4 步:tool.execute 通过 Operations 接口读文件
│ └── ops.readFile() → 不直接调 fs
└── 第 5 步:afterToolCall 做结果后处理
│
▼
ToolResultMessage { content: 文件内容, isError: false }
│
▼ 追加到对话历史,下一轮发给模型
工具不是简单的函数调用,而是一条受控管道。转换后仍无效的参数会被 Schema 检查挡住;宿主配置 beforeToolCall 后可以拦截操作;Operations 抽象让部分系统能力可替换。工具边界内从参数验证到 execute/after hook 的失败会被翻译成 isError: true 的 ToolResultMessage,让模型有机会继续;边界外异常仍交给宿主处理。
但还有一个问题:工具执行时发出的 tool_execution_start、tool_execution_update、tool_execution_end 事件,到底是谁在监听?Agent 内核为什么完全不需要知道 UI 的存在?
下一章,我们打开 Agent 的”记忆系统”——消息系统。不,等等——在那之前,还有一个更基础的问题:这些消息到底长什么样?工具结果消息、模型回复消息、用户输入消息,它们的结构是什么?Agent 内部的消息和发给模型的消息一样吗?
本章关键源码索引:
packages/ai/src/types.ts:427-431— Tool(第一层)packages/agent/src/types.ts:371-394— AgentTool(第二层)packages/coding-agent/src/core/extensions/types.ts:435-482— ToolDefinition(第三层)packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-18— wrapToolDefinition(包装器)packages/agent/src/agent-loop.ts:562-626— prepareToolCall(五步管道的前 3 步)packages/agent/src/agent-loop.ts:628-669— executePreparedToolCall(第 4 步 + 框架兜底 catch)packages/agent/src/agent-loop.ts:671-714— finalizeExecutedToolCall(第 5 步)packages/agent/src/agent-loop.ts:716-721— createErrorToolResult(错误消息搬运函数)packages/coding-agent/src/core/tools/bash.ts:380-407— Bash 工具区分抛错与非零退出码packages/coding-agent/src/core/tools/read.ts:284-287— Read 工具附加文件总行数packages/coding-agent/src/core/tools/edit.ts:328-330— Edit 工具附加文件路径与 code/错误文本packages/coding-agent/src/core/tools/read.ts:43-50— ReadOperations(Operations 抽象)