piagent_
← ~/guides

自定义工具与 SDK 集成

最后更新: 2026年8月11日

$ pi –tool

TL;DR. 给模型加新工具有两条路径:扩展里调 pi.registerTool({ name, label, description, parameters, execute }),其中 parameterstypeboxType.Object schema,execute 返回 { content: [{ type: 'text', text }], details: {} };在 Node.js 应用里嵌入 pi 时,用独立的 defineTool() 工具函数定义、再通过 SDK 选项的 customTools: [...] 传入。自定义工具属于扩展层的事——目前文档里没有内建的 MCP server adapter,第三方协议支持来自社区扩展。两条路径共享同一套权限模型和输出截断规则。

每一个 pi 能调的工具——无论是内置的 readwriteeditbashgrepfindls,还是扩展注册的——在协议层面形状相同。能写一个工具,就能写任意一个;pi.registerTooldefineTool 的差别主要在调用位置。

何时走哪条路径

扩展——工具随用户的交互式 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

parameterstypebox 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-aiStringEnum,而不是 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() },
  };
}

两条看起来可选但其实不是的规则:

  1. 截断长输出。超过几千 token 的工具输出会在到达模型前被压缩或丢弃。把大结果包起来、分页、或者把完整输出写到文件再返回简短指针。

  2. 抛错表示错误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-agentdefineTool()。函数接收同样的 { 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({ ... }) 在协议层面产出同样的形状,所以 parametersexecute 在两条路径下遵循同样的规则。内联的 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 桥接(非内建)