前 33 章分别解决了模型、工具、会话、Skills、Extensions 与桌面状态。本章不再引入新的 大概念,而是把它们装进同一个应用。
目标不是复制 Pi 的终端界面,而是建立一个自己的桌面产品:
用户输入任务,Agent 能选 Skill、调用受控工具、持续对话、保存会话,并把每一步可靠地 映射到桌面 UI。
1. 学习目标
完成本章后,你应能够:
- 画出桌面 UI 到 Provider、Tool 和 Session 文件的完整边界;
- 使用 AgentSession 组织对话、流、工具、会话与压缩;
- 区分 Skill、Extension、宿主 Policy 与 OS Sandbox 的职责;
- 把一次高风险工具调用接入审批、审计和取消;
- 解释 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 会被 edit 或 bash 绕过。bindExtensions() 还负责连接 UI、Command
Context、Error/Shutdown,并发出 session_start 与 resources_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 应:
- 判断当前 Run 是否允许切换;
- 调用 Runtime 的 Model Selection API;
- 等待成功结果或对应 Event;
- 更新桌面 UI 的 Session 顶栏/状态栏;
- 重新计算可用 Thinking Level;
- 保存用户选择。
不同 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 至少跑通:
- 纯文本多轮对话;
- Thinking 与文本流式展示;
- Model/Thinking 切换;
- Read Tool 成功;
- Controlled Write Tool 审批通过、拒绝和超时;
- 两个并行 Tool;
- Steering 与 Follow-up;
- Abort;
- Session 保存、恢复和 Branch;
- Compaction;
- Skill 被发现、读取并影响执行;
- Extension Reload;
- RPC/Worker 崩溃恢复(若使用 RPC);
- 多窗口错误路由防护。
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. 练习
- 为什么 View 不应直接保存
AgentSession对象? - Skill “被发现”和“被使用”有什么区别?
- 写入审批为什么要使用 Canonical Arguments,而不是 Tool Start 的 Raw Args?
- 为什么
agent_end之后不能立即把 UI 设为 Idle? - SDK 迁移到 RPC 时,哪一层应该保持不变?
- 设计一个“用户关闭审批窗口”的 Fail Closed 状态转换。