返回课程首页

22

为什么 Pi 需要 Extension

从工具审批需求理解事件驱动扩展。

源码基线:Pi v0.82.0 · commit 518855d

桌面 Agent 已经能对话、调用 Tool,也能用 Skill 分析日志。现在出现一个新要求:

所有危险 Tool 执行前都要经过审批,但我不想修改 Pi 的 Agent Loop。

如果把判断散落到每个 Tool,会重复;如果直接改 Agent Loop,后续升级又难以合并。Extension 就是 Pi 留出的运行行为扩展层。

1. 先看没有 Extension 时会怎样

flowchart LR
    M["模型"] --> C["Tool Call"]
    C --> T["Tool.execute()"]
    T --> R["Tool Result"]
    R --> M

审批逻辑可以写进 Tool:

execute: async (_id, params) => {
  await requestApproval(params);
  return runTool(params);
}

但每个 Tool 都要重复,而且内置 Tool 或第三方 Tool 不一定由你实现。

2. Extension 插在哪里

Extension 可以监听 tool_call

flowchart LR
    M["模型"] --> C["Tool Call"]
    C --> E["Extension Runner<br/>tool_call"]
    E -->|允许| T["Tool.execute()"]
    E -->|block| B["错误 Tool Result"]
    T --> R["Tool Result"]
    R --> M
    B --> M

它不是替代 Agent Loop,而是在 Core 明确发出的事件点观察、修改或阻止流程。

3. Extension 的定位

Pi 的 Extension 是默认导出 Factory 的 TypeScript 或 JavaScript Module:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event) => {
    // 注册行为
  });
}

Factory 在加载阶段拿到受控的 ExtensionAPI,用它注册:

  • Event Handler;
  • Custom Tool;
  • Command;
  • Provider;
  • Flag 等能力。

Factory 可以是 Async,Loader 会等待它完成,再进入后续启动阶段。

4. Extension、Skill 与 Tool 的区别

概念 核心形态 谁决定使用 能否改变运行流程
Skill Markdown 工作流 模型或显式调用方 只能通过指令间接影响
Tool Schema + execute 模型产生 Tool Call 执行一个具体能力
Extension TS/JS Factory + 注册结果 宿主在加载时启用 可监听、修改、阻止和注册

error-log-analysis Skill 告诉模型“怎样分析”;read Tool 负责读文件;审批 Extension 决定 某次 Tool Call 是否允许执行。

5. 为什么 Skill 不适合做强制审批

在 Skill 中写:

删除文件前必须询问用户。

这是给模型的软指令。模型可能没读取 Skill,Skill 也可能在 Compaction 后只剩摘要。

tool_call Handler 处于实际执行路径:

return {
  block: true,
  reason: "Approval required",
};

Runner 把 Block 结果交回 Tool 流程,Tool 不会执行。但多个 tool_call Handler 共享同一 可变 Input,后续 Extension 还能改动已审批参数,而且修改后不会再次 Schema Validation。 因此 Extension Gate 只有在受控的 Extension Chain 中才能形成可靠拦截;真正的桌面安全 边界应在宿主最终执行层检查最终参数,或严格控制 Extension 顺序与 Trust Set。无论如何, 都不能只依赖 Prompt/Skill。

6. 为什么不直接修改 Agent Loop

直接改 Core 的成本包括:

  • 每次升级都要重新合并;
  • Policy 与模型循环耦合;
  • 难以按项目启用;
  • 难以单独测试、分发和停用;
  • 多种需求不断增加分支。

Extension 让 Core 维护稳定事件点,让产品逻辑在外部组合。这不是“Extension 一定安全”, 而是把变化放到明确边界。

7. 为什么采用事件驱动

同一个 Run 中有多个阶段:

flowchart TD
    I["input"] --> B["before_agent_start"]
    B --> A["agent_start"]
    A --> T["turn_start"]
    T --> M["message events"]
    M --> S["tool_execution_start"]
    S --> C["tool_call"]
    C -->|允许| X["tool_execution_update(可选)"]
    C -->|阻止| E["tool_execution_end(错误)"]
    X --> R["tool_result"]
    R --> E["tool_execution_end"]
    E --> Z["ToolResult message"]
    Z --> Q["turn_end / agent_end"]

不同需求只订阅自己关心的阶段:

  • 输入规范化监听 input
  • System Prompt 注入监听 before_agent_start
  • 审批监听 tool_call
  • 审计监听 Tool Execution Event;
  • 清理资源监听 session_shutdown

Core 不需要知道每个 Extension 的业务含义。

8. 第一个最小 Extension

课程提供本地示例:

examples/extensions/block-destructive-shell.ts.example

.ts.example 是为了避免课程站点把独立 Extension 当作自身源码编译。实际使用时先复制或 改名为 .ts,再用 pi -e /absolute/path/block-destructive-shell.ts 临时加载。

核心代码:

export default function blockDestructiveShell(pi: ExtensionAPI) {
  pi.on("tool_call", async (event) => {
    if (event.toolName !== "bash") return;

    const command =
      typeof event.input.command === "string"
        ? event.input.command
        : "";

    if (/(^|\s)rm\s+-rf(\s|$)/.test(command)) {
      return {
        block: true,
        reason: "Blocked by the desktop agent policy.",
      };
    }
  });
}

这不是完整 Shell Security Parser,只是让第一条 Extension 链路足够小:

  1. Module 被加载;
  2. Factory 被调用;
  3. Handler 注册到 tool_call
  4. 模型产生 Bash Tool Call;
  5. Runner 调用 Handler;
  6. 命中规则时返回 Block。

9. 从硬阻止走向桌面审批

最小示例直接阻止。完整桌面产品通常需要:

tool_call
→ 生成 Approval Request
→ UI 展示 Tool + Arguments + 风险
→ 用户允许/拒绝
→ Handler 返回 Allow 或 Block

关键是 Handler 的 Promise 必须等到桌面 UI 回答。具体 Bridge 属于宿主集成,将在 Tool Extension 与桌面状态章节完成;本章先确认拦截点。

10. Extension 的发现位置

coding-agent 自动发现 .ts.js 文件:

用户:~/.pi/agent/extensions/*.{ts,js}
项目:.pi/extensions/*.{ts,js}
目录:.../extensions/<name>/index.{ts,js}

项目自动资源与项目 Settings 中的 Extension 只在项目被 Trust 后加载。用户/全局 Extension 和临时 CLI Extension 会在 Pre-trust 阶段加载,并可参与 project_trust 决策。Settings 和 Pi Package 也可以提供路径。

显式 --extension / -e 适合临时测试;其 Path 保存在 Resource Loader 的 Additional Extension Paths 中,Reload 时也会重新解析加载,只是 Scope 为 Temporary。

11. Factory 不是后台服务启动点

文档特别要求:不要在 Factory 中启动长期 Process、Socket、Watcher 或 Timer。

原因是 Factory 可能在根本不会启动 Session 的调用中运行,例如只列模型。正确模式:

Factory:注册能力,可做有界的一次性初始化
session_start:启动 Session Scoped Resource
session_shutdown:幂等清理

Async Factory 适合一次性初始化,例如获取 Provider Model List;长期资源仍应推迟。

12. Extension 的权限边界

Extension Module 在本机进程中运行,拥有用户进程权限,可以:

  • 读写文件;
  • 访问环境变量;
  • 发起网络请求;
  • 启动子进程;
  • 注册高权限 Tool;
  • 拦截和改变 Agent 行为。

所以“Extension API 是受控接口”不等于 JavaScript Module 被沙箱隔离。API 限制的是它与 AgentSession 的正式耦合面,不限制 Module 自身的 Node 能力。

13. 本章小结

  • Extension 是改变 Pi 运行行为的 TS/JS 扩展层;
  • Skill 是工作流指令,Tool 是具体执行能力,Extension 是注册和拦截机制;
  • 强制审批不能只依赖 Skill/Prompt;Extension Gate 也必须位于受控链中,最终参数应由宿主 执行层兜底检查;
  • Event 让不同需求接入明确生命周期,而不修改 Agent Loop;
  • tool_call 可以在 Tool 执行前返回 Block;某 Handler 放行后,后续 Handler 仍可能修改 Input,一旦 Block 则立即停止后续 Handler;
  • Factory 用于注册,长期资源应在 Session Start 启动并在 Shutdown 清理;
  • 项目 Extension 受 Project Trust 控制;
  • Extension 有完整系统权限,只能加载可信源码;
  • 最小示例只演示拦截链,不声称简单正则可以完整理解 Shell。

14. 自测

  1. 为什么审批逻辑不适合只写在 Skill 中?
  2. Tool 与 Extension 各负责什么?
  3. tool_call 返回 Block 后,Tool 是否还会执行?
  4. Event Driven 给 Core 和产品逻辑带来什么边界?
  5. 为什么不应在 Extension Factory 中启动长期 Watcher?
  6. “Extension API 受控”为什么不等于 Extension 被沙箱隔离?