M11. 扩展系统:把 Agent 改造成你的 Agent#
读完前面的十章,你已经知道 Pi 的内核为何保持克制:它提供一条可靠的循环、少量默认工具和一组事件,但不替你决定要不要 MCP、审批、计划模式或子 Agent。二次开发真正的入口,就是扩展系统。
这一章不把扩展当成“多写几个工具”。更准确的理解是:扩展为运行时增加了新的能力、入口和控制点,同时仍然要遵守 Pi 已有的工具协议与事件时序。
1. 先区分四种改造方式#
| 机制 | 面向谁 | 典型职责 | 是否改变模型可调用能力 |
|---|---|---|---|
registerTool | 模型 | 查询、计算、写入或外部操作 | 是 |
registerCommand | 用户/操作者 | /review、/reload 一类显式入口 | 通常间接改变 |
pi.on | 运行时 | 观察、阻断或在生命周期节点插入逻辑 | 可能改变 |
| UI/主题扩展 | 人 | 状态展示、交互和快捷键 | 通常不直接改变 |
一个简单判断是:模型需要“调用一个能力”时注册工具;人需要“主动发起一个动作”时注册命令;你要在既定流程前后插入策略时监听事件。把三者揉成一个巨型工具,后面很难判断是谁触发了副作用。
2. 一个工具的最小契约#
官方扩展 API 要求工具有名字、描述、参数 schema 和异步执行函数。参数验证不是装饰,而是模型输出进入你的程序前的第一道类型边界。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
const notes = new Map<string, string>();
pi.registerTool({
name: "lookup_note",
label: "Lookup note",
description: "Find a note by its stable identifier.",
parameters: Type.Object({
noteId: Type.String({ description: "Stable note identifier" }),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
if (signal.aborted) {
return {
content: [{ type: "text", text: "Lookup cancelled." }],
details: { cancelled: true },
};
}
const note = notes.get(params.noteId);
if (!note) {
return {
content: [{ type: "text", text: `No note found for ${params.noteId}.` }],
details: { found: false },
};
}
return {
content: [{ type: "text", text: note }],
details: { found: true, noteId: params.noteId },
};
},
});
}这里的 Map 只是为了让示例自包含;真实项目可以换成宿主应用提供的存储适配器。值得保留的是四个习惯:使用 schema 约束输入;通过 signal 响应取消;把给模型看的文本和给程序看的 details 分开;找不到数据时返回可理解的工具结果,而不是让异常穿透整个 Agent Loop。
如果需要在执行较慢时显示进度,可以通过 onUpdate 发送中间结果。更新内容应该是状态,而不是把一大段日志不断复制到上下文中。
3. 命令是人的控制面#
工具描述的是模型可以调用什么,命令描述的是用户可以明确要求运行什么。比如把“重新加载索引”注册成 /reindex,比让模型拥有一个模糊的 manage_index 更容易审计。
pi.registerCommand("reindex", {
description: "Rebuild the local note index",
handler: async (_args, ctx) => {
await ctx.ui.notify("Index rebuild started", "info");
// 调用宿主应用的确定性函数;不要在这里解析自然语言。
},
});命令处理器适合确定性工作流:读取配置、切换模式、生成报告或显示诊断信息。它不应该偷偷替模型做一轮推理,否则命令、工具和会话消息的责任会互相污染。
4. 事件钩子是策略层#
扩展最有价值的地方往往不是增加工具,而是在工具真正执行前获得一次判断机会。例如,应用可以拦截危险的 shell 命令,让用户明确决定是否允许:
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const command = String(event.input.command ?? "");
const looksDangerous = command.includes("rm -rf") || command.includes("mkfs");
if (!looksDangerous) return;
const allowed = await ctx.ui.confirm(
"Potentially destructive command",
command,
);
if (!allowed) {
return { block: true, reason: "Blocked by the user" };
}
});这段代码表达的是“策略可以拒绝一次调用”,不是“扩展本身就是安全边界”。扩展代码与宿主进程拥有相同的运行权限;如果进程能读密钥、写任意文件,恶意或误写的扩展同样能做到。真正的隔离仍应交给容器、操作系统权限、受限工作目录和网络策略。
官方还提供 session_start 等生命周期事件。它们适合初始化状态、根据启动原因恢复资源,或在新会话、恢复和分叉之间做清理。事件处理器应保持短小,并把长任务交给可取消的服务函数。
5. 动态注册与热重载带来的新问题#
pi.registerTool() 不只可以在扩展加载时调用,也可以在运行时注册新工具;活动会话会刷新工具列表,不必依赖下一次启动。这个能力很适合按项目、用户或模式打开能力,但也带来两个问题:
- 工具集合会变化,模型在不同轮次看到的可用工具可能不同。
- 动态能力必须有明确的启用条件和回收路径,否则一次临时授权会变成长期权限。
实践上,可以把工具的启用状态写成显式状态机:disabled → enabled → revoked,在 session_start、模式切换和会话结束时分别处理。不要仅凭 prompt 里的自然语言决定“现在应该允许哪个工具”。
6. 扩展设计的检查表#
- 工具是否只有一个清楚的职责?
- 参数 schema 是否拒绝歧义输入,而不是在执行函数里猜?
- 长任务是否使用
AbortSignal? - 工具结果是否区分模型文本与程序详情?
- 拒绝、超时、找不到资源是否都会回到模型可理解的结果?
- 副作用前是否有宿主层权限检查?
pi.on是否只做必要的观察或干预,没有无限触发自身事件?- 动态注册的工具是否有启用、撤销和审计记录?
7. 扩展的最小信任边界#
扩展可以改变模型可见工具、用户可用命令和运行时策略,因此加载扩展本身就是一次权限决策。工具 schema 只能约束参数形状,tool_call 只能拦截已到达的调用;真正的文件、进程、网络和凭据权限仍由宿主环境决定。扩展系统的底层原则是把“可编程”与“可授权”分开,不能因为代码来自本地目录就默认它安全。
资料
本文依据 Pi Extensions 官方文档 和 Pi 官方仓库重写示例。API 细节变动时,以官方文档为准。