Skip to content

第 11 章 · Extensions 进阶:自定义工具与 UI

本章目标:掌握扩展开发的核心能力——注册生产级自定义工具、拦截与改写内置工具行为、向模型注入上下文、自定义 slash 命令与 TUI 渲染,并完成一个"自动跑测试"的实战扩展。

11.1 自定义工具的完整定义

第 10 章我们用 pi.registerTool() 注册过最简工具。生产级工具定义还有几个关键字段:

typescript
// ~/.pi/agent/extensions/db-tools.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "db_query",                    // LLM 看到的工具名
    label: "DB Query",                   // TUI 里显示的标签
    description: "查询项目数据库并返回行数据",
    // 一行摘要:出现在系统提示词 Available tools 列表中(省略则不出现)
    promptSnippet: "Query the project database",
    // 追加到系统提示词 Guidelines 的条目(仅当工具激活时)
    promptGuidelines: [
      "Use db_query when the user asks about stored data instead of guessing."
    ],
    parameters: Type.Object({
      sql: Type.String({ description: "只读 SELECT 语句" }),
      limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
    }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      // signal:Esc 中断时要感知;onUpdate:向 UI 流式汇报进度
      onUpdate?.({ content: [{ type: "text", text: "执行查询中..." }] });
      const rows = await runQuery(params.sql, params.limit ?? 20, signal);
      return {
        content: [{ type: "text", text: JSON.stringify(rows) }], // 发给 LLM
        details: { rows },                                       // 留给渲染/状态
      };
    },
  });
}

三个容易踩坑的点(都来自官方文档的明确要求):

  1. 枚举参数必须用 StringEnum@earendil-works/pi-ai 导出),Type.Union/Type.Literal 与 Google 的 API 不兼容;
  2. 报错必须 throwexecute 里抛出的错误会被 pi 捕获、标记 isError: true 并报告给 LLM;返回值永远不会设置错误标志;
  3. 输出必须截断:内置上限是 50KB 或 2000 行,超限会导致上下文溢出。直接用官方导出的截断工具:
typescript
import {
  truncateHead, truncateTail,
  DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize,
} from "@earendil-works/pi-coding-agent";

async execute(_id, params, _signal, _onUpdate, _ctx) {
  const output = await runLongTask();
  // 日志类输出保留尾部更有价值;文件读取/搜索结果用 truncateHead
  const t = truncateTail(output, {
    maxLines: DEFAULT_MAX_LINES,
    maxBytes: DEFAULT_MAX_BYTES,
  });
  let text = t.content;
  if (t.truncated) {
    text += `\n\n[已截断:${t.outputLines}/${t.totalLines} 行 (${formatSize(t.outputBytes)})]`;
  }
  return { content: [{ type: "text", text }], details: {} };
}

写文件的工具要进文件队列

如果自定义工具会修改文件,必须用 withFileMutationQueue(absolutePath, fn) 包裹整个读改写窗口——默认并行执行下,不进队列的工具可能与内置 edit 同时读写同一文件导致改动丢失。

11.2 拦截与改写内置工具:tool_call 事件

不必重写整个工具也能改变它的行为。tool_call 事件在工具执行前触发,支持三种玩法:修改入参、阻止执行、提前终止

typescript
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event, ctx) => {
    if (isToolCallEventType("bash", event)) {
      // ① 就地修改 input:真正的执行会用改后的命令
      event.input.command = `source ~/.profile\n${event.input.command}`;
      // ② 危险命令直接拦截
      if (event.input.command.includes("rm -rf")) {
        const ok = await ctx.ui.confirm("危险命令", "允许执行 rm -rf 吗?");
        if (!ok) return { block: true, reason: "用户拒绝了该命令" };
      }
    }
    if (isToolCallEventType("edit", event)) {
      // ③ 保护敏感文件不被写入
      if (/\.env|node_modules/.test(String(event.input.path))) {
        return { block: true, reason: "禁止修改受保护路径" };
      }
    }
  });

  pi.on("tool_result", async (event, _ctx) => {
    // 工具执行完还能改写结果(链式中间件:后注册的看到的是前者改过的)
    if (event.toolName === "bash") {
      return { content: [{ type: "text", text: `${event.content}` }] };
    }
  });
}

需要完全替换内置工具时,用同名注册覆盖它(交互模式会显示警告);渲染器按槽位独立继承——你的覆盖只写 execute 时,内置的 renderCall/renderResult 会自动沿用,非常适合"加日志/权限控制但不重画 UI"的场景。还可以用 pi --no-builtin-tools 只跑扩展工具。

11.3 向模型注入上下文

两个层级:

typescript
// 层级一:每轮对话前注入持久消息 / 改系统提示词
pi.on("before_agent_start", async (event, _ctx) => {
  return {
    // 注入的消息会存入会话并发给 LLM(display 控制是否在 TUI 显示)
    message: { customType: "test-runner", content: "当前分支有未通过的测试", display: true },
    // 链式追加系统提示词(前面的扩展改过的结果已在 event.systemPrompt 里)
    systemPrompt: event.systemPrompt + "\n\n运行测试时始终使用 pnpm vitest run。",
  };
});

// 层级二:每次 LLM 调用前非破坏性地增删消息(deep copy,随便改)
pi.on("context", async (event, _ctx) => {
  return { messages: event.messages.filter((m) => !isNoise(m)) };
});

11.4 自定义 slash 命令与 UI 渲染

typescript
import type { AutocompleteItem } from "@earendil-works/pi-tui";

pi.registerCommand("fix-tests", {
  description: "运行测试并把失败项交给模型修复",
  // 参数自动补全
  getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
    const items = ["all", "changed", "failed"].map((v) => ({ value: v, label: v }));
    const hit = items.filter((i) => i.value.startsWith(prefix));
    return hit.length ? hit : null;
  },
  handler: async (args, ctx) => {
    const result = await pi.exec("pnpm", ["vitest", "run", "--reporter=json"], {});
    // 把失败摘要作为用户消息发给模型,触发修复回合
    pi.sendUserMessage(`以下是测试失败摘要,请逐个修复:\n${result.stdout.slice(-4000)}`);
  },
});

TUI 渲染三件套:registerMessageRenderer(自定义消息,参与 LLM 上下文)、registerEntryRenderer(配合 appendEntry 的纯展示卡片,不进上下文)、工具上的 renderCall/renderResult。更复杂的交互(向导、游戏)用 ctx.ui.custom() 拿到完整组件能力,对话框则有 select/confirm/input/editor 四种,均支持 { timeout } 自动关闭。

11.5 实战:一个完整的"测试守卫"扩展

把本章能力组合起来——每次模型想结束回合前自动跑一次相关测试:

typescript
// ~/.pi/agent/extensions/test-guard.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  let enabled = false;

  pi.registerCommand("guard", {
    description: "开关:回合结束前自动跑测试",
    handler: async (_args, ctx) => {
      enabled = !enabled;
      ctx.ui.setStatus("test-guard", enabled ? "🛡 开启" : undefined);
      ctx.ui.notify(enabled ? "测试守卫已开启" : "测试守卫已关闭", "info");
    },
  });

  pi.on("turn_end", async (event, ctx) => {
    if (!enabled) return;
    const r = await pi.exec("pnpm", ["vitest", "run", "--reporter=dot"], {
      signal: ctx.signal, timeout: 120_000,
    });
    if (r.code === 0) return;
    const summary = truncateTail(r.stdout, {
      maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES,
    }).content;
    // steer:本轮工具执行完后立刻插话要求修复
    pi.sendUserMessage(
      `检测到 ${r.code === 0 ? "无" : ""}测试失败,请立即修复:\n${summary}`,
      { deliverAs: "steer" },
    );
  });

  pi.on("session_shutdown", async () => {
    enabled = false; // 清理会话级状态
  });
}

多个扩展之间协作用共享事件总线:pi.events.emit("my:event", data) / pi.events.on("my:event", cb),避免互相 import。

本章小结

  • 工具定义四要素:parameters(TypeBox)+ execute(throw 报错、截断输出)+ promptSnippet/Guidelines(提示词可见性)+ 可选渲染器;
  • tool_call 可改参/可拦截,tool_result 可链式改写结果,同名注册可整体覆盖内置工具;
  • 上下文注入分两层:before_agent_start(消息+系统提示词)与 context(每次调用的消息集);
  • 命令补全、四种对话框、ctx.ui.custom() 组件、消息/条目双渲染体系构成完整 UI 能力;
  • 扩展间通信走 pi.events 总线,会话级状态在 session_shutdown 清理。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 自定义工具的 execute 需要把执行标记为失败并告知 LLM,正确做法是?

2. 关于 tool_call 事件中对 event.input 的就地修改,下列说法正确的是?

3. 想让某个自定义工具出现在系统提示词的 Available tools 一行列表里,应该定义哪个字段?

4. 扩展 A 和扩展 B 需要互相发送消息,官方推荐的方式是?

🛠️ 动手实践

  1. 给第 10 章的问候扩展加上 renderCall/renderResult:折叠态只显示一行,展开态(expanded)列出全部历史问候。
  2. 编写一个"路径保护"扩展:拦截 write/edit 工具,凡是目标路径匹配 .env*.pemnode_modules/ 的一律 block,并用 notify 说明原因。
  3. 把 11.5 的 test-guard 扩展跑起来:故意写坏一个断言 → 观察模型被 steer 后自动修复 → 用 /reload 热更新扩展逻辑。

完成后继续第 12 章:Themes 主题定制