9 min read教程

M11. 扩展系统:把 Agent 改造成你的 Agent

理解 registerTool、registerCommand、pi.on 与 UI 扩展的职责分工和控制边界。

M11. 扩展系统:把 Agent 改造成你的 Agent#

读完前面的十章,你已经知道 Pi 的内核为何保持克制:它提供一条可靠的循环、少量默认工具和一组事件,但不替你决定要不要 MCP、审批、计划模式或子 Agent。二次开发真正的入口,就是扩展系统。

这一章不把扩展当成“多写几个工具”。更准确的理解是:扩展为运行时增加了新的能力、入口和控制点,同时仍然要遵守 Pi 已有的工具协议与事件时序。

1. 先区分四种改造方式#

机制面向谁典型职责是否改变模型可调用能力
registerTool模型查询、计算、写入或外部操作
registerCommand用户/操作者/review/reload 一类显式入口通常间接改变
pi.on运行时观察、阻断或在生命周期节点插入逻辑可能改变
UI/主题扩展状态展示、交互和快捷键通常不直接改变

一个简单判断是:模型需要“调用一个能力”时注册工具;人需要“主动发起一个动作”时注册命令;你要在既定流程前后插入策略时监听事件。把三者揉成一个巨型工具,后面很难判断是谁触发了副作用。

2. 一个工具的最小契约#

官方扩展 API 要求工具有名字、描述、参数 schema 和异步执行函数。参数验证不是装饰,而是模型输出进入你的程序前的第一道类型边界。

ts
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 更容易审计。

ts
pi.registerCommand("reindex", {
  description: "Rebuild the local note index",
  handler: async (_args, ctx) => {
    await ctx.ui.notify("Index rebuild started", "info");
    // 调用宿主应用的确定性函数;不要在这里解析自然语言。
  },
});

命令处理器适合确定性工作流:读取配置、切换模式、生成报告或显示诊断信息。它不应该偷偷替模型做一轮推理,否则命令、工具和会话消息的责任会互相污染。

4. 事件钩子是策略层#

扩展最有价值的地方往往不是增加工具,而是在工具真正执行前获得一次判断机会。例如,应用可以拦截危险的 shell 命令,让用户明确决定是否允许:

ts
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() 不只可以在扩展加载时调用,也可以在运行时注册新工具;活动会话会刷新工具列表,不必依赖下一次启动。这个能力很适合按项目、用户或模式打开能力,但也带来两个问题:

  1. 工具集合会变化,模型在不同轮次看到的可用工具可能不同。
  2. 动态能力必须有明确的启用条件和回收路径,否则一次临时授权会变成长期权限。

实践上,可以把工具的启用状态写成显式状态机:disabled → enabled → revoked,在 session_start、模式切换和会话结束时分别处理。不要仅凭 prompt 里的自然语言决定“现在应该允许哪个工具”。

6. 扩展设计的检查表#

  • 工具是否只有一个清楚的职责?
  • 参数 schema 是否拒绝歧义输入,而不是在执行函数里猜?
  • 长任务是否使用 AbortSignal
  • 工具结果是否区分模型文本与程序详情?
  • 拒绝、超时、找不到资源是否都会回到模型可理解的结果?
  • 副作用前是否有宿主层权限检查?
  • pi.on 是否只做必要的观察或干预,没有无限触发自身事件?
  • 动态注册的工具是否有启用、撤销和审计记录?

7. 扩展的最小信任边界#

扩展可以改变模型可见工具、用户可用命令和运行时策略,因此加载扩展本身就是一次权限决策。工具 schema 只能约束参数形状,tool_call 只能拦截已到达的调用;真正的文件、进程、网络和凭据权限仍由宿主环境决定。扩展系统的底层原则是把“可编程”与“可授权”分开,不能因为代码来自本地目录就默认它安全。

资料

本文依据 Pi Extensions 官方文档Pi 官方仓库重写示例。API 细节变动时,以官方文档为准。

相关文章