返回课程首页

31

使用 SDK 嵌入桌面应用

使用 AgentSession、Services 和 Runtime 组织应用。

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

上一章选择了 Coding Agent SDK。本章把它放进桌面进程:

Desktop Window
→ Application Controller
→ AgentSessionRuntime
→ AgentSession
→ Pi Agent/Model/Tools

目标是不依赖 Pi TUI,同时保留 Skills、Extensions、Session 与 Compaction。

1. 最小同进程嵌入

import {
  createAgentSession,
  ModelRuntime,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
  cwd: workspacePath,
  modelRuntime,
  sessionManager: SessionManager.inMemory(workspacePath),
});

const unsubscribe = session.subscribe(handleEvent);
await session.prompt("分析当前项目");

这个 Factory 创建单个 AgentSession。不传 Resource Loader 时使用 DefaultResourceLoader 和标准发现。

2. 桌面架构不要让组件直接持有 Session

flowchart TD
    V["View Components"] --> ST["Desktop Store"]
    ST --> CT["Agent Controller"]
    CT --> RT["AgentSessionRuntime"]
    RT --> S["current AgentSession"]
    S --> PI["Pi Runtime"]
    S --> EV["AgentSessionEvent"]
    EV --> CT

Controller 负责:

  • Prompt/Abort/Queue;
  • Event Subscription;
  • Runtime Replacement;
  • Extension Binding;
  • Error Mapping;
  • Dispose。

View 只消费 State 和发 Intent,避免 Session Replacement 后仍引用旧对象。

3. AgentSession 管什么

  • Message History;
  • Current Model/Thinking;
  • Prompt、Steer、Follow-up;
  • Active Tool;
  • Agent Event;
  • Compaction;
  • In-place Tree Navigation;
  • Extension Runner;
  • Session Persistence。

New/Resume/Fork/Import 属于 AgentSessionRuntime,不是单个 AgentSession。

4. 创建可替换 Runtime

const createRuntime: CreateAgentSessionRuntimeFactory = async ({
  cwd,
  agentDir,
  sessionManager,
  sessionStartEvent,
  projectTrustContext,
}) => {
  const services = await createAgentSessionServices({
    cwd,
    agentDir,
    resourceLoaderReloadOptions: {
      resolveProjectTrust: ({ extensionsResult }) =>
        resolveDesktopProjectTrust({
          cwd,
          extensionsResult,
          projectTrustContext,
        }),
    },
  });
  return {
    ...(await createAgentSessionFromServices({
      services,
      sessionManager,
      sessionStartEvent,
    })),
    services,
    diagnostics: services.diagnostics,
  };
};

const runtime = await createAgentSessionRuntime(
  createRuntime,
  {
    cwd,
    agentDir,
    sessionManager: SessionManager.create(cwd),
  },
);

Factory 把 Process-wide Input 捕获在 Closure 中,并按 Effective CWD 重建 CWD-bound Services。 类型注解保证参数不退化成 Implicit anyagentDir 必须继续传入 Services。 resolveDesktopProjectTrust() 是宿主实现的已类型化 Helper,它用本次 Replacement 传入的 projectTrustContext 连接桌面信任 UI/Policy。

5. AgentSessionServices

Services 聚合创建 Session 所需的共享/工作区依赖,例如:

  • CWD/AgentDir;
  • Settings;
  • Auth/Model Runtime;
  • Resource Loader;
  • Diagnostics;
  • Package/Trust 相关服务。

它们不是全都 Process-global。切换到另一个 Session CWD 时,应重建与 CWD 绑定的 Services, 而不是只改一个 String。

6. AgentSessionRuntime 的职责

Runtime 持有:

current session
current services
current cwd
diagnostics
runtime factory

并实现:

  • newSession()
  • switchSession()
  • fork() / Clone;
  • importFromJsonl()

这些操作会 Shutdown/Dispose 旧 Session,再创建并应用新 Runtime。

7. Replacement 后必须重新绑定

let unsubscribe = runtime.session.subscribe(handleEvent);

runtime.setBeforeSessionInvalidate(() => {
  unsubscribe();
  detachOldDesktopBridgeSynchronously();
});

runtime.setRebindSession(async (nextSession) => {
  unsubscribe = nextSession.subscribe(handleEvent);
  try {
    await bindDesktopExtensions(nextSession);
  } catch (error) {
    unsubscribe();
    throw error;
  }
});

原因:

  • runtime.session 指向新对象;
  • Subscription 绑定旧 AgentSession;
  • Extension UI/Command Binding 也属于旧 Session;
  • 捕获的旧 Context 会失效。

setBeforeSessionInvalidate() 在旧 Extension Context 失效前同步拆除宿主 UI/Bridge; setRebindSession() 在新 Session 创建后负责订阅和绑定。先订阅、后 bindExtensions(), 才能捕获 Extension 处理 session_startresources_discover 期间通过 pi.sendMessage() 等方式产生的公开 Session Event。这两个生命周期事件本身只发给 Extension Runner,不属于 AgentSessionEvent。若采用相反顺序,就要在绑定后从 Session Snapshot 显式补水。不要只 更新 Store 中的 Session ID。

8. Event Adapter

function handleEvent(event: AgentSessionEvent) {
  switch (event.type) {
    case "message_update":
      store.applyAssistantDelta(event);
      break;
    case "tool_execution_start":
      store.startTool(event);
      break;
    case "tool_execution_end":
      store.finishTool(event);
      break;
    case "agent_settled":
      store.setIdle();
      break;
  }
}

Pi 没有为所有 AgentMessage 提供统一、稳定的 Message ID;Assistant responseId 也不是 每个 Provider 都保证存在。Reducer 应为每条会话消息生成自己的 UI Key,并只在 Tool 状态中使用稳定的 toolCallId。Replacement 时先完成旧 Subscription Teardown,再连接新 Session。

9. Streaming 与 UI

prompt() 等待整个 Run,包括 Tool Loop。界面更新来自 Event:

prompt intent
→ optimistic user state
→ message_start/update/end
→ tool events
→ agent_settled

不要等待 prompt() Resolve 后才展示文字,否则失去 Streaming。

10. Steering 与 Follow-up

Streaming 中:

  • steer():尽快把用户消息送入正在运行的 Agent;
  • followUp():排队到当前 Run 后续;
  • prompt(..., { streamingBehavior }):统一入口。

桌面 UI 要显示 Queue State。直接重复调用 Idle-only 操作会得到 Busy/Invalid State。

11. 依赖注入

可注入:

  • Model Runtime/Auth;
  • SessionManager;
  • ResourceLoader;
  • Custom Tools;
  • Built-in Tool Set;
  • Settings;
  • Inline Extensions;
  • Allow/Exclude Tool Names。

测试中注入 In-memory Session、Faux Provider 和 Test Resource Loader;生产中注入 Persistent Store 与真实 Permission Backend。

12. ModelRuntime 生命周期

Model/Auth 配置可以在多个 Session 之间共享,但要明确:

  • Registry Update 是否广播;
  • OAuth Refresh 并发;
  • Credential Storage;
  • Provider Extension Reload;
  • Session Model Snapshot。

共享 Runtime 不代表多个 Session 共享 Agent State。

13. 多窗口策略

每窗口一个 Runtime

简单隔离:

Window A → Runtime A → Session A
Window B → Runtime B → Session B

优点是 Event、Queue、Approval 清晰;代价是资源与 Provider Client 可能重复。

应用级 Runtime Registry

windowId → runtimeId → AgentSessionRuntime

适合窗口切换同一 Session,但 Controller 必须做 Ownership、Reference Count 和 Subscription Routing。

一个 Runtime 同时只能有一个 Current Session。

14. 多会话并发

每个 AgentSession 本身各自拥有:

  • Agent Run;
  • Abort Controller;
  • Tool Batch;
  • SessionManager;
  • Event Stream。

Extension Instance/Runtime State 是否独立则取决于 ResourceLoader/Services:若要求扩展 隔离,就不能复用同一个 ResourceLoader/Extension Runtime。可以有意共享 ModelRuntime 与 认证设施。共享 Tool Backend 必须支持 Concurrency。Filesystem Mutation 应使用 Pi 的 File Mutation Queue 或宿主 Lock;Database/API 用 Transaction/Idempotency。

15. Project Trust

Default Resource Loader 可能遇到项目动态配置。简单调用 createAgentSession() 不会自动弹出 桌面信任对话,也不会替宿主解析 Trust。桌面应用要提供明确 Trust Flow:

discover pre-trust extensions
→ project_trust decision
→ load project resources

SDK 宿主应在 createAgentSessionServices()resourceLoaderReloadOptions.resolveProjectTrust 中连接决策,或自行构建已带 Resolver Reload 的 DefaultResourceLoader 再传给 createAgentSession()。使用 AgentSessionRuntime Replacement 时,还要把本次 projectTrustContext 交给 Resolver。 无交互环境默认不能凭空获得桌面 Dialog;宿主要保存 Decision 的 Scope。

Trust Project 不等于 Trust 每个 Package,Marketplace/Package 仍需审查。

16. Extension UI Binding

桌面应用可实现 ExtensionUIContext

  • Select;
  • Confirm;
  • Input;
  • Notify;
  • Status。

不支持的终端专属能力应明确 No-op/Unsupported。Approval Extension 在调用 Confirm 前检查 ctx.hasUI;无 UI 时默认拒绝。

17. 生命周期管理

创建:

services → session/runtime → subscribe → bind extensions

销毁:

abort active work
→ unsubscribe
→ await runtime.dispose()
  (内部:session_shutdown → beforeSessionInvalidate → session.dispose)
→ release host resources

AgentSessionRuntime.dispose() 会先发 session_shutdown,再让 Session 失效; AgentSession.dispose() 自身不会发这个扩展生命周期事件。拥有 Runtime 的宿主必须调用前者, 否则会跳过 Extension Shutdown Handler。宿主仍要清理自己的 Event Listener、Window Bridge 与 Shared Resource Reference。

18. Error Adapter

将错误分层:

UI 状态
Provider/Auth 需要登录/重试
Tool Tool Card Error
Extension Load/Handler Diagnostics/Policy Failure
Session File 恢复/只读打开
Runtime Replacement 保留旧 UI 快照,提示失败
App Bridge Worker/Controller Error

不要把所有 Error 都显示成“模型失败”。

19. Diagnostics

Controller 应聚合多个诊断来源,而不是只读取 runtime.diagnostics

  • resourceLoader.getExtensions().errors
  • getSkills().diagnostics
  • getPrompts().diagnostics
  • getThemes().diagnostics(即使桌面不使用 Pi TUI,也可能需要展示加载问题);
  • runtime.diagnostics
  • runtime.modelFallbackMessage
  • Session Recovery/Replacement 的异常路径。

安全关键 Policy 缺失时,Controller 可以禁用 Prompt/高风险 Tool。

20. 一个 Controller 轮廓

class DesktopAgentController {
  private runtime: AgentSessionRuntime;
  private unsubscribe?: () => void;

  async start() {
    await this.bind(this.runtime.session);
    this.runtime.setBeforeSessionInvalidate(
      () => this.detachBridgeSynchronously(),
    );
    this.runtime.setRebindSession(
      async (session) => this.bind(session),
    );
  }

  private async bind(session: AgentSession) {
    this.unsubscribe?.();
    this.unsubscribe = session.subscribe(
      (event) => this.store.reduce(event),
    );
    try {
      await session.bindExtensions(this.extensionBindings);
    } catch (error) {
      this.unsubscribe();
      this.unsubscribe = undefined;
      throw error;
    }
  }

  async dispose() {
    this.unsubscribe?.();
    await this.runtime.dispose();
  }
}

这是宿主结构示例,不是 Pi 导出的 Class。

21. 常见误区

“runtime.session 永远是同一个对象”

错误。Replacement 后会变化。

“订阅 Runtime 一次就覆盖所有 Session”

错误。Subscription 属于具体 AgentSession。

“修改 cwd 字段就完成工作区切换”

错误。CWD-bound Services/Resources 要重建。

“prompt Resolve 后再更新 UI”

会丢掉 Streaming 体验;应消费 Event。

“多窗口可以共享一个 Current Session 而无路由”

会造成 Event/Approval 混线。

22. 本章小结

  • createAgentSession 适合单 Session,AgentSessionRuntime 负责 Replacement;
  • Desktop View 应通过 Store/Controller 间接访问 Pi;
  • Services 中有 CWD-bound 依赖,切工作区要重建;
  • Runtime Session Replacement 后必须重订阅、重绑 Extension;
  • Streaming UI 来自 Event,而不是等待 prompt 完成;
  • 多窗口优先每窗口 Runtime,或建立严格 Registry/Ownership;
  • Shared Backend 需要并发与幂等;
  • Project Trust、Extension UI、Diagnostics 都要由桌面宿主适配;
  • 由 Runtime Owner 调用 runtime.dispose() 才会先发 Extension Shutdown;宿主资源仍需自行清理。

23. 自测

  1. createAgentSession 与 AgentSessionRuntime 分别适合什么?
  2. 为什么 Session Replacement 后要重新订阅?
  3. 哪些 Service 与 CWD 绑定?
  4. Desktop View 为什么不应直接持有 AgentSession?
  5. prompt Promise 与 Streaming Event 各负责什么?
  6. 多窗口共享 Runtime 的主要风险是什么?
  7. 无 UI 时 Approval 应怎样处理?
  8. dispose 后宿主还要清理哪些资源?