第七章:工具系统:内置工具与权限控制
在上一章我们深入剖析了 Pi 的交互式 TUI 界面。从本章开始,我们将进入 Pi 最核心的”能力层”——工具系统。如果说 TUI 是 Pi 的”脸面”,Skills 是它的”经验”,那么工具系统就是它的”双手”。Pi 能帮你读代码、写文件、跑测试、部署服务,一切行为都由工具驱动。
而 Pi 的工具系统,是整个编码 Agent 领域里最极简的设计。
7.1 Pi 的极简工具哲学
7.1.1 只有 4 个核心工具
Pi 的内置核心工具只有 4 个:
| 工具 | 类别 | 一句话功能 |
|---|---|---|
read |
只读 | 读取文件内容 |
write |
写入 | 创建或覆写文件 |
edit |
修改 | 精确替换文件中的文本 |
bash |
执行 | 执行任意 shell 命令 |
没有 grep(虽然可选启用)、没有 web_search、没有 browser、没有 diff、没有 patch、没有 memory、没有 todo、没有 delegate_task。Pi 的作者 Mario Zechner 给出过一个极具冲击力的解释:
「如果我不需要它,它就不会被构建。」
这句话不是口号,而是 Pi 整个架构的设计原则。让我们把它展开来看。
7.1.2 为什么这 4 个工具就够用了
仔细想一下:一个编码 Agent 到底需要做什么?无非是:
- 了解现状——读文件、查日志、看配置(
read+bash执行ls/grep/find) - 做出修改——改代码、改配置、写新文件(
edit+write) - 验证结果——跑测试、跑 lint、跑构建(
bash)
Pi 的设计者发现:bash 工具本身就是一座万能桥梁。通过 bash,模型可以调用系统中任何命令行工具——grep 搜索代码、find 定位文件、git 管理版本、npm/pip 安装依赖、docker 构建镜像、curl 访问 API。只要你能在终端里干的活,模型就能通过 bash 来干。
这意味着 Pi 不需要为 grep 单独做一个工具——模型会用 bash 执行 grep -rn "function" src/。不需要为 git 做一整套 git_commit、git_push、git_diff 工具——模型直接执行 git diff HEAD~1。不需要为包管理器做工具——模型直接执行 npm install 或 pip install。
这就是所谓的 “原语而非功能”(Primitives, not Features)。给你 4 个足够底层的原语,你可以组合出无限种上层行为,而不是被一套封闭的内置工具集锁死。
7.1.3 与 Claude Code 20+ 工具的对比
为了更直观地理解 Pi 的”极简”有多极端,我们把 Pi 和 Anthropic 的 Claude Code(同为终端编码 Agent)做一个工具数量对比:
| 工具类别 | Claude Code(内置) | Pi(内置) |
|---|---|---|
| 文件读取 | read_file |
read |
| 文件搜索 | search_files(内置 grep) |
无——用 bash + grep |
| 文件列出 | list_directory |
无——用 bash + ls |
| 文件写入 | write_file |
write |
| 文件编辑 | replace_content |
edit |
| Diff/Patch | apply_diff、view_diff |
无——用 bash + git diff / patch |
| 终端执行 | run_terminal_command |
bash |
| 后台任务 | background_task |
无 |
| TODO 管理 | todo_write |
无 |
| Plan 模式 | enter_plan_mode |
无 |
| Web 搜索 | web_search |
无——用 bash + curl |
| 图片处理 | process_image |
无 |
| 子代理 | 内建 task 代理 | 无 |
| LSP/符号 | LSP 集成 | 无——用 bash + rg / LSP CLI |
| Notebook | notebook_read/edit |
无 |
| 记忆 | 项目级记忆 | 无 |
| 权限弹窗 | 原生弹窗 GUI | 无——工具默认可信 |
Claude Code 有超过 20 个内置工具,而 Pi 只有 4 个。这不是说 Pi 能力弱——恰恰相反,Pi 用 4 个工具覆盖了 Claude Code 20+ 工具的全部能力范围,只是实现方式不同:
- Claude Code 的
search_files→ Pi 的bash+grep -rn - Claude Code 的
list_directory→ Pi 的bash+ls -la - Claude Code 的
web_search→ Pi 的bash+curl(或社区包) - Claude Code 的
apply_diff→ Pi 的bash+patch/git apply - Claude Code 的
todo_write→ Pi 社区包pi-plugin-todo
这种设计的代价是模型需要更”懂”命令行工具,但收益是巨大的:
- 系统提示词极短(~1000 tokens):每个 token 都是真金白银,少即是多
- 无供应商锁定:你的 prompt 里没有 Claude Code 特有的工具定义,切换模型不会被”内置工具集”拖累
- 扩展性无限:任何新增能力都通过社区包实现,核心不受污染
- 透明可控:你永远知道模型在做什么——每个
bash命令都在你眼前执行
7.2 read 工具详解
7.2.1 功能定义
read 是 Pi 最基础、最频繁使用的只读工具。它的功能极其简单:接收一个文件路径,返回文件内容。
在 Pi 的工具定义中(TypeScript 接口):
// 简化示意,实际定义在 pi-agent-core 中
interface ReadTool {
name: "read";
parameters: {
file_path: string; // 绝对路径
offset?: number; // 起始行号(1-indexed)
limit?: number; // 读取行数
};
}
核心行为:
- 接收绝对路径:模型需要提供文件的完整路径。相对路径需要模型自行基于当前工作目录拼接。
- 支持分页读取:通过
offset和limit参数,可以分段读取大文件,避免一次性加载过多内容导致上下文溢出。 - 读取目录:当
file_path指向一个目录时,read返回该目录下的文件/子目录列表(类似ls),每行一个条目,目录以/结尾。 - 返回原始内容:
read不对文件内容做任何加工——不语法高亮、不格式化、不截断(除非超出输出限制)。
7.2.2 使用场景与最佳实践
read 是模型在动手改代码之前必做的第一步。典型使用流程:
用户: "帮我重构 src/utils.ts 里的 formatDate 函数"
模型执行:
1. read("src/utils.ts") ← 先看代码现状
2. bash("grep -rn formatDate") ← 找到所有引用处
3. edit("src/utils.ts", ...) ← 精确替换
4. bash("npm test") ← 验证结果
以下是一些最佳实践:
对于大文件(>500 行),分段读取:模型应该先用 read 扫一眼文件开头(了解 import、导出结构),再用 bash + grep 定位到目标函数的具体行号范围,最后用带 offset/limit 的 read 精确读取需要的片段。
读取目录前先想清楚:如果你不确定文件在哪,先 read("src/") 看目录结构,而不是让模型用 read 一个文件一个文件地试。
把 read 当作”确认”步骤:每次 edit 或 write 之后,模型应该再次 read 修改过的文件,确认修改符合预期。好的 Agent 会自动这样做。
7.2.3 与 grep / find / ls 只读工具的区别
在 Pi 的默认配置中,只有 read 这一个内置只读工具。但 Pi 也提供了一组可选只读工具(详见 7.6 节):
| 工具 | 默认状态 | 功能 | 与 bash 等价命令 |
|---|---|---|---|
read |
默认启用 | 读取文件/目录 | 无直接 shell 等价(cat 无法分段读取) |
grep |
可选启用 | 正则搜索文件内容(默认不启用) | grep -rn "pattern" dir/ |
find |
可选启用 | glob 模式匹配文件名(默认不启用) | find dir -name "*.ts" |
ls |
可选启用 | 列出目录(默认不启用) | ls -la |
为什么 read 是默认启用的,而 grep/find/ls 不是?因为 read 的分页读取能力是 cat 做不到的——cat 会把整个文件全部输出,对于大文件来说既不经济也可能超出 token 限制。而 grep/find/ls 都有完全等价且更灵活的 shell 命令,把它们做成独立工具只是多了一种表达方式,不会带来本质的能力提升。
一个微妙但重要的点:当你用 bash 执行 grep 时,命令、参数、输出格式完全由模型控制(可以加 -n 显示行号、加 -A3 显示上下文、加 --include 过滤文件)。但当你用内置 grep 工具时,参数和输出格式由工具定义写死。前者的灵活性远高于后者。这是 Pi 选择”能用 bash 就不做内置工具”的深层原因。
7.3 write 工具详解
7.3.1 功能定义
write 工具的功能是创建新文件或覆写已有文件。它接收两个参数:
interface WriteTool {
name: "write";
parameters: {
file_path: string; // 目标文件路径(绝对路径)
content: string; // 要写入的完整内容
};
}
行为特征:
- 目标文件不存在 → 创建:自动创建必要的父目录。
- 目标文件已存在 → 覆盖:原有内容被完全替换,不留历史(除非文件在 git 版本控制下)。
- 原子操作:
write要么完整写入成功,要么不改变文件。不存在”写了一半出错”的情况。 - content 是完整内容:你必须提供目标文件的全部内容,不能只提供”要新增的片段”。
7.3.2 何时用 write
write 只应在以下两种场景使用:
- 创建新文件:你要新建一个
src/components/Button.ts,这个文件还不存在。 - 完整重写:你要对一个文件做”推倒重来”式的修改——改动范围超过文件内容的 50% 以上,用
edit的精确替换反而不如直接重写整个文件更简单。
典型例子:
用户: "帮我写一个 TypeScript 的 LRU 缓存实现"
模型: write("src/lru-cache.ts", "export class LRUCache<K, V> { ... }")
7.3.3 何时用 edit vs. write
这是新手使用 Pi 时最容易困惑的地方。规则很简单:
| 场景 | 用什么 | 原因 |
|---|---|---|
| 创建新文件 | write |
edit 只修改已有文件 |
| 改一个函数、一行配置、一个 import | edit |
精确替换,不碰不相关代码 |
| 改函数签名 + 重命名变量 + 重构算法 | 优先 edit(分多次) |
每次 edit 是独立可回滚的操作 |
| 文件太短(<20行)且改动 >50% | write |
两次 edit 不如一次 write 简洁 |
| 文件太长(>500行)且改动 <50% | edit |
用 write 会消耗大量 token 重写未改部分 |
一个常见的”反面教材”:
// 不推荐:文件 300 行,只改 3 行,却用 write 重写了全部 300 行
write("src/app.ts", "import ...\n\nconst app = ...\n\n// ... 297 行未改代码 ...")
正确的做法是用 edit 做精确替换:
// 推荐:只替换需要修改的那 3 行
edit("src/app.ts", "const port = 3000;", "const port = 8080;")
关键原则:能用 edit 就绝不用 write。 edit 更精确、更省 token、更不容易引入意外改动。write 是”核选项”——只在必须创建新文件或推倒重来时使用。
7.4 edit 工具详解
7.4.1 功能定义
edit 是 Pi 工具系统中最精妙的设计,也是编码 Agent 的”看家本领”。它的工作原理是精确字符串替换:
interface EditTool {
name: "edit";
parameters: {
file_path: string; // 目标文件路径(绝对路径)
old_string: string; // 要被替换的文本(必须精确匹配)
new_string: string; // 替换后的文本
replace_all?: boolean; // 是否替换所有匹配项(默认 false)
};
}
工作流程:
- 读取
file_path对应文件的内容 - 在其中精确搜索
old_string - 如果
replace_all为false(默认),替换第一个匹配项;如果为true,替换所有匹配项 - 如果找不到
old_string,返回错误(不会做”模糊匹配”)
关键点:
old_string必须逐字符匹配文件中的实际内容——包括空格、缩进、换行。一个空格都对不上就会失败。- 这是刻意的设计:精确性 > 便利性。如果允许模糊匹配,模型可能会”一不小心”改错地方,后果可能很严重。
- 每次
edit操作可以被 git 精确追踪——git diff会显示确切的改动。
7.4.2 replaceAll 参数
replace_all 参数控制替换范围:
false(默认):只替换文件中首次出现的old_string。适用于你清楚目标位置是唯一的场景。true:替换文件中所有出现的old_string。适用于全局重命名、统一修改的情况。
使用 replace_all: true 需要格外小心。考虑以下场景:
// 你想把 utils.ts 里的 "logger" 改成 "newLogger"
// 但文件中可能有 50 处使用 logger
edit("src/utils.ts", "logger", "newLogger", replace_all: true)
// ✓ 如果所有 logger 你确实都想改 → 正确
// ✗ 如果文件里还有 "console.logger" 或其他上下文不相关的 logger → 灾难
最佳实践:默认不用 replace_all,除非你明确知道要全局替换且确认所有匹配项都在目标范围内。当你需要改多个不同位置时,分多次 edit 比一次 replace_all 更安全、更可审计。
7.4.3 使用场景与最佳实践
场景一:修改函数实现
// old_string:
function getConfig() {
return { port: 3000 };
}
// new_string:
function getConfig() {
return { port: 8080, env: "production" };
}
edit("src/config.ts", oldString, newString)
场景二:修改单行
edit("src/app.ts", "import express from 'express';",
"import express from 'express';\nimport helmet from 'helmet';")
场景三:删除代码
edit("src/app.ts", "\n // 旧的调试代码\n debugLog(state);\n", "")
最佳实践清单:
- 编辑前必须 read:在调用
edit之前,务必先用read确认文件当前内容。不要凭记忆或猜测写入old_string。 - old_string 尽量包含上下文:如果你的
old_string只有一行代码(比如const port = 3000;),而文件中恰好多处出现了同样的一行,edit 可能会改错位置。把old_string扩展到包含周围 2-3 行以增加唯一性。 - 每次修改后验证:
edit成功返回后,用read确认修改结果,再用bash跑相关测试或 lint。 - 小步快跑:一次 edit 改一个关注点。不要在一次 edit 中同时改 API 路由、数据库 schema 和前端组件——分开改,每次验证。
- 利用空白和缩进定位:文件中的缩进模式通常是唯一的——利用这一点来构造更精确的
old_string。
7.5 bash 工具详解
7.5.1 功能定义
bash 是 Pi 的”瑞士军刀”。它执行任意 shell 命令,将标准输出和标准错误返回给模型。
interface BashTool {
name: "bash";
parameters: {
command: string; // 要执行的 shell 命令
timeout?: number; // 超时时间(毫秒),默认 120000(2分钟)
workdir?: string; // 工作目录(默认当前会话的工作目录)
};
}
返回值包含:
- stdout:标准输出内容
- stderr:标准错误内容
- exit_code:命令退出码
- timed_out:是否因超时而终止
7.5.2 timeout 参数
Pi 为每个 bash 命令设置了默认 2 分钟(120000ms) 的超时。这个值可以通过 timeout 参数覆盖。
为什么要有超时?
- 防止模型执行了一个死循环或挂起的命令后,Agent 自己也跟着”卡死”
- 控制单次工具调用的延迟,保证交互流畅度
- 对于编译、测试等预期耗时的操作,模型需要主动设置更长的
timeout
// 快速命令——默认 2 分钟足够
bash("grep -rn 'TODO' src/")
// 长时间命令——需要更长超时
bash("npm run build", timeout=600000) // 10 分钟
bash("npm test", timeout=300000) // 5 分钟
注意:timeout 只在命令执行阶段生效。进程被终止后,已产生的输出仍然会返回给模型。
7.5.3 workdir 参数
workdir 允许你指定命令执行的工作目录,不改变 Agent 会话的全局工作目录。
这在以下场景非常有用:
// 在 monorepo 的某个子包中运行测试
bash("npm test", workdir="/home/user/project/packages/database")
// 在另一个仓库中执行 git 操作
bash("git log --oneline -5", workdir="/home/user/another-project")
// 临时在 /tmp 中操作
bash("curl -o data.json https://api.example.com/data", workdir="/tmp")
不指定 workdir 时,命令在 Agent 会话的当前工作目录下执行。
7.5.4 输出截断行为
bash 命令的输出有大小限制。当输出超过一定阈值(通常是 ~5000 行或 ~50KB),Pi 会截断输出,并告知模型输出已被截断。
这意味着:
- 模型不能依赖
cat来替代read——对于大文件,cat very-large-file.log的输出会被截断,模型看不到完整内容 - 对于预期会输出大量内容的命令,需要使用
grep、head、tail、>重定向等方式缩小输出范围 - 最佳实践:永远让 bash 的输出尽可能小——加过滤条件、限制行数、只输出你需要的信息
# 不推荐:输出可能有 10000 行
bash("npm test")
# 推荐:只关注失败的测试
bash("npm test 2>&1 | grep -A5 'FAIL'")
# 推荐:用 wc 先测大小
bash("npm test 2>&1 | wc -l")
7.5.5 安全注意事项
bash 工具赋予模型在系统上执行任意命令的能力,这意味着安全性需要你自行把控。
Pi 的内置安全边界:
- 无权限弹窗:Pi 不会在执行每个命令前弹出”是否允许”的确认框。这是因为 Pi 的目标用户是需要高效率的专业开发者,确认弹窗在 100 次交互后会变得极为恼人。
- 无命令白名单:Pi 不做参数级别的命令过滤。工具要么全给,要么全不给。
- 信任模型:Pi 的哲学是你选择了一个你信任的模型(通过供应商和模型配置),然后你把”手脚”交给它。
你应当做的安全防护:
| 防护层级 | 具体措施 |
|---|---|
| 操作系统层 | 用 Docker 容器隔离(见第十二章);用 chroot / firejail 沙箱 |
| 用户权限层 | 用专用 Linux 用户运行 Pi(最小权限原则);chmod 限制敏感文件 |
| 文件系统层 | 把工作目录限制在项目目录内;敏感文件放项目外 |
| 网络层 | 容器级网络隔离;企业内网走代理 |
| 审计层 | git 记录所有修改;bash 命令日志记录 |
| 配置层 | --exclude-tools bash 可完全禁用 bash(见 7.7 节) |
一个实用的 Docker 安全启动模版:
docker run -it --rm \
-v $(pwd):/workspace \
-w /workspace \
--network none \ # 无网络——纯本地编码
node:20-slim \
npx pi
如果你需要网络(比如安装 npm 包),可以改为 --network host 或只开放必要的出站端口。更多容器安全内容见第十二章。
7.6 可选只读工具
7.6.1 grep:正则搜索
grep 工具提供与命令行 grep 相似的功能,但以结构化的方式返回结果:
interface GrepTool {
name: "grep";
parameters: {
pattern: string; // 正则表达式
path?: string; // 搜索路径(默认当前工作目录)
include?: string; // 文件名过滤(如 "*.ts")
};
}
返回格式:文件路径:行号:匹配行内容(与 grep -n 的输出格式一致)。
内置 grep 的优势是:结果被结构化返回,模型不需要解析 shell 输出格式;而且启用了内置 grep 时,模型不会因为 shell 差异(macOS grep vs GNU grep)而产生兼容性问题。
7.6.2 find:glob 文件查找
find 工具按 glob 模式匹配文件名:
interface FindTool {
name: "find";
parameters: {
pattern: string; // glob 模式(如 "src/**/*.ts")
};
}
相比 bash + find,内置 find 的优势是平台无关——Windows 的 dir /s /b、macOS 的 find、Linux 的 find 参数格式各不相同,内置工具消除了这种差异。
7.6.3 ls:列出目录
ls 是 read 目录读取功能的加强版,提供更丰富的选项:
interface LsTool {
name: "ls";
parameters: {
path?: string; // 路径(默认当前目录)
};
}
实际上,由于 read 工具本身就支持读取目录(返回文件列表),ls 作为独立工具的价值相对有限。它存在的意义更多是”语义明确”——当你只想看目录结构而不想读文件时,用 ls 比用 read 在语义上更清晰。
7.6.4 默认不启用的原因
grep、find、ls 这三个工具默认不在 Pi 的内置工具列表中。为什么不默认启用?回到 Mario 的哲学:
bash+ 对应命令行工具已经可以完全覆盖:grep -rn、find、ls -la功能等价且更灵活。- 减少模型的选择成本:工具越多,模型在”该用哪个工具”上花的心思就越多。4 个工具,选择成本为零——
read看文件,bash干一切。 - 保持系统提示词精简:每个工具的定义(名称、参数、描述)都会占用系统提示词的 token 配额。3 个额外工具 ≈ 增加 200-400 tokens,对于 1000 token 的系统提示词来说比例不小。
- 它们是”锦上添花”,不是”雪中送炭”:默认不启用不会降低 Pi 的基本能力,但启用后可以让某些交互更加流畅。
如果你觉得默认 4 个工具不够顺手,可以用 --tools 参数显式启用它们(见 7.7 节)。
7.7 工具控制选项
Pi 提供了多层级的工具控制机制,让你精确掌控模型手中握着哪些”武器”。
7.7.1 –tools:白名单模式
命令行参数:pi --tools read,write,edit,bash,grep
指定允许使用的工具列表(逗号分隔)。只有列表中的工具对模型可见、可调用。
# 只允许只读操作
pi --tools read
# 允许读写但禁用 bash(极安全模式)
pi --tools read,write,edit
# 启用全部内置工具(包括可选只读工具)
pi --tools read,write,edit,bash,grep,find,ls
白名单模式适用于:
- 代码审查:只给
read+ 可选只读工具,模型只能看不能改 - 新人学习:先用只读模式熟悉 Pi,再逐步放开权限
- 敏感环境:生产服务器上用
read,edit,write(禁用 bash,防止误操作)
7.7.2 –exclude-tools:黑名单模式
命令行参数:pi --exclude-tools bash
从默认工具集中移除指定工具。被移除的工具完全不可用。
# 不信任 bash,只允许文件操作
pi --exclude-tools bash
# 只给 read + bash(不能写文件)
pi --exclude-tools write,edit
黑名单模式比白名单模式更”懒人友好”——你只需声明”不想给模型什么”,而不是声明”想给模型什么”。在默认 4 个工具已经够用的前提下,--exclude-tools bash 是控制风险最常用的方式。
7.7.3 –no-builtin-tools / -nbt
命令行参数:pi --no-builtin-tools 或 pi -nbt
禁用所有内置工具(read、write、edit、bash 以及 grep/find/ls 如果启用的话)。
这个选项的典型使用场景是:你通过 Extensions 系统注册了一套自定义工具,完全不依赖 Pi 的内置工具。例如,你写了一个 Extension 把 read/write/edit/bash 全部替换成了通过 RPC 发送到远程服务器的版本——这时内置的工具对你来说是冗余的。
7.7.4 –no-tools / -nt
命令行参数:pi --no-tools 或 pi -nt
禁用所有工具——包括内置工具和通过 Extensions 注册的自定义工具。
用 --no-tools 启动的 Pi 就是一个纯粹的”聊天”模式——模型只能回答问题,不能执行任何代码、读取任何文件、修改任何东西。这在以下场景有用:
- 纯咨询:问模型一个技术问题,不需要它操作文件
- 调试系统提示词:剥离所有工具定义后,查看模型的原始行为
- 作为教学工具:让学生先用”无工具”模式理解 LLM 的 base behavior,再逐步加上工具
7.7.5 配置示例
所有工具控制选项都可以写在配置文件中(~/.pi/config.yaml),避免每次都敲命令行参数:
# ~/.pi/config.yaml
# 例1:默认只给只读能力(代码审查配置)
tools: [read, grep, find, ls]
# 例2:完全信任模式(默认行为)
# 不写 tools 字段 = 4 个核心工具全部启用
# 例3:安全模式(禁用 bash)
exclude_tools: [bash]
# 例4:纯聊天模式
no_tools: true
每种配置对应不同的使用场景,你可以创建多个配置集(profiles)之间切换——关于 profiles 的详细用法,见第五章。
7.8 只读模式详解
7.8.1 –tools read,grep,find,ls 组合
只读模式是通过 --tools 白名单实现的”安全子集”——只给模型观察世界的能力,不给它改变世界的能力。
pi --tools read,grep,find,ls
在这个模式下:
- ✅
read可以读取文件内容 - ✅
grep可以搜索代码 - ✅
find可以按模式查找文件 - ✅
ls可以浏览目录结构 - ❌
write不可用——不能创建或修改文件 - ❌
edit不可用——不能替换文件内容 - ❌
bash不可用——不能执行任何命令
模型被限制在纯观察者角色。它可以完整地理解你的代码库、分析架构问题、找到 bug、提出修改建议——但它不能亲自动手。你需要自己根据它的建议去修改代码。
7.8.2 适用场景
| 场景 | 为什么用只读模式 |
|---|---|
| 代码审查 | 给模型一个 PR 的 diff 或 commit 范围,让它分析代码质量、潜在 bug、安全问题。只读模式保证模型不会”顺手帮你改掉”。 |
| 架构分析 | 让模型阅读整个项目的代码结构,理解模块依赖关系,输出架构文档或重构建议。它只能分析,不能真正动手。 |
| 学习代码库 | 新人接手一个项目时,用只读模式让 Pi 帮助你理解代码。问它”这个模块的入口在哪”、”这个函数的调用链是什么”,它读代码回答你,但不会误操作破坏任何文件。 |
| 安全审计 | 排查依赖漏洞、检查敏感信息泄露、审查配置文件。只读模式确保审计过程本身不会引入问题。 |
| CI/CD 集成 | 在 CI 管道中运行 Pi,分析代码变更并生成报告。只读模式保证 CI 环境中的代码不会被 Agent 意外修改。 |
一个典型的 CI 集成示例:
# .github/workflows/code-review.yml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Pi code review
env:
ANTHROPIC_API_KEY: $
run: |
pi --tools read,grep,find,ls --print \
"Review the code changes in this PR. Focus on: \
1. Potential bugs \
2. Security issues \
3. Performance problems \
4. Code style violations \
Output your findings in a structured review format."
7.9 自定义工具:Extensions API 概览
Pi 的内置工具是极简的,但它的扩展系统允许你注册任意自定义工具。这是 Pi 实现”原语而非功能”的另一个维度——内置工具给你最基础的 4 个,但你可以通过 Extensions 给自己造出任何你需要的工具。
7.9.1 pi.registerTool() API
每个 Pi Extension 都是一个 TypeScript 模块,通过 pi.registerTool() 向 Agent 注册自定义工具。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// 注意:以下示例中的 `pi` 变量来自 Extension 的默认导出函数参数
// export default function(pi: ExtensionAPI) { ... }
// 注册一个自定义工具
pi.registerTool({
name: "format_date",
description: "格式化日期字符串,支持多种输出格式",
parameters: Type.Object({
date: Type.String({ description: "日期字符串,如 '2025-06-30'" }),
format: Type.Union([
Type.Literal("iso"),
Type.Literal("cn"),
Type.Literal("us"),
], { default: "iso", description: "输出格式:iso / cn / us" }),
}),
execute: async (args: {
date: string;
format?: "iso" | "cn" | "us";
}, context: ToolContext) => {
// ... 执行逻辑
return "2025年6月30日";
},
});
关键组成:
| 组件 | 说明 |
|---|---|
name |
工具名称,模型通过这个名字调用工具 |
description |
工具描述,会出现在模型的系统提示词中 |
parameters |
TypeBox schema,定义工具的输入参数类型和约束 |
execute |
工具的执行函数,接收参数和上下文,返回结果 |
7.9.2 TypeBox 参数定义
Pi 使用 TypeBox 来定义工具的参数 schema。TypeBox 是一个高性能的 TypeScript JSON Schema 库,它让你用 TypeScript 代码直接写 schema,同时享受类型推导的便利。
为什么用 TypeBox 而不是 JSON Schema 字符串?
- 类型安全:TypeBox 生成运行时 JSON Schema(发送给 LLM),同时也生成 TypeScript 类型(用于 execute 函数的参数类型推导)。一处定义,两处受益。
- 表达式简洁:
Type.String()比手写{ "type": "string" }更不容易出错。 - 支持联合类型、枚举、可选参数:
Type.Union、Type.Optional、Type.Enum等比手写 JSON Schema 优雅得多。 - 社区标准:TypeBox 在 LLM 工具定义领域已经成为事实标准,Fastify、Platformatic 等主流框架都使用它。
你的工具参数 schema 会被转换为 JSON Schema 格式,注入到模型的系统提示词中。一个设计良好的工具描述和参数 schema,直接影响模型能否正确调用你的工具。
一些 TypeBox 常用模式:
// 字符串参数
Type.String({ description: "文件路径", minLength: 1 })
// 数字参数(带范围)
Type.Number({ description: "超时时间(秒)", minimum: 1, maximum: 3600 })
// 布尔参数
Type.Boolean({ description: "是否递归删除", default: false })
// 枚举参数
Type.Enum({ A: "A", B: "B" } as const)
// 联合类型
Type.Union([Type.String(), Type.Number()])
// 可选参数
Type.Optional(Type.String({ description: "备注" }))
// 数组参数
Type.Array(Type.String({ description: "标签" }))
7.9.3 execute 函数
execute 是你的工具的核心逻辑。它接收两个参数:
type ExecuteFunction = (
args: InferredFromSchema, // 从 TypeBox schema 推导出的参数类型
context: ToolContext // 工具执行上下文
) => Promise<string | object>;
args:模型调用工具时传入的参数,类型由你的 TypeBox schema 自动推导。类型安全,不需要手动 as 断言。
context:工具执行上下文,包含:
| 属性 | 类型 | 说明 |
|---|---|---|
context.workingDirectory |
string |
当前会话的工作目录 |
context.sessionId |
string |
当前会话 ID |
context.readFile(path) |
(path: string) => Promise<string> |
读取文件(等价于内置 read 工具) |
context.writeFile(path, content) |
(path, content) => Promise<void> |
写入文件(等价于内置 write 工具) |
context.executeCommand(cmd) |
(cmd: string) => Promise<BashResult> |
执行命令(等价于内置 bash 工具) |
context.log |
Logger |
日志接口 |
execute 函数的返回值会被序列化后返回给模型。你可以返回字符串(简单场景)或对象(结构化数据)。
execute: async (args, context) => {
// 可以调用内置工具的等价 API
const packageJson = await context.readFile(
path.join(context.workingDirectory, "package.json")
);
// 可以执行命令
const result = await context.executeCommand("npm test -- --json");
// 返回结构化数据
return {
success: true,
testCount: 42,
failures: 0,
duration: "1.2s",
};
};
7.9.4 与内置工具的互操作
自定义工具可以通过 context 对象与内置工具无缝协作:
自定义工具 "sync_database"
├── context.readFile("schema.sql") ← 读 schema 文件
├── context.executeCommand("pg_dump") ← 导出数据
├── context.writeFile("backup.sql") ← 写备份文件
└── 返回摘要给模型
模型看到的结果:
"数据库同步完成:3 张表,1247 行数据,备份已保存到 backup.sql"
这意味着你完全可以写一个”超级工具”——把多个内置工具的逻辑封装成一个高层次的工具调用。模型只需要调用一个 deploy_app 自定义工具,它内部自动完成了 read 配置、write 部署脚本、bash 多条命令的完整流程。
7.10 工具执行流程
7.10.1 工具调用 → 参数验证 → 执行 → 结果返回
Pi 的工具执行遵循严格的生命周期:
1. 模型生成工具调用请求
└─ {"name": "read", "parameters": {"file_path": "/workspace/src/app.ts", "offset": 1, "limit": 50}}
2. Pi Agent Core 接收请求
├─ 验证工具名称:name 是否在注册的工具列表中?
├─ 验证参数 schema:parameters 是否符合 TypeBox schema?
├─ 验证权限:该工具是否被 --exclude-tools 禁用?
└─ 如果验证失败 → 返回错误给模型(模型自行调整重试)
3. 调用 execute 函数
└─ 执行工具逻辑(同步或异步)
4. 结果处理
├─ 捕获 stdout/stderr(bash 工具)
├─ 处理超时(如果设置了 timeout)
├─ 处理异常(try-catch)
└─ 将结果序列化为 LLM 可读格式
5. 结果返回给模型
└─ 模型根据结果决定下一步:继续调用工具 or 输出文本回复
这个流程中的每一步都是透明的:
- 工具调用请求和结果都会显示在 TUI 中
- 命令的输入输出可以在终端中看到
- 错误信息会完整传递给模型,不会被静默吞掉
7.10.2 并行工具调用支持
Pi 支持模型的并行工具调用(Parallel Tool Calls)。当模型在一次推理中返回多个独立的工具调用时,Pi 会并发执行它们。
模型在一次推理中返回:
[
{"name": "bash", "parameters": {"command": "npm test -- --only=unit"}},
{"name": "read", "parameters": {"file_path": "src/app.ts"}},
{"name": "bash", "parameters": {"command": "npm run lint"}},
]
Pi 并发执行这三个调用 → 所有结果返回后才发起下一次模型推理
并行执行的条件:
- 多个工具调用之间没有数据依赖(read app.ts 的结果不依赖 npm test 的结果)
- 每个工具独立执行,互不干扰
并行工具调用的优势:
- 减少交互轮次:本来需要 3 轮的交互压缩为 1 轮
- 降低延迟:3 个命令并发跑,总耗时 = max(三个命令各自耗时)
- 节省 token:减少模型推理次数
如果工具调用之间有依赖(比如先 bash 创建目录,再 write 写入文件),模型需要分两轮发出调用——这由模型自身判断,Pi 不会自动推断依赖关系。
7.10.3 错误处理
工具执行过程中可能出现多种错误,Pi 的处理方式如下:
| 错误类型 | 示例 | Pi 的处理 |
|---|---|---|
| 参数验证失败 | file_path 不是字符串 |
返回错误描述给模型,模型自行修正参数后重试 |
| 文件不存在 | read("nonexistent.ts") |
返回 File not found 错误 |
| 命令执行失败 | bash("npm install bad-package") |
返回 exit_code + stderr,模型根据错误信息调整策略 |
| 超时 | 命令运行超过 timeout |
终止进程,返回 timed_out: true + 已产生的输出 |
| 权限不足 | bash("rm /etc/passwd") |
返回 OS 级别的权限错误(Permission denied) |
| edit 匹配失败 | old_string 在文件中找不到 |
返回错误,提示模型检查 old_string 是否与文件内容精确匹配 |
| 工具被禁用 | 调用 bash 但被 --exclude-tools bash 禁用 |
返回工具不可用错误 |
Pi 不替模型做错误恢复——它把错误信息原样返回给模型,由模型自己决定如何处理。这是 Pi 哲学的一部分:Agent 框架提供诚实的信息,模型做出决策。
一个典型的错误恢复交互:
模型: bash("npm testx") ← 拼写错误
→ 返回: command not found: testx
模型: bash("npm test") ← 修正拼写,自动重试
→ 返回: 3 tests failed
模型: read("src/__tests__/user.test.ts") ← 查看失败测试
→ ...分析原因是 API 返回值格式变化...
模型: edit("src/user.ts", ...) ← 修复代码
→ 修改成功
模型: bash("npm test") ← 重新测试
→ 返回: 42 tests passed
整个错误恢复过程不需要用户干预——模型自己发现错误、自己修正、自己验证。这就是一个有工具能力的 Agent 真正强大的地方。
7.11 本章小结
本章我们全面解析了 Pi 的工具系统:
- Pi 的极简工具哲学源于 “原语而非功能” 的设计理念:4 个核心工具(
read、write、edit、bash)通过组合使用,可以覆盖编码 Agent 的几乎所有工作流。 read是唯一的默认只读工具,支持分页读取和目录浏览,是模型动手前必备的”观察”步骤。write用于创建新文件和完全重写,是”核选项”——能用edit就绝不用write。edit是 Pi 最核心的编辑原语:精确字符串替换,默认只换第一个匹配,可选replace_all做全局替换。精准匹配是刻意为之——宁可报错也不要改错。bash是万能桥梁:通过它,模型可以调用系统中任何命令行工具,覆盖代码搜索、版本管理、构建测试、包管理等全部领域。超时控制和输出截断是你需要理解的两个关键行为。- 可选只读工具(
grep、find、ls)默认不启用,但在需要跨平台一致性或结构化输出时可以选择开启。 - 通过
--tools、--exclude-tools、--no-builtin-tools、--no-tools四个控制选项,你可以精确控制模型拥有哪些工具。只读模式(--tools read,grep,find,ls)是代码审查和安全场景的最佳配置。 - Extensions API(
pi.registerTool()+ TypeBox +execute函数)让你可以无限扩展工具集。 - 工具执行遵循严格的调用→验证→执行→返回流程,支持并行调用以提升效率,错误信息原样返回让模型自行恢复。
工具系统是 Pi 的”双手”。理解了这 4 个工具的设计哲学和使用方式,你就理解了 Pi 如何用极简实现强大。
下一章:第八章:Skills技能系统 将进入 Pi 另一个核心子系统——Skills 技能系统:如何用 Markdown 文件教会 Agent 新的工作流,让它从”会干活”变成”越用越聪明”。