开源编程 Agent pi 的基础工具层:四把工具的参数、边界与克制
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 默认打开给模型的工具只有四把:read、bash、edit、write。仓库里明明已经实现了 grep、find、ls,却没有把它们放进默认集——这个「实现了但不默认给」的取舍,比工具本身的实现更值得你看。
站内已有两篇讲通用方法论的文章:工具怎么设计 讲的是拆分粒度与职责边界的一般原则,工具描述怎么写 讲的是描述文本该怎么组织才让模型不误用。这篇不重复那些原则,只做一件事——把一个真实开源项目的工具层代码摊开,看这些原则落到具体的参数表和 if 分支上长什么样。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star,你可以随时把仓库拉下来对着看。
一、默认打开的是哪四个
pi 的工具代码分在两层。底层在 packages/agent/src/harness/tools/,这一层的 index.ts 只导出四个工厂函数:createBashTool、createEditTool、createReadTool、createWriteTool,其余导出全是类型——每把工具的输入类型、选项类型、返回细节类型,以及公共的 ExecutionToolContext。整个目录里没有 grep、没有 find、没有 ls。
上层在 packages/coding-agent/src/core/tools/,这一层多出了 grep.ts、find.ts、ls.ts,工具名的联合类型写成:
export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
export const allToolNames: Set<ToolName> = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
七个名字都在。但是往下翻,组装函数把它们分成了两组:createCodingTools 只返回 read、bash、edit、write 四个;createReadOnlyTools 返回 read、grep、find、ls 四个。真正决定「你敲 pi 之后模型手里有什么」的那一行在 packages/coding-agent/src/core/sdk.ts:
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
所以 grep、find、ls 是备件,不是标配。它们的存在是为只读场景准备的,默认会话里模型看不到。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 四把基础工具的实现 | 定义参数 schema、描述文本、执行逻辑 | packages/agent/src/harness/tools/(bash.ts / read.ts / write.ts / edit.ts) | 想改工具行为、或想搞清楚模型到底能传什么参数时 |
| 工具名与组装 | 七个工具名的枚举、按用途分组 | packages/coding-agent/src/core/tools/index.ts | 想自己组一套工具集,或想知道只读模式给了什么 |
| 默认激活集 | 决定默认会话打开哪几个 | packages/coding-agent/src/core/sdk.ts | 排查「模型为什么不用 grep」时 |
| 命令行开关 | --tools、--exclude-tools、--no-tools | packages/coding-agent/src/cli/args.ts | 想临时收窄或放开工具范围时 |
| 截断规则 | 行数与字节双上限、头截断与尾截断 | packages/agent/src/harness/utils/truncate.ts | 输出被砍掉、想知道砍在哪时 |
| 系统提示词拼装 | 按实际可用工具动态加规则 | packages/coding-agent/src/core/system-prompt.ts | 想知道模型被怎么引导时 |
二、bash:两个参数,把复杂度推到外面
bash.ts 里的参数 schema 短得有点意外:
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
一个必填的命令字符串,一个可选的秒级超时。没有 cwd 参数,没有环境变量参数,没有后台执行开关,没有「是否需要确认」的标志位。工作目录直接取自执行环境的 env.cwd,inheritEnv 写死为 true,工具自己额外注入的 env 是个空对象。
超时的校验也直白:不填就没有超时;填了就必须是有限正数,否则抛 Invalid timeout: must be a finite number of seconds;上限是常量 MAX_TIMEOUT_SECONDS,值为 2_147_483_647 / 1000,也就是 32 位有符号整数最大值换算成的秒数。
输出这块做了两件对使用体验影响很大的事。第一是流式回传:执行过程中通过 onUpdate 往外推进度,节流常量 BASH_UPDATE_THROTTLE_MS 是 100,也就是最快 100 毫秒推一次,长命令不会等到结束才有反应。第二是截断兜底:truncate.ts 里 DEFAULT_MAX_LINES 是 2000,DEFAULT_MAX_BYTES 是 50KB,谁先到算谁。bash 用的是尾截断,保留末尾——因为报错和最终结果通常在后面。被截断时,完整输出会落到一个临时文件,返回文本里追加一行形如 [Showing lines X-Y of Z. Full output: <路径>] 的提示,模型可以顺着这个路径自己去捞。
失败路径同样被规整成三种带上下文的错误:被中断是 Command aborted,超时是 Command timed out after N seconds,非零退出码是 Command exited with code N——而且这三条都会把已经拿到的输出拼在前面,不是干巴巴一句失败。这一点和 工具返回值怎么设计 里讲的「失败也要带够诊断信息」是同一个意思。
那可扩展性去哪了?在 BashToolOptions 里,只有两项:commandPrefix(在命令前拼一段前缀)和 prepare(一个可以改写 BashExecution 的钩子,能动 command、cwd、env、inheritEnv)。想把命令丢进沙箱、丢到远程机器上执行,是通过这个钩子改写执行体,而不是往参数表里加字段。参数表面向模型,钩子面向集成方,两拨人不互相污染。
三、read 与 write:路径、截断提示、写入排队
read 的参数是三个:path、可选的 offset(1 起数的行号)、可选的 limit(最多读多少行)。没有正则、没有「只读某个函数」这类语义化选项。
它有意思的地方在返回文本。read 用的是头截断,砍掉之后不只说「被截断了」,而是直接把下一步该怎么做写进去:[Showing lines A-B of C. Use offset=N to continue.]。遇到单行本身就超过 50KB 的极端情况——压缩过的 JS、一整行的 JSON——它连内容都不给,改成一条可执行的建议,让你换 bash 去取那一段。这种「工具自己教模型下一步」的写法,比在系统提示词里写十条规矩省事得多。
越界也不含糊:offset 超过文件总行数会直接抛 Offset N is beyond end of file (M lines total),不会静默返回空。
图片是 read 里唯一的「特事特办」:描述文本里写明支持 jpg、png、gif、webp、bmp,命中图片时返回一段文字加一个图片附件。BMP 是个例外,没配 imageProcessor 时它只返回一句说明,让你去配一个处理器。ReadToolOptions 也就两项:autoResizeImages(默认 true)和 imageProcessor。缩放这种依赖原生库的活儿,被做成注入项而不是硬依赖。
write 更短,两个参数:path 和 content。描述文本一句话说完边界——不存在就创建,存在就整体覆盖,父目录自动建。没有 append 模式,没有「仅当文件不存在时才写」的选项,没有 dry-run。
但它在执行前套了一层 withFileMutationQueue。这个队列按「执行环境 + 规范化后的绝对路径」做键,把落在同一个文件上的写操作串成一条链,前一个不完成后一个不开始。edit 用的是同一个队列。并发写同一个文件在多任务场景里是真会发生的,这一层是防止两次修改互相盖掉。想了解并发编排本身的坑,可以看 多任务并发编排。
路径解析也值得留一眼。path-utils.ts 里的 normalizeToolPath 会把各种 Unicode 空格换成普通空格,还会把开头的 @ 削掉——这是为了兜住模型从聊天界面里带出来的 @ 引用写法。read 专用的 resolveReadToolPath 更进一步,会依次试几个变体:原路径、把 AM. / PM. 里的空格换成窄不换行空格、NFD 规范化形式、把直引号换成弯引号,哪个存在就用哪个。这几行代码没有任何抽象美感,纯粹是被 macOS 截图文件名和各种输入法折磨出来的补丁。真实项目的工具层里,这类代码的占比往往比你预期的高。
四、edit:把改文件收敛成一次性文本替换
edit 的参数是 path 加一个 edits 数组,每项两个字段:oldText 和 newText。没有行号,没有 diff 格式,没有 AST 定位。
约束全写在描述里,而且写得很硬:每个 oldText 必须在原文件中唯一,且不能与同一次调用中的其他 oldText 重叠;所有替换都对着原始文件匹配,不是一个接一个增量地应用;如果两处改动挨得近,要合并成一个 edit 而不是拆成两个;不要为了连接两处远距离改动而把大段没变的内容也塞进来。这四句话对应的正是模型在批量改文件时最容易出的几类问题:锚点不唯一、改动区间互相咬住、按增量顺序理解替换、以及为了对齐位置而把大段无关内容一起塞进 oldText。
实现上有两个细节值得抄。一是 prepareArguments:模型有时会把 edits 当成字符串传过来(一段 JSON 文本),也有模型会退回到老式的顶层 oldText/newText 写法。这个函数在校验前先做归一化,JSON 字符串尝试解析,老式写法折叠成数组里的一项。与其在提示词里反复强调格式,不如在入口处把常见变形接住。二是写回时的保真:先 stripBom 剥掉 BOM,detectLineEnding 记住原来的换行风格,统一按 LF 处理完再 restoreLineEndings 还原,最后把 BOM 拼回去。改一个 CRLF 文件不会让整个文件在 git 里变成全量改动。
返回值除了一句「成功替换了几块」,还在 details 里带上 diff、patch 和 firstChangedLine。前端拿它渲染变更,人拿它做复核。
五、边界与代价:这套设计明确不管的事
工具集小到这个程度,代价是实打实的。
它把搜索完全交给了 bash。系统提示词的拼装逻辑里有这么一段判断:当 bash 可用、而 grep / find / ls 都不可用时,追加一条规则 Use bash for file operations like ls, rg, find。换句话说,默认配置下模型是被明确告知「文件操作走 bash」的。好处是模型可以用管道、用 head -c、用组合命令,表达力远超一个固定参数表;代价是搜索结果的格式不受控,长度靠通用截断兜底,跨平台差异也要模型自己扛——机器上没装 ripgrep,这条规则里的 rg 就落空了。
它也没有在工具层做权限与审批。四个工具里找不到任何「危险命令拦截」「路径白名单」的分支。bash 拿到什么执行什么,write 覆盖文件不问一声。安全边界被推到了外面——用 prepare 钩子改写执行体、用 --exclude-tools 摘掉工具、或者干脆把整个执行环境换成受限实现。这是个清晰的分工,但它意味着你不能假设「默认就是安全的」。这一层的取舍可以对照 最小权限设计 一起看。
还有几件事它明确不管:不管 undo,write 覆盖了就是覆盖了,回滚靠版本控制;不管语法正确性,edit 只做文本替换,替换完文件能不能编译不归它;不管大文件的高效访问,read 一次性把整个文件读进内存再切片;不管 bash 的交互式命令,需要 stdin 的程序在这套捕获逻辑里是走不通的。
最后是那个「小」本身的代价。工具越少,同一件事就越依赖模型自己组合命令,token 消耗和往返轮次都会往上走。这是拿确定性换灵活性的典型交易,不同任务上的账算出来不一样。
六、上手与避坑清单
别指望模型会自己用 grep。 为什么会踩:你在仓库里看到了 grep.ts,理所当然以为它在。实际上默认激活集里没有它,模型只会用 bash 去搜。怎么避:想让它出现,得在创建会话时显式指定工具名,或者用命令行的工具开关;不想折腾就接受「搜索走 bash」这个前提,然后确认机器上装了你在提示里提到的搜索命令。
bash 不填 timeout 就是真的没有超时。 为什么会踩:超时这种字段容易被当成「不填就有个兜底值」,于是整批命令都不带它。这里的参数描述白纸黑字写着 no default timeout,一条卡住的命令可以一直挂着。怎么避:凡是可能阻塞的命令,让模型带上 timeout;在集成侧,可以用 prepare 钩子统一给命令加约束。
看到 50KB 或 2000 行就该想到截断。 为什么会踩:模型基于被截断的输出下结论,你却以为它看到了全部。怎么避:认准返回文本里那段方括号提示——bash 会给临时文件路径,read 会给 offset=N 的续读建议。这两个提示出现,就说明手里的信息不完整,该继续读的要继续读。
edit 的 oldText 不唯一会失败。 为什么会踩:模型习惯截一小段代码当锚点,而这段代码在文件里出现了三次。怎么避:让它把上下文取足到唯一为止;同一区域的多处改动合并成一个 edit,别拆开还互相重叠。
write 返回的字节数别拿来做校验。 为什么会踩:返回文案是 Successfully wrote N bytes,但代码里取的是 content.length,也就是 JavaScript 字符串的长度。写中文时这个数和实际落盘的 UTF-8 字节数对不上。怎么避:要核实写入结果,用 bash 去 stat 或者重新 read,别信这个数字。
路径带 @ 前缀不会报错。 为什么会踩:你以为传错了会失败,实际上 normalizeToolPath 悄悄把开头的 @ 削掉了。这是个善意的兜底,但也意味着路径拼错的一部分情况会被吞掉。怎么避:排查「读到了不该读的文件」时,记得工具收到的路径可能和模型写出来的不完全一样。
先解决模型能不能连上。 为什么会踩:工具层再干净,没有可用的模型也跑不起来。海外模型服务商官方对中国大陆存在区域限制、不支持直连,市面上存在第三方中转但这里不做任何背书;各家规则不同且会调整,以官方最新说明为准。怎么避:先确认账号与接入方式可用,再来折腾工具配置,别把接入问题误判成工具问题。
收束
把这四把工具看完,你会发现 pi 的取舍其实只有一句话:参数表面向模型,尽量少;扩展点面向集成方,放在选项和钩子里。 模型看到的是 command 和 timeout,集成方看到的是 prepare 和 commandPrefix;模型看到的是 path 和 content,集成方看到的是可替换的文件操作实现。两拨使用者的需求被彻底分开,所以参数表才能一直保持在两三个字段。
拿这个标准回头量一量你自己的工具层,可以问三个问题:模型能传的参数里,有几个其实是给集成方留的开关?工具失败时返回的文本,够不够模型自己决定下一步?输出被截断的时候,有没有告诉它怎么把剩下的拿到手?
想接着往下读,按这个顺序:packages/agent/src/harness/tools/read.ts(截断提示写得最全)、packages/agent/src/harness/utils/truncate.ts(头截断和尾截断的区别)、packages/coding-agent/src/core/system-prompt.ts(工具集怎么反过来影响提示词)。这三个文件加起来不到 700 行,读一遍的成本很低,而且看到的是真在跑的代码,不是被概括过的说法。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 逐段读开源编程 Agent pi 的主循环 和 开源编程 Agent pi 的文件编辑。