跳到主内容
创建时间:最后更新:作者:白川

编码工具实现:路径、进程与结果边界

逐个读源码核对 Pi 八个内置编码工具的真实名称、参数 schema、返回结构与失败语义,并说明输出截断与取消传播的机制。

编码工具实现:路径、进程与结果边界

工具能力来自宿主实现,而不是提示词声明。 模型说"我要读文件"只是产生了一个 toolCall 块;能不能读、读到的是不是工作区里的文件、输出会不会撑爆上下文、取消时进程会不会真的死掉——全部由内置工具的代码决定。

这篇做三件事:核对内置工具的真实清单与 schema;逐个读读写文件与执行命令的返回结构;把错误、输出截断和取消三条传播路径讲清楚。

读它需要能读 TypeScript,并且对文件系统与子进程的基本行为有印象。读完你能自己遍历工具对象打印契约,能解释每个工具的失败语义,也能判断哪些边界是内置的、哪些必须你自己补。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

最终要跑通什么

  1. 打印出当前版本里每个内置工具的真实名字、参数 schema 和返回结构。
  2. 亲手触发一次输出截断,确认模型拿到的是截断后的内容加上"完整输出在哪"。
  3. 取消一次正在跑命令的工具,确认子进程树被杀掉而不是留下孤儿。

内置工具真实清单

先纠正一个流传很广的说法。内置工具一共八个,名字来自内置工具清单里的 allToolNames:

typescript
// 内置工具一共八个
export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls";
export const allToolNames: Set<ToolName> = new Set([
	"read", "bash", "powershell", "edit", "write", "grep", "find", "ls",
]);

没有 search,没有 replace,没有 list。 搜索是 grep 和 find,替换是 edit,列举是 ls。模型按名字猜会直接拿到 Tool <name> not found。

源码里提供了四组工厂函数:

工厂函数返回的工具公开导出用途
createCodingTools(cwd)
read、bash、edit、write
是
默认集合,也是 defaultTools 设置的默认值
createReadOnlyTools(cwd)
read、grep、find、ls
是
只读集合,审查类任务用
createAllTools(cwd)
全部八个,返回 Record<ToolName, Tool>
否,仅内部使用
需要逐个挑选时用
createToolDefinition(name, cwd)
单个 ToolDefinition
否,仅内部使用
按名字取一个

公开导出的是前两组,加上八个单工具工厂:createReadTool、createBashTool、createPowerShellTool、createEditTool、createWriteTool、createGrepTool、createFindTool、createLsTool。

createAllTools 返回的是 Record,不是数组——注意这个区别,因为 AgentTool[] 和 Record<ToolName, AgentTool> 在传参时不能互换。

八个工具的真实契约

下表逐字对照源码整理。"失败语义"一列最关键:它决定模型看到失败后能不能自我纠正。

工具参数(全部)成功返回失败语义details
read
path(必填)、offset、limit
文件文本;图片返回文本 + 图片块
ops.access() 抛错即失败,错误原文是 Node 的 ENOENT/EACCES
{ truncation? }
write
path、content
Successfully wrote to <path>
父目录创建或写入失败抛错
undefined
edit
path、edits[](每项 oldText/newText)
Successfully replaced N block(s) in <path>.
匹配失败 / 不唯一 / 空 oldText / 内容无变化 均抛错
{ diff, patch, firstChangedLine? }
grep
pattern、path、glob、ignoreCase、literal、context、limit
path:line: 内容 形式的命中行;无命中返回 No matches found
路径不存在抛 Path not found;缺 rg 抛错
{ truncation?, matchLimitReached?, linesTruncated? }
find
pattern(glob)、path、limit
相对搜索根的文件路径;无命中返回 No files found matching pattern
路径不存在抛 Path not found;缺 fd 抛错
{ truncation?, resultLimitReached? }
ls
path、limit
目录条目,目录带 / 后缀;空目录返回 (empty directory)
路径不存在抛 Path not found;非目录抛 Not a directory
{ truncation?, entryLimitReached? }
bash
command、timeout(秒)
stdout 与 stderr 合并文本
非 0 退出码返回 isError: true 并附 Command exited with code N;超时抛错
{ truncation?, fullOutputPath? }
powershell
同 bash
同上
同上
同上

几个必须知道的细节:

read 的失败信息来自 Node 的 access。 模型看到的是 ENOENT: no such file or directory, access '/abs/path'——它没说"这个目录下有什么",模型要自我纠正就得自己再调一次 ls。截断后文本末尾会追加 [Showing lines X-Y of Z. Use offset=N to continue.];offset 超出文件末尾则抛 Offset N is beyond end of file (M lines total)。

edit 的失败有多种,每种都带定位信息。 编辑匹配层有四个错误分支:找不到原文(Could not find the exact text in <path>)、原文不唯一(Found N occurrences of the text in <path>)、oldText 为空、替换后内容与原文完全相同。另外两个编辑区间重叠时会抛 Replacement range is outside the base content.。

edit 还有一层兼容处理:把 edits 当 JSON 字符串发来、或只发单个 {oldText, newText} 对象,都会被修正后再执行——这层在 schema 校验之前运行。

grep / find / ls 的无命中不是失败。 返回 No matches found 这类文本,isError 为 false。

bash 的退出码非 0 也不是抛错。 exitCode !== 0 时返回带 isError: true 的正常结果;只有 exitCode === null(拿不到退出码)才抛 Command terminated without an exit code。命令跑失败是业务结果,不是程序错误。

输出截断:不让工具撑爆上下文

八个工具共用截断工具里的三个常量:

常量值含义
DEFAULT_MAX_LINES
2000
行数上限
DEFAULT_MAX_BYTES
50KB
字节上限
GREP_MAX_LINE_LENGTH
500
单行截断长度(grep 专用)

两个上限先到先算。截断模块提供几个方向不同的函数:

函数保留方向典型用途
truncateHead(content, options)
保留开头
read、文件名列表——你要看的是开头
truncateTail(content, options)
保留结尾
bash 输出——报错在最后
truncateLine(text, maxLength?)
截断单行
grep 命中行——单行太长会挤爆上下文
truncateMiddle(content, maxBytes)
头尾各留一半,中间标注
长文本的结构化输出(MCP 工具用它)

前三个是公开导出的,truncateMiddle 目前只在内部使用。

truncateHead 有一个特殊分支:如果第一行本身就超过字节上限,它返回空内容并置 firstLineExceedsLimit: true。read 遇到这种情况不会返回空,而是给模型一条明确的转路建议——注意这条提示本身不是错误消息,它告诉模型换条路继续读:

text
[Line 1 is 12.4MB, exceeds 50.0KB limit. Use bash: sed -n '1p' <path> | head -c 51200]

这是"参数错误要给模型能自我纠正的提示"的具体形态——不是只说"太大了",而是给出下一步怎么读。

bash 的截断还有第二层保护。输出累积器在流式接收输出时就只保留一个尾部滚动窗口(内存上限是字节上限的两倍),超出后把完整内容写到临时文件(pi-bash 前缀),并把路径放进 details.fullOutputPath 和 structuredContent.full_output_path。模型看到的内容末尾会附上完整输出的位置。

三种工具的默认条目上限也各不相同,超限时同样会给模型指引:

工具默认上限超限提示
grep
100 条匹配
100 matches limit reached. Use limit=200 for more, or refine pattern
find
1000 个结果
1000 results limit reached. Use limit=2000 for more
ls
500 个条目
500 entries limit reached. Use limit=1000 for more

取消传播:三层,缺一不可

取消从 session.abort() 开始,沿 AbortSignal 向下传。三层都要处理,否则会出现"界面停了但进程还在跑"。

第一层,Agent 循环。 agent.abort() 触发 AbortController。prepareToolCall() 在参数校验后检查 signal?.aborted,已中止就立刻返回 Operation aborted 的错误结果,execute() 根本不会被调用。

第二层,工具实现。 八个工具都各自处理信号。read、grep、find、ls 的模式一致:

typescript
// read、grep、find、ls 共用的取消模式:在 abort 监听器里 reject
return new Promise((resolve, reject) => {
	// 进入前先看一眼:已经中止就不必再发起 I/O
	if (signal?.aborted) {
		reject(new Error("Operation aborted"));
		return;
	}
	let aborted = false;
	const onAbort = () => {
		aborted = true;
		reject(new Error("Operation aborted"));
	};
	signal?.addEventListener("abort", onAbort, { once: true });
	// ... 实际 I/O ...
});

write 和 edit 的处理方式不同。它们不用事件监听器抛错,而是每次 await 之后检查 signal.aborted:

typescript
// write 和 edit 改为在每次 await 之后检查 signal.aborted
const throwIfAborted = (): void => {
	if (signal?.aborted) throw new Error("Operation aborted");
};

源码注释解释了原因:从 abort 监听器里 reject 会在文件系统操作仍在进行时释放变更队列,导致同一文件的后续写入可能和前一次交错。改成 await 之后检查,能观测到同样的中止,同时把队列锁住直到当前操作落定。

第三层,子进程。 这一层最容易被忽略,也最容易留隐患。bash 的子进程用 detached: process.platform !== "win32" 启动,然后靠 killProcessTree(child.pid) 收尾:

typescript
// 杀掉整棵进程树:只杀直接子进程会留下孤儿孙进程
export function killProcessTree(pid: number): void {
	if (process.platform === "win32") {
		// 用 System32 的 taskkill.exe,不依赖 PATH
		spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
			["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, windowsHide: true });
	} else {
		try {
			process.kill(-pid, "SIGKILL");   // 负 pid = 进程组
		} catch {
			// 进程组已经不在了,退回到只杀直接子进程
			try { process.kill(pid, "SIGKILL"); } catch { /* 已经死了 */ }
		}
	}
}

Unix 上 process.kill(-pid, "SIGKILL") 的负 pid 指的是进程组。这是必须的:sh -c "npm test" 会起孙进程,只杀直接子进程的话孙进程会变成孤儿继续占资源。Windows 上对应的是 taskkill /F /T。

另外 bash 还有一层环境清理:源码记录 detached 子进程的 pid,退出时统一清理。

路径解析:不是简单的 path.resolve

路径工具里有几个解析函数。resolveToCwd(filePath, cwd) 是主力:

typescript
// 路径解析只做兼容处理,不设边界
export function resolveToCwd(filePath: string, cwd: string): string {
	return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}

它做四件事:Unicode 空格归一化(\u00A0、\u202F 等变体转成普通空格)、剥掉开头的 @(CLI 的 @file 语法)、~ 展开成 home 目录、绝对路径原样通过而相对路径拼到 cwd 后面。

resolveReadPathAsync(filePath, cwd) 只给 read 用,多了四个 macOS 兼容回退:AM/PM 前的窄不换行空格、NFD 分解形式、弯引号变体、以及 NFD 加弯引号的组合。这些是为 macOS 截图文件名准备的兼容逻辑,不是安全边界。

内置工具不做工作区边界

这一点必须讲透,因为它是整个工具设计里最容易误解的地方。

write 和 edit 都走 resolveToCwd(path, cwd)。这个函数的行为是:相对路径拼到工作目录,绝对路径原样通过。

typescript
// 绝对路径原样通过:cwd 被忽略
resolveToCwd("/etc/hosts", "/repo")  // → "/etc/hosts"

write 的完整路径解析只有两行:

typescript
// 没有范围检查:路径原样交给 resolveToCwd
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
const dir = dirname(absolutePath);

没有任何范围检查。 传 /etc/hosts 给 write 就会写到 /etc/hosts。edit 同理。grep、find、ls 的 path 参数也是同一套解析,同样不设防。

这不是 bug,是明确的设计选择。官方安全文档写得很直白:Pi 没有内置权限系统,工具用的是启动 Pi 那个进程的操作系统权限。

层次谁负责具体做法
相对路径基准
Pi 内置
相对 cwd 解析,cwd 由宿主决定
绝对路径越界
宿主
内置工具不管,宿主必须自己校验
软链接逃逸
宿主
resolve 只做词法解析,指向外部的软链接能绕过
操作系统权限
宿主进程
Pi 以启动它的用户身份运行
真正的隔离
宿主
容器、micro-VM 或沙箱

所以你不能把内置工具当作"安全的工作区工具"直接用在不可信输入上。想要一个改不到工作区之外的工具,就得自己写边界校验,因为内置工具不提供这一层。内置工具提供的是能力,边界必须由宿主补上。

文件写入的串行化

write 和 edit 都被 withFileMutationQueue() 包住:

typescript
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
	// 读改写全程在队列内
});

队列的键是 realpath 解析后的路径(文件不存在时退回词法解析)。同一个文件的写操作串行执行,不同文件的写操作仍然并行。这解决了"两个工具同时改同一个文件"的竞态。

官方扩展文档明确要求:自定义的文件修改工具也要用 withFileMutationQueue() 把完整的读-改-写过程包起来。这个函数从 @earendil-works/pi-coding-agent 直接导出。

打印出真实契约

要核对当前版本的实际契约,最直接的办法是遍历工厂返回的工具对象。八个单工具工厂都是公开导出的:

typescript
import {
	createBashTool,
	createEditTool,
	createFindTool,
	createGrepTool,
	createLsTool,
	createPowerShellTool,
	createReadTool,
	createWriteTool,
} from "@earendil-works/pi-coding-agent";

// cwd 是相对路径的基准目录,由宿主决定,模型改不了
const cwd = process.cwd();

// 公开导出的工厂:单工具一个,用数组收集
const tools = [
	createReadTool(cwd),
	createBashTool(cwd),
	createPowerShellTool(cwd),
	createEditTool(cwd),
	createWriteTool(cwd),
	createGrepTool(cwd),
	createFindTool(cwd),
	createLsTool(cwd),
];

for (const tool of tools) {
	// name 是模型调用用的名字,label 是界面显示用的,两个都要看
	console.log(`--- ${tool.name} ---`);
	console.log("label:", tool.label);
	// parameters 就是 TypeBox schema,直接序列化即可看到字段与类型
	console.log("参数:", JSON.stringify(tool.parameters, null, 2));
}

需要真实跑一次并观察返回结构时,加一个探针工具:

typescript
import { Type } from "@earendil-works/pi-ai";
import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
	cwd: process.cwd(),
	// 只留一个不会改动磁盘的工具,避免副作用
	tools: ["read", "ls"],
	customTools: [
		defineTool({
			name: "probe",
			label: "probe",
			description: "打印工具结果结构,不做任何事",
			parameters: Type.Object({ toolName: Type.String() }),
			async execute(toolCallId, params) {
				// content 是模型读到的,details 是程序读到的:两者分开才能看清结构
				return {
					content: [{ type: "text", text: `toolCallId=${toolCallId}` }],
					details: { params },
				};
			},
		}),
	],
	sessionManager: SessionManager.inMemory(process.cwd()),
});

try {
	// 订阅要早于 prompt,否则快速完成的运行会被错过
	const unsubscribe = session.subscribe((event) => {
		if (event.type === "tool_execution_end") {
			// isError 区分成功与失败;durationMs 缺席说明失败发生在执行之前
			console.log(event.toolName, "isError:", event.isError);
			console.log("result:", JSON.stringify(event.result, null, 2));
			console.log("durationMs:", event.durationMs);
		}
	});

	await session.prompt("列出当前目录,然后用 probe 工具跑一次");
} finally {
	// dispose 不能省
	unsubscribe?.();
	session.dispose();
}

session.dispose() 不能省——它会中止在跑的工作、断开 agent 事件订阅、清空监听器。

谁负责什么

职责由谁承担具体表现
工具名与参数 schema
Pi 内置
八个工具,参数固定
相对路径基准目录
宿主
cwd 传给工厂函数
绝对路径越界防护
宿主
内置工具完全不检查
软链接逃逸防护
宿主
需要 realpath 之后二次校验
命令白名单与超时上限
宿主
内置 bash 的 timeout 不传就是无超时
环境变量收窄
宿主
内置 bash 通过 spawnHook / operations 注入点收窄
工具失败语义
Pi 内置 + 宿主自定义
抛错即失败,返回 isError 也算失败
输出截断
Pi 内置
2000 行 / 50KB,完整输出落临时文件
同一文件的写操作串行化
Pi 内置
withFileMutationQueue()
子进程树清理
Pi 内置
killProcessTree() 杀进程组
操作系统级隔离
宿主
容器或沙箱

你现在能做什么

  • 跑上面那段遍历工厂的脚本,把你安装的版本的八个工具参数 schema 打印出来,核对哪些参数和本文不一致——pi --version 记下来再比对。
  • 用探针工具跑一次 read,观察 tool_execution_end 事件里的 result.details 里到底有没有 truncation 字段,确认"成功返回也可能带 details"这一点。
  • 造一个超过 2000 行或 50KB 的文件,调用 read 一次,确认返回文本末尾带有 [Showing lines X-Y of Z. Use offset=N to continue.],再用 offset 继续读完。
  • 跑一条会起子进程的命令(例如 sh -c "sleep 300 & wait"),在它运行期间取消,然后检查系统里有没有残留的 sleep 进程——这验证的是进程组是否被整组杀掉。

排障速查

现象检查点处理
模型调用了不存在的工具
工具名是否在 allToolNames 里
内置只有 read/bash/powershell/edit/write/grep/find/ls,没有 search/replace/list
createAllTools 的返回值传不进参数
返回类型
它返回 Record<ToolName, AgentTool>,用 Object.values() 转成数组
工具改到了工作区外的文件
绝对路径是否被放行
内置工具不做边界校验,必须在宿主层校验
软链接指向外部仍被写入
校验用的是词法还是真实路径
resolve 不解引用,要用 realpath 后再 relative 判定
read 返回空内容
第一行超过字节上限
read 会给出 sed 转路建议
模型看不到命令的报错
裁剪方向
命令输出用 truncateTail,read 文件才用 truncateHead
上下文被输出撑爆
截断阈值
2000 行 / 50KB,超出部分落临时文件并把路径给模型
超时后子进程还在跑
是否杀了进程组
Unix 上要 process.kill(-pid, "SIGKILL"),只杀直接子进程会留孙进程
取消后写入错乱了
写工具是否在 abort 监听器里 reject
要在每次 await 后检查 signal.aborted
grep / find 报找不到后端
宿主环境
ensureTool("rg") / ensureTool("fd") 下载失败,离线模式会跳过下载
edit 说 oldText 不唯一
是否用完整上下文
内置 edit 要求唯一匹配,重复会抛错而不是取第一个

参考资料