自定义工具与 SDK 集成
最后更新: 2026年8月11日
$ pi –tool
TL;DR. 给模型加新工具有两条路径:扩展里调 pi.registerTool({ name, label, description, parameters, execute }),其中 parameters 是 typebox 的 Type.Object schema,execute 返回 { content: [{ type: 'text', text }], details: {} };在 Node.js 应用里嵌入 pi 时,用独立的 defineTool() 工具函数定义、再通过 SDK 选项的 customTools: [...] 传入。自定义工具属于扩展层的事——目前文档里没有内建的 MCP server adapter,第三方协议支持来自社区扩展。两条路径共享同一套权限模型和输出截断规则。
每一个 pi 能调的工具——无论是内置的 read、write、edit、bash、grep、find、ls,还是扩展注册的——在协议层面形状相同。能写一个工具,就能写任意一个;pi.registerTool 和 defineTool 的差别主要在调用位置。
何时走哪条路径
扩展——工具随用户的交互式 pi 安装走,他们可以 pi install git:...、发布到 npm,或直接放到 ~/.pi/agent/extensions/。扩展是以完整系统权限运行在 pi 进程里的 TypeScript 模块,因此适用于需要接触文件系统、运行子进程或访问网络的工具。
SDK——当你把 pi 嵌入到 Node.js 应用(Slack bot、CI 步骤、内部 Web 服务)里,想把 agent 的工具面严格限制在应用自身暴露的范围内。SDK 用同一套原语,但允许你在不开扩展包的前提下内联定义工具。
两者不能互换。扩展可以 registerTool 但不能仅 defineTool;SDK 应用通过 customTools 定义工具,自身不以扩展形式运行。另一条分界线是执行环境:扩展跑在用户跑 pi 的地方;SDK 嵌入的 pi 跑在你跑宿主应用的地方。
最简 registerTool 例子
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_id, params) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
}
存成 greet.ts,用 pi -e ./greet.ts 一次性加载。TUI 里 LLM 现在能用 name 参数调用 greet 工具。下一次发送 prompt 时,该工具会出现在模型可用工具列表里。
完整签名是 async execute(toolCallId, params, signal, onUpdate, ctx)。这五个参数分别支持取消(signal)、流式进度(onUpdate)和上下文读取(ctx)。对于一次性同步工具这种最常见情况,_id 与尾部参数虽然类型上可选,但惯例上仍然按该形状写——即使你不用。
parameters schema
parameters 是 typebox schema 而非普通对象。schema 是 LLM 决定要不要调用你的工具时所看到的内容,所以每个字段的 description 比平时更重要。
import { Type } from "typebox";
parameters: Type.Object({
query: Type.String({ description: "Search query text" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50, default: 10 })),
format: Type.Optional(Type.Union([
Type.Literal("json"),
Type.Literal("text"),
])),
})
需要在所有 provider 上都正常渲染的字符串枚举,应使用 @earendil-works/pi-ai 的 StringEnum,而不是 Type.Union([Type.Literal(...), ...])。Google Generative AI API 拒绝纯字符串 union,而 StringEnum 产出的方言是 Google 期望的。这是个文档脚注,但对任何工具需要跑通完整 provider 矩阵的人是个尖锐的坑。
execute 的返回值长什么样
形状固定:{ content: [...], details?: {} }。content 数组承载模型在 transcript 里看到的内容;details 对模型不可见,专供工具间或你自己的 UI 使用。
async execute(_id, params, signal, onUpdate, ctx) {
const text = await fetchSomething(params.query, { signal });
return {
content: [{ type: "text", text }],
details: { fetchedAt: new Date().toISOString() },
};
}
两条看起来可选但其实不是的规则:
-
截断长输出。超过几千 token 的工具输出会在到达模型前被压缩或丢弃。把大结果包起来、分页、或者把完整输出写到文件再返回简短指针。
-
抛错表示错误。
execute返回任何值都视为成功——哪怕返回值长得像错误信息。想标记工具调用失败,请 throw;框架会在工具结果上设isError,模型看到的是失败而不是“成功”的错误文本。
isError 也可以在返回值里显式设——当你想在不带异常的情况下表达软失败时。两条路径服务于不同的错误处理风格;每个工具挑一种用到底。
写工具与文件变更队列
会修改工作目录文件的工具(writer、formatter、code generator)应当把写操作走 withFileMutationQueue。队列把你的写操作和 pi 自带的 edit 工具串行化,避免并发工具调用互相覆盖、或者跟会话内的文件 watcher 打架。
async execute(_id, params, signal, onUpdate, ctx) {
return ctx.withFileMutationQueue(async () => {
await fs.writeFile(params.path, params.content, "utf-8");
return {
content: [{ type: "text", text: `wrote ${params.path}` }],
details: {},
};
});
}
跳过队列会撞上隐性竞态:当 LLM 在同一轮里把你的工具和 edit 串起来调用。队列也是 pi 在把结果作为内联变更呈现给用户之前做 diff 的机制。
SDK:defineTool + customTools
通过 SDK 嵌入 pi 时,入口是 @earendil-works/pi-coding-agent 的 defineTool()。函数接收同样的 { name, label, description, parameters, execute } 形状,返回 SDK 能识别的工具定义。再通过选项对象上的 customTools 传入:
import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const greet = defineTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_id, params) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
const session = await createAgentSession({
customTools: [greet],
// ...other options
});
defineTool() 与 pi.registerTool({ ... }) 在协议层面产出同样的形状,所以 parameters 和 execute 在两条路径下遵循同样的规则。内联的 pi.registerTool({ ... })(在扩展里)能正确推断参数类型,因为 TypeScript 直接看到了 schema;defineTool() 在 SDK 侧也做到同样的事。
权限:完整系统访问是设计选择
扩展和 SDK 嵌入的工具以宿主进程的完整系统权限运行。没有每次调用的权限提示、没有沙箱、也没有工具执行前的审查步骤。README 写得直白:“Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.”
对工具作者的含义:你发布的每一个工具,都假设用户会拿它在生产数据上跑。在 description 里写清副作用——“读文件”、“调用 https://example.com"、"写盘"——再写一份 README 列出你的工具接触的网络端点和文件路径。对 SDK 嵌入场景,边界是你应用自己的信任模型:如果宿主进程能读数据库,你注册的任何工具也能。
没办法把工具声明为只读、也没办法在执行时请求授权。如果这种程度的隔离必要,把 pi 跑在容器或 VM 里、把边界当成宿主。
MCP、Model Context Protocol 与第三方 adapter
pi 自己的文档覆盖了扩展和 SDK,但截至 v0.84.x 都没有文档化的 MCP server adapter、tools.md 页或 mcp.md 页。pi-mcp-adapter 是社区维护的扩展而非内建模块——它出现在资源地图里,提供到 MCP server 的 JSON-RPC 桥接,但它的 API 表面不属于 pi 稳定文档的一部分,会变。
今天需要暴露 MCP 风格工具时,把这个选择当作任何第三方 adapter 看待:
- 从资源地图拉
pi-mcp-adapter,并先验证它在你目标 pi 版本上能跑再依赖 - 写新代码时,倾向用文档化的
registerTool/defineTool表面,留在受支持的路径上 - 如果你写 MCP 一侧,要实现的 JSON-RPC schema 是标准那一份——adapter 选哪个不改变你的 server 必须暴露什么
缺少内建 MCP adapter 是一个真实的限制,尤其是移植严重依赖 MCP 的 Claude Code / Codex workflow 时。社区 adapter 补上了大部分缺口,但要预期读它的源码而不是依赖它的文档来面面俱到。
拼起来:一个够真实的小工具
按 glob 搜索文件内容、返回匹配项的小工具,分别走两条路径做对照:
// 扩展路径
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "ripgrep",
label: "ripgrep",
description: "Search file contents with ripgrep",
parameters: Type.Object({
pattern: Type.String({ description: "regex pattern" }),
path: Type.String({ description: "directory to search" }),
}),
async execute(_id, params, signal) {
const proc = Bun.spawn(["rg", "--json", params.pattern, params.path], {
signal,
});
const text = await new Response(proc.stdout).text();
return {
content: [{ type: "text", text: text.slice(0, 8000) }],
details: { truncated: text.length > 8000 },
};
},
});
}
// SDK 路径
import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const ripgrep = defineTool({
name: "ripgrep",
label: "ripgrep",
description: "Search file contents with ripgrep",
parameters: Type.Object({
pattern: Type.String({ description: "regex pattern" }),
path: Type.String({ description: "directory to search" }),
}),
async execute(_id, params, signal) {
const proc = Bun.spawn(["rg", "--json", params.pattern, params.path], { signal });
const text = await new Response(proc.stdout).text();
return {
content: [{ type: "text", text: text.slice(0, 8000) }],
details: { truncated: text.length > 8000 },
};
},
});
const session = await createAgentSession({ customTools: [ripgrep] });
函数体一字不差;只有注册表面不同。这就是整个故事:根据你的工具是作为包分发给用户、还是内联在应用里跑,选择对应路径。
延伸阅读
- Extensions——事件、自定义工具、斜杠命令、项目信任
- SDK——在 Node.js 应用里嵌入 pi,
customTools,会话生命周期 - Packages——通过 npm 或 git 安装扩展和技能
- RPC——用 JSON-RPC 从非 Node 宿主驱动 pi
- JSON output——SDK 的结构化输出
- Skills——跨多个 agent 兼容的可复用技能模块
@earendil-works/pi-coding-agent——SDK 源码,defineTool,会话类型@earendil-works/pi-ai——StringEnum等 AI 工具函数- pi-mcp-adapter——社区 MCP 桥接(非内建)