数据截至 (上游 commit 44ba8283e9f7)
第 02 章 · Agent 会话运行时(host / driver)
本章讲本 shelf 的核心母题:怎么把一个「真正的 agent」(能长时间自主读写文件、调工具)接进一个聊天客户端。Cherry Studio 的答案是 host/driver 分层——自己只做 host,把 agent 内核外包给 Claude Agent SDK。读完你能讲清这条分层线、一个 turn 的完整生命周期、断线怎么恢复。
1. 先建直觉:host 和 driver 各管什么
它要解决的小问题。 一个 agent 会话需要:稳定的 UI turn、落库、中途跟进(steer)、断线恢复、空闲回收。但 host 不应该知道底下那个 agent 到底是「一个常驻子进程」「一个 websocket」「每轮一个 HTTP 请求」还是「Claude Code SDK 的 query」。
思路(分层):
┌─────────────────────────── HOST ───────────────────────────┐
│ AgentSessionRuntimeService │
│ 每个 session 一个 entry: │
│ currentTurn / pendingTurns / connection / resumeToken │
│ idleTimer / steer 状态 │
│ 负责:UI turn 生命周期、落库、跟进队列、恢复、空闲回收 │
└───────────────┬─────────────────────────────────────────────┘
│ 通用接口 AgentRuntimeConnection
│ send / redirect / reconcile / close
│ ← events: AgentRuntimeEvent 异步流
┌───────────────┴─────────────────────────────────────────────┐
│ DRIVER (AgentSessionRuntimeDriver) │
│ ClaudeCodeRuntimeDriver │
│ 持有 Claude Agent SDK 的 query、SDK 输入队列、resume 处理 │
│ 把 SDK 消息翻译成通用 AgentRuntimeEvent │
└─────────────────────────────────────────────────────────────┘
仓库 docs/references/ai/agent-session-runtime.md 把这条边界讲得很清楚:host 拥有 Cherry 的 UI/会话生命周期;driver 拥有具体 agent 运行时的生命周期。 Claude Code 是第一个 driver。
2. 通用接口:host 和 driver 的「合同」
这份接口是整章的关键,它定义了「任何 agent 运行时要被 host 托管,必须长什么样」。在 src/main/ai/runtime/types.ts:
AgentSessionRuntimeDriver(types.ts:228):validateSession()预检、listAvailableTools()列工具、connect()建连接、可选onSessionIdle()。AgentRuntimeConnection(types.ts:177):一条活的连接,提供send()(发一轮)、可选redirect()(中途 steer)、reconcile()(按最新 agent 配置对账运行中的连接:能热补丁的当场补,不能补的返回rebuild由 host 在安全边界重连)、可选getContextUsage()、close(),以及events异步事件流 。AgentRuntimeEvent(types.ts:100):driver 往 host 推的统一事件——这是解耦的核心。
driver 能发的事件(host 只认 AgentRuntimeEvent 这一组,不认 SDK 细节;下表列核心几种——上游后来又扩充了 tool-approval-request、usage、api-retry、supported-commands、background-*、autonomous-turn-state 等):
| 事件 | 含义 | host 怎么处理 |
|---|---|---|
chunk | 一段 UI chunk(文本/工具/思考) | 喂进当前 turn 的 controller |
resume-token | 不透明的恢复令牌 | 存进 entry.lastResumeToken,落库到 assistant 行 |
turn-complete | 这一轮答完了 | 关当前 turn,drain pendingTurns 或转 idle |
steer-undelivered | 这轮没来得及注入的 steer | 排进 pendingTurns 作下一轮 |
steer-boundary | steer 已注入,要把回答滚成两行 | 见 §5 / 第 04 章 |
compaction-start/complete/error | 上下文压缩 | 更新共享缓存状态、发 anchor chunk |
context-usage | 上下文窗口用量 | 写共享缓存供 UI 显示 |
error | 运行时出错 | 把当前 turn 标 error |
这套事件让 host 完全不碰 Claude SDK 的消息类型——换一个 driver(比如自研的、或别家 SDK),只要把它的输出翻译成这组事件即可。driver 经 runtimeDriverRegistry(src/main/ai/runtime/registry.ts,RuntimeDriverRegistry)按 agentType 注册/查找 。
3. 一个 turn 的完整生命周期(主线走一遍)
renderer 发 Ai_Stream_Open(topic = agent-session:<sessionId>)
│
▼
AgentChatContextProvider.prepareDispatch
校验:有 agent / 有工作区 / 工作区路径合法 / agentType 有 driver / 有模型
原子落库:一条 user 行 + 一条 pending assistant 行
调 AgentSessionRuntimeService.beginTurn(...)
│ 返回 listeners(持久化/终态/trace) + turnId
▼
AiStreamManager 起 execution → AiService.streamText()
检测到 request.runtime.kind === 'agent-session'
→ 不建普通 Agent,改调 openTurnStream()
│
▼
AgentSessionRuntimeService.openTurnStream(sessionId, turnId, signal)
返回一个 ReadableStream<UIMessageChunk>:
① ensureConnection(entry):没连接就 connect driver(可能复用 warm query)
② admitTurn:connection.send({ message }) 把这轮发给 agent
③ driver 的 events 流里来的 chunk → 塞进这条 stream
│
▼
driver 发 turn-complete → 标记 turn 终态
有 pendingTurns → scheduleNextTurn(同一个 warm 连接里 drain)
没有 → 起 idle 计时器(默认 5 分钟)
几个关键点(都能在 AgentSessionRuntimeService.ts 核对):
- 连接跨 turn 保持温热。
markTurnTerminal(AgentSessionRuntimeService.ts:881,新版多了expectedTurnId参数防止过期 turn 误收)在一轮结束后不关连接,只把 entry 转idle;排队的 steer 在同一个温热子进程里继续(scheduleNextTurn)。只有closeSession或 idle TTL 到期才真正拆连接。 - start 用
ensureConnection去重。 两条 stream 同时打开时,共享同一个在飞的连接 Promise(entry.connecting),避免各自 spin 一个连接(ensureConnection,AgentSessionRuntimeService.ts:1418)。 - 每一步异步后都重新校验 entry 还活着。 因为 DB 落库会让出事件循环,期间 session 可能被拆/重开;几乎每个 async 方法都
isCurrentEntry(entry)复查,防止把死 entry 复活成一个没有连接的 doomed turn(例:startNextTurn,AgentSessionRuntimeService.ts:2494)。
4. Claude Code driver:把 SDK 接成 driver
ClaudeCodeRuntimeDriver(src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.ts)是目前唯一的 driver。它内部用 @anthropic-ai/claude-agent-sdk 的 query。难点在于:Claude Agent SDK 的 query 是一个长驻的、双向的异步生成器(你不断 push 用户消息,它不断 yield SDK 消息),要把它桥接成 host 要的「一组事件 + 一个 send 入口」。
桥接的两个内部队列(示意理解):
host.send(userMessage) ──push──▶ SdkInputQueue ──▶ Claude SDK query(prompt 入口)
│ yield SDKMessage
▼
host ◀──events── AsyncEventQueue ◀── runQueryLoop 把 SDKMessage 翻译成
AgentRuntimeEvent(chunk/resume-token/...)
SdkInputQueue 和 AsyncEventQueue 是文件里两个手写的异步队列类(ClaudeCodeRuntimeDriver.ts:283 / :51)。核心循环 runQueryLoop(:260)遍历 SDK 消息,做这些翻译:
system/init→ 更新 resume token(SDK 的session_id);stream_event/ assistant / user 消息 →chunk(经ClaudeCodeStreamAdapter转成 UI chunk);result→ 更新 resume token、发一条 usage chunk、context-usage、turn-complete;system/status与compact_boundary→ 压缩相关事件;- 抛错 →
error(或对截断流抢救成turn-complete)。
resume token = SDK session_id。 host 把它当不透明值:存起来、落到 assistant 行;重连时 driver 把它映射成 SDK 的 options.resume。这就是断线恢复的锚——即使一轮出错,只要 driver 已发过 resume token,error 行也会带上它,下次连接能从最新状态恢复(见 docs/references/ai/agent-session-runtime.md 的 Resume token persistence)。
工作区是真实磁盘目录。 validateSession(ClaudeCodeRuntimeDriver.ts:1358)要求 session 有 workspace.path 并预备好目录——agent 是个文件系统 agent,用户附件被转成绝对路径附在文本后,让 agent 用自己的工具去读(buildAgentUserContent,:471)。
5. 中途 steer 与「滚动」一条回答(steer-boundary)
第 01 章说过 agent steering 经 connection.redirect() 注入。这里补它最巧的一环:注入之后,一条回答会被滚成两行。
问题。 用户在 agent 答到一半时补了一句「顺便也删 debugger」。Claude 通过 PreToolUse hook 把这句作为 additionalContext 注进去,然后继续答。但如果直接把 steer 的 user 消息排在整段回答之后,历史顺序就乱了(回答里其实已经响应了这句 steer)。
解法(滚动 roll): driver 在「模型即将吐出 post-steer 那段回答」前发一个 steer-boundary 事件;host 据此:
时间轴:
A1a(steer 前的回答) ─ 收尾成一行(success)
U2(steer 的 user 消息)─ 排在中间
A2(steer 后的回答) ─ 开新一行,把缓冲的 post-steer chunk 回放进去
host 端的状态机在 AgentSessionRuntimeService 里:rolling / rollBuffer / rollSteerInputs 字段、handleRuntimeEvent 的 steer-boundary 分支(:573)、scheduleContinuationTurn / startContinuationTurn(:840 / :861)、flushRollBuffer(:939)。driver 端 arm 这个 boundary 的逻辑在 ClaudeCodeRuntimeConnection(steerBoundaryPending,ClaudeCodeRuntimeDriver.ts:354、:293)。
这段相当烧脑,完整时序留到 第 04 章展开。本章你只要记住:steer 注入会把一条 assistant 回答拆成 A1a + A2 两行,好让 steer 的 user 消息在历史里排在中间。