Pi Agent · Book
M05

第5章:工具系统 —— Agent 的手脚是怎么被管住的

7185字 · 含 297 行代码 · 约 36 分钟

第 3 章讲 Agent Loop 时,我们追踪了”模型决定调用 read 工具”到”工具结果回到模型面前”这段旅程。但当时把它当黑盒跳过了——只说了”Loop 执行工具”,没说具体怎么执行的。

这一章就来打开这个黑盒。

校对口径:本章对应 Pi v0.80.2agent-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 层——纯模型适配层。它唯一关心的事情是:怎么把工具的信息告诉模型。 namedescription 会出现在发给模型的 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 串行:一个批次的工具不是”一起跑就完了”

并行 vs 串行 三阶段设计
并行 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 并发执行,afterToolCalltool_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;
    }
}

两个关键原则

  1. 能识别的错误一定要包装:附上”是什么错、为什么、怎么办”的线索。比如”文件不存在”比”操作失败”更具体;“文件 /a.ts 不存在,目录下有 [b.ts, c.ts]“又提供了可操作的下一步。
  2. 识别不了的不要硬编码描述:直接 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),
    }
});

每个工具定义自己需要的最小接口

一个有趣的细节:接口是按工具需求裁剪的,不是大一统的。

工具接口方法
ReadReadOperationsreadFile, access(另含可选 detectImageMimeType
WriteWriteOperationswriteFile, mkdir
EditEditOperationsreadFile, writeFile, access
BashBashOperationsexec
GrepGrepOperationsisDirectory, readFile;搜索进程仍由工具直接启动
FindFindOperationsexists, glob;默认查找路径仍由工具直接启动进程
LsLsOperationsexists, 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_starttool_execution_updatetool_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 抽象)