数据截至 (上游 commit 59a71b235dad)
pi-coding-agent:工具集、自扩展与 CLI 模式
30 秒导读: 前面几章讲的
pi-ai(统一 LLM 层)、pi-agent-core(agent 循环)、Harness(会话树/系统提示/Skills)都是通用的——它们不知道"编码"是什么。本章讲的pi-coding-agent就是把这些通用件焊成一个真正能改代码的命令行工具的那一层:它给模型装上"手脚"(读 文件、跑 shell、精确改文件),提供一个能在运行时用 TypeScript 长出新能力的自扩展系统,并让同一个 agent 能被人(终端 UI)和程序(RPC/SDK)两种方式驱动。
本章聚焦"特化"与"扩展机制"。纯 agent 循环见 02-agent-loop.md,harness/系统提示/压缩见 03-harness-context.md,终端渲染库见 05-tui.md。
1. 这是什么(零基础也能懂)
一句话定义: pi-coding-agent 是 pi 这个包(@earendil-works/pi-coding-agent),它把通用 agent 变成一个编码 agent——一个住在你终端里、能读你项目、能跑命令、能精确改文件、还能被你自己写插件魔改的 AI 助手。
它给通用 agent 补了三样东西:
| 补的东西 | 白话 | 本章第几节 |
|---|---|---|
| 手脚(内置工具) | 让模型能真的 read/bash/edit/write/grep/find/ls | §3 |
| 自扩展(可编程性) | 让你用一个 TS 文件在运行时注册新工具/命令/键位/模型 provider | §4(重点) |
| 三种驱动方式 | 同一个 agent,人用 TUI 聊、程序用 RPC 调、脚本用 -p 一次性跑 | §6 |
给谁用: 想在终端里让 AI 帮忙改一个真实代码库的工程师;以及想把 pi 当库嵌进自己产品、或给它定制行为的开发者。
用起来什么样(最小真实示例):
# 交互式:开一个终端 UI,像聊天一样让它改代码
pi
# print / 非交互:一次性跑完就退出,适合脚本和 CI
pi -p "把 src/ 里所有 var 换成 const,跑一遍测试"
# RPC:作为子进程被别的程序驱动(stdin 发命令,stdout 收事件)
pi --mode rpc
作为库嵌入也只要一个函数(packages/coding-agent/src/core/sdk.ts:171 createAgentSession):
// 示意,非源码
import { createAgentSession } from "@earendil-works/pi-coding-agent";
// 默认就带上 read/bash/edit/write 四个内置工具
const { session } = await createAgentSession({
tools: ["read", "bash"], // 只开这两个
customTools: [myDeployTool], // 再塞一个你自己的工具
});
await session.prompt("看一眼 README 然后总结这个项目");
一句话直觉: 把通用 agent 想成一台只有大脑、没有身体的机器人。pi-coding-agent 给它装上了标准的手脚(文件/shell 工具),又留了一排外接口(扩展系统),你可以随时插上自制的义肢;最后它还有三种遥控器(TUI / print / RPC)。
2. 顶层全景(它大概怎么转)
先看这一层各部件怎么摆放。怎么读这张图: 上半是"能力供给"(工具 + 扩展),中间 AgentSession 是总装配台,下半是三种"驱动壳"。数据从下往上:某个 mode 收到用户/程序的输入 → 交给 AgentSession → 它调 pi-agent-core 的循环 → 循环调工具 → 工具结果回流。
能力供给层
┌──────────────────────┐ ┌───────────────────────────┐
│ 内置工具 (core/tools) │ │ 自扩展系统 (core/extensions)│
│ read bash edit write │ │ loader(jiti+虚拟模块) │
│ grep find ls │ │ runner(事件/上下文) │
└──────────┬───────────┘ └──────────────┬────────────┘
│ ToolDefinition │ registerTool/命令/键位/flag/provider
└───────────────┬────────────────┘
▼
┌──────── ─────────────────┐
│ AgentSession │ 总装配台
│ · 工具注册表 + 活跃工具 │
│ · 系统提示(随工具重建) │ → 调 pi-agent-core 的 Agent 循环
│ · beforeToolCall 钩子 │ → 用 pi-ai 说话
│ · 模型/信任/会话服务 │
└───────────┬─────────────┘
│ 同一个 session,三种壳
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
交互式 TUI print / 非交互 RPC / SDK
(人来聊) (pi -p,跑完退出) (程序 stdin/stdout 驱动)
modes/interactive modes/print-mode modes/rpc
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 内置工具 | 7 个文件/shell 工具的定义+执行+渲染 | packages/coding-agent/src/core/tools/ |
ToolDefinition | 一个工具的完整契约(schema+execute+render) | core/extensions/types.ts:449 |
| 扩展 loader | 用 jiti 把 TS 扩展动态编译加载 | core/extensions/loader.ts |
| 扩展 runner | 持有已加载扩展,分发生命周期事件、造 ExtensionContext | core/extensions/runner.ts |
AgentSession | 总装配台:建工具注册表、拼系统提示、装 agent-core 钩子 | core/agent-session.ts:310 |
| ModelRegistry / 信任 | 模型/API key 解析、项目信任门禁 | core/model-registry.ts、core/trust-manager.ts |
| 三种 mode | 把 session 接到人/程序 | src/modes/ |
| orchestrator(已移除) | 早期实验性多 pi 监督包,当前 commit 已删除(§7) | — |
主线走一遍(高层): 启动时 mode 造出一个 AgentSession → session 把内置工具定义和扩展注册的工具合并成一张注册表,再据此拼系统提示 → 用户发一句话 → session 调 agent-core 的循环 → 模型要调 edit → 循环执行 edit 工具的 execute() → 结果回流、渲染 → 循环继续,直到模型不再要工具。
3. 内置工具集:从定义到执行到 渲染
这节讲清一个工具的完整链路:它是怎么被定义、被 LLM 调用、执行、再渲染回终端的。这是"手脚"这一层。
3.1 一个工具的三段式契约
pi 里每个工具都是一个 ToolDefinition(core/extensions/types.ts:449)。它同时服务三个不同读者,所以有三段:
| 段 | 给谁看 | 字段 |
|---|---|---|
| 给 LLM | 模型决定要不要调、怎么调 | name、description、parameters(TypeBox schema)、promptSnippet、promptGuidelines |
| 执行 | 运行时真正干活 | execute(toolCallId, params, signal, onUpdate, ctx) |
| 给人看 | TUI 里怎么画这次调用/结果 | renderCall、renderResult |
参数 schema 用 TypeBox(Type.Object({...}))声明,既是运行时校验器、又能推出 TS 静态类型。看 edit 的 schema(core/tools/edit.ts:45 editSchema):一个 path 加一个 edits[] 数组,每项 {oldText, newText},description 字段是直接写给模型看的用法说明。
工具定义完还要包一层才能进 agent 循环。wrapToolDefinition(core/tools/tool-definition-wrapper.ts:5)把富定义削成 agent-core 认识的最小 AgentTool——只留 name/description/parameters/execute,把 renderCall/renderResult 这些 UI 细节留在外面:
// 示意,非源码 —— wrapper 的核心就是"降维"
return {
name, label, description, parameters,
// 执行时把 runner 造的 ExtensionContext 注进去(ctxFactory)
execute: (id, params, signal, onUpdate) =>
definition.execute(id, params, signal, onUpdate, ctxFactory?.()),
};
为什么要分这两层? 因为 agent-core 只关心"怎么调工具",不关心"终端里长啥样"。渲染是 TUI 的事(见 05-tui.md)。这个切分让内置工具和扩展工具走完全一样的通路。
3.2 内置的七个工具
| 工具 | 干什么 | 关键实现点 |
|---|---|---|
read | 读文件(文本+图片) | 支持 offset/limit;超限截头并提示"用 offset 续读";图片走附件 |
bash | 跑 shell 命令 | 流式输出、可超时、进程树 kill、输出截尾存临时文件 |
edit | 精确文本替换 | 多处 disjoint 编辑一次做完;模糊匹配容错;写前串行化 |
write | 建/覆盖文件 | 自动建父目录;流式语法高亮预览 |
grep | 搜内容 | 底层调 ripgrep --json,尊重 .gitignore |
find | 按 glob 找文件 | 同样走 ripgrep,尊重 .gitignore |
ls | 列目录 | 字母序、目录带 /、含 dotfile |
这七个由 core/tools/index.ts 统一组装。注意几组预设集合(index.ts:138 起):createCodingToolDefinitions = read/bash/edit/write(默认能改代码的集合),createReadOnlyToolDefinitions = read/grep/find/ls( 只读)。SDK 默认活跃工具就是前四个(sdk.ts:255 defaultActiveToolNames)。
3.3 重点走读:edit 工具怎么"精确落到文件上"
edit 是这层工程含量最高的一支,因为它要解决一个反复出现的难题:模型给的 oldText 几乎从不和文件里的字节一字不差(智能引号、破折号、行尾空格、CRLF、BOM……),但你又必须唯一、精确地替换,否则会改错地方。
它的做法是先试精确、再退模糊,匹配到了才动手。核心在 core/tools/edit-diff.ts:207 fuzzyFindText:
oldText 在文件里找不到精确匹配?
│ 是
▼
归一化(normalizeForFuzzyMatch):
· NFKC 归一
· 每行去尾部空白
· 智能引号 → ' "
· 各种破折号 → -
· 各种特殊空格 → 普通空格
│
▼
在"归一化后的文件"里再找一次
│
├─ 找到 → 用归一化坐标做替换,再把改动"贴回"原文件
│ (applyReplacementsPreservingUnchangedLines,只重写被碰到的行)
└─ 没找到 → 报"找不到"错误
真实实现的护栏很密(edit-diff.ts:304 applyEditsToNormalizedContent):
- 唯一性检查:
countOccurrences若 >1,报"有 N 处,请加上下文让它唯一"(edit-diff.ts:268)。 - 重叠检查:多个 edit 排序后若区间相交,报"合并成一个 edit"(
edit-diff.ts:349)。 - 空改检查:替换后内容没变就报错,避免静默无操作(
edit-diff.ts:361)。 - 行尾/ BOM 保真:先
stripBom+normalizeToLF匹配,写回时restoreLineEndings还原 CRLF、再补回 BOM(edit.ts:340-347)。
有一个兼容性巧思在 edit.ts:94 prepareEditArguments:有些模型(注释里点名 Opus 4.6、GLM-5.1)会把 edits 数组当成 JSON 字符串发过来,或者用老的顶层 oldText/newText。prepareArguments 这个钩子在 schema 校验之前先把这些形状掰正——这是"迁就现实里模型的坏习惯"的典型容错。
3.4 输出为什么不会撑爆上下文:截断 + 累加器
工具输出可能巨大(bash 跑个 build、read 读个大文件)。pi 用两个限额谁先到谁生效来截(core/tools/truncate.ts:11):默认 2000 行 或 50KB。
方向还不一样:
read用truncateHead(留开头,你要看文件前面)。bash用truncateTail(留结尾,你要看错误和最终结果)。
bash 的流式输出靠 OutputAccumulator(core/tools/output-accumulator.ts:35)做到有界内存:它用流式 UTF-8 解码器边收边算,只在内存里保留一段"尾巴",一旦超限就开一个临时文件把全量落盘,并在给模型的文本里附上 Full output: /tmp/pi-bash-xxx.log 的指引(bash.ts:368)。这样模型看到截断版、又知道去哪拿全量。
3.5 并发安全:同文件写操作串行化
edit 和 write 都可能被并行触发(模型一轮里同时改两个文件)。为避免同一文件被两个写操作踩踏,它们都包在 withFileMutationQueue(core/tools/file-mutation-queue.ts:32)里:
- 同一文件的变更排队串行;不同文件照样并行。
- key 用
realpath解析(file-mutation-queue.ts:16),这样./a.ts和符号链接指向同一真实文件时也认得出是同一个,不会漏锁。
一个容易忽略的正确性细节:edit 的 abort 不从事件监听器里 reject,而是每次 await 后检查 signal.aborted(edit.ts:317 throwIfAborted)。注释解释了原因——从监听器 reject 会在文件操作还没落定时就释放队列锁,导致下一个排队者提前进场。这是"锁必须锁到操作真正结束"的教科书式处理。
4.【重点】自扩展系统:让 agent 在运行时长出新能力
这是 pi 的核心卖点。前面的内置工具是"出厂配置",而扩展系统让你不改 pi 源码、不重新编译,只写一个 TypeScript 文件,就能给 agent 注册新工具、新斜杠命令、新键位、新 CLI flag、甚至新的模型 provider,还能挂各种生命周期钩子。
4.1 一个扩展长什么样
扩展就是一个默认导出工厂函数的模块,拿到一个 pi: ExtensionAPI 对象,在里面注册东西:
// 示意,非源码 —— 一个最小扩展
import { Type } from "typebox";
export default (pi) => {
// 注册一个 LLM 能调的新工具
pi.registerTool({
name: "deploy",
label: "deploy",
description: "把当前分支部署到 staging",
parameters: Type.Object({ env: Type.String() }),
async execute(id, { env }, signal, onUpdate, ctx) {
const r = await ctx /* 用 ctx.exec 等能力干活 */;
return { content: [{ type: "text", text: `deployed to ${env}` }] };
},
});
// 注册斜杠命令、键位、CLI flag
pi.registerCommand("standup", { handler: async (args, ctx) => { /* ... */ } });
pi.registerShortcut("ctrl+g", { handler: (ctx) => { /* ... */ } });
pi.registerFlag("verbose", { type: "boolean", default: false });
// 挂生命周期钩子
pi.on("tool_call", (event, ctx) => {
if (event.toolName === "bash" && /rm -rf/.test(event.input.command)) {
return { block: true, reason: "危险命令,已拦截" }; // 能拦!
}
});
};
ExtensionAPI 的完整能力面在 core/extensions/types.ts:1214。它是这套系统的"公开 API 契约",分几大块:事件订阅 on(...)、工具注册 registerTool、命令/键位/flag 注册、消息渲染器、以及一批运行时动作(sendMessage/setModel/setActiveTools/registerProvider…)。
4.2 loader:用 jiti 动态加载 TypeScript
扩展是 .ts 文件,而 pi 常以编译好的 Bun 单文件二进制分发——问题来了:运行时怎么加载并执行一段从没被编译进二进制的 TS?
答案是 jiti(一个即时 TS/ESM 加载器)。core/extensions/loader.ts:452 每次为一个扩展新建 jiti 实例并 jiti.import()。难点是扩展里 import "@earendil-works/pi-tui" 这类对 pi 自身包的引用——二进制里没有 node_modules。pi 用两条路解决(loader.ts:44 VIRTUAL_MODULES):
| 运行形态 | 机制 | 效果 |
|---|---|---|
| Bun 二进制 | virtualModules + tryNative:false | 把静态 import 进来的 bundled 包(pi-ai/pi-tui/pi-agent-core/typebox…)当"虚拟模块"喂给扩展,jiti 全权接管所有 import,不碰文件系统 |
| Node / 开发态 | alias | 把包名解析到 workspace 里的 dist/ 或 node_modules |
关键点在 loader.ts:16-25 的注释:那些 import * as _bundledPiTui ... 必须是静态 import,这样 Bun 打包时才会把它们塞进二进制;然后 VIRTUAL_MODULES 再把这些已在内存里的模块对象注入扩展的沙箱。这解决了"编译型二进制却要动态执行第三方 TS 并共享同一份运行时对象"的根本矛盾——扩展拿到的 pi-tui 和主程序用的是同一个实例,不会出现两份互不认识的类型。
发现规则(loader.ts:606 discoverExtensionsInDir,一层不递归):
- 项目级
cwd/.pi/extensions/ - 全局
~/.pi/agent/extensions/ extensions/*.ts直接文件;或子目录带index.ts;或子目录带package.json的pi.extensions声明