返回课程首页

29

构建可靠的 Extension

处理错误、取消、冲突、重载、并发和版本兼容。

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

一个 Extension 在 Demo 中工作,不等于能进入生产。它可能:

  • Factory 启动失败;
  • Handler 卡住整个事件链;
  • Reload 后留下两个 Watcher;
  • 与另一个 Extension 抢同名 Tool;
  • 在 Agent End 中递归启动新 Run;
  • 把 Session A 的状态泄漏到 Session B;
  • 更新 Package 后不再兼容当前 Pi。

本章给 Extension 建立生产级设计与测试方法。

1. 可靠性的六个维度

flowchart TD
    R["Reliable Extension"] --> I["Initialization"]
    R --> E["Event/Tool errors"]
    R --> L["Lifecycle cleanup"]
    R --> C["Conflict/concurrency"]
    R --> P["Performance/security"]
    R --> V["Test/version/distribution"]

不要只测试 Happy Path。

2. Factory 初始化失败

Module Import、默认导出检查或 Factory 执行失败,Loader 返回:

{
  extension: null,
  error: "Failed to load extension: ...",
}

该 Extension 对象不会进入最终 Extension 列表,后续 Path 继续加载。但 Factory 失败并不等于 “事务回滚”:Factory 在抛错前对共享 Runtime 做过的预绑定注册,仍可能残留。例如排队等待 Runner 绑定的 registerProvider() 可能随后被 Flush,registerFlag() 写入的默认值也可能保留。

因此可靠的 Factory 应:

  1. 先完成读取、远程请求、Schema 校验等可能失败的工作;
  2. 把注册动作集中到最后一个尽量不失败的阶段;
  3. 不把“Factory Throw”当成撤销此前副作用的机制。

Timer、Watcher、Socket 与 EventBus Listener 等长期资源不要在 Factory 中启动,应延迟到 session_start 或首次使用。Factory 也可能被用于最终不启动 Session 的加载流程;更危险的是, 若它启动资源后抛错,Extension 对象及其 Shutdown Handler 都会被丢弃,之后无法依靠 session_shutdown 清理。

桌面 Agent 应:

  • 展示 Extension Path;
  • 展示 Loader 返回的 Path 与 Error;
  • 标记它提供的 Tool/Policy 未启用;
  • 对安全关键 Extension 选择 Fail Startup,而不是静默降级。

Pi Loader 默认是收集错误并继续;“关键 Policy 缺失是否阻止应用启动”由宿主决定。 默认 Loader 诊断是格式化后的错误文本,并不保证带 Stack;需要 Stack 时,宿主应在加载边界 增加自己的诊断与日志采集。

3. Async Factory 的 Timeout

Loader 会 await factory(api),没有自动 Timeout。远程初始化必须自行限时:

export default async function (pi: ExtensionAPI) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 5_000);
  try {
    const config = await fetchConfig(controller.signal);
    registerFromConfig(pi, config);
  } finally {
    clearTimeout(timer);
  }
}

还应决定失败策略:

  • Required Config:Throw,Extension 不加载;
  • Optional Enhancement:注册降级能力并报告诊断。

4. Handler 执行失败

错误语义不统一:

Handler 抛错后
普通 Lifecycle/Context/Result/Input Error Listener 收到,后续继续
project_trust 收集 Error,继续找 Decision
tool_call 错误传播,Tool 失败且不执行
EventBus Listener Console Error;Emit 不 Await

安全 Handler 要“故障即拒绝”,tool_call 已是 Fail Closed。普通 Audit Handler 抛错不会 阻断 Tool,因此关键 Audit 不能只依赖事后观察 Event。

5. Handler Timeout 与取消

Runner 顺序 await Handler,没有自动 Timeout。

pi.on("tool_result", async (event, ctx) => {
  const timeout = new AbortController();
  const timer = setTimeout(() => timeout.abort("redaction timeout"), 3_000);
  const signal = ctx.signal
    ? AbortSignal.any([ctx.signal, timeout.signal])
    : timeout.signal;

  try {
    return await redactRemote(event, signal);
  } catch {
    // tool_result 的普通 Throw 会被 Runner 隔离,原结果仍可能继续流向模型。
    // 安全脱敏不能靠 Throw 实现 Fail Closed,而要显式返回安全替代内容。
    return {
      content: [{ type: "text", text: "[结果因脱敏服务不可用而隐藏]" }],
      details: null,
      isError: true,
    };
  } finally {
    clearTimeout(timer);
  }
});

要求:

  • ctx.signal 与独立 Timeout Signal 一起传给 Fetch/Model/Process;
  • Timeout 后真正 Abort 自己的工作,而不只是停止等待;
  • 明确 Timeout 时 Fail Open/Closed;
  • 不在 message_update 高频 Event 做远程调用。

对于 Approval,Timeout 应默认拒绝,而不是默认允许。 普通 tool_result Handler Throw 并不是 Fail Closed:Runner 会报告错误后继续使用已有结果。 安全脱敏要返回安全替代结果,或在更早的 tool_call/宿主 Policy 边界拒绝执行。

6. Tool 的 Abort

Custom Tool 必须把 signal 传到底层:

Extension Tool
→ Driver/Fetch/Child Process
→ Abort-aware cancellation

Abort 只能请求停止,不能回滚已经发生的写入。Tool 应:

  • 在 Side Effect 前检查 Signal;
  • 使用 Idempotency Key;
  • 对多步写入使用 Transaction;
  • 返回前完成 Cleanup;
  • 不在后台继续 Detached Work。

7. Extension 冲突

跨 Extension:

  • Tool/Flag:前者胜,产生 Conflict Diagnostic;
  • Command:都保留,获得 name:1/name:2
  • Handler:全部运行;
  • EventBus Channel:没有自动 Namespace。

最终 Registry 中 Extension Tool 还能覆盖 Built-in,SDK Custom Tool 又可覆盖 Extension。

启动门禁应把意外冲突视作错误,而不是依赖加载顺序碰运气。

8. 重复注册

同一个 Extension 内:

  • Tool/Command/Flag Map:后写覆盖;
  • Handler Array:重复订阅会执行多次;
  • Flag Definition 可后写,但 Default Value 可能保留第一次。

Factory 应只注册一次。不要在某个事件 Handler 中动态再次调用 pi.on();同一 Extension 实例里显式重复订阅,才会让 Handler 重复执行。New/Resume/Fork/Reload 会重建 Extension 对象与 Runner,session_start 本身不会让旧 Runner 的 Handler 自动累积。

但 Module-level 资源与 EventBus 可以跨实例存活。长期 EventBus Subscription 要保存 Unsubscribe,并在 Shutdown 清理,否则重载后的新实例可能与旧 Listener 同时存在。

9. 事件递归

flowchart LR
    A["agent_settled"] --> S["sendUserMessage"]
    S --> R["new Agent Run"]
    R --> A

防护方法:

  • CustomType/Source Filter;
  • Session Entry Mark;
  • Run-local Boolean Guard;
  • Max Iteration;
  • Business Idempotency Key;
  • ctx.hasPendingMessages() 时不追加。

Guard 的生命周期要匹配需求。全局 Module Variable 可能误伤其他 Session。

10. Reload 清理

Reload:

session_shutdown(reason=reload)
→ reload resource/factory
→ rebuild runner/tools
→ session_start(reason=reload)

在 Shutdown 关闭:

  • Timer;
  • Watcher;
  • Socket;
  • Child Process;
  • EventBus Subscription;
  • Temporary Directory;
  • External Lease。

清理必须幂等,因为 Quit、Reload、New、Resume、Fork 都可能进入 Shutdown。

11. Session 切换

New/Resume/Fork 会 Tear Down 旧 Runtime 并建立新 Runtime。状态恢复放在 session_start

pi.on("session_start", (_event, ctx) => {
  state = rebuildFrom(ctx.sessionManager.getBranch());
});

旧 Command Context 不应在 Session Replacement 后继续使用;后续动作放进 withSession(nextCtx)

需要跨 Session 的 Account Config 用外部 Store,不要从“上一个 Session 的最后状态”隐式继承。

还要注意 v0.82.0 的实现边界:AgentSession.reload() 会重建 Runner,却没有像完整 Session Replacement 那样统一使旧 Runner/Context 失效。Extension 仍应把旧 pi/ctx 视为过期; 桌面宿主可增加 Reload Generation、主动 Cleanup 和“旧 Context 不得产生副作用”的回归测试。

12. 多会话并发

桌面应用可能同时维护多个 AgentSession。每个 Session 应拥有:

  • 独立 Extension Runtime;
  • 独立 Closure State;
  • 独立 Abort/Queue;
  • 独立 Approval Request;
  • 独立 Session Store。

风险来自 Module-level Singleton:

const mutableState = new Map(); // 所有 Factory Instance 共享

如果必须共享 Connection Pool,Key 至少包含 Tenant/Workspace,并确保并发、权限与 Shutdown 引用计数正确。

13. 性能预算

每个 Hook 都在关键路径:

Event 性能风险
input/before_agent_start 增加首 Token 延迟
context 每个 LLM Turn 都执行
message_update 每个 Stream Update 都执行
tool_call 阻塞 Tool 启动
tool_result 阻塞模型看到结果
agent_settled 可影响“完成”后操作

为 Handler 记录:

  • Count;
  • P50/P95/P99;
  • Timeout/Error;
  • Payload Size;
  • External Dependency。

不要在每个 Token Update 序列化整个 Session。

14. 安全边界

Extension 是本地代码,拥有完整进程权限。可靠性与安全共同要求:

  • 来源 Pin;
  • Code Review;
  • Dependency Review;
  • Least Tool/Network/File Permission;
  • Final Host Policy;
  • Secret Redaction;
  • Audit Integrity;
  • Safe Update/Rollback。

ExtensionAPI 的类型边界和 Project Trust 都不是 OS Sandbox。

15. 测试金字塔

flowchart TD
    U["Unit<br/>pure policy/schema"] --> H["Harness<br/>events/tools"]
    H --> S["Session lifecycle<br/>reload/switch"]
    S --> E["End-to-end<br/>desktop bridge"]
    E --> V["Version compatibility"]

Unit

测试 Pattern、SQL Classifier、Argument Canonicalization。

Harness

用 Inline Factory + Faux Provider,断言 Event Order、Block、Result Patch、Error Listener。

Lifecycle

测试 Reload、Shutdown Cleanup、State Reconstruction、Old Context。

Desktop

测试 Approval Dialog、Abort、Crash Recovery、Multi-window Routing。

16. Faux Provider 测试 Extension

const extension: InlineExtension = (pi) => {
  pi.on("tool_call", (event) => {
    if (event.toolName === "danger") {
      return { block: true, reason: "denied" };
    }
  });
};

const harness = await createHarness({
  extensionFactories: [extension],
  tools: [dangerTool],
});

让 Faux 返回 danger Tool Call,断言:

  • Tool Execute Count 为 0;
  • Execution End isError=true
  • ToolResult Message 含 Reason;
  • Extension Error 为空。

这是 Pi 仓库内部 Test Harness 的形状;应用可以建立等价公开测试夹具。

17. 测试 Handler Error

至少覆盖:

  1. 普通 Handler Throw 后,后续 Handler 仍执行;
  2. tool_call Handler Throw 后,Tool 不执行;
  3. tool_result Handler Throw 后,原/前一 Patch 保留且链继续;
  4. Error Listener 收到 Path、Event、Message;
  5. Error Listener 自身不能破坏 Agent 主流程。

最后一点要由宿主 Binding 测试,因为 Runner emitError() 会直接调用 Listener。

18. 版本兼容

Extension Package 应声明:

  • 自身 SemVer;
  • 支持的 Pi Version Range;
  • Node/Bun Runtime 要求;
  • Migration/Breaking Change;
  • Provider/Tool Result Schema Version;
  • Session Custom Entry Schema Version。

不要只靠 TypeScript Compile:Event Order、Reload 行为和 Provider Payload 都可能在版本间改变。

这里的“支持 Pi Version Range”是扩展文档与 CI Compatibility Matrix,不是当前 Loader 会读取 并校验的 Manifest 字段。官方 Package 示例里 Pi Core Package 通常放在 peerDependencies,其范围为 "*";若要严格限制版本,应由发布流程、安装器或宿主门禁执行。 为支持矩阵运行 Contract Tests。

19. Pi Package 分发

{
  "name": "@team/pi-policy",
  "version": "2.1.0",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions/policy.ts"]
  }
}

Runtime Dependency 放 dependencies;Pi Core Package 按官方规则放 peerDependencies。npm/Git 来源固定版本或 Ref。

Package 有完整系统访问,安装前审查 Extension、Dependency、Install Behavior 与附带 Skill。

20. 发布门禁

Source Pin
→ Static Review
→ Unit/Harness/Lifecycle Tests
→ Multi-session/Abort Tests
→ Permission Diff
→ Package Smoke Test
→ Signed Artifact/Hash
→ Canary
→ Rollback Ready

Pi 提供 Package Mechanism,但签名、Canary、Permission Diff 和 Rollback UI 是桌面产品治理。

21. 可观测性

记录:

extension source/version
event type
handler duration
toolCallId/sessionId
decision/result class
error/timeout/abort
reload generation

不要记录:

  • Raw Secret;
  • 完整 Prompt(默认);
  • 未脱敏 Tool Content;
  • OAuth Credential;
  • 用户拒绝原因中的敏感文本。

22. 故障策略表

场景 推荐默认
可选装饰 Extension 加载失败 降级 + 可见诊断
安全 Policy Extension 加载失败 阻止 Agent 启动/高风险 Tool
Approval Timeout 拒绝
Audit Sink 暂时失败 本地缓冲或阻止高风险动作
Dynamic Model Catalog 失败 使用最后可信 Catalog/显示不可用
Resource Discovery 失败 保留其他 Extension,显示具体 Path
Cleanup 失败 记录并继续其余 Cleanup,必要时终止 Worker

这些是宿主策略建议;Pi 默认 Loader/Runner 的行为在前文已单独说明。

23. 常见误区

“Loader 会因为一个 Extension 失败而停止”

默认不会。安全关键场景由宿主提升为 Startup Failure。

“所有 Handler 都有自动 Timeout”

错误。Extension 要自己实现。

“Reload 会自动清理我的 Watcher”

错误。只会发 Lifecycle Event;资源由 Extension 关闭。

“Module-level State 天然按 Session 隔离”

错误。Module Singleton 可能被多个 Instance 共享。

“TypeScript 通过就代表版本兼容”

错误。还要 Contract 与 Lifecycle Test。

24. 本章小结

  • Loader 默认收集 Extension 初始化错误并继续,关键 Policy 的 Fail Startup 由宿主决定;
  • Async Factory/Handler 没有自动 Timeout;
  • tool_call Error Fail Closed,普通 Handler 多数隔离继续;
  • Abort 不回滚副作用,Tool 要支持 Transaction/Idempotency;
  • 冲突、重复注册和 EventBus Channel 都要显式治理;
  • Shutdown 必须幂等清理所有长期资源;
  • Session State、Global State 与 Module Singleton 不可混用;
  • 高频 Hook 需要性能预算;
  • Faux Harness 应覆盖 Block、Patch、Error、Abort 和 Lifecycle;
  • Version Compatibility 需要 Contract Test,不只 Type Check;
  • Package 分发必须 Pin 来源、审查依赖并准备回退。

25. 自测

  1. Factory 失败时 Pi 默认是否停止加载全部 Extension?
  2. 哪类 Handler Error 会让 Tool Fail Closed?
  3. Approval Handler 为什么必须有 Timeout?
  4. Abort 为什么不能替代 Transaction?
  5. 为什么不要在事件 Handler 中动态重复 pi.on?
  6. 多 Session 怎样避免 Module Singleton 泄漏?
  7. message_update Handler 有什么性能风险?
  8. Extension Harness 至少要测哪些失败路径?
  9. TypeScript Compile 为什么不足以证明版本兼容?
  10. 安全 Policy Extension 加载失败时宿主应怎样处理?