6.4 工具系统:定义、校验与执行
本页分析版本earendil-works/pi@16787ad2026-09-21本章解决什么问题:5.3 沿着一次请求走完了工具回环,但没有回答「一个工具到底是什么东西」。本章把工具系统当作一个独立模块拆开:同一个工具在三个 package(包)里有三种类型;模型生成的参数究竟在哪一层被拦下;八个内置工具由谁创建、谁包装、谁激活;以及工具输出如何在「说清楚」与「别把上下文撑爆」之间取舍。 前置知识:2.1 类型入门(编译期与运行时的分界)、3.4 工具调用、5.3 一次 Tool Call 的完整循环、6.3 pi-agent-core。 学习目标:① 说清
Tool/AgentTool/ToolDefinition三层类型各自多了什么、为什么要分三层;② 指出 typebox 校验的确切调用点,并解释它为什么不在工具内部、也不在 Provider(模型服务提供方)层;③ 记住八个内置工具与默认激活的四个,说出注册链路上的每个函数;④ 逐段读懂 read 工具从参数到输出的全过程;⑤ 讲清三种截断策略的分工与OutputAccumulator的作用;⑥ 分清 agent 包的harness/tools与 coding-agent 的core/tools是两套并行实现。
建立直觉:说明书在天上,函数在地上
一个工具由两半组成,而且这两半从不在同一台机器上:
- 说明书:工具叫什么、干什么用、参数长什么样。这一半会被序列化进 HTTP 请求,跟着每一轮对话飞到模型服务那边。模型能看到的只有这三样东西。
- 函数体:真正打开文件、真正 fork 出一个 shell 进程的那段代码。它永远留在你的机器上,模型看不到、也无法直接触发它——模型只能「请求」调用,由 Agent Loop(Agent 循环)决定要不要执行、怎么执行。
执行部分(execute)是本地函数,模型永远拿不到它。
所以「模型调用了一个工具」这句话严格来说是错的:模型只是在文本流里吐出了一个结构化的调用请求,执行是本地 harness(运行框架)自己做的决定。
Pi 的工具系统就是围绕这条分界线组织的。往下走会看到:三层类型的差别,本质上是「离模型有多远」——最内层只有说明书,往外每加一层,就多一点只有本地才关心的东西。
三层洋葱:Tool → AgentTool → ToolDefinition
最内层在 pi-ai 包,只描述「发给模型的那份声明」:
Tool// packages/ai/src/types.ts:600-605
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
}往外一层是 agent 包(npm 包名 @earendil-works/pi-agent-core),它给声明补上了执行能力:
AgentTool// packages/agent/src/types.ts:443-457(有省略)
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
label: string;
prepareArguments?: (args: unknown) => Static<TParameters>;
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
// …(省略:458-467 行 replay、executionMode 及其注释)
}execute 上面那行注释是整个工具系统最重要的一条约定(源码事实,packages/agent/src/types.ts:451):失败就抛异常,不要把错误编码进 content。为什么?因为「转成模型能读懂的错误结果」这件事必须统一做,否则每个工具都会发明自己的错误格式。统一转换的代码在循环里(packages/agent/src/agent-loop.ts:804-810)。
最外层是 coding-agent 包的 ToolDefinition——这是 Pi 命令行界面(CLI)内部真正使用的形态:
ToolDefinitionTool(pi-ai) | AgentTool(pi-agent-core) | ToolDefinition(coding-agent) | |
|---|---|---|---|
| 定义位置 | packages/ai/src/types.ts:600 | packages/agent/src/types.ts:443 | packages/coding-agent/src/core/extensions/types.ts:455 |
| 声明字段 | name / description / parameters | 继承,加 label | 同左 |
| 执行 | 无 | execute 四参数 | execute 五参数(末位 ctx: ExtensionContext) |
| 提示词元数据 | 无 | 无 | promptSnippet / promptGuidelines |
| 终端界面渲染 | 无 | 无 | renderCall / renderResult / renderShell |
| 谁消费它 | Provider 适配器序列化成请求字段 | Agent Loop 查找与执行 | AgentSession 注册表、系统提示词、TUI |
三层的分工可以这样记:pi-ai 只关心「发出去的样子」,pi-agent-core 只关心「怎么跑」,coding-agent 才关心「怎么显示、怎么写进提示词」。分层的收益是 pi-agent-core 可以被任何应用复用(它不知道终端界面的存在);代价是层与层之间需要适配器,也就是后面要讲的 wrapToolDefinition。
AgentToolResult<T>(packages/agent/src/types.ts:420-432)有两个内容字段:content 是给模型看的文本或图片,details 是给日志和界面看的结构化数据。T 默认是 JsonValue | undefined:details 会随 toolResult 消息一起落盘,所以 pi-ai 把它限定为能原样存成 JSON 的值(ToolResultMessage 的定义,packages/ai/src/types.ts:539-551)。read 工具往 details 里塞的是截断信息(ReadToolDetails,read.ts:27-29),终端界面据此显示「已截断」的提示,而模型完全不会看到 details。一个结果,两个受众。 一份 schema,三个用途
所有内置工具的 parameters 都用 typebox 写成(三个包统一锁定 typebox@1.3.27,见 packages/agent/package.json:67、packages/coding-agent/package.json:68、packages/ai/package.json:76)。read 工具的参数声明只有五行:
// packages/coding-agent/src/core/tools/read.ts:14-18 与 :25
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
export type ReadToolInput = Static<typeof readSchema>;这五行同时干了三件事,这也是 typebox 与「先写 TypeScript 类型再手写校验」相比的核心优势:
- 它就是一份 JSON Schema(用文本描述数据结构的通用规范)。Anthropic 适配器取出它的
properties与required,放进请求的input_schema字段(源码事实,packages/ai/src/api/anthropic-messages.ts:1466-1488),不需要额外的格式转换。 - 它是运行时校验规则,下一节详述。
Static<typeof readSchema>把它反推成 TypeScript 类型,于是execute里的params.path有编译期补全。
在 Pi 仓库根目录可以三十秒内验证前两件事(真实运行,输出如下):
node --input-type=module -e "
import { Type } from 'typebox';
import { Value } from 'typebox/value';
import { Compile } from 'typebox/compile';
const s = Type.Object({ path: Type.String(), offset: Type.Optional(Type.Number()) });
console.log(JSON.stringify(s));
const args = { path: 'a.ts', offset: '12' };
Value.Convert(s, args);
console.log('after Convert:', JSON.stringify(args));
const v = Compile(s);
console.log('Check:', v.Check(args));
console.log('Check(bad):', v.Check({ offset: 3 }));
console.log([...v.Errors({ offset: 3 })].map(e => e.instancePath + ' ' + e.message));
"{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"offset":{"type":"number"}}}
after Convert: {"path":"a.ts","offset":12}
Check: true
Check(bad): false
[ ' must have required properties path' ]注意第二行:字符串 "12" 被 Value.Convert 原地改成了数字 12。模型有时会把数字写成字符串,这一步先做「善意的类型强转」,再校验——Pi 用的正是这套组合。
同一份 schema 还能再往前用一步:让模型在生成参数时就受它约束。内置的 read / bash / edit / write 在定义里都声明了 constrainedSampling: { type: "json_schema", strict: "prefer" }(例如 read.ts:80、bash.ts:243;powershell 复用 bash 的定义,grep / find / ls 没有声明)。这是在请求 Provider 做受约束采样(Constrained Sampling):解码过程本身被限制在 schema 允许的形状里,模型想写错格式都写不出来。"prefer" 的意思是能开就开——Provider 支持严格模式、且 schema 能改写成严格子集时才启用,做不到就退回普通模式;只有 "require" 才会直接报错(resolveJsonSchemaStrictSampling,packages/ai/src/api/constrained-sampling.ts:208-228)。严格子集要求每个属性都列进 required,所以改写时把可选属性变成「原类型或 null」(constrained-sampling.ts:106-113)。记住这一点,下一节校验实现里的第一步就好理解了。
校验发生在哪一层:2.1 那个伏笔的兑现
2.1 留下过一个明确的缺口:类型只存在于编译期,运行时的数据它管不着。工具参数正是这个缺口最危险的入口——它由模型在运行时生成,是一段谁也不能保证格式的 JSON 文本。
Pi 把这道关卡放在了 Agent Loop 里、工具执行之前,具体就一行:
validateToolArguments// packages/agent/src/agent-loop.ts:719-721
try {
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);为什么放这里,而不是别的地方?可以反过来想三种备选方案:
- 放进每个工具的 execute 开头:八个内置工具、加上扩展(Extension)注册的任意多个工具,每个都要写一遍同样的校验代码,且没人能保证扩展作者会写。
- 放在 Provider 适配器里(收到模型响应时立刻校验):那一层只知道
toolCall.name是个字符串,手上没有工具列表,也不该知道。 - 不校验,直接执行:
params.path在编译期是string,运行时可能是undefined、数组、甚至一段 JSON 截断后的残片——readFile(undefined)的报错信息对模型毫无帮助。
放在循环里是唯一同时满足「只写一遍」「手上有工具定义」「出错还能补救」的位置。校验实现本身在 pi-ai:
validateToolArguments四个容易读漏的细节(均为源码事实):
- 校验器是编译并缓存的。
getValidator用Compile(schema)生成校验函数,并以 schema 对象为键存进WeakMap(packages/ai/src/utils/validation.ts:271-280,缓存声明在:6)。内置工具的 schema 是模块级常量,因此每轮对话都命中缓存。 - 可选参数的 null 按「没给」处理。受约束采样下,模型会给可选参数填
null;normalizeOptionalNulls(:240-269)在强转之前(调用点:319)把这类 null 删掉;属性本身允许 null(schema 里写了 null 类型)或是必填属性时则保留原值。这条行为有专门的测试:packages/ai/test/validation.test.ts:101「treats null as omission for optional non-nullable properties」。 - 有一条「不是 typebox」的兼容分支。当
parameters缺少 typebox 的标记 symbol(例如扩展从 JSON 文件里读进来的裸 schema)时,先走手写的 JSON Schema 强转(:323-335)。 - 错误消息是写给模型看的。失败时把每条错误的路径与原始参数一起拼进异常(
:341-349),模型读到Validation failed for tool "read":加逐条说明后,通常能自己改正重发。
这条「错误即对话内容」的处理观在 5.3 讲过:异常在 prepareToolCall 的 catch 里被转成 isError 的工具结果(packages/agent/src/agent-loop.ts:764-770),循环不会因为模型写错参数而中断。
官方文档说明这一顺序:packages/agent/README.md:126 写明 beforeToolCall 钩子运行在「tool_execution_start 与校验过的参数解析之后」——与源码里 721 行(校验)先于 722 行(钩子)一致。
beforeToolCall 拿到的是校验后的 validatedArgs(agent-loop.ts:727),钩子对它的原地修改不会触发二次校验。这个行为被一条测试固化下来:packages/agent/test/agent-loop.test.ts:480「should execute mutated beforeToolCall args without revalidation」。源码与注释都没有解释这是刻意的信任边界还是历史行为——据此推断(尚未在源码中直接证实),扩展被视为与宿主同等可信,所以不再设防。作为对照,agent 包里 AgentHarness 自己的执行路径做法不同:钩子若返回替换参数,applyBeforeToolDecision 会对新参数再跑一次 validateToolArguments(packages/agent/src/harness/execution/tools.ts:101-122)。两条路径的差别见本章最后一节。 八个内置工具与注册链路
Pi 的 CLI 内置八个工具。pi --help 打印的帮助文本把它们和默认开关一起列了出来(真实采集,research/cli-captures/pi-help.txt:177-185;源码 packages/coding-agent/src/cli/args.ts:438-446):
Built-in Tool Names:
read - Read file contents
bash - Execute bash commands
powershell - Execute PowerShell commands on Windows
edit - Edit files with find/replace
write - Write files (creates/overwrites)
grep - Search file contents (read-only, off by default)
find - Find files by glob pattern (read-only, off by default)
ls - List directory contents (read-only, off by default)官方文档 packages/coding-agent/docs/usage.md:218 给出同一份清单。源码侧的权威定义是一个字面量联合类型加一个集合(源码事实,packages/coding-agent/src/core/tools/index.ts:95-105):
| 工具 | 参数 schema 位置 | 输出截断策略 | 默认激活 | 备注 |
|---|---|---|---|---|
read | read.ts:14-18 | truncateHead | 是 | 文本按行截断;图片走附件分支 |
bash | bash.ts:38-41 | truncateTail | 是 | 流式 onUpdate;超限写临时文件 |
powershell | 复用 bash.ts:38-41 | truncateTail | 否 | 面向 Windows;与 bash 共用 createShellToolDefinition(powershell.ts:49-57) |
edit | edit.ts:21-42 | 不截断(返回差异) | 是 | 走文件互斥队列;兼容单个编辑对象的输入 |
write | write.ts:11-14 | 不截断 | 是 | 走文件互斥队列 |
grep | grep.ts:21-33 | truncateLine | 否 | 依赖 ripgrep |
find | find.ts:26-32 | — | 否 | 按 glob 找文件 |
ls | ls.ts:11-14 | — | 否 | 列目录 |
「默认激活」这一列来自 createAgentSession 里的硬编码清单 ["read", "bash", "edit", "write"](源码事实,packages/coding-agent/src/core/sdk.ts:258)。这份默认值可以用设置项 defaultTools 替换:没有传 --tools 也没有 --no-tools / --no-builtin-tools 时,启动时激活的内置工具取 defaultTools,没配才用上面那四个(sdk.ts:259-265;官方文档 packages/coding-agent/docs/settings.md:285-303 给了 ["read", "powershell", "edit", "write"] 这样的 Windows 例子)。八个工具都在注册表里,但默认只有四个会进入 context.tools——powershell / grep / find / ls 造好了却不激活,等着你用设置、命令行开关或扩展打开。
命令行的 --tools / --exclude-tools 走的是另一条更狠的路:它们最终变成 allowedToolNames / excludedToolNames(main.ts:538-543 → packages/coding-agent/src/core/sdk.ts:260-261 → agent-session.ts:426-428),而 isAllowedTool(agent-session.ts:3149-3150)这个判定同时用来过滤注册表(:3159、:3162、:3198)和激活列表(:3214),并把所有被允许的工具直接设为激活(:3216-3221)。换句话说:pi --tools read,grep,find,ls 之后,被排除的四个工具(bash / powershell / edit / write)连注册表里都不剩,扩展也无法把它们打开。
工具是这样被造出来并接到 Agent 上的:
createAllToolDefinitions图 6.4-1 工具的一生:从工厂函数到一条 toolResult 消息
上半部分是构建期,只在会话启动与 reload 时跑一次;下半部分是运行期,每次模型请求工具都跑一遍。请重点关注两点:① 注册表(_toolRegistry)和激活列表(agent.state.tools)是两个东西,前者是全集,后者是子集;② 同一份 tools 数组既被声明给模型(P:写进 transcript 的 system 消息,Provider 发请求时再序列化成 input_schema 之类的字段),又被用来按名字查找本地函数——图中从 AgentContext.tools 分出的两条箭头就是本章开头说的「说明书」与「函数体」。
图里每个节点都对应真实源码。构建期的起点在 AgentSession._buildRuntime(agent-session.ts:3252-3255),Agent 实例创建时 tools 是空数组(packages/coding-agent/src/core/sdk.ts:366-372),要等 setActiveToolsByName 赋值。注意 setActiveToolsByName 除了写 agent.state.tools,还会重建系统提示词的输入(agent-session.ts:1279-1291)——因为激活哪些工具会改变提示词里的 tools 与 rules 两段,详见 6.7 系统提示词。
运行期的 P 节点值得多说一句。Agent Loop 不会把 context.tools 原样塞进每次请求,而是在每次请求前把它与 transcript 里已经声明过的工具比较,把差异写成一条 system 消息的 toolsAdded / toolsRemoved(declareToolChanges,packages/agent/src/agent-loop.ts:322-362;调用点在循环开头 :109 与每轮开头 :210)。Provider 发请求时再按顺序重放这些 system 消息,得到当前的完整工具列表(getCurrentTools,例如 packages/ai/src/api/anthropic-messages.ts:1139)。所以一个扩展在工具执行期间注册并激活了新工具,下一轮的 context.tools 会从 agent.state.tools 重新取(packages/coding-agent/src/core/agent-session.ts:715),下一次请求前模型就能「看到」它——这就是「调用一个工具后解锁另一批工具」的实现方式,细节在 7.1 Extension 系统。
read 工具全路径走读
read 是最适合精读的工具:无副作用、分支清晰,而且藏着几处「真实世界补丁」。
resolveReadPathAsync图 6.4-2 read 工具的数据流:从一个字符串路径到一段带提示的文本
读的顺序是从上往下。左侧两个分支节点是路径解析的两次兜底,右侧的菱形是唯一的分岔点。值得留意的是最后两步的顺序:先截断,再拼提示——截断结果里的 outputLines 决定了提示里那个 offset 该写几。
几处值得停下来看的地方:
路径解析的三重兜底。 resolveToCwd 除了展开 ~ 和处理相对路径,还会剥掉开头的 @(packages/coding-agent/src/core/tools/path-utils.ts:48-50 的 stripAtPrefix: true)。官方文档对此有一句相当直白的说明(packages/coding-agent/docs/extensions.md:2026):有些模型会把用户输入里的 @文件名 语法原样带进工具参数,内置工具因此统一剥掉前导 @。若首次解析找不到文件,还会依次尝试四种 macOS 文件名变体:窄不换行空格、NFD 分解形式、弯引号,以及 NFD 加弯引号的组合(path-utils.ts:93-115)。这些分支不是理论洁癖,是被真实的截图文件名逼出来的。
图片按魔数而不是扩展名判断。 ops.detectImageMimeType 读文件头部字节来认类型,因此一个内容是文本、名字叫 .png 的文件会被当文本处理——这条行为有对应测试(packages/coding-agent/test/tools.test.ts:200 与 :240)。
ops 是可注入的。 ReadOperations(read.ts:35-48)把 readFile / access / detectImageMimeType 三个动作抽成接口,默认实现打的是本地文件系统(read.ts:44-48)。注释写明这是为了把文件读取委托给远程系统(例如 SSH)。内置工具普遍有各自的 Operations 接口(bash.ts:59-78、edit.ts:83-90、write.ts:27-32、grep.ts:53-58;powershell 直接复用 BashOperations,powershell.ts:23)。
参数到这里已经可信。 execute 内部不再判断 path 是不是字符串,只做业务判断(offset 越界抛错,read.ts:142-144)。这正是上一节那道关卡换来的收益。
截断策略:上下文是稀缺资源
工具输出会一字不差地进入下一轮请求的上下文。一次 cat 大文件或者 npm install 的完整日志就能吃掉几万 token。truncate.ts 用两个常数划下红线(源码事实,packages/coding-agent/src/core/tools/truncate.ts:11-12):DEFAULT_MAX_LINES = 2000、DEFAULT_MAX_BYTES = 50 * 1024,先撞上哪条算哪条。
同一个文件里给出三种策略,对应三种「读者最想看哪一段」的判断:
| 函数 | 位置 | 保留哪一段 | 谁在用 | 为什么 |
|---|---|---|---|---|
truncateHead | truncate.ts:78 | 开头 | read | 读文件从头读,续读靠 offset |
truncateTail | truncate.ts:168 | 结尾 | bash(经 OutputAccumulator) | 报错信息与最终结果都在末尾 |
truncateLine | truncate.ts:268 | 单行前 500 字符 | grep | 防一行超长的压缩文件把结果撑爆 |
truncateTailtruncateHead 有个对称的边界处理:首行独自超限时返回空内容并置 firstLineExceedsLimit(truncate.ts:103-119),read 据此给模型一条兜底建议——改用 bash 跑 sed -n 'Np' 文件 | head -c 51200(read.ts:158-161)。
这引出全书反复出现的一条设计原则:截断必须自带「怎么继续」的说明。read 截断时拼的是 [Showing lines 1-2000 of 2500. Use offset=2001 to continue.](read.ts:163-173);用户自己给的 limit 提前截住时拼的是 [N more lines in file. Use offset=M to continue.](read.ts:174-178);bash 截断时拼的是 [Showing lines … Full output: /tmp/pi-bash-xxxx.log](bash.ts:321-340)。模型读到的不是「内容没了」,而是「内容在别处,这样拿」。
bash 的流式累积器
bash 和 read 有个根本差别:read 一次拿到完整内容,bash 的输出是一段段流进来的,而且可能持续几分钟。OutputAccumulator 就是为这个场景写的(packages/coding-agent/src/core/tools/output-accumulator.ts:35-222):
它同时解决三个问题(源码事实):
- 内存有界:内部只保留约
2 × maxBytes的滚动尾部(trimTail,output-accumulator.ts:179-194),不会因为一条yes命令把进程撑爆。 - 完整输出不丢:一旦总量超限就把原始字节写进
${tmpdir}/pi-bash-<hex>.log(ensureTempFile,:211-221;文件名生成在:19-22),于是模型可以用grep去查那份完整日志。 - 跨块的 UTF-8 不会乱码:用流式
TextDecoder(:40、:70)增量解码,一个中文字被切在两个数据块之间也能正确拼回——这条有专门的测试(packages/coding-agent/test/tools.test.ts:746「should decode UTF-8 characters split across output chunks」)。
界面上那种「命令还在跑,输出一行行往外冒」的效果,来自 bash 工具以 100 毫秒为间隔节流调用 onUpdate(BASH_UPDATE_THROTTLE_MS,定义在 renderers/bash.ts:19;节流逻辑 bash.ts:286-299),循环把每次回调转成 tool_execution_update 事件(agent-loop.ts:786-799)。另外 bash 把非零退出码(以及拿不到退出码的情况)转成异常抛出(bash.ts:368-373)——回到那条约定:失败就抛。
两个适配器与一把锁
wrapToolDefinition:五参数变四参数
Agent Loop 只认识四参数的 AgentTool,而 coding-agent 的工具是五参数的 ToolDefinition。中间这层适配器只有十几行:
// packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20(有省略)
export function wrapToolDefinition<TDetails = unknown>(
definition: ToolDefinition<any, TDetails>,
ctxFactory?: () => ExtensionContext,
): AgentTool<any, TDetails> {
return {
name: definition.name,
// …(省略:11-16 行,label/description/parameters/constrainedSampling/prepareArguments/executionMode 逐个搬运)
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
definition.execute(toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.() as ExtensionContext)),
};
}它包装的东西可以一句话概括:把「上下文从哪来」这个问题从循环手里挪走。循环只管传四个参数,第五个由工厂函数在调用时现取。createReadTool 就是 wrapToolDefinition(createReadToolDefinition(...))(read.ts:201-203)。
被原样搬过去的字段里,prepareArguments 值得看一眼真实用法。模型发来的 toolCall.arguments 在 pi-ai 里被限定为 JSON 对象(JsonObject,packages/ai/src/types.ts:386-394),但对象里面长什么样,不同模型各有各的「方言」。edit 工具的 prepareEditArguments(edit.ts:103-134)就是一个兼容垫片:有的模型把 edits 数组写成一段 JSON 字符串,有的只给一个 { oldText, newText } 对象而不是一元素数组,还有的沿用旧版的顶层 oldText / newText 字段——垫片把这几种写法都整理成标准的 edits 数组,然后才交给 schema 校验。
反方向也有一个适配器:createToolDefinitionFromAgentTool(tool-definition-wrapper.ts:36-47)把外部传进来的裸 AgentTool 合成一个最小 ToolDefinition(没有提示词元数据、没有渲染函数)。它的存在是为了让 AgentSession 的注册表始终是 definition-first——因为系统提示词和终端界面渲染都只从 definition 上取字段(调用点 agent-session.ts:3244-3251)。
注册表里的每个工具——内置的和扩展注册的都一样——都是经 wrapRegisteredTools 包出来的(packages/coding-agent/src/core/extensions/wrapper.ts:17-27;两处调用点在 agent-session.ts:3195 与 :3196)。这一层很薄:它就是 wrapToolDefinition,只是把第五个参数的来源固定成扩展运行器的 runner.createContext(),让工具和事件处理器拿到同一份上下文。工具调用前后的拦截(tool_call / tool_result 事件)不在这里做,而是由 AgentSession 通过 agent-core 的 beforeToolCall / afterToolCall 钩子完成(wrapper.ts:1-6 的文件注释)。
file-mutation-queue:并行执行下的写冲突
工具默认并行执行(官方文档 packages/agent/README.md:117-122)。于是一个真实风险出现了:模型在同一批里请求 edit 和 write 改同一个文件,两个 execute 同时读到旧内容、各自算出新内容,后写的覆盖先写的。
withFileMutationQueue键的选择很关键:先 resolve 成绝对路径,再用 realpath 归一(file-mutation-queue.ts:16-26),因此指向同一个文件的两个符号链接会落到同一把锁上;文件还不存在时(ENOENT)退回用解析后的绝对路径当键。write 与 edit 各有一处调用点(write.ts:67、edit.ts:163)。官方文档还建议扩展作者的自定义工具也用它(packages/coding-agent/docs/extensions.md:2028),并给出了失败场景的例子。
executionMode: "sequential",整批就退回顺序执行(agent-loop.ts:512-519)。但内置的 edit / write 都没有声明 sequential——它们选择了更细的粒度:并行跑,只在真正碰同一个文件时排队。这样两个改不同文件的 edit 仍然是并发的。 两套 tools:harness/tools 与 core/tools
读到这里可能会困惑:agent 包里也有一个 read.ts,而且 description 文案和 coding-agent 的逐字相同。它们是什么关系?
结论先说:两套并行实现,互不引用(源码事实)。
packages/agent/src/harness/tools/ | packages/coding-agent/src/core/tools/ | |
|---|---|---|
| 工具数量 | 4 个:read / bash / edit / write | 8 个,多 powershell / grep / find / ls |
| 工具类型 | AgentHarnessTool(harness/types.ts:107-122) | ToolDefinition |
| 文件与 shell | 全部走抽象的 ExecutionEnv(harness/types.ts:407),由工具上下文的 env 字段提供(harness/tools/tool-context.ts:4-6) | 直接用 node:fs 与子进程 |
| 错误风格 | Result 值 + getOrThrow | 直接抛异常 |
| 提示词与渲染 | 无 | 有 promptSnippet 与渲染函数 |
| 服务对象 | AgentHarness(库使用者) | Pi CLI 本体 |
AgentHarnessTool 的 execute 是六个参数:toolCallId、params、onUpdate,然后是应用自定义的 TContext、一个记录本次调用身份的 invocation(供崩溃恢复后安全重放时使用),以及调用上下文 context(harness/types.ts:113-121)。它不经过 Agent Loop:AgentHarness 有自己的一套执行路径,查找、垫片、校验在 prepareToolCall(packages/agent/src/harness/execution/tools.ts:78-98),执行在 executeToolCall(:125-158),直接按六个参数调用工具。这也是步骤 1 的 grep 里会多出两行 harness/execution/tools.ts 的原因。
关键事实:Pi CLI 的主路径不使用 AgentHarness,也不使用 harness/tools。对 packages/coding-agent/src 下的 core/、modes/ 与 main.ts 搜索 AgentHarness 零命中(本章实践任务会让你亲手验证这一条)。整个 packages/coding-agent/src 里的命中全部集中在 src/experimental/ 目录:那里的实验性服务端进程(例如 experimental/session-worker.ts)用 AgentHarness 驱动会话,并从 pi-agent-core 导入 harness 版的 createReadTool / createWriteTool / createBashTool(session-worker.ts:13-26、:831)。它不在 pi 命令默认的交互、print、RPC 这几条运行路径上,本书不展开。
两套实现存在明显重复:read 工具的 description 逐字相同(harness/tools/read.ts:53 对比 core/tools/read.ts:76),truncate.ts 几乎是复制的,差异只在 harness 版为脱离 Node 运行时手写了 utf8ByteLength(harness/utils/truncate.ts:54),而 coding-agent 版直接用 Buffer.byteLength。据此推断(尚未在源码中直接证实),harness/tools 是更新的一代抽象,目标是让工具不依赖具体后端;packages/agent/docs/ 下的 harness.md、tool-durability.md 等设计文档描述的都是 harness 这一侧的持久化与恢复设计,可佐证这个方向,但仓库里没有「coding-agent 何时迁移过去」的直接陈述。一种看法是这份重复是分层演进的合理代价,换来 pi-agent-core 可以在非 Node 环境里跑;代价是同一个 bug 要修两遍。
读本书主线时请记住:Pi CLI 用的是 core/tools。
完整调用链速查
一次工具调用从头到尾经过的函数(每一环都可用全文搜索复核):
Agent.prompt—packages/agent/src/agent.ts:368- →
runAgentLoop(messages, this.createContextSnapshot(), ...)—packages/agent/src/agent.ts:434(快照把state.tools放进AgentContext.tools,agent.ts:460) - →
runLoop主循环 —packages/agent/src/agent-loop.ts:162 - →
declareToolChanges把context.tools与已声明的工具求差,写成 system 消息的toolsAdded—packages/agent/src/agent-loop.ts:109、:210(函数本体:332);随后streamAssistantResponse把整个 transcript 交给 Provider,由 Provider 重放出工具列表 —:380-406 - → 过滤出 toolCall 内容块 —
packages/agent/src/agent-loop.ts:258 - →
executeToolCalls分派串行/并行 —packages/agent/src/agent-loop.ts:269(分派逻辑:505-520) - →
prepareToolCall:查找:710→ 垫片:720→ 校验:721→ 钩子:722-750 - →
executePreparedToolCall→prepared.tool.execute(...)—packages/agent/src/agent-loop.ts:782 - → 具体工具,例如
createReadToolDefinition的 execute —packages/coding-agent/src/core/tools/read.ts:81-196 - →
finalizeExecutedToolCall(afterToolCall覆盖)—packages/agent/src/agent-loop.ts:816 - →
createToolResultMessage—packages/agent/src/agent-loop.ts:880 - → push 进上下文
:273-276;AgentSession 在message_end里落盘 —packages/coding-agent/src/core/agent-session.ts:922-941
实践任务
目标:不写一行新代码、不需要任何 API Key,亲手确认三件事:① Agent Loop 里的 typebox 校验只有一个调用点;② Pi CLI 的主路径确实不使用 AgentHarness;③ read 工具的 2000 行上限与续读提示是真实生效的。
前提:已按 4.1 的步骤把 Pi 源码放在 _sources/pi 并执行过 npm install。以下命令全部在 Pi 仓库根目录执行,全部只读,不联网。
步骤 1:找出校验调用点。
grep -rn "validateToolArguments" packages/agent/src packages/ai/src --include="*.ts"预期现象:正好七行(真实运行结果):
packages/agent/src/agent-loop.ts:16: validateToolArguments,
packages/agent/src/agent-loop.ts:721: const validatedArgs = validateToolArguments(tool, preparedToolCall);
packages/agent/src/harness/execution/tools.ts:1:import { type ToolResultMessage, validateToolArguments } from "@earendil-works/pi-ai";
packages/agent/src/harness/execution/tools.ts:93: const args = validateToolArguments(tool, preparedCall) as Record<string, JsonValue>;
packages/agent/src/harness/execution/tools.ts:114: const validatedArgs = validateToolArguments(prepared.tool, {
packages/ai/src/utils/validation.ts:307: return validateToolArguments(tool, toolCall);
packages/ai/src/utils/validation.ts:317:export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {前两行在 Agent Loop:第一行是 import,第二行是调用。中间三行属于 AgentHarness 自己的执行路径(上一节讲过,它不经过 Agent Loop,而且会对钩子替换的参数再校验一次)。最后两行在 pi-ai:定义,以及同文件里 validateToolCall 的转调(Agent Loop 没用它,自己做了查找)。Pi CLI 走的 Agent Loop 里,业务调用点只有 721 那一行。
步骤 2:验证两套 tools 的边界。
grep -rn "AgentHarness\|agent-harness" packages/coding-agent/src/core packages/coding-agent/src/modes packages/coding-agent/src/main.ts --include="*.ts"预期现象:零输出(真实运行结果)。这就是「Pi CLI 主路径不使用 AgentHarness」的直接证据。作为对照,把搜索范围放大到整个 packages/coding-agent/src 并加 -l 只列文件名,会看到四个命中文件,全部在 src/experimental/ 下(真实运行结果):
packages/coding-agent/src/experimental/micro/tools.ts
packages/coding-agent/src/experimental/mini/worker/run.ts
packages/coding-agent/src/experimental/session-worker.ts
packages/coding-agent/src/experimental/services/worker.ts步骤 3:跑 read 工具的真实测试。
npm test --workspace=@earendil-works/pi-coding-agent -- \
test/tools.test.ts -t "read tool" --reporter=verbose预期现象:12 条用例执行(✓),其余跳过(↓)。作者本机真实运行的输出节选如下(中间省略了其余 9 条 ✓,耗时会不同):
✓ test/tools.test.ts > Coding Agent Tools > read tool > should truncate files exceeding line limit 1ms
✓ test/tools.test.ts > Coding Agent Tools > read tool > should truncate when byte limit exceeded 1ms
✓ test/tools.test.ts > Coding Agent Tools > read tool > should include truncation details when truncated 1ms
Test Files 1 passed (1)
Tests 12 passed | 72 skipped (84)步骤 4:读断言,对上源码。打开 packages/coding-agent/test/tools.test.ts,看第 100-112 行那条用例:它写入 2500 行,然后断言输出里有 Line 2000、没有 Line 2001,并且包含 [Showing lines 1-2000 of 2500. Use offset=2001 to continue.]。把这三条断言分别对上 truncate.ts:11(2000 这个常数)、truncate.ts:126-137(收集整行的循环)与 read.ts:169(提示文案模板)。
步骤 5(选做):跑校验层自己的测试。
npm test --workspace=@earendil-works/pi-ai -- test/validation.test.ts --reporter=verbose作者本机真实运行的结果是 Tests 9 passed (9),其中「coerces serialized plain JSON schemas with AJV-compatible primitive rules」验证的正是本章说的「非 typebox 纯 JSON Schema」那条分支,「treats null as omission for optional non-nullable properties」验证的是可选参数 null 的处理。
如何判断成功:① 步骤 1 得到七行、步骤 2 的第一条命令得到零行;② 步骤 3 全绿;③ 你能回答:如果把 read.ts:156 的 truncateHead 换成 truncateTail,步骤 4 那条用例会先挂在哪条断言上(提示:expect(output).toContain("Line 1"))。
常见错误:
- 忘了
--include="*.ts":会同时搜到dist/里的编译产物,行数对不上。 -t "read tool"不加引号:shell 会把tool当成第二个文件过滤参数,筛出来的用例数变成 0。- 在
packages/coding-agent目录里执行npm test --workspace=...:--workspace必须在仓库根目录用。 - 用
./test.sh跑全量:能跑通,但要等很久,且会把所有包的输出混在一起。
对应源码位置:packages/agent/src/agent-loop.ts:721(校验调用点)、packages/ai/src/utils/validation.ts:317-350(校验实现)、packages/coding-agent/src/core/tools/read.ts:156 与 :163-178(截断与续读提示)、packages/coding-agent/src/core/tools/truncate.ts:11-12(两个上限常数)。测试文件:packages/coding-agent/test/tools.test.ts:100、:114、:185;packages/ai/test/validation.test.ts:64、:101。
本章小结
- 一个工具 = 一份发给模型的说明书 + 一个只在本地存在的函数。三层类型
Tool→AgentTool→ToolDefinition依次是「发出去的样子」「怎么跑」「怎么显示和进提示词」。 - 参数 schema 用 typebox 写一遍,服务三个用途:当 JSON Schema 下发给模型、当运行时校验规则、当 TypeScript 类型。内置工具还默认请求 Provider 按 schema 做受约束采样(
strict: "prefer",能开就开)。 - Agent Loop 里校验只在一个地方发生:
packages/agent/src/agent-loop.ts:721,夹在兼容垫片与beforeToolCall钩子之间。这是 2.1「编译期管不了运行时数据」那个缺口在 Pi 里的补法。校验失败不会中断循环,而是变成模型能读懂的错误文本。 - 八个内置工具,默认激活四个(read / bash / edit / write;设置项
defaultTools可改)。powershell 面向 Windows,与 bash 共用一套 shell 工具实现。注册表是全集、激活列表是子集;--tools这份 allowlist 更狠——它同时收窄注册表与激活列表。 - 截断是一等公民:
truncateHead(read,要开头)、truncateTail(bash,要结尾)、truncateLine(grep,防单行爆炸),双上限 2000 行 / 50KB 先到先截,且截断信息必须自带「怎么继续」。 - 两个适配器与一把锁:
wrapToolDefinition把五参数适配成四参数(prepareArguments垫片也随之搬运,edit 用它兼容各家模型的参数方言),createToolDefinitionFromAgentTool反向合成,withFileMutationQueue按 realpath 序列化同一文件的并发写。 - agent 包的
harness/tools与 coding-agent 的core/tools是两套并行实现,互不引用;harness 工具只被AgentHarness自己的执行路径使用(coding-agent 里只有src/experimental/用到),本书主线跟的是后者。 - 关键术语:Tool / AgentTool / ToolDefinition、typebox、JSON Schema、运行时校验、截断(Truncation)、Operations 注入、文件互斥队列、ExecutionEnv。
- 关键源码索引:
packages/ai/src/types.ts:600(Tool)、packages/agent/src/types.ts:443(AgentTool)、packages/coding-agent/src/core/extensions/types.ts:455(ToolDefinition)、packages/agent/src/agent-loop.ts:721(校验调用点)、packages/ai/src/utils/validation.ts:317(校验实现)、packages/coding-agent/src/core/tools/index.ts:95-105(八个工具名)与:182(成组工厂)、packages/coding-agent/src/core/sdk.ts:258-265(默认四个与defaultTools)、packages/coding-agent/src/core/agent-session.ts:1279(激活)、packages/agent/src/agent-loop.ts:332(declareToolChanges)、packages/ai/src/api/constrained-sampling.ts:208(受约束采样)、packages/coding-agent/src/core/tools/read.ts:66-199、powershell.ts:49-57、truncate.ts:11-13、output-accumulator.ts:35-222、tool-definition-wrapper.ts:5-47、file-mutation-queue.ts:32-61。测试:packages/coding-agent/test/tools.test.ts、packages/ai/test/validation.test.ts、packages/agent/test/agent-loop.test.ts。 - 自测问题:① 模型发来
{"path": ["a.ts"]}调用 read,这次调用会走到read.ts的 execute 吗?如果发来的是{"path": 123}呢?错误信息最终以什么形式出现在对话里?②--tools read,grep之后,注册表里还有几个工具?context.tools里有几个?③ bash 输出 10 万行时,模型看到的是哪 2000 行?完整输出去哪了?④ 为什么edit不直接声明executionMode: "sequential",而要用文件互斥队列? - 下一章:6.5 Session 存储格式与会话树——工具结果作为
toolResult消息落盘之后,会话文件长什么样。 - 尚未展开的内容:扩展如何用
pi.registerTool()注册自定义工具、如何用同名工具覆盖内置工具,见 7.3 自定义工具与斜杠命令;工具调用与结果在终端界面上的渲染(renderCall/renderResult)见 6.9 pi-tui;promptSnippet/promptGuidelines如何拼进系统提示词见 6.7 系统提示词与 Prompt Templates;grep 工具依赖的 ripgrep 二进制如何定位与下载(grep.ts:7的ensureTool)本书未展开。
✅ 自测问题参考答案先自己回答,再点开对照
- 不会。校验发生在工具之外——
agent-loop.ts:721的validateToolArguments是 Agent Loop 里唯一的业务调用点,path声明是 string 而模型给了数组,Value.Convert没法把数组转成字符串,typebox 在这里就把它拦下,read.ts的 execute 一次都不会被调用。错误最终以一条isError: true的 toolResult 消息出现在对话里,内容是Validation failed for tool "read":加逐条错误路径(这里是- path: must be string),末尾还附上Received arguments:与原始 JSON——因为这段文字是写给模型看的,要让它知道自己当时发了什么。{"path": 123}则不同:Value.Convert会先把数字123强转成字符串"123",校验通过,execute 会被调用(本书实测validateToolArguments返回{"path":"123"});之后 read 在ops.access那一步找不到名为123的文件而抛错,同样变成一条isError: true的 toolResult。 - 注册表里只剩 2 个(read 与 grep),
context.tools里也是 2 个。--tools是一份 allowlist,它比「默认激活四个」更狠:isAllowedTool同时用来过滤注册表(agent-session.ts:3159、:3162、:3198)和激活列表(:3214),并把所有被允许的工具直接设为激活(:3216-3221)。所以被排除的六个内置工具连注册表里都不剩,扩展也没法再把它们打开。对比一下不加--tools、也没配defaultTools的默认情形:注册表 8 个、激活 4 个(read/bash/edit/write)。 - 看到的是最后 2000 行——bash 用
truncateTail,因为命令的报错信息和最终结果都在末尾。而且这是「2000 行或 50KB,先到先截」的双上限,10 万行的输出多半在 50KB 上就先被截住,实际行数会少于 2000。完整输出没有丢:OutputAccumulator把原始字节写进${tmpdir}/pi-bash-<hex>.log,路径拼在结果末尾的[Showing lines … Full output: /tmp/pi-bash-xxxx.log]里,模型可以再用 grep 去查那份完整日志。这正是那条设计原则——截断必须自带「怎么继续」的说明。 - 因为
executionMode: "sequential"的粒度太粗:批内只要有一个工具声明它,整批都退回顺序执行(agent-loop.ts:512-519),两个改不同文件的 edit 也会被迫排队,白白丢掉并发。文件互斥队列的粒度是「按文件」——withFileMutationQueue以文件的 realpath 为键(符号链接会归一到同一把锁,文件不存在时退回绝对路径),只有真正碰同一个文件的调用才排队,其余照旧并行。