Pi :内置四工具源码拆解——文件锁、变更快照与 bash 隔离
源码目录:
packages/agent/src/harness/tools/。
〇、先看共同底座:ExecutionToolContext/env
四个工具没有一个直接碰 Node 的fs或child_process。它们全部通过注入的env(ExecutionEnv)操作文件系统、执行命令:
// tools 看到的操作面env.readTextFile(path,signal)env.readBinaryFile(path,signal)env.writeFile(path,content,signal)env.fileInfo(path,signal)env.absolutePath(path,signal)env.canonicalPath(path)env.runCommand(...)// bash 工具走的命令执行这个抽象有三个直接收益:
- 可测试:单测注入内存版 env,不碰真实文件系统;
- 可隔离:Gondolin 模式(专项四)把
read/write/edit/bash全部路由进微 VM,靠的就是"工具只认 env 接口,不认 OS"——换一个远端 env 实现,工具代码一行不改; - 可审计:所有文件操作都过一个口,可以在 env 层统一打点。
这是"工具层与操作系统解耦"的标准姿势——你的 Java 工具也应该定义FilePort/CommandPort接口,而不是直接在工具里new FileOutputStream()。
一、read:offset/limit 分页 + 图片 + 输出有界
read的 schema(tool-read.ts):
constreadSchema=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"})),});三个细节值得抄:
① 输出有界,且明确告诉模型怎么读完。工具描述原话:
For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.
“输出截断 + 指引模型用 offset 继续读完”——Pi 把"怎么处理大文件"写进工具描述,模型按指引自动分页。
② 图片是一等输入。read支持 jpg/png/gif/webp/bmp,读图走imageProcessor转成{type:"image", data, mimeType}内容块直接作为附件发给模型。返回的提示里甚至带hints(图片处理器的建议,比如"这张图可能被裁剪了")。
③ 路径解析做了 Unicode 容错(path-utils.ts):
constvariants=[resolved,resolved.replace(/(AM|PM)\./gi,`${NARROW_NO_BREAK_SPACE}$1.`),// 全角空格resolved.normalize("NFD"),// 音标字符分解resolved.replace(/'/g,"’"),// 直引号→弯引号];模型可能把文件路径里的全角空格、直引号、Unicode 组合字符搞混,read会逐个变体尝试。模型输出文件路径是不可靠的,工具端要做"模糊匹配 + 容错"。
二、write:创建父目录 + 字节数回执
write(tool-write.ts)很薄,但有一个行为值得注意:
description:"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."自动创建父目录,返回Successfully wrote ${content.length} bytes to ${path}。content.length是字符数不是字节数,但这不耽误模型理解"写入了多少"。write 是整个工具集里最直白的,它真正的复杂度在锁。
三、edit:精确文本替换,附带变更快照
edit(tool-edit.ts)是四个工具里工程含量最高的。schema:
consteditSchema=Type.Object({path:Type.String(...),edits:Type.Array(Type.Object({oldText:Type.String({description:"Exact text for one targeted replacement. It must be unique in the original file..."}),newText:Type.String({description:"Replacement text for this targeted edit."}),}),{description:"One or more targeted replacements. Each edit is matched against the original file, not incrementally..."}),});执行流水线(去掉样板后):
const{bom,text:content}=stripBom(readResult.value);// ① 去 BOMconstoriginalEnding=detectLineEnding(content);// ② 检测换行符constnormalizedContent=normalizeToLF(content);// ③ 统一成 LFconst{baseContent,newContent}=applyEditsToNormalizedContent(normalizedContent,edits,path);constfinalContent=bom+restoreLineEndings(newContent,originalEnding);// ④ 还原换行符awaitenv.writeFile(absolutePath,finalContent,signal);constdiffResult=generateDiffString(baseContent,newContent);// ⑤ 生成 diff 快照return{content:"...",details:{diff,patch:generateUnifiedPatch(...),firstChangedLine}};五个要点:
- 先规范化再编辑,编辑完还原:BOM、CRLF/LF 先剥掉/统一,替换完再还原。否则模型用 LF 的 oldText 匹配 CRLF 文件会失败。
- 每个 oldText 必须唯一、且互不重叠:“It must be unique in the original file and must not overlap with any other edits[].oldText”——schema 层就挡住"模糊替换"。
- edits 全部针对原文件匹配,不增量匹配:“Each edit is matched against the original file, not incrementally”——避免前一个编辑改变行号影响后一个。
- 返回 diff/patch/firstChangedLine:这就是"文件变更快照"。模型、UI、审计都能看到"这次编辑到底改了什么"。
generateUnifiedPatch给出统一 diff,firstChangedLine定位首行变化。 - 兼容层
prepareArguments:老格式的{oldText, newText}被折叠成新的edits数组——工具 schema 演进不破坏历史调用。
对比很多 Agent 用"整文件重写"改文件,Pi 的 edit 是精确补丁 + 快照——副作用最小、可 diff、可回滚。
四、文件锁:按规范路径串行化变更
file-mutation-queue.ts实现的是"同一文件上的变更串行化"——这是文件锁的 Promise 版本:
constkey=awaitgetMutationQueueKey(env,path);// 规范路径作为锁 keyconstcurrentQueue=state.queues.get(key)??Promise.resolve();// 排队:当前队列完成后才轮到下一个constchainedQueue=currentQueue.then(()=>nextQueue);state.queues.set(key,chainedQueue);awaitcurrentQueue;try{returnawaitfn();}finally{releaseNext();...}细节:
- 锁粒度 = 规范路径(canonical path),不是传进来的字符串。
../a.md和a.md、符号链接和真实路径会归到同一个锁——按规范路径加锁才挡得住"同文件不同路径"的并发编辑。 - 锁作用域 = 单个
env(WeakMap<ExecutionEnv, ...>)。不同 env(比如 Gondolin 的远端 env)各归各的。 - 为什么需要它:一个 turn 里可能有多个工具调用(batch),两个 edit 同时改同一个文件,读-改-写会互相覆盖。文件锁把"读原文件 → 应用编辑 → 写回"变成同一路径上的原子操作。
这是"Agent 改代码"场景最容易翻车的地方:并发写同一文件。你在 Java 里做工具层时,务必有同款"按规范路径串行化文件变更"的机制。
五、bash:隔离、捕获、会话环境注入
bash 工具(tools/bash.ts+shell-output.ts)是权限最大、最需要防护的工具。它的设计:
① 输出捕获 + 双限截断。命令输出边跑边攒,超限即截断:
constmaxOutputBytes=DEFAULT_MAX_BYTES*2;// 100KB 上限// 截断到尾部 N 行 / N KB,谁先到算谁截断结果带完整元信息(truncate.ts的TruncationResult:truncated / truncatedBy / totalLines / totalBytes / outputLines / lastLinePartial),并且完整输出落临时文件(fullOutputPath)——模型需要时再读全文,主上下文只留尾部。
② 输出净化:sanitizeBinaryOutput过滤掉二进制控制字符(只保留 tab/换行/回车),防止二进制输出污染上下文。
③ 超时显性化:timeout 可选、无默认,但受MAX_TIMEOUT_SECONDS硬上限约束;超时以错误返回:“Command timed out after N seconds”——模型能看到并决定怎么办。
④ 会话环境注入(environment-variables.md):bash 工具运行的命令会拿到当前会话状态:
PI_SESSION_ID 当前会话 ID PI_SESSION_FILE 会话 JSONL 路径(临时会话为空) PI_PROVIDER 当前 provider PI_MODEL 当前模型 PI_REASONING_LEVEL 当前推理级别命令可以据此自查:“我在哪个会话、用什么模型跑的”——这是 agent 自我感知能力的底座。spawnHook可以再改 env(比如注入 CI=1),exposeSessionEnvironment: false关闭注入。
⑤ 命令执行本身走 env 抽象(runCommand),所以 bash 同样可以被 Gondolin 路由进 VM——这就是"工具隔离"的实现路径:bash 不是"在进程里开 shell",而是"向 env 端口提交一个命令"。
六、对照你的工程:四工具的移植清单
| Pi 的机制 | 你要不要做 | 说明 |
|---|---|---|
| env 抽象(FilePort/CommandPort) | 必须 | 可测试 + 可隔离 + 可审计的根源 |
| read 分页 + 输出有界 | 必须 | 防止大文件灌爆上下文 |
| edit 精确替换 + diff 快照 | 强烈建议 | 副作用最小、可回滚、可审计 |
| 文件锁(规范路径串行化) | 必须 | 并发改同一文件会互相覆盖 |
| bash 输出双限截断 + 临时文件 | 必须 | 命令输出是上下文杀手 |
| 会话环境注入 | 建议 | 命令能感知自己在哪、用什么模型 |
| 路径 Unicode 容错 | 建议 | 模型给的文件路径不可靠 |
知识卡片(本节体系归档)
┌──────────────────────────────────────────────────────────┐ │ 知识节点:内置四工具(env 抽象 + 文件锁 + bash 隔离) │ │ │ │ What env 抽象(FilePort/CommandPort);read 分页/图片; │ │ edit 精确替换/BOM/diff 快照;规范路径文件锁; │ │ bash 输出双限截断 + 会话 env 注入 │ │ │ │ Why 一般原理:工具层与 OS 解耦。不变量: │ │ ① 工具只认 env 接口,可测试/可隔离/可审计 │ │ ② 同文件变更按规范路径串行化 │ │ ③ 输出必须有界,完整内容落临时文件 │ │ │ │ How 校验动作: │ 画四工具结构 + PaiFlow 文件操作换 FilePort 接口 │ │ │ │ Pits 坑点: │ 并发写文件 / 输出灌爆 / 模型给的路径不可靠 │ │ │ │ Transfer 到 PaiFlow:FilePort + 规范路径锁 + 输出截断 │ └──────────────────────────────────────────────────────────┘源码与文档出处:packages/agent/src/harness/tools/{read,write,edit,bash,file-mutation-queue,path-utils,tool-context}.ts、packages/agent/src/harness/utils/{truncate,shell-output}.ts、packages/coding-agent/docs/environment-variables.md。