编码工具实现:路径、进程与结果边界
逐个读源码核对 Pi 八个内置编码工具的真实名称、参数 schema、返回结构与失败语义,并说明输出截断与取消传播的机制。
编码工具实现:路径、进程与结果边界
工具能力来自宿主实现,而不是提示词声明。 模型说"我要读文件"只是产生了一个 toolCall 块;能不能读、读到的是不是工作区里的文件、输出会不会撑爆上下文、取消时进程会不会真的死掉——全部由内置工具的代码决定。
这篇做三件事:核对内置工具的真实清单与 schema;逐个读读写文件与执行命令的返回结构;把错误、输出截断和取消三条传播路径讲清楚。
读它需要能读 TypeScript,并且对文件系统与子进程的基本行为有印象。读完你能自己遍历工具对象打印契约,能解释每个工具的失败语义,也能判断哪些边界是内置的、哪些必须你自己补。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 打印出当前版本里每个内置工具的真实名字、参数 schema 和返回结构。
- 亲手触发一次输出截断,确认模型拿到的是截断后的内容加上"完整输出在哪"。
- 取消一次正在跑命令的工具,确认子进程树被杀掉而不是留下孤儿。
内置工具真实清单
先纠正一个流传很广的说法。内置工具一共八个,名字来自内置工具清单里的 allToolNames:
// 内置工具一共八个
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。
源码里提供了四组工厂函数:
公开导出的是前两组,加上八个单工具工厂:createReadTool、createBashTool、createPowerShellTool、createEditTool、createWriteTool、createGrepTool、createFindTool、createLsTool。
createAllTools 返回的是 Record,不是数组——注意这个区别,因为 AgentTool[] 和 Record<ToolName, AgentTool> 在传参时不能互换。
八个工具的真实契约
下表逐字对照源码整理。"失败语义"一列最关键:它决定模型看到失败后能不能自我纠正。
几个必须知道的细节:
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。命令跑失败是业务结果,不是程序错误。
输出截断:不让工具撑爆上下文
八个工具共用截断工具里的三个常量:
两个上限先到先算。截断模块提供几个方向不同的函数:
前三个是公开导出的,truncateMiddle 目前只在内部使用。
truncateHead 有一个特殊分支:如果第一行本身就超过字节上限,它返回空内容并置 firstLineExceedsLimit: true。read 遇到这种情况不会返回空,而是给模型一条明确的转路建议——注意这条提示本身不是错误消息,它告诉模型换条路继续读:
[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。模型看到的内容末尾会附上完整输出的位置。
三种工具的默认条目上限也各不相同,超限时同样会给模型指引:
取消传播:三层,缺一不可
取消从 session.abort() 开始,沿 AbortSignal 向下传。三层都要处理,否则会出现"界面停了但进程还在跑"。
第一层,Agent 循环。 agent.abort() 触发 AbortController。prepareToolCall() 在参数校验后检查 signal?.aborted,已中止就立刻返回 Operation aborted 的错误结果,execute() 根本不会被调用。
第二层,工具实现。 八个工具都各自处理信号。read、grep、find、ls 的模式一致:
// 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:
// 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) 收尾:
// 杀掉整棵进程树:只杀直接子进程会留下孤儿孙进程
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) 是主力:
// 路径解析只做兼容处理,不设边界
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)。这个函数的行为是:相对路径拼到工作目录,绝对路径原样通过。
// 绝对路径原样通过:cwd 被忽略
resolveToCwd("/etc/hosts", "/repo") // → "/etc/hosts"write 的完整路径解析只有两行:
// 没有范围检查:路径原样交给 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 那个进程的操作系统权限。
所以你不能把内置工具当作"安全的工作区工具"直接用在不可信输入上。想要一个改不到工作区之外的工具,就得自己写边界校验,因为内置工具不提供这一层。内置工具提供的是能力,边界必须由宿主补上。
文件写入的串行化
write 和 edit 都被 withFileMutationQueue() 包住:
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
// 读改写全程在队列内
});队列的键是 realpath 解析后的路径(文件不存在时退回词法解析)。同一个文件的写操作串行执行,不同文件的写操作仍然并行。这解决了"两个工具同时改同一个文件"的竞态。
官方扩展文档明确要求:自定义的文件修改工具也要用 withFileMutationQueue() 把完整的读-改-写过程包起来。这个函数从 @earendil-works/pi-coding-agent 直接导出。
打印出真实契约
要核对当前版本的实际契约,最直接的办法是遍历工厂返回的工具对象。八个单工具工厂都是公开导出的:
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));
}需要真实跑一次并观察返回结构时,加一个探针工具:
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 --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进程——这验证的是进程组是否被整组杀掉。