返回课程首页

28

高级扩展:Provider、资源与运行环境

扩展模型、认证、Skills、资源、沙箱和外部服务。

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

到目前为止,Extension 都在改变已有 Agent。现在把边界再向外推:

  • 接入公司模型网关;
  • 动态发现模型;
  • 动态提供 Skill;
  • 把 Bash 放进容器或远程主机;
  • 让多个 Extension 协作;
  • 在 Session Branch 中恢复状态。

这些能力都很强,也更接近系统权限和供应链边界。

1. 高级扩展的五条通路

flowchart TD
    E["Extension"] --> P["Provider / Auth / Models"]
    E --> R["Skill / Prompt / Theme Paths"]
    E --> X["Tool Operations / Bash Spawn"]
    E --> B["Inter-extension Event Bus"]
    E --> S["Session / External State"]

Pi 没有把它们包装成一个“插件万能对象”;每条通路有独立 Contract。

2. 两种 Provider 注册形式

完整 Provider Object

pi.registerProvider(
  createProvider({
    id: "company-ai",
    name: "Company AI",
    baseUrl: "https://ai.internal/v1",
    auth: { /* canonical auth */ },
    models: [],
    api: openAICompletionsApi(),
  }),
);

它可提供 Native Auth、Model Discovery/Refresh/Filter、Stream/StreamSimple 等完整行为。

Name + ProviderConfig

pi.registerProvider("company-ai", {
  baseUrl: "https://ai.internal/v1",
  apiKey: "$COMPANY_AI_KEY",
  api: "openai-completions",
  models: [/* definitions */],
});

这是兼容配置形式,适合 Proxy、固定 Catalog 和 Legacy OAuth。

3. 注册发生在什么时候

Factory 阶段:

registerProvider
→ Pending Queue
→ Runner.bindCore
→ Model Registry

绑定后从 Command/Event 再注册或注销,会立即作用于当前 Registry,不需要 Reload。

unregisterProvider(name) 按 Provider ID 从共享 Registry 删除 Provider/Models,并恢复被 覆盖的 Built-in Models(若有)。这里没有“只能注销本扩展所注册 Provider”的所有权检查; 多个 Extension 共用 Registry,因此 Provider ID 必须命名空间化,宿主也应限制谁能注销谁。

4. 三种动态模型刷新契约

这些 API 名字相近,但返回值和持久化责任不同。

Legacy ProviderConfig

ProviderConfig.refreshModels() 返回 ProviderModelConfig[]

let cachedModels: ProviderModelConfig[] = [];

async refreshModels({ signal, allowNetwork }) {
  if (!allowNetwork) return cachedModels;

  const response = await fetch(
    "http://localhost:8080/v1/models",
    { signal },
  );
  const payload = await response.json();
  cachedModels = payload.data.map(toModelConfig);
  return cachedModels;
}

刷新返回的列表替换该 Extension 提供的 Models。allowNetwork=false 表示只允许离线/缓存 刷新;Provider 注册时也会主动触发一次这种刷新,不能无条件请求网络。Context 还提供 allowNetwork、Credential、Store 与 Signal;它没有额外的 Network Client。

完整 Provider Object

完整 Provider.refreshModels() 返回 Promise<void>,刷新后通过同步 getModels() 发布列表:

let models: Model<Api>[] = [];

const provider: Provider = {
  // ...
  getModels: () => models,
  async refreshModels(context) {
    if (!context.allowNetwork) return;
    models = await fetchFullModels(context.signal);
  },
};

createProvider()

createProvider({ fetchModels }) 接收抓取模型的 Callback,并替调用方处理 ModelsStore 的读取与 写入。不要把它误写成 Legacy refreshModels()

因此“谁持久化 Catalog”取决于所选契约:Legacy/手写 Provider 由扩展设计,createProvider 则已有 Store 组合逻辑。实时本地 Server 可以在线查询,静态企业 Catalog 可以使用 TTL 缓存; 两者都必须尊重 allowNetwork

5. Model Definition 不能省略什么

ProviderConfig.models 使用较轻的 ProviderModelConfig[],至少要正确声明:

  • ID 与 Name;
  • Reasoning;
  • Input Types;
  • Cost;
  • Context Window;
  • Max Tokens。

Provider 顶层可给这类兼容配置提供 API/Base URL 等默认信息。

createProvider({ models }) 接收的是完整 Model[]。每个 Model 除了上述字段,还必须显式 包含:

  • provider
  • api
  • baseUrl

createProvider 顶层的 baseUrl 不会自动把这些字段补进每个完整 Model。两种结构不能混用。

错误的 Context Window 会让 Compaction Threshold 失真;错误 Cost 会污染 Usage;错误 Reasoning Capability 会影响 Thinking Level Clamp。

6. 自定义认证

完整 Provider 有两种认证契约:

  • ApiKeyAuth:可选 login、可选 check,必需 resolve
  • OAuthAuthloginrefreshtoAuth

兼容 Config 还支持 Legacy OAuth:

login callbacks
→ credentials
→ refreshToken
→ getApiKey

桌面 Agent 不应把 Access Token 通过 Custom Message 或 Tool Result 传给模型。Credential 交给 Auth Storage/Provider Runtime,UI 只处理授权状态。

apiKey Config 支持 Environment Reference,甚至 Leading Command 解析。第三方 Provider Config 因而也是执行与 Secret 风险,必须审查来源。

7. 动态发现 Skill

pi.on("resources_discover", async (event) => ({
  skillPaths: [
    resolve(event.cwd, ".generated/skills"),
  ],
}));

Runner 累积所有 Extension 返回的 Paths,并记录 Extension Path。AgentSession:

  1. 转成 Temporary Top-level Resource Metadata;
  2. 调用 ResourceLoader.extendResources()
  3. 重新加载 Skill/Prompt/Theme;
  4. 重建 Base System Prompt。

Event 发生在 Session Start 后,Reason 是 Startup 或 Reload。

8. Dynamic Resource 的边界

resources_discover 当前只能贡献:

  • Skill Paths;
  • Prompt Paths;
  • Theme Paths。

它不能通过任意 customResources 字段发明新的 Core Resource Type,也不用于再次添加 Extension Path。

若应用需要自己的索引、知识库或配置文件,应由 Extension 自己管理,再通过 Tool、 before_agent_start 或 Context 暴露给 Agent。

9. 相对路径与来源

Extension 返回的 Resource Path 最终交给相应 Loader。为了避免 CWD/Reload 差异,动态资源 应优先返回 Absolute Path。

AgentSession 为它生成:

source: extension:<name>
scope: temporary
origin: top-level
baseDir: extension directory(文件 Extension)

Inline Extension 没有普通文件 BaseDir。复杂相对引用需要 Extension 自己解析。

10. 自定义 Tool Operations

Built-in Tool Factory 支持可替换 Operations:

const remoteRead = createReadTool(cwd, {
  operations: {
    readFile: remoteReadFile,
    access: remoteAccess,
  },
});

Read/Write/Edit/Bash/Ls/Grep/Find 各有对应 Operations Interface。Extension 可把执行路由到:

  • SSH;
  • Container;
  • Micro-VM;
  • Remote Workspace;
  • Broker Service。

注册时仍要保证 Result Shape 与内置 Contract 匹配。

11. Bash Spawn Hook

const bash = createBashTool(cwd, {
  spawnHook: ({ command, cwd, env }) => ({
    command: wrapInSandbox(command),
    cwd: mapWorkspace(cwd),
    env: { ...env, CI: "1" },
  }),
});

Spawn Hook 可以改 Command、CWD、Environment。它不是沙箱本身;wrapInSandbox() 必须连接 真实 OS Sandbox/Container/Remote Executor。

若返回本地 Shell Command 但没有权限隔离,改个目录名称不会形成安全边界。

12. Session Environment

createBashTool() 默认向命令暴露:

PI_SESSION_ID
PI_SESSION_FILE
PI_PROVIDER
PI_MODEL
PI_REASONING_LEVEL

注入发生在 Spawn Hook 前,所以 Hook 收到这些 Environment。PI_SESSION_FILE 仅在持久化 Session 有文件路径时存在,临时 Session 不会有它。若不需要:

createBashTool(cwd, {
  exposeSessionEnvironment: false,
});

这能减少泄漏面,但不能清除进程继承的其他 Environment;Sandbox 还应建立 Environment Allowlist。

这些变量只注入“注册到 Pi、可被模型调用”的 Bash Tool。用户直接输入的 ! / !! 走另一条 路径,不会自动获得这组 PI_* 变量。

13. User Bash 与模型 Bash 不同

user_bash Event 处理用户直接输入的 ! / !! Command,可:

  • 提供 Custom BashOperations;
  • 包装 Local Operations;
  • 直接返回完整 BashResult。

模型调用 Bash Tool 则走 tool_call、Execute、tool_result。要统一远程执行环境,两个入口 都要覆盖,不能只替换其中一个。

14. External Service Integration

Extension 可直接使用 Node/Fetch 访问外部服务,但应设计:

  • AbortSignal;
  • Timeout;
  • Retry Budget;
  • Idempotency Key;
  • Credential Scope;
  • Response Size Limit;
  • Circuit Breaker;
  • Audit/Redaction。

外部服务响应属于不可信输入。即使来自“内部 API”,也不要未经边界标记就把其中的自然语言 当成高优先级指令。

15. Extension Event Bus

const unsubscribe = pi.events.on(
  "approval:changed",
  (data) => {
    // validate data
  },
);

pi.events.emit("approval:changed", {
  version: 1,
  mode: "readonly",
});

源码是 Node EventEmitter 的薄封装:

  • Channel 是 String;
  • Data 是 Unknown;
  • Emit 是同步触发 Listener;
  • Wrapper 支持 Async Handler,但 emit() 不 Await;
  • Handler Error 被 Catch 后写 Console;
  • on() 返回 Unsubscribe;
  • 内部/宿主 EventBusControllerclear() 所有 Listener。

因此不要把 Event Bus 当成可靠 Message Queue:没有持久化、Ack、Replay 或 Backpressure。 Extension 得到的 pi.events 类型只有 on()emit(),不能调用 clear()

16. Extension 间通信协议

共享 Bus 意味着 Channel Collision 也可能发生。建议:

<publisher>/<domain>/<event>@v<version>

Payload 进入时 Runtime Validate。不要只用 TypeScript Interface,因为另一个 Extension 或旧 版本在运行时仍可能发送任意对象。

对强安全决策,Bus 只能传递状态通知,最终 Policy 应查询权威 Store 或 Host Binding。

17. Extension 自身状态

三种生命周期:

状态 保存位置 生命周期
临时 Cache Factory Closure 当前 Extension Instance
Branch-aware Tool State Tool Result Details 当前 Session Branch
非模型 Branch State Custom Entry 当前 Session Branch
跨 Session Account/Config External Store 由宿主管理

Tool Result Details 很适合 Branch-aware State:session_start 遍历当前 Branch,找到本 Tool 的 Result Details,重建最新状态。

Custom Entry 同样有 parentId,属于 Session Tree,也会出现在当前 getBranch() 中。它适合 保存不进入模型上下文、但仍需随 Branch 切换的状态;它不是跨所有 Branch 的 Session Global。

18. 为什么 Tool Details 适合 Branch

假设 Todo Tool 每次 Result 保存完整 Items:

Branch A: result items=[A]
Branch B: result items=[A,B]

切换 Branch 后从 ctx.sessionManager.getBranch() Replay,就能得到该 Branch 自己的状态。

如果只写一个全局 JSON 文件,Branch Navigation 不会自动回滚它。是否需要 Branch Semantics 决定存储选择。

19. Reload 与清理

高级 Extension 往往有:

  • Socket;
  • File Watcher;
  • Remote Client;
  • Token Refresh Timer;
  • Event Bus Subscription。

session_shutdown

unsubscribe?.();
watcher?.close();
controller?.abort();

清理函数要幂等。Reload 会重新执行 Factory 和 Session Start;漏清理会产生重复 Listener、 重复网络连接和重复 Side Effect。

20. 沙箱分层

flowchart TD
    P["Prompt / Skill"] --> G["Extension Gate"]
    G --> O["Tool Operations"]
    O --> S["OS/Container Sandbox"]
    S --> E["External Service"]
  • Prompt:软规则;
  • Gate:受控 Chain 中的 Policy;
  • Operations:把执行路由到隔离后端;
  • OS/Container:真正限制 Filesystem/Network/Process;
  • External Service:还需要服务端 Authorization。

Pi 提供接点,不自动创建完整 Sandbox。

21. 一个企业桌面 Agent 组合

flowchart LR
    UI["Desktop UI"] --> SDK["AgentSession"]
    SDK --> PR["Company Provider"]
    SDK --> DR["Dynamic Skills"]
    SDK --> TO["Remote Tool Operations"]
    TO --> VM["Sandbox Worker"]
    SDK --> EB["Extension Bus"]
    EB --> AU["Audit Extension"]
    EB --> PO["Policy Extension"]

每块可独立测试,但 Provider、Resource、Tool 与 Bus 的 Version Contract 必须一起治理。

22. 常见误区

“registerProvider 后一定要 Reload”

不对。Factory 阶段排队;Bind 后调用立即生效。

“resources_discover 能发明任意资源类型”

错误。当前只支持 Skill、Prompt、Theme Path。

“Spawn Hook 就是 Sandbox”

错误。它只是改写执行入口。

“Event Bus 会等待 Async Listener”

错误。Emit 不 Await,也没有 Delivery Guarantee。

“Custom Entry 是整个 Session 共用的一份全局状态”

错误。它属于带 Parent 的 Session Tree Entry,读取当前 Branch 时才出现;跨 Session 用 External Store。

23. 本章小结

  • Extension 可注册完整 Provider 或兼容 ProviderConfig;
  • Factory Provider Registration 排队,Bind 后注册立即生效;
  • Legacy Config、完整 Provider 与 createProvider 有三种不同刷新契约;
  • 动态刷新必须遵守 allowNetwork,Store 责任取决于所选契约;
  • resources_discover 贡献 Skill/Prompt/Theme Path,并触发 Loader 与 Prompt 重建;
  • 自定义业务资源应由 Extension 自己管理,通过 Tool/Context 暴露;
  • Built-in Tool Operations 可路由到 Remote/Container Backend;
  • Spawn Hook 可改 Command/CWD/Env,但不是 Sandbox;
  • User Bash 与模型 Bash 是不同入口;
  • Event Bus 是进程内、无持久化、Emit 不 Await 的通信层;
  • Extension State 要按 Instance、Branch、Session、Global 生命周期选存储;
  • 高级资源必须在 Shutdown 幂等清理。

24. 自测

  1. 完整 Provider Object 与 ProviderConfig 有什么区别?
  2. Factory 阶段 Provider Registration 为什么排队?
  3. 三种动态模型刷新契约的返回值与 Store 责任有什么不同?
  4. resources_discover 当前支持哪三类资源?
  5. Spawn Hook 为什么不等于 Sandbox?
  6. 如何同时接管用户 ! Command 与模型 Bash Tool?
  7. Event Bus 的 Async Handler 会被 emit Await 吗?
  8. Branch-aware State 为什么适合放 Tool Result Details?
  9. 跨 Session 配置应放在哪里?
  10. Reload 时哪些长期资源需要清理?