第 11 章 · Extensions 进阶:自定义工具与 UI
本章目标:掌握扩展开发的核心能力——注册生产级自定义工具、拦截与改写内置工具行为、向模型注入上下文、自定义 slash 命令与 TUI 渲染,并完成一个"自动跑测试"的实战扩展。
11.1 自定义工具的完整定义
第 10 章我们用 pi.registerTool() 注册过最简工具。生产级工具定义还有几个关键字段:
// ~/.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 }, // 留给渲染/状态
};
},
});
}三个容易踩坑的点(都来自官方文档的明确要求):
- 枚举参数必须用
StringEnum(@earendil-works/pi-ai导出),Type.Union/Type.Literal与 Google 的 API 不兼容; - 报错必须
throw:execute里抛出的错误会被 pi 捕获、标记isError: true并报告给 LLM;返回值永远不会设置错误标志; - 输出必须截断:内置上限是 50KB 或 2000 行,超限会导致上下文溢出。直接用官方导出的截断工具:
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 事件在工具执行前触发,支持三种玩法:修改入参、阻止执行、提前终止。
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 向模型注入上下文
两个层级:
// 层级一:每轮对话前注入持久消息 / 改系统提示词
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 渲染
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 实战:一个完整的"测试守卫"扩展
把本章能力组合起来——每次模型想结束回合前自动跑一次相关测试:
// ~/.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 需要互相发送消息,官方推荐的方式是?
🛠️ 动手实践
- 给第 10 章的问候扩展加上
renderCall/renderResult:折叠态只显示一行,展开态(expanded)列出全部历史问候。 - 编写一个"路径保护"扩展:拦截
write/edit工具,凡是目标路径匹配.env、*.pem、node_modules/的一律block,并用notify说明原因。 - 把 11.5 的 test-guard 扩展跑起来:故意写坏一个断言 → 观察模型被 steer 后自动修复 → 用
/reload热更新扩展逻辑。
完成后继续第 12 章:Themes 主题定制。