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):
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" })),
});
三个细节值得抄:
① 输出有界,且明确告诉模型怎么读完。 工具描述原话:
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):
const variants = [
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:
const editSchema = 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); // ① 去 BOM
const originalEnding = detectLineEnding(content); // ② 检测换行符
const normalizedContent = normalizeToLF(content); // ③ 统一成 LF
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
const finalContent = bom + restoreLineEndings(newContent, originalEnding); // ④ 还原换行符
await env.writeFile(absolutePath, finalContent, signal);
const diffResult = 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 版本:
const key = await getMutationQueueKey(env, path); // 规范路径作为锁 key
const currentQueue = state.queues.get(key) ?? Promise.resolve();
// 排队:当前队列完成后才轮到下一个
const chainedQueue = currentQueue.then(() => nextQueue);
state.queues.set(key, chainedQueue);
await currentQueue;
try { return await fn(); }
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)是权限最大、最需要防护的工具。它的设计:
① 输出捕获 + 双限截断。 命令输出边跑边攒,超限即截断:
const maxOutputBytes = 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。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)