源码目录:packages/agent/src/harness/tools/


〇、先看共同底座:ExecutionToolContext / env

四个工具没有一个直接碰 Node 的 fschild_process。它们全部通过注入的 envExecutionEnv)操作文件系统、执行命令:

// 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 工具走的命令执行

这个抽象有三个直接收益:

  1. 可测试:单测注入内存版 env,不碰真实文件系统;
  2. 可隔离:Gondolin 模式(专项四)把 read/write/edit/bash 全部路由进微 VM,靠的就是"工具只认 env 接口,不认 OS"——换一个远端 env 实现,工具代码一行不改;
  3. 可审计:所有文件操作都过一个口,可以在 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:创建父目录 + 字节数回执

writetool-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:精确文本替换,附带变更快照

edittool-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.mda.md、符号链接和真实路径会归到同一个锁——按规范路径加锁才挡得住"同文件不同路径"的并发编辑
  • 锁作用域 = 单个 envWeakMap<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.tsTruncationResulttruncated / 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}.tspackages/agent/src/harness/utils/{truncate,shell-output}.tspackages/coding-agent/docs/environment-variables.md

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐