返回课程首页

34

组装完整桌面 Agent

把对话、工具、记忆、Skills 和 Extensions 接到一起。

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

前 33 章分别解决了模型、工具、会话、Skills、Extensions 与桌面状态。本章不再引入新的 大概念,而是把它们装进同一个应用。

目标不是复制 Pi 的终端界面,而是建立一个自己的桌面产品:

用户输入任务,Agent 能选 Skill、调用受控工具、持续对话、保存会话,并把每一步可靠地 映射到桌面 UI。

1. 学习目标

完成本章后,你应能够:

  1. 画出桌面 UI 到 Provider、Tool 和 Session 文件的完整边界;
  2. 使用 AgentSession 组织对话、流、工具、会话与压缩;
  3. 区分 Skill、Extension、宿主 Policy 与 OS Sandbox 的职责;
  4. 把一次高风险工具调用接入审批、审计和取消;
  5. 解释 SDK 同进程方案与 RPC Sidecar 方案的替换位置。

2. 最终产品是什么样

我们用一个具体任务贯穿本章:

“读取项目中的错误日志,使用故障排查 Skill 找出原因,修改配置文件,但写入前必须让我确认。”

这个看似简单的请求会串起:

  • User Message;
  • Skill Discovery 与 Skill 内容加载;
  • Model Streaming 与 Thinking;
  • Read Tool;
  • Extension 的 Tool Gate;
  • Desktop Approval;
  • Write Tool;
  • Tool Result;
  • 下一轮模型判断;
  • Session JSONL;
  • UI State Reconcile。

3. 六层架构

flowchart LR
    UI["Desktop View"] --> ST["Serializable Store"]
    ST --> CT["Application Controller"]
    CT --> RT["Agent Runtime Adapter"]
    RT --> AS["Pi AgentSession"]
    AS --> MD["Model Runtime / Provider"]
    AS --> RS["Resources<br/>Skills + Extensions"]
    AS --> TL["Tools"]
    TL --> SG["Sandbox / Broker"]
    AS --> SM["SessionManager / JSONL"]

每层只做一类工作:

负责 不负责
View 渲染、收集用户意图 直接持有 AgentSession
Store 可序列化 UI 状态 执行网络或工具
Controller Prompt、Abort、审批、切换会话 拼接 Provider Payload
Runtime Adapter SDK/RPC 差异、订阅与 Generation 业务 UI 组件
AgentSession Agent Loop、资源、队列、压缩、持久化 OS 权限隔离
Sandbox/Broker 最终文件、进程、网络权限 决定对话如何展示

4. 先选 SDK 还是 RPC

如果桌面主进程本身是 Node(例如 Electron Main),可以直接使用 Coding Agent SDK。若主进程 是 Swift、Rust 或希望 Agent 崩溃时不带崩 UI,就把同一 Runtime 放进 Node Sidecar,通过 RPC 连接。

SDK:
View → Controller → AgentSession

RPC:
View → Controller → JSONL Client → Node Sidecar → AgentSession

后续设计都依赖一个 AgentRuntimePort,而不是依赖具体传输:

interface AgentRuntimePort {
  submitPrompt(text: string): PromptLifecycle;
  abort(): Promise<void>;
  switchModel(provider: string, modelId: string): Promise<void>;
  openSession(sessionId: string): Promise<void>;
  resolveApproval(requestId: string, decision: "approve" | "reject"): void;
  subscribe(listener: (event: DesktopAgentEvent) => void): () => void;
}

interface PromptLifecycle {
  accepted: Promise<void>;
  completion:
    | Promise<"settled" | "handled">
    | { kind: "not-observable" };
}

这是宿主接口,不是 Pi 导出的类型。它让 UI 不需要知道底层是 SDK 还是 RPC。 Adapter 必须显式统一两种不同语义:SDK session.prompt() 的 Promise 会等整轮 Run(包括自动 Retry/Compaction)完成,或在输入被 Command/Handler 处理后返回;RPC Prompt Response 只确认 Accepted/Queued/Handled。accepted 表示入口接受。标准 RPC v0.82.0 对三种情况返回同形 Response,Handled 后也没有终止 Event,因此不能靠“暂时没看到 agent_start”可靠分类。本书 的 RPC Sidecar 要扩展明确的 prompt_completed/Handled 信号;若使用原样标准协议,Adapter 必须返回 completion: { kind: "not-observable" },UI 不能永久等待。settled/handled 是 宿主扩展协议或 SDK Event Tracker 提供的状态,不是原生 RPC Response Discriminant。

5. 启动阶段:先建立可信环境

应用启动不是“马上创建一个 Agent”。更稳妥的顺序是:

读取应用配置并确定 Workspace/CWD
→ 创建未信任 Settings/ResourceLoader
→ 只加载 Global/CLI 等 Pre-trust Extensions
→ 由它们参与 project_trust 决策
→ 使用决策完整 Reload 项目资源
→ 建立最终 Model/Auth/Services
→ 检查关键 Extension 诊断
→ 创建 AgentSession
→ 订阅 Event
→ bindExtensions
→ 生成 UI Snapshot

Project Trust 必须在加载项目级 Extension 前决定。简单 createAgentSession() 不替宿主自动 完成整个 Trust Flow;第 30、31 章给出了 Services 与自定义 ResourceLoader 两种接线方式。

6. Runtime 的最小组装

下面是结构示意,省略了产品自己的 Auth Store 与 Trust UI:

const { session } = await createAgentSession({
  cwd: workspacePath,
  sessionManager,
  resourceLoader,
  settingsManager,
  tools: ["read", "controlled_write"],
  customTools: [controlledWriteTool],
});

const unsubscribe = session.subscribe((event) => {
  runtimeAdapter.accept(event);
});

await session.bindExtensions(extensionBindings);

创建后立即把当前 Session、Model、Thinking、消息和诊断投影成 Store Snapshot。不要等第一条 事件出现后才初始化 UI。

这里显式关闭默认 bash/edit/write,只保留 Read 和经过审批/最终 Policy 的 controlledWriteTool。另一种方案是统一拦截 Bash、Edit、Write 及所有自定义副作用入口; 只审批 write 会被 editbash 绕过。bindExtensions() 还负责连接 UI、Command Context、Error/Shutdown,并发出 session_startresources_discover;Reload/Replacement 后必须重新订阅和绑定。

生产应用还应保存:

  • 当前 Runtime Generation;
  • unsubscribe
  • Session/Workspace 身份;
  • Runtime Abort/Dispose 路径;
  • Extension Load Diagnostics。

7. 对话输入:Intent 不等于 Message

用户点击“发送”时,View 只提交 Intent:

SendPrompt(text, attachments)

Controller 先检查:

  • 当前窗口是否拥有这个 Runtime;
  • 是否存在待处理审批;
  • 这是立即 Prompt、Steering 还是 Follow-up;
  • 附件是否已复制到受控位置;
  • Session 是否正在切换。

随后才调用 session.prompt() 或 RPC Command。UI 可先显示“待发送”,但 Runtime 接受后 也可能只是 Extension Command/Input Handler 已处理,不能一律进入正式 Run;要根据 PromptLifecycle 与实际 Agent/Queue Event 区分 Run、Handled 和 Queued。

8. Skill 怎样进入这次任务

Skill 不是一个神秘的“模型插件”。它先作为 Resource 被发现,其摘要进入可用 Skill 列表; 当任务匹配时,Agent 读取对应 SKILL.md,再按其中步骤行动。

在本例中:

用户:排查错误日志
→ System Prompt 中可见 diagnose Skill 摘要
→ 模型决定读取 diagnose/SKILL.md
→ Read Tool 返回完整指令
→ 后续模型 Turn 按 Skill 工作流执行

桌面 UI 可以显示“已使用 diagnose Skill”,但不要把“模型看见了 Skill 列表”误写成 “Skill 已执行”。只有真正读取并采用其内容后,Skill 才参与本次任务。

9. Extension 怎样改变行为

Extension 在 Runtime 建立时注册 Tool、Command 与 Event Handler。我们为写入工具增加 Approval Gate:

tool_call(controlled_write)
→ Canonicalize Arguments
→ Risk Classify
→ Send Approval Request to Host
→ Await Approve/Reject/Timeout/Abort
→ Final Host Policy Check
→ Execute or Block

Extension 负责把请求接入受控链,桌面宿主负责窗口路由和决策 UI,Sandbox/Broker 负责最终 权限。Prompt 中写“请先询问”只能改善模型行为,不是安全边界。

RPC 宿主若通过 Extension UI 审批,必须处理 extension_ui_request 并发送 extension_ui_response。v0.82.0 内置 RpcClient 没有完整 UI Response API,需要使用 自定义 JSONL Client 或扩展它。

Extension 本身是 Pi 进程内本地代码,可以直接访问 Node Filesystem/Process,绕过 Tool Broker。只有把整个 Agent/Extension Worker 放进 OS Sandbox/Container,并只开放 Broker 通道,才能约束恶意或失陷的 Extension。

10. 一次完整运行

sequenceDiagram
    participant U as User
    participant D as Desktop
    participant S as AgentSession
    participant M as Model
    participant E as Extension
    participant T as Tool/Sandbox
    participant J as Session JSONL

    U->>D: 排查日志并修复配置
    D->>S: prompt()
    S->>M: Context + Skill 摘要 + Tools
    M-->>S: Thinking/Text + read ToolCall
    S->>E: tool_call(read)
    E-->>S: allow
    S->>T: read
    T-->>S: ToolResult
    S->>M: ToolResult + 下一 Turn
    M-->>S: controlled_write ToolCall
    S->>E: tool_call(controlled_write)
    E->>D: Approval Request
    U->>D: approve
    D-->>E: approved
    E-->>S: allow
    S->>T: controlled_write through sandbox
    T-->>S: ToolResult
    S->>M: ToolResult + 下一 Turn
    M-->>S: Final Answer
    S->>J: append entries
    S-->>D: agent_settled

关键点是 Tool Result 会回到模型,模型才知道动作是否成功。桌面应用不能在工具完成后直接 假装整个任务完成。

11. Streaming、Thinking 与 Tool 卡片

Store 同时维护三个投影:

conversation.streaming.text
conversation.streaming.thinking
tools[toolCallId]

Text/Thinking 按消息合并;Tool 按 toolCallId Upsert。多个并行工具的事件可能交错,因此 不能只保存一个 currentTool

message_end 使用最终 Message Reconcile 草稿;agent_end 进入 Settling;只有 agent_settled 才回到 Idle,因为之后还可能发生自动 Retry、Compaction 或队列继续。

12. 模型与 Thinking 切换

切换模型不是只改下拉框。Controller 应:

  1. 判断当前 Run 是否允许切换;
  2. 调用 Runtime 的 Model Selection API;
  3. 等待成功结果或对应 Event;
  4. 更新桌面 UI 的 Session 顶栏/状态栏;
  5. 重新计算可用 Thinking Level;
  6. 保存用户选择。

不同 Model 的 Thinking、图片、Context Window 和 Tool 能力不同。UI 要以 Model Metadata 为 依据禁用选项,不要写死“所有 Provider 都支持 High Thinking”。

Pi 会追加 model_change Session Entry 并更新 Settings;它不会改写 JSONL SessionHeader。

13. Steering 与 Follow-up

运行中的新输入有两种语义:

  • Steering:希望尽快影响当前工作;
  • Follow-up:当前 Run 原本将停止时,在 agent_end/agent_settled 之前注入,并继续同一个 Agent Loop。

桌面输入框应明确展示排队状态。若用户点击停止:

abort current run
→ cancel pending approval
→ mark running tool interrupted
→ decide whether queued messages remain
→ wait for settled/reconcile

Abort 是取消请求,不是副作用回滚。已经写入的文件仍要依靠 Transaction、Backup 或 Idempotency 处理。

14. 会话保存与恢复

SessionManager 把消息、模型变化、压缩、分支与 Custom Entry 记录到 JSONL Tree。UI 列表只 保存摘要索引,不要复制一份“看起来相同但无法恢复 Branch”的自定义对话格式。

恢复时:

选择 Session
→ teardown 旧 Runtime
→ 创建/切换新 Runtime
→ generation + 1
→ 读取当前 Branch
→ 重建 UI Snapshot
→ 订阅并回放 Buffer Event

旧 Runtime 的 Event、Context 与 Approval 必须失效。

15. Compaction 不是删除聊天记录

上下文接近模型窗口时,Compaction 为模型生成摘要和保留尾部消息;Session Tree 仍保存历史 条目。UI 应区分:

  • Session 历史:用户可回看;
  • 当前模型 Context:这次请求真正发送的内容;
  • Compaction Summary:代替较早上下文进入模型。

Compaction 开始、结束、错误都应进入 Store。压缩失败不应让界面永远停在“处理中”。

16. 图片、附件与大结果

附件进入 Agent 前要经过:

  • MIME/大小检查;
  • 路径规范化;
  • Workspace/权限检查;
  • 必要的复制或 Content-addressed Store;
  • Provider 能力检查。

大 Tool Result 不要在 View State、Session 与模型 Context 中无上限复制三遍。可以让 Tool 返回摘要与 Artifact Reference,再由受控 Read Tool 按需读取。

17. 多窗口与多 Session

建议“一份 Runtime 对应一个明确 Owner”,事件信封至少包含:

runtimeId + sessionId + generation + localSequence

多窗口共享同一 Session 时,必须定义谁能发送 Prompt、批准 Tool、切换模型和关闭 Runtime。 最简单的生产规则是单写者、多观察者。

18. 错误恢复

错误按边界处理:

错误 默认处理
Provider 暂时失败 有预算的 Retry,保留可见状态
Tool Validation 失败 生成 Error ToolResult,让模型可修正
Approval Timeout 拒绝
Extension 加载失败 诊断;安全关键扩展缺失则阻止运行
SDK Session Replacement Generation 切换并重绑订阅
RPC Worker Exit 中断草稿/工具,重启后 Snapshot Rehydrate
Session 写入失败 明确告警,不能假装已持久化

不要把所有错误压成聊天气泡中的一句红字。

19. Shutdown 顺序

应用或 Workspace 关闭时:

停止接收新 Intent
→ Abort 当前 Run
→ 结束 Pending Approval
→ 发 Session Shutdown / Dispose Runtime
→ Extension 清理 Watcher/Socket/Timer
→ Flush Session/Telemetry
→ 关闭 Worker
→ 清空敏感内存引用

Cleanup 要幂等。窗口关闭、应用退出、Reload、New/Resume/Fork 都可能触发相近路径。

20. 最小验收场景

完整桌面 Agent 至少跑通:

  1. 纯文本多轮对话;
  2. Thinking 与文本流式展示;
  3. Model/Thinking 切换;
  4. Read Tool 成功;
  5. Controlled Write Tool 审批通过、拒绝和超时;
  6. 两个并行 Tool;
  7. Steering 与 Follow-up;
  8. Abort;
  9. Session 保存、恢复和 Branch;
  10. Compaction;
  11. Skill 被发现、读取并影响执行;
  12. Extension Reload;
  13. RPC/Worker 崩溃恢复(若使用 RPC);
  14. 多窗口错误路由防护。

21. 常见误区

“用了 AgentSession 就不需要应用架构”

错误。AgentSession 提供 Agent Runtime,桌面状态、窗口、权限和恢复仍属于宿主。

“Skill 和 Extension 是一回事”

错误。Skill 是给模型读取的工作方法;Extension 是在 Runtime 中执行的代码扩展。

“审批通过就代表一定安全”

错误。审批只是决策,最终执行还要经过受控 Tool Backend 与 Sandbox。

“Session 保存了,所以 UI State 也能原样恢复”

错误。Session 保存 Agent 语义;窗口布局、弹窗和临时草稿要由 UI 自己管理或重建。

22. 本章重点总结

  • 用 Runtime Port 隔离 UI 与 SDK/RPC,桌面层只处理 Intent 和 Event;
  • 启动时先处理 Trust、Resources 和关键扩展诊断,再接受 Prompt;
  • Skill 提供模型可读取的方法,Extension 提供运行时代码接点,两者职责不同;
  • 高风险工具必须经过 Canonicalize、Approval、Final Policy 与受控 Backend;
  • Streaming、Tool、Approval 都用稳定 ID 和 Generation 映射到 Store;
  • Session Tree 保存 Agent 历史,Compaction 只改变当前模型 Context;
  • Abort 不回滚副作用,Worker 隔离也不自动形成权限沙箱;
  • 完整产品必须验证恢复、多窗口、清理和失败路径。

23. 练习

  1. 为什么 View 不应直接保存 AgentSession 对象?
  2. Skill “被发现”和“被使用”有什么区别?
  3. 写入审批为什么要使用 Canonical Arguments,而不是 Tool Start 的 Raw Args?
  4. 为什么 agent_end 之后不能立即把 UI 设为 Idle?
  5. SDK 迁移到 RPC 时,哪一层应该保持不变?
  6. 设计一个“用户关闭审批窗口”的 Fail Closed 状态转换。