znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第七章:工具系统:内置工具与权限控制

在上一章我们深入剖析了 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 到底需要做什么?无非是:

  1. 了解现状——读文件、查日志、看配置(read + bash 执行 ls/grep/find
  2. 做出修改——改代码、改配置、写新文件(edit + write
  3. 验证结果——跑测试、跑 lint、跑构建(bash

Pi 的设计者发现:bash 工具本身就是一座万能桥梁。通过 bash,模型可以调用系统中任何命令行工具——grep 搜索代码、find 定位文件、git 管理版本、npm/pip 安装依赖、docker 构建镜像、curl 访问 API。只要你能在终端里干的活,模型就能通过 bash 来干。

这意味着 Pi 不需要为 grep 单独做一个工具——模型会用 bash 执行 grep -rn "function" src/。不需要为 git 做一整套 git_commitgit_pushgit_diff 工具——模型直接执行 git diff HEAD~1。不需要为包管理器做工具——模型直接执行 npm installpip 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_diffview_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

这种设计的代价是模型需要更”懂”命令行工具,但收益是巨大的:

  1. 系统提示词极短(~1000 tokens):每个 token 都是真金白银,少即是多
  2. 无供应商锁定:你的 prompt 里没有 Claude Code 特有的工具定义,切换模型不会被”内置工具集”拖累
  3. 扩展性无限:任何新增能力都通过社区包实现,核心不受污染
  4. 透明可控:你永远知道模型在做什么——每个 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;      // 读取行数
  };
}

核心行为:

  • 接收绝对路径:模型需要提供文件的完整路径。相对路径需要模型自行基于当前工作目录拼接。
  • 支持分页读取:通过 offsetlimit 参数,可以分段读取大文件,避免一次性加载过多内容导致上下文溢出。
  • 读取目录:当 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/limitread 精确读取需要的片段。

读取目录前先想清楚:如果你不确定文件在哪,先 read("src/") 看目录结构,而不是让模型用 read 一个文件一个文件地试。

read 当作”确认”步骤:每次 editwrite 之后,模型应该再次 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 只应在以下两种场景使用:

  1. 创建新文件:你要新建一个 src/components/Button.ts,这个文件还不存在。
  2. 完整重写:你要对一个文件做”推倒重来”式的修改——改动范围超过文件内容的 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)
  };
}

工作流程:

  1. 读取 file_path 对应文件的内容
  2. 在其中精确搜索 old_string
  3. 如果 replace_allfalse(默认),替换第一个匹配项;如果为 true,替换所有匹配项
  4. 如果找不到 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", "")

最佳实践清单:

  1. 编辑前必须 read:在调用 edit 之前,务必先用 read 确认文件当前内容。不要凭记忆或猜测写入 old_string
  2. old_string 尽量包含上下文:如果你的 old_string 只有一行代码(比如 const port = 3000;),而文件中恰好多处出现了同样的一行,edit 可能会改错位置。把 old_string 扩展到包含周围 2-3 行以增加唯一性。
  3. 每次修改后验证edit 成功返回后,用 read 确认修改结果,再用 bash 跑相关测试或 lint。
  4. 小步快跑:一次 edit 改一个关注点。不要在一次 edit 中同时改 API 路由、数据库 schema 和前端组件——分开改,每次验证。
  5. 利用空白和缩进定位:文件中的缩进模式通常是唯一的——利用这一点来构造更精确的 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 的输出会被截断,模型看不到完整内容
  • 对于预期会输出大量内容的命令,需要使用 grepheadtail> 重定向等方式缩小输出范围
  • 最佳实践:永远让 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 的内置安全边界:

  1. 无权限弹窗:Pi 不会在执行每个命令前弹出”是否允许”的确认框。这是因为 Pi 的目标用户是需要高效率的专业开发者,确认弹窗在 100 次交互后会变得极为恼人。
  2. 无命令白名单:Pi 不做参数级别的命令过滤。工具要么全给,要么全不给。
  3. 信任模型: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:列出目录

lsread 目录读取功能的加强版,提供更丰富的选项:

interface LsTool {
  name: "ls";
  parameters: {
    path?: string;         // 路径(默认当前目录)
  };
}

实际上,由于 read 工具本身就支持读取目录(返回文件列表),ls 作为独立工具的价值相对有限。它存在的意义更多是”语义明确”——当你只想看目录结构而不想读文件时,用 ls 比用 read 在语义上更清晰。

7.6.4 默认不启用的原因

grepfindls 这三个工具默认不在 Pi 的内置工具列表中。为什么不默认启用?回到 Mario 的哲学:

  1. bash + 对应命令行工具已经可以完全覆盖grep -rnfindls -la 功能等价且更灵活。
  2. 减少模型的选择成本:工具越多,模型在”该用哪个工具”上花的心思就越多。4 个工具,选择成本为零——read 看文件,bash 干一切。
  3. 保持系统提示词精简:每个工具的定义(名称、参数、描述)都会占用系统提示词的 token 配额。3 个额外工具 ≈ 增加 200-400 tokens,对于 1000 token 的系统提示词来说比例不小。
  4. 它们是”锦上添花”,不是”雪中送炭”:默认不启用不会降低 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-toolspi -nbt

禁用所有内置工具(read、write、edit、bash 以及 grep/find/ls 如果启用的话)。

这个选项的典型使用场景是:你通过 Extensions 系统注册了一套自定义工具,完全不依赖 Pi 的内置工具。例如,你写了一个 Extension 把 read/write/edit/bash 全部替换成了通过 RPC 发送到远程服务器的版本——这时内置的工具对你来说是冗余的。

7.7.4 –no-tools / -nt

命令行参数pi --no-toolspi -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 字符串?

  1. 类型安全:TypeBox 生成运行时 JSON Schema(发送给 LLM),同时也生成 TypeScript 类型(用于 execute 函数的参数类型推导)。一处定义,两处受益。
  2. 表达式简洁Type.String() 比手写 { "type": "string" } 更不容易出错。
  3. 支持联合类型、枚举、可选参数Type.UnionType.OptionalType.Enum 等比手写 JSON Schema 优雅得多。
  4. 社区标准: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 个核心工具(readwriteeditbash)通过组合使用,可以覆盖编码 Agent 的几乎所有工作流。
  • read 是唯一的默认只读工具,支持分页读取和目录浏览,是模型动手前必备的”观察”步骤。
  • write 用于创建新文件和完全重写,是”核选项”——能用 edit 就绝不用 write
  • edit 是 Pi 最核心的编辑原语:精确字符串替换,默认只换第一个匹配,可选 replace_all 做全局替换。精准匹配是刻意为之——宁可报错也不要改错。
  • bash 是万能桥梁:通过它,模型可以调用系统中任何命令行工具,覆盖代码搜索、版本管理、构建测试、包管理等全部领域。超时控制和输出截断是你需要理解的两个关键行为。
  • 可选只读工具grepfindls)默认不启用,但在需要跨平台一致性或结构化输出时可以选择开启。
  • 通过 --tools--exclude-tools--no-builtin-tools--no-tools 四个控制选项,你可以精确控制模型拥有哪些工具。只读模式--tools read,grep,find,ls)是代码审查和安全场景的最佳配置。
  • Extensions APIpi.registerTool() + TypeBox + execute 函数)让你可以无限扩展工具集。
  • 工具执行遵循严格的调用→验证→执行→返回流程,支持并行调用以提升效率,错误信息原样返回让模型自行恢复。

工具系统是 Pi 的”双手”。理解了这 4 个工具的设计哲学和使用方式,你就理解了 Pi 如何用极简实现强大。

下一章:第八章:Skills技能系统 将进入 Pi 另一个核心子系统——Skills 技能系统:如何用 Markdown 文件教会 Agent 新的工作流,让它从”会干活”变成”越用越聪明”。