Skip to content

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 循环)决定要不要执行、怎么执行。
📘 概念工具的两半(declaration vs. implementation)
声明部分(name / description / parameters)是给模型读的说明书,随请求下发;
执行部分(execute)是本地函数,模型永远拿不到它。
所以「模型调用了一个工具」这句话严格来说是错的:模型只是在文本流里吐出了一个结构化的调用请求,执行是本地 harness(运行框架)自己做的决定。

Pi 的工具系统就是围绕这条分界线组织的。往下走会看到:三层类型的差别,本质上是「离模型有多远」——最内层只有说明书,往外每加一层,就多一点只有本地才关心的东西。

三层洋葱:Tool → AgentTool → ToolDefinition ​

最内层在 pi-ai 包,只描述「发给模型的那份声明」:

earendil-works/pi@16787ad第 600–605 行在 GitHub 查看 ↗
pi-ai 层的 Tool:只有 name、description、parameters 三个必填字段,外加一个可选的 constrainedSampling(请求 Provider 端做受约束采样)。没有 execute——这一层根本不负责执行。
ts
// 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),它给声明补上了执行能力:

earendil-works/pi@16787ad第 443–468 行在 GitHub 查看 ↗
AgentTool 继承 Tool,加了 label(界面显示名)、可选的 prepareArguments 兼容垫片、四参数的 execute,以及两个可选字段:replay(崩溃恢复时能否重放)与 executionMode(本工具是否必须串行)。
ts
// 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)内部真正使用的形态:

earendil-works/pi@16787ad第 455–492 行在 GitHub 查看 ↗
ToolDefinition 在 AgentTool 的字段之外,加了 promptSnippet / promptGuidelines(进系统提示词的说明文字)、renderShell / renderCall / renderResult(终端界面渲染函数),并且 execute 是五个参数——第五个是 ExtensionContext。
Tool(pi-ai)AgentTool(pi-agent-core)ToolDefinition(coding-agent)
定义位置packages/ai/src/types.ts:600packages/agent/src/types.ts:443packages/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。

🌱 初学者提示TDetails 这个泛型是干什么的
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 工具的参数声明只有五行:

ts
// 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 类型再手写校验」相比的核心优势:

  1. 它就是一份 JSON Schema(用文本描述数据结构的通用规范)。Anthropic 适配器取出它的 properties 与 required,放进请求的 input_schema 字段(源码事实,packages/ai/src/api/anthropic-messages.ts:1466-1488),不需要额外的格式转换。
  2. 它是运行时校验规则,下一节详述。
  3. Static<typeof readSchema> 把它反推成 TypeScript 类型,于是 execute 里的 params.path 有编译期补全。

在 Pi 仓库根目录可以三十秒内验证前两件事(真实运行,输出如下):

bash
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));
"
text
{"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 里、工具执行之前,具体就一行:

packages/agent/src/agent-loop.ts · validateToolArguments
earendil-works/pi@16787ad第 719–721 行在 GitHub 查看 ↗
校验调用点:整个仓库里 validateToolArguments 只有这一处被 Agent Loop 调用。它夹在 prepareArguments 垫片之后、beforeToolCall 钩子之前。
ts
// 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:

packages/ai/src/utils/validation.ts · validateToolArguments
earendil-works/pi@16787ad第 317–339 行在 GitHub 查看 ↗
先 structuredClone 复制一份参数,用 normalizeOptionalNulls 把「可选属性给了 null」当作没给、直接删掉,再用 Value.Convert 做类型强转,取出编译好的校验器执行 Check;对非 typebox 的纯 JSON Schema 另有一条手写强转分支。

四个容易读漏的细节(均为源码事实):

  • 校验器是编译并缓存的。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):

text
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 位置输出截断策略默认激活备注
readread.ts:14-18truncateHead是文本按行截断;图片走附件分支
bashbash.ts:38-41truncateTail是流式 onUpdate;超限写临时文件
powershell复用 bash.ts:38-41truncateTail否面向 Windows;与 bash 共用 createShellToolDefinition(powershell.ts:49-57)
editedit.ts:21-42不截断(返回差异)是走文件互斥队列;兼容单个编辑对象的输入
writewrite.ts:11-14不截断是走文件互斥队列
grepgrep.ts:21-33truncateLine否依赖 ripgrep
findfind.ts:26-32—否按 glob 找文件
lsls.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 上的:

earendil-works/pi@16787ad第 182–193 行在 GitHub 查看 ↗
八个工厂函数的汇总入口,返回一个「工具名 → ToolDefinition」的记录。AgentSession 在构建运行时的时候调用它,并把 cwd 与若干设置注入进去。

图 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 是最适合精读的工具:无副作用、分支清晰,而且藏着几处「真实世界补丁」。

earendil-works/pi@16787ad第 101–131 行在 GitHub 查看 ↗
read 的 execute 主体:解析路径 → 检查可读 → 用文件魔数(而非扩展名)判断是不是图片 → 图片走 processImage 变附件(当前模型不支持图片时额外附一句说明),文本走下面的截断分支。

图 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,先撞上哪条算哪条。

同一个文件里给出三种策略,对应三种「读者最想看哪一段」的判断:

函数位置保留哪一段谁在用为什么
truncateHeadtruncate.ts:78开头read读文件从头读,续读靠 offset
truncateTailtruncate.ts:168结尾bash(经 OutputAccumulator)报错信息与最终结果都在末尾
truncateLinetruncate.ts:268单行前 500 字符grep防一行超长的压缩文件把结果撑爆
earendil-works/pi@16787ad第 168–213 行在 GitHub 查看 ↗
truncateTail 从数组末尾往前收集整行。唯一允许返回半行的地方是这个边界情况:一行都还没收够、而当前这行自己就超过字节上限,此时按 UTF-8 边界从行尾截一段,并置 lastLinePartial。

truncateHead 有个对称的边界处理:首行独自超限时返回空内容并置 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):

earendil-works/pi@16787ad第 91–119 行在 GitHub 查看 ↗
snapshot 把当前滚动尾部交给 truncateTail,再用真实的总行数/总字节数覆盖统计字段;persistIfTruncated 为真且确实截断时,顺手把完整输出落到临时文件,并在返回值里带上路径。

它同时解决三个问题(源码事实):

  • 内存有界:内部只保留约 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。中间这层适配器只有十几行:

ts
// 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 同时读到旧内容、各自算出新内容,后写的覆盖先写的。

earendil-works/pi@16787ad第 32–61 行在 GitHub 查看 ↗
按文件维度串行化:以文件的 realpath 为键取出当前队列,把自己接在后面,执行完再释放。不同文件之间互不阻塞。

键的选择很关键:先 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 / write8 个,多 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。

完整调用链速查 ​

一次工具调用从头到尾经过的函数(每一环都可用全文搜索复核):

  1. Agent.prompt — packages/agent/src/agent.ts:368
  2. → runAgentLoop(messages, this.createContextSnapshot(), ...) — packages/agent/src/agent.ts:434(快照把 state.tools 放进 AgentContext.tools,agent.ts:460)
  3. → runLoop 主循环 — packages/agent/src/agent-loop.ts:162
  4. → declareToolChanges 把 context.tools 与已声明的工具求差,写成 system 消息的 toolsAdded — packages/agent/src/agent-loop.ts:109、:210(函数本体 :332);随后 streamAssistantResponse 把整个 transcript 交给 Provider,由 Provider 重放出工具列表 — :380-406
  5. → 过滤出 toolCall 内容块 — packages/agent/src/agent-loop.ts:258
  6. → executeToolCalls 分派串行/并行 — packages/agent/src/agent-loop.ts:269(分派逻辑 :505-520)
  7. → prepareToolCall:查找 :710 → 垫片 :720 → 校验 :721 → 钩子 :722-750
  8. → executePreparedToolCall → prepared.tool.execute(...) — packages/agent/src/agent-loop.ts:782
  9. → 具体工具,例如 createReadToolDefinition 的 execute — packages/coding-agent/src/core/tools/read.ts:81-196
  10. → finalizeExecutedToolCall(afterToolCall 覆盖)— packages/agent/src/agent-loop.ts:816
  11. → createToolResultMessage — packages/agent/src/agent-loop.ts:880
  12. → push 进上下文 :273-276;AgentSession 在 message_end 里落盘 — packages/coding-agent/src/core/agent-session.ts:922-941

实践任务 ​

🛠 实践任务用 grep 定位校验调用点,再用真实测试验证截断行为

目标:不写一行新代码、不需要任何 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 条 ✓,耗时会不同):

text
 ✓ 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)本书未展开。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 不会。校验发生在工具之外——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. 注册表里只剩 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)。
  3. 看到的是最后 2000 行——bash 用 truncateTail,因为命令的报错信息和最终结果都在末尾。而且这是「2000 行或 50KB,先到先截」的双上限,10 万行的输出多半在 50KB 上就先被截住,实际行数会少于 2000。完整输出没有丢:OutputAccumulator 把原始字节写进 ${tmpdir}/pi-bash-<hex>.log,路径拼在结果末尾的 [Showing lines … Full output: /tmp/pi-bash-xxxx.log] 里,模型可以再用 grep 去查那份完整日志。这正是那条设计原则——截断必须自带「怎么继续」的说明。
  4. 因为 executionMode: "sequential" 的粒度太粗:批内只要有一个工具声明它,整批都退回顺序执行(agent-loop.ts:512-519),两个改不同文件的 edit 也会被迫排队,白白丢掉并发。文件互斥队列的粒度是「按文件」——withFileMutationQueue 以文件的 realpath 为键(符号链接会归一到同一把锁,文件不存在时退回绝对路径),只有真正碰同一个文件的调用才排队,其余照旧并行。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)