Skip to content

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

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

三层洋葱:Tool → AgentTool → ToolDefinition

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

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

earendil-works/pi@c13ffe1第 380–403 行在 GitHub 查看 ↗
AgentTool 继承 Tool,加了 label(界面显示名)、可选的 prepareArguments 兼容垫片、四参数的 execute,以及可选的 executionMode(本工具是否必须串行)。
ts
// 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)内部真正使用的形态:

earendil-works/pi@c13ffe1第 449–486 行在 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:480packages/agent/src/types.ts:380packages/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

🌱 初学者提示TDetails 这个泛型是干什么的
AgentToolResult<T>packages/agent/src/types.ts:355-369)有两个内容字段:content 是给模型看的文本或图片,details 是给日志和界面看的结构化数据。read 工具往 details 里塞的是截断信息(ReadToolDetailsread.ts:28-30),终端界面据此显示「已截断」的提示,而模型完全不会看到 details。一个结果,两个受众。

一份 schema,三个用途

所有内置工具的 parameters 都用 typebox 写成(三个包统一锁定 typebox@1.3.7,见 packages/agent/package.json:35packages/coding-agent/package.json:57packages/ai/package.json:73)。read 工具的参数声明只有五行:

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

  1. 它就是一份 JSON Schema(用文本描述数据结构的通用规范)。Anthropic 适配器直接把它塞进请求的 input_schema 字段(源码事实,packages/ai/src/api/anthropic-messages.ts:1318),不需要任何转换。
  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 用的正是这套组合。

校验发生在哪一层:2.1 那个伏笔的兑现

2.1 留下过一个明确的缺口:类型只存在于编译期,运行时的数据它管不着。工具参数正是这个缺口最危险的入口——它由模型在运行时生成,是一段谁也不能保证格式的 JSON 文本。

Pi 把这道关卡放在了 Agent Loop 里、工具执行之前,具体就一行:

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

packages/ai/src/utils/validation.ts · validateToolArguments
earendil-works/pi@c13ffe1第 278–299 行在 GitHub 查看 ↗
先 structuredClone 复制一份参数,再用 Value.Convert 做类型强转,取出编译好的校验器执行 Check;对非 typebox 的纯 JSON Schema 另有一条手写强转分支。

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

  • 校验器是编译并缓存的getValidatorCompile(schema) 生成校验函数,并以 schema 对象为键存进 WeakMappackages/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 讲过:异常在 prepareToolCallcatch 里被转成 isError 的工具结果(packages/agent/src/agent-loop.ts:657-663),循环不会因为模型写错参数而中断。

官方文档说明这一顺序:packages/agent/README.md:122 写明 beforeToolCall 钩子运行在「tool_execution_start 与校验过的参数解析之后」——与源码里 618 行(校验)先于 619 行(钩子)一致。

⚠️ 常见误解以为钩子改过的参数会被重新校验
beforeToolCall 拿到的是校验后的 validatedArgsagent-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)把它们和默认开关一起列了出来:

text
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 位置输出截断策略默认激活备注
readread.ts:20-24truncateHead文本按行截断;图片走附件分支
bashbash.ts:40-43truncateTail流式 onUpdate;超限写临时文件
editedit.ts:33-53不截断(返回差异)走文件互斥队列
writewrite.ts:14-17不截断走文件互斥队列
grepgrep.ts:24-36truncateLine依赖 ripgrep
findfind.ts:20-26按 glob 找文件
lsls.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 / excludedToolNamesmain.ts:493-499packages/coding-agent/src/core/sdk.ts:246agent-session.ts:386-387),而 isAllowedTool 这个判定同时用来过滤注册表agent-session.ts:2473:2509)和激活列表(:2525),并把所有被允许的工具直接设为激活(:2527-2532)。换句话说:pi --tools read,grep,find,ls 之后,被排除的四个工具连注册表里都不剩,扩展也无法把它们打开。

工具是这样被造出来并接到 Agent 上的:

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

图 6.4-1 工具的一生:从工厂函数到一条 toolResult 消息
上半部分是构建期,只在会话启动与 reload 时跑一次;下半部分是运行期,每次模型请求工具都跑一遍。请重点关注两点:① 注册表(_toolRegistry)和激活列表(agent.state.tools)是两个东西,前者是全集,后者是子集;② 同一份 tools 数组既被序列化下发给模型,又被用来按名字查找本地函数——图中从 AgentContext.tools 分出的两条箭头就是本章开头说的「说明书」与「函数体」。

图里每个节点都对应真实源码。构建期的起点在 AgentSession._buildRuntimeagent-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 是最适合精读的工具:无副作用、分支清晰,而且藏着几处「真实世界补丁」。

earendil-works/pi@c13ffe1第 236–263 行在 GitHub 查看 ↗
read 的 execute 主体:解析路径 → 检查可读 → 用文件魔数(而非扩展名)判断是不是图片 → 图片走 processImage 变附件,文本走下面的截断分支。
图加载中…

图 6.4-2 read 工具的数据流:从一个字符串路径到一段带提示的文本
读的顺序是从上往下。左侧两个分支节点是路径解析的两次兜底,右侧的菱形是唯一的分岔点。值得留意的是最后两步的顺序:先截断,再拼提示——截断结果里的 outputLines 决定了提示里那个 offset 该写几。

几处值得停下来看的地方:

路径解析的三重兜底。 resolveToCwd 除了展开 ~ 和处理相对路径,还会剥掉开头的 @packages/coding-agent/src/core/tools/path-utils.ts:48-50stripAtPrefix: 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 是可注入的。 ReadOperationsread.ts:43-56)把 readFile / access / detectImageMimeType 三个动作抽成接口,默认实现打的是本地文件系统(read.ts:52-56)。注释写明这是为了把文件读取委托给远程系统(例如 SSH)。七个内置工具都有各自的 Operations 接口(bash.ts:56-74edit.ts:74-87write.ts:25-30grep.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 = 2000DEFAULT_MAX_BYTES = 50 * 1024先撞上哪条算哪条

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

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

truncateHead 有个对称的边界处理:首行独自超限时返回空内容并置 firstLineExceedsLimittruncate.ts:103-119),read 据此给模型一条兜底建议——改用 bashsed -n 'Np' 文件 | head -c 51200read.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):

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

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

  • 内存有界:内部只保留约 2 × maxBytes 的滚动尾部(trimTailoutput-accumulator.ts:179-194),不会因为一条 yes 命令把进程撑爆。
  • 完整输出不丢:一旦总量超限就把原始字节写进 ${tmpdir}/pi-bash-<hex>.logensureTempFile: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 毫秒为间隔节流调用 onUpdateBASH_UPDATE_THROTTLE_MSbash.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。中间这层适配器只有十几行:

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,
		// …(省略: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)。

反方向也有一个适配器:createToolDefinitionFromAgentTooltool-definition-wrapper.ts:36-47)把外部传进来的裸 AgentTool 合成一个最小 ToolDefinition(没有提示词元数据、没有渲染函数)。它的存在是为了让 AgentSession 的注册表始终是 definition-first——因为系统提示词和终端界面渲染都只从 definition 上取字段(调用点 agent-session.ts:2556-2562)。

注册表里的每个工具——内置的和扩展注册的都一样——还要再过一层 wrapRegisteredToolpackages/coding-agent/src/core/extensions/wrapper.ts:17-37;两处调用点在 agent-session.ts:2506:2507):它在 execute 前后各取一次活跃工具列表做差集,把执行期间新注册的工具名并进结果的 addedToolNameswrapper.ts:22-35)。内置工具不会在执行中注册新工具,这一层对它们是空转;真正用得上它的是扩展——这就是「调用一个工具后解锁另一批工具」的实现方式,细节在 7.1 Extension 系统

file-mutation-queue:并行执行下的写冲突

工具默认并行执行(官方文档 packages/agent/README.md:113-115)。于是一个真实风险出现了:模型在同一批里请求 editwrite 改同一个文件,两个 execute 同时读到旧内容、各自算出新内容,后写的覆盖先写的。

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

键的选择很关键:先 resolve 成绝对路径,再用 realpath 归一(file-mutation-queue.ts:16-26),因此指向同一个文件的两个符号链接会落到同一把锁上;文件还不存在时(ENOENT)退回用解析后的绝对路径当键。writeedit 各有一处调用点(write.ts:203edit.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 / write7 个,多 grep / find / ls
工具类型AgentHarnessToolharness/types.ts:99-112ToolDefinition
文件与 shell全部走抽象的 ExecutionEnvharness/types.ts:373直接用 node:fs 与子进程
错误风格Result 值 + getOrThrow直接抛异常
提示词与渲染promptSnippet 与渲染函数
服务对象AgentHarness(库使用者)Pi CLI 本体

AgentHarnessToolexecute 也是五个参数,但第五个是应用自定义的 TContext 而不是 ExtensionContextAgentHarness 在每回合把它闭包绑定成四参数的 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

完整调用链速查

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

  1. Agent.promptpackages/agent/src/agent.ts:337
  2. runAgentLoop(messages, this.createContextSnapshot(), ...)packages/agent/src/agent.ts:403(快照把 state.tools 放进 AgentContext.toolsagent.ts:430
  3. runLoop 主循环 — packages/agent/src/agent-loop.ts:155
  4. streamAssistantResponsecontext.tools 放进请求 — packages/agent/src/agent-loop.ts:298-302
  5. → 过滤出 toolCall 内容块 — packages/agent/src/agent-loop.ts:203
  6. executeToolCalls 分派串行/并行 — packages/agent/src/agent-loop.ts:214(分派逻辑 :411-426
  7. prepareToolCall:查找 :607 → 垫片 :617校验 :618 → 钩子 :619-643
  8. executePreparedToolCallprepared.tool.execute(...)packages/agent/src/agent-loop.ts:675
  9. → 具体工具,例如 createReadToolDefinition 的 execute — packages/coding-agent/src/core/tools/read.ts:216-328
  10. finalizeExecutedToolCallafterToolCall 覆盖)— packages/agent/src/agent-loop.ts:709
  11. createToolResultMessagepackages/agent/src/agent-loop.ts:773
  12. → push 进上下文 :218-221;AgentSession 在 message_end 里落盘 — packages/coding-agent/src/core/agent-session.ts:625-642

实践任务

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

目标:不写一行新代码、不需要任何 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 条用例执行(),其余跳过()。作者本机真实运行的结尾如下(耗时会不同):

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 | 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:288truncateHead 换成 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:173packages/ai/test/validation.test.ts:36

本章小结

  • 一个工具 = 一份发给模型的说明书 + 一个只在本地存在的函数。三层类型 ToolAgentToolToolDefinition 依次是「发出去的样子」「怎么跑」「怎么显示和进提示词」。
  • 参数 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 / ToolDefinitiontypeboxJSON 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-351truncate.ts:11-13output-accumulator.ts:35-222tool-definition-wrapper.ts:5-47file-mutation-queue.ts:32-61。测试:packages/coding-agent/test/tools.test.tspackages/ai/test/validation.test.tspackages/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-tuipromptSnippet / promptGuidelines 如何拼进系统提示词见 6.7 系统提示词与 Prompt Templates;grep 工具依赖的 ripgrep 二进制如何定位与下载(grep.ts:10ensureTool)本书未展开。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)