6.4 工具系统:定义、校验与执行
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题: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:480-485
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:380-394(有省略)
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>>;
// …(省略:395-402 行 executionMode 及其注释)
}execute 上面那行注释是整个工具系统最重要的一条约定(源码事实,packages/agent/src/types.ts:388):失败就抛异常,不要把错误编码进 content。为什么?因为「转成模型能读懂的错误结果」这件事必须统一做,否则每个工具都会发明自己的错误格式。统一转换的代码在循环里(packages/agent/src/agent-loop.ts:697-703)。
最外层是 coding-agent 包的 ToolDefinition——这是 Pi 命令行界面(CLI)内部真正使用的形态:
ToolDefinitionTool(pi-ai) | AgentTool(pi-agent-core) | ToolDefinition(coding-agent) | |
|---|---|---|---|
| 定义位置 | packages/ai/src/types.ts:480 | packages/agent/src/types.ts:380 | packages/coding-agent/src/core/extensions/types.ts:449 |
| 声明字段 | 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:355-369)有两个内容字段:content 是给模型看的文本或图片,details 是给日志和界面看的结构化数据。read 工具往 details 里塞的是截断信息(ReadToolDetails,read.ts:28-30),终端界面据此显示「已截断」的提示,而模型完全不会看到 details。一个结果,两个受众。 一份 schema,三个用途
所有内置工具的 parameters 都用 typebox 写成(三个包统一锁定 typebox@1.3.7,见 packages/agent/package.json:35、packages/coding-agent/package.json:57、packages/ai/package.json:73)。read 工具的参数声明只有五行:
// packages/coding-agent/src/core/tools/read.ts:20-24
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 适配器直接把它塞进请求的
input_schema字段(源码事实,packages/ai/src/api/anthropic-messages.ts:1318),不需要任何转换。 - 它是运行时校验规则,下一节详述。
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 用的正是这套组合。
校验发生在哪一层:2.1 那个伏笔的兑现
2.1 留下过一个明确的缺口:类型只存在于编译期,运行时的数据它管不着。工具参数正是这个缺口最危险的入口——它由模型在运行时生成,是一段谁也不能保证格式的 JSON 文本。
Pi 把这道关卡放在了 Agent Loop 里、工具执行之前,具体就一行:
validateToolArguments// packages/agent/src/agent-loop.ts:616-618
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:232-241,缓存声明在:6)。内置工具的 schema 是模块级常量,因此每轮对话都命中缓存。 - 有一条「不是 typebox」的兼容分支。当
parameters缺少 typebox 的标记 symbol(例如扩展从 JSON 文件里读进来的裸 schema)时,先走手写的 JSON Schema 强转(:283-295)。 - 错误消息是写给模型看的。失败时把每条错误的路径与原始参数一起拼进异常(
:301-307),模型读到Validation failed for tool "read":加逐条说明后,通常能自己改正重发。
这条「错误即对话内容」的处理观在 5.3 讲过:异常在 prepareToolCall 的 catch 里被转成 isError 的工具结果(packages/agent/src/agent-loop.ts:657-663),循环不会因为模型写错参数而中断。
官方文档说明这一顺序:packages/agent/README.md:122 写明 beforeToolCall 钩子运行在「tool_execution_start 与校验过的参数解析之后」——与源码里 618 行(校验)先于 619 行(钩子)一致。
beforeToolCall 拿到的是校验后的 validatedArgs(agent-loop.ts:624),钩子对它的原地修改不会触发二次校验。这个行为被一条测试固化下来:packages/agent/test/agent-loop.test.ts:444「should execute mutated beforeToolCall args without revalidation」。源码与注释都没有解释这是刻意的信任边界还是历史行为——据此推断(尚未在源码中直接证实),扩展被视为与宿主同等可信,所以不再设防。 七个内置工具与注册链路
Pi 的 CLI 内置七个工具。pi --help 的真实采集输出(research/cli-captures/pi-help.txt:170-177)把它们和默认开关一起列了出来:
Built-in Tool Names:
read - Read file contents
bash - Execute bash commands
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:214 给出同一份清单。源码侧的权威定义是一个字面量联合类型加一个集合(源码事实,packages/coding-agent/src/core/tools/index.ts:83-84):
| 工具 | 参数 schema 位置 | 输出截断策略 | 默认激活 | 备注 |
|---|---|---|---|---|
read | read.ts:20-24 | truncateHead | 是 | 文本按行截断;图片走附件分支 |
bash | bash.ts:40-43 | truncateTail | 是 | 流式 onUpdate;超限写临时文件 |
edit | edit.ts:33-53 | 不截断(返回差异) | 是 | 走文件互斥队列 |
write | write.ts:14-17 | 不截断 | 是 | 走文件互斥队列 |
grep | grep.ts:24-36 | truncateLine | 否 | 依赖 ripgrep |
find | find.ts:20-26 | — | 否 | 按 glob 找文件 |
ls | ls.ts:14-17 | — | 否 | 列目录 |
「默认激活」这一列对应 AgentSession 里那份硬编码清单 ["read", "bash", "edit", "write"](源码事实,packages/coding-agent/src/core/agent-session.ts:2592-2594)。七个工具都在注册表里,但默认只有四个会进入 context.tools 随请求下发——grep / find / ls 造好了却不激活,等着你用命令行开关或扩展打开。
命令行的 --tools / --exclude-tools 走的是另一条更狠的路:它们最终变成 allowedToolNames / excludedToolNames(main.ts:493-499 → packages/coding-agent/src/core/sdk.ts:246 → agent-session.ts:386-387),而 isAllowedTool 这个判定同时用来过滤注册表(agent-session.ts:2473、:2509)和激活列表(:2525),并把所有被允许的工具直接设为激活(:2527-2532)。换句话说:pi --tools read,grep,find,ls 之后,被排除的四个工具连注册表里都不剩,扩展也无法把它们打开。
工具是这样被造出来并接到 Agent 上的:
createAllToolDefinitions图 6.4-1 工具的一生:从工厂函数到一条 toolResult 消息
上半部分是构建期,只在会话启动与 reload 时跑一次;下半部分是运行期,每次模型请求工具都跑一遍。请重点关注两点:① 注册表(_toolRegistry)和激活列表(agent.state.tools)是两个东西,前者是全集,后者是子集;② 同一份 tools 数组既被序列化下发给模型,又被用来按名字查找本地函数——图中从 AgentContext.tools 分出的两条箭头就是本章开头说的「说明书」与「函数体」。
图里每个节点都对应真实源码。构建期的起点在 AgentSession._buildRuntime(agent-session.ts:2563-2566),Agent 实例创建时 tools 是空数组(packages/coding-agent/src/core/sdk.ts:294-300),要等 setActiveToolsByName 赋值。注意 setActiveToolsByName 除了写 agent.state.tools,还会重建系统提示词(agent-session.ts:936-940)——因为激活哪些工具会改变提示词里的「可用工具」小节,详见 6.7 系统提示词。
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:1865):有些模型会把用户输入里的 @文件名 语法原样带进工具参数,内置工具因此统一剥掉前导 @。若首次解析找不到文件,还会依次尝试四种 macOS 文件名变体:窄不换行空格、NFD 分解形式、弯引号,以及 NFD 加弯引号的组合(path-utils.ts:93-115)。这些分支不是理论洁癖,是被真实的截图文件名逼出来的。
图片按魔数而不是扩展名判断。 ops.detectImageMimeType 读文件头部字节来认类型,因此一个内容是文本、名字叫 .png 的文件会被当文本处理——这条行为有对应测试(packages/coding-agent/test/tools.test.ts:188 与 :228)。
ops 是可注入的。 ReadOperations(read.ts:43-56)把 readFile / access / detectImageMimeType 三个动作抽成接口,默认实现打的是本地文件系统(read.ts:52-56)。注释写明这是为了把文件读取委托给远程系统(例如 SSH)。七个内置工具都有各自的 Operations 接口(bash.ts:56-74、edit.ts:74-87、write.ts:25-30、grep.ts:51-61)。
参数到这里已经可信。 execute 内部不再判断 path 是不是字符串,只做业务判断(offset 越界抛错,read.ts:274-276)。这正是上一节那道关卡换来的收益。
截断策略:上下文是稀缺资源
工具输出会一字不差地进入下一轮请求的上下文。一次 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:290-293)。
这引出全书反复出现的一条设计原则:截断必须自带「怎么继续」的说明。read 截断时拼的是 [Showing lines 1-2000 of 2500. Use offset=2001 to continue.](read.ts:295-305);用户自己给的 limit 提前截住时拼的是 [N more lines in file. Use offset=M to continue.](read.ts:306-310);bash 截断时拼的是 [... Full output: /tmp/pi-bash-xxxx.log](bash.ts:404-420)。模型读到的不是「内容没了」,而是「内容在别处,这样拿」。
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:690「should decode UTF-8 characters split across output chunks」)。
界面上那种「命令还在跑,输出一行行往外冒」的效果,来自 bash 工具以 100 毫秒为间隔节流调用 onUpdate(BASH_UPDATE_THROTTLE_MS,bash.ts:200;节流逻辑 bash.ts:369-382),循环把每次回调转成 tool_execution_update 事件(agent-loop.ts:679-692)。另外 bash 把非零退出码转成异常抛出(bash.ts:451-453)——回到那条约定:失败就抛。
两个适配器与一把锁
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,
// …(省略:10-16 行,label/description/parameters 等字段逐个搬运)
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
definition.execute(toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.() as ExtensionContext)),
};
}它包装的东西可以一句话概括:把「上下文从哪来」这个问题从循环手里挪走。循环只管传四个参数,第五个由工厂函数在调用时现取。createReadTool 就是 wrapToolDefinition(createReadToolDefinition(...))(read.ts:349-351)。
反方向也有一个适配器:createToolDefinitionFromAgentTool(tool-definition-wrapper.ts:36-47)把外部传进来的裸 AgentTool 合成一个最小 ToolDefinition(没有提示词元数据、没有渲染函数)。它的存在是为了让 AgentSession 的注册表始终是 definition-first——因为系统提示词和终端界面渲染都只从 definition 上取字段(调用点 agent-session.ts:2556-2562)。
注册表里的每个工具——内置的和扩展注册的都一样——还要再过一层 wrapRegisteredTool(packages/coding-agent/src/core/extensions/wrapper.ts:17-37;两处调用点在 agent-session.ts:2506 与 :2507):它在 execute 前后各取一次活跃工具列表做差集,把执行期间新注册的工具名并进结果的 addedToolNames(wrapper.ts:22-35)。内置工具不会在执行中注册新工具,这一层对它们是空转;真正用得上它的是扩展——这就是「调用一个工具后解锁另一批工具」的实现方式,细节在 7.1 Extension 系统。
file-mutation-queue:并行执行下的写冲突
工具默认并行执行(官方文档 packages/agent/README.md:113-115)。于是一个真实风险出现了:模型在同一批里请求 edit 和 write 改同一个文件,两个 execute 同时读到旧内容、各自算出新内容,后写的覆盖先写的。
withFileMutationQueue键的选择很关键:先 resolve 成绝对路径,再用 realpath 归一(file-mutation-queue.ts:16-26),因此指向同一个文件的两个符号链接会落到同一把锁上;文件还不存在时(ENOENT)退回用解析后的绝对路径当键。write 与 edit 各有一处调用点(write.ts:203、edit.ts:312)。官方文档还建议扩展作者的自定义工具也用它(packages/coding-agent/docs/extensions.md:1867),并给出了失败场景的例子。
executionMode: "sequential",整批就退回顺序执行(agent-loop.ts:418-425)。但内置的 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 | 7 个,多 grep / find / ls |
| 工具类型 | AgentHarnessTool(harness/types.ts:99-112) | ToolDefinition |
| 文件与 shell | 全部走抽象的 ExecutionEnv(harness/types.ts:373) | 直接用 node:fs 与子进程 |
| 错误风格 | Result 值 + getOrThrow | 直接抛异常 |
| 提示词与渲染 | 无 | 有 promptSnippet 与渲染函数 |
| 服务对象 | AgentHarness(库使用者) | Pi CLI 本体 |
AgentHarnessTool 的 execute 也是五个参数,但第五个是应用自定义的 TContext 而不是 ExtensionContext;AgentHarness 在每回合把它闭包绑定成四参数的 AgentTool 再交给循环(packages/agent/src/harness/agent-harness.ts:347-352,调用点 :396)——和 wrapToolDefinition 是同一个套路的两个实例。
关键事实:coding-agent 从不使用 AgentHarness,也不使用 harness/tools。对 packages/coding-agent/src 全文搜索 AgentHarness 零命中(本章实践任务会让你亲手验证这一条)。仓库内 harness 工具的实际使用者只有 agent 包自己的测试(packages/agent/test/harness/tools.test.ts)。官方文档 packages/agent/docs/agent-harness.md:82-84 只介绍了这四个工具「完全通过 ExecutionEnv 操作文件与 shell」,没有任何一处说 coding-agent 会用它们。
两套实现存在明显重复:truncate.ts 几乎是复制的,差异只在 harness 版为脱离 Node 运行时手写了 utf8ByteLength,而 coding-agent 版直接用 Buffer.byteLength。据此推断(尚未在源码中直接证实),harness/tools 是更新的一代抽象,目标是让工具不依赖具体后端;packages/agent/docs/ 下的 harness-v2.md、durable-harness.md 等设计文档可佐证这个方向,但仓库里没有任何迁移计划的直接陈述。一种看法是这份重复是分层演进的合理代价,换来 pi-agent-core 可以在非 Node 环境里跑;代价是同一个 bug 要修两遍。
读本书主线时请记住:Pi CLI 用的是 core/tools。
完整调用链速查
一次工具调用从头到尾经过的函数(每一环都可用全文搜索复核):
Agent.prompt—packages/agent/src/agent.ts:337- →
runAgentLoop(messages, this.createContextSnapshot(), ...)—packages/agent/src/agent.ts:403(快照把state.tools放进AgentContext.tools,agent.ts:430) - →
runLoop主循环 —packages/agent/src/agent-loop.ts:155 - →
streamAssistantResponse把context.tools放进请求 —packages/agent/src/agent-loop.ts:298-302 - → 过滤出 toolCall 内容块 —
packages/agent/src/agent-loop.ts:203 - →
executeToolCalls分派串行/并行 —packages/agent/src/agent-loop.ts:214(分派逻辑:411-426) - →
prepareToolCall:查找:607→ 垫片:617→ 校验:618→ 钩子:619-643 - →
executePreparedToolCall→prepared.tool.execute(...)—packages/agent/src/agent-loop.ts:675 - → 具体工具,例如
createReadToolDefinition的 execute —packages/coding-agent/src/core/tools/read.ts:216-328 - →
finalizeExecutedToolCall(afterToolCall覆盖)—packages/agent/src/agent-loop.ts:709 - →
createToolResultMessage—packages/agent/src/agent-loop.ts:773 - → push 进上下文
:218-221;AgentSession 在message_end里落盘 —packages/coding-agent/src/core/agent-session.ts:625-642
实践任务
目标:不写一行新代码、不需要任何 API Key,亲手确认三件事:① typebox 校验在整个仓库里只有一个调用点;② coding-agent 确实不使用 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:11: validateToolArguments,
packages/agent/src/agent-loop.ts:618: const validatedArgs = validateToolArguments(tool, preparedToolCall);
packages/ai/src/utils/validation.ts:268: return validateToolArguments(tool, toolCall);
packages/ai/src/utils/validation.ts:278:export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {第一行是 import,第四行是定义,第三行是同文件里 validateToolCall 的转调(Agent Loop 没用它,自己做了查找)。真正的业务调用点只有 618 那一行。
步骤 2:验证两套 tools 的边界。
grep -rn "AgentHarness\|agent-harness" packages/coding-agent/src --include="*.ts"预期现象:零输出(真实运行结果)。这就是「coding-agent 不使用 AgentHarness」的直接证据。作为对照,再跑一次 grep -rln "AgentHarness" packages/agent/src,会看到命中集中在 harness/ 目录下。
步骤 3:跑 read 工具的真实测试。
npm test --workspace=@earendil-works/pi-coding-agent -- \
test/tools.test.ts -t "read tool" --reporter=verbose预期现象:12 条用例执行(✓),其余跳过(↓)。作者本机真实运行的结尾如下(耗时会不同):
✓ 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 | 62 skipped (74)步骤 4:读断言,对上源码。打开 packages/coding-agent/test/tools.test.ts,看第 88-100 行那条用例:它写入 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:301(提示文案模板)。
步骤 5(选做):跑校验层自己的测试。
npm test --workspace=@earendil-works/pi-ai -- test/validation.test.ts --reporter=verbose作者本机真实运行的结果是 Tests 4 passed (4),其中「coerces serialized plain JSON schemas with AJV-compatible primitive rules」验证的正是本章说的「非 typebox 纯 JSON Schema」那条分支。
如何判断成功:① 步骤 1 得到四行、步骤 2 得到零行;② 步骤 3 全绿;③ 你能回答:如果把 read.ts:288 的 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:618(校验调用点)、packages/ai/src/utils/validation.ts:278-310(校验实现)、packages/coding-agent/src/core/tools/read.ts:288 与 :295-310(截断与续读提示)、packages/coding-agent/src/core/tools/truncate.ts:11-12(两个上限常数)。测试文件:packages/coding-agent/test/tools.test.ts:88、:102、:173;packages/ai/test/validation.test.ts:36。
本章小结
- 一个工具 = 一份发给模型的说明书 + 一个只在本地存在的函数。三层类型
Tool→AgentTool→ToolDefinition依次是「发出去的样子」「怎么跑」「怎么显示和进提示词」。 - 参数 schema 用 typebox 写一遍,服务三个用途:当 JSON Schema 下发给模型、当运行时校验规则、当 TypeScript 类型。
- 校验只在一个地方发生:
packages/agent/src/agent-loop.ts:618,夹在兼容垫片与beforeToolCall钩子之间。这是 2.1「编译期管不了运行时数据」那个缺口在 Pi 里的补法。校验失败不会中断循环,而是变成模型能读懂的错误文本。 - 七个内置工具,默认激活四个(read / bash / edit / write)。注册表是全集、激活列表是子集;
--tools这份 allowlist 更狠——它同时收窄注册表与激活列表。 - 截断是一等公民:
truncateHead(read,要开头)、truncateTail(bash,要结尾)、truncateLine(grep,防单行爆炸),双上限 2000 行 / 50KB 先到先截,且截断信息必须自带「怎么继续」。 - 两个适配器与一把锁:
wrapToolDefinition把五参数适配成四参数,createToolDefinitionFromAgentTool反向合成,withFileMutationQueue按 realpath 序列化同一文件的并发写。 - agent 包的
harness/tools与 coding-agent 的core/tools是两套并行实现,互不引用;本书主线跟的是后者。 - 关键术语:Tool / AgentTool / ToolDefinition、typebox、JSON Schema、运行时校验、截断(Truncation)、Operations 注入、文件互斥队列、ExecutionEnv。
- 关键源码索引:
packages/ai/src/types.ts:480(Tool)、packages/agent/src/types.ts:380(AgentTool)、packages/coding-agent/src/core/extensions/types.ts:449(ToolDefinition)、packages/agent/src/agent-loop.ts:618(校验调用点)、packages/ai/src/utils/validation.ts:278(校验实现)、packages/coding-agent/src/core/tools/index.ts:83-84(七个工具名)与:156(成组工厂)、packages/coding-agent/src/core/agent-session.ts:926(激活)与:2592-2594(默认四个)、packages/coding-agent/src/core/tools/read.ts:203-351、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": 123}调用 read,这次调用会走到read.ts的 execute 吗?错误信息最终以什么形式出现在对话里?②--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:10的ensureTool)本书未展开。