第 04 模块 · 2 节

自定义工具与斜杠命令

《Pi 进阶实战》04 扩展开发 Extensions · 本节时长 36 分钟

贯穿项目这一节动手把 md-tools 变成 Pi 的原生能力:用 pi.registerTool 注册一个让模型直接调用的「检查 markdown」工具,再用 pi.registerCommand 加一个 /md 斜杠命令。这是 M05 贯穿项目的地基。

registerTool:给模型一把新工具

pi.registerTool 注册的工具会出现在系统提示词里,模型可以直接调用它。核心字段:

字段 作用
name 工具名(模型调用时用)
label 展示名
description 给模型看的说明(决定它何时调用)
parameters 参数 schema(用 typebox 的 Type
execute 真正执行逻辑

官方强调:promptSnippet 让它进入「Available tools」一行式条目,promptGuidelines 往默认提示词加「工具专属建议」。

md-tools,一个检查工具:

import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

pi.registerTool({
  name: "md_tools",
  label: "MD Tools",
  description: "处理 markdown 文件:stats 统计字数/段落/标题,convert 转纯文本,rename 改名(先预览)",
  parameters: Type.Object({
    action: StringEnum(["stats", "convert", "rename"] as const),
    path: Type.String({ description: "目标 markdown 文件路径" }),
  }),
  async execute(toolCallId, params, signal, onUpdate, ctx) {
    // 实际调用你的 index.js,这里示意
    return {
      content: [{ type: "text", text: `[md-tools] 对 ${params.path} 执行 ${params.action}` }],
      details: {},
    };
  },
});

注意用 StringEnum(来自 @earendil-works/pi-ai)而不是 Type.Union/Type.Literal——官方明确说后者在 Google 的 API 上不工作。


registerCommand:给你自己一条斜杠命令

pi.registerCommand 注册的是人用的斜杠命令(比如 /md)。官方给了 stats 的例子:

pi.registerCommand("stats", {
  description: "Show session statistics",
  handler: async (args, ctx) => {
    const count = ctx.sessionManager.getEntries().length;
    ctx.ui.notify(`${count} entries`, "info");
  },
});

对我们:

pi.registerCommand("md", {
  description: "用 md-tools 处理 markdown 文件",
  handler: async (args, ctx) => {
    // args 是命令后的参数,例如 "stats README.md"
    ctx.ui.notify(`md-tools 收到: ${args}`, "info");
  },
});

同名命令冲突:多个扩展注册同名命令时,Pi 会都保留并加数字后缀(如 /review:1/review:2),按加载顺序。


给命令加参数自动补全

pi.registerCommand 还能配 getArgumentCompletions,给 /command ... 加自动补全。官方 deploy 的例子:

pi.registerCommand("deploy", {
  description: "Deploy to an environment",
  getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
    const envs = ["dev", "staging", "prod"];
    const items = envs.map((e) => ({ value: e, label: e }));
    const filtered = items.filter((i) => i.value.startsWith(prefix));
    return filtered.length > 0 ? filtered : null;
  },
  handler: async (args, ctx) => {
    ctx.ui.notify(`Deploying: ${args}`, "info");
  },
});

md-tools,你可以补全 stats / convert / rename 这几个动作,再补当前目录的 .md 文件。


错误处理:工具「抛异常」才叫失败

官方强调一个关键点:

要标记工具执行失败(isError: true),必须从 executethrow 一个错误。只 return 值,无论返回值里放什么属性,都不会被标记为失败。

async execute(toolCallId, params) {
  if (!isValid(params.input)) {
    throw new Error(`Invalid input: ${params.input}`);
  }
  return { content: [{ type: "text", text: "OK" }], details: {} };
}

抛出的错误会被捕获、以 isError: true 报告给模型,执行继续。这对 md-tools 处理「文件不存在」这类错误很重要。


落地练习:注册你的第一个工具和命令

  1. 写一个扩展文件,注册 md_tools 工具 + /md 命令(用上面的骨架)
  2. 放进 ~/.pi/agent/extensions/pi -e ./文件.ts 启动
  3. 输入 /md stats,再让模型调用 md_tools 工具,确认两者都工作

怎么判断做对了?——/md 有响应;模型的工具列表里能看到 md_tools 并能成功调用。

卡住了怎么办? 工具不出现 → 确认 registerTool 的参数 schema 正确。命令没反应 → 检查 registerCommand 名字和 handler。想验证 → 用 pi -e 指定路径先测。


常见坑:字符串枚举用了 Type.Union,Google 上翻车

写参数 schema 时最容易踩的坑:字符串枚举写成 Type.Union([Type.Literal("a"), Type.Literal("b")])

官方明确:这种写法在 Google 的 API 上不工作。 要用 StringEnum@earendil-works/pi-ai)代替:

import { StringEnum } from "@earendil-works/pi-ai";
action: StringEnum(["stats", "convert", "rename"] as const)

如果你接了 Google 系模型,这个坑会让你工具直接失效。养成用 StringEnum 的习惯。


小结

  1. registerTool 给模型新工具,核心是 parameters + execute
  2. 字符串枚举用 StringEnum,别用 Type.Union
  3. registerCommand 给人用斜杠命令,可加参数补全
  4. 工具失败要 throwreturn 不算失败
  5. 同名命令会加数字后缀共存

下一节,深入生命周期——事件顺序、状态管理、reload 等进阶用法。