跳到主要内容

第 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 / applyPolicyUpdate / 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:83):validateSession() 预检、listAvailableTools() 列工具、connect() 建连接、可选 onSessionIdle()
  • AgentRuntimeConnection(types.ts:62):一条活的连接,提供 send()(发一轮)、可选 redirect()(中途 steer)、可选 applyPolicyUpdate()(改权限/工具策略)、可选 getContextUsage()close(),以及 events 异步事件流。
  • AgentRuntimeEvent(types.ts:44):driver 往 host 推的统一事件——这是解耦的核心。

driver 能发的事件(host 只认这些,不认 SDK 细节):

事件含义host 怎么处理
chunk一段 UI chunk(文本/工具/思考)喂进当前 turn 的 controller
resume-token不透明的恢复令牌存进 entry.lastResumeToken,落库到 assistant 行
turn-complete这一轮答完了关当前 turn,drain pendingTurns 或转 idle
steer-undelivered这轮没来得及注入的 steer排进 pendingTurns 作下一轮
steer-boundarysteer 已注入,要把回答滚成两行见 §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:371)在一轮结束后不关连接,只把 entry 转 idle;排队的 steer 在同一个温热子进程里继续(scheduleNextTurn)。只有 closeSession 或 idle TTL 到期才真正拆连接。
  • start 用 ensureConnection 去重。 两条 stream 同时打开时,共享同一个在飞的连接 Promise(entry.connecting),避免各自 spin 一个连接(ensureConnection,AgentSessionRuntimeService.ts:496)。
  • 每一步异步后都重新校验 entry 还活着。 因为 DB 落库会让出事件循环,期间 session 可能被拆/重开;几乎每个 async 方法都 isCurrentEntry(entry) 复查,防止把死 entry 复活成一个没有连接的 doomed turn(例:startNextTurn,AgentSessionRuntimeService.ts:757)。

4. Claude Code driver:把 SDK 接成 driver

ClaudeCodeRuntimeDriver(src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.ts)是目前唯一的 driver。它内部用 @anthropic-ai/claude-agent-sdkquery。难点在于: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/...)

SdkInputQueueAsyncEventQueue 是文件里两个手写的异步队列类(ClaudeCodeRuntimeDriver.ts:88 / :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-usageturn-complete;
  • system/statuscompact_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.mdResume token persistence)。

工作区是真实磁盘目录。 validateSession(ClaudeCodeRuntimeDriver.ts:505)要求 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 字段、handleRuntimeEventsteer-boundary 分支(:573)、scheduleContinuationTurn / startContinuationTurn(:840 / :861)、flushRollBuffer(:939)。driver 端 arm 这个 boundary 的逻辑在 ClaudeCodeRuntimeConnection(steerBoundaryPending,ClaudeCodeRuntimeDriver.ts:142:293)。

这段相当烧脑,完整时序留到 第 04 章展开。本章你只要记住:steer 注入会把一条 assistant 回答拆成 A1a + A2 两行,好让 steer 的 user 消息在历史里排在中间。

6. idle 生命周期与恢复

turn 终态 → entry 转 idle,保留:连接(若还活)/ lastResumeToken / pendingTurns
idle 窗口内来新 turn → beginTurn 复用同一 entry,只换 UI turn
idle 计时器到期(默认 5min)→ closeSession:
清 pendingTurns、关连接、(Claude)预热下一次 query(prewarm)
进程重启 → onInit 里 reconcileStalePendingMessages:
把上次崩溃留下的 pending assistant 行标成 error(不让它永远「思考中」)

实现:idle 计时器 refreshIdleTimer(AgentSessionRuntimeService.ts:1003)、崩溃恢复 reconcileStalePendingMessages(:179)、Claude 预热 onSessionIdleClaudeCodeWarmQueryManager.prewarmAgentSession

7. 后台 / 定时 agent:同一套 host,无人值守

agent 不一定有人坐在屏幕前。runAgentTask(src/main/ai/agents/runAgentTask.ts)是「定时任务 agent」的业务逻辑:每次触发新建一个 session(不跨触发复用上下文——周期任务是离散调用,不是对话,持久状态放工作区文件如 heartbeat.md),跑一轮 agent,把结果通过 ChannelAdapterListener 推到订阅的 IM 频道。

它复用的正是第 01 章的管线:塞一个自定义 sentinel listener 累积文本(注意源码注释提醒 text-delta 的字段是 delta 不是 text,runAgentTask.ts:170),塞 channel listener 推送,经 startAgentSessionRun 起流。heartbeat 这类周期任务还会读工作区里的 heartbeat.md 当 prompt(:122)。

这说明 host/driver + listener 抽象的复用度:有人聊天、API 调用、IM 机器人、定时无人值守,四种场景共用同一个 agent 运行时。

8. 巧妙之处

  • host/driver 用事件解耦。 host 一行 Claude SDK 代码都不碰,全靠 AgentRuntimeEvent。这让「接一个新 agent 运行时」=「写一个把它输出翻译成这组事件的 driver」。
  • 连接温热跨 turn。 一个 agent 会话的多轮共享同一个 SDK query/子进程,steer 和跟进直接 drain 进去,省掉每轮重建的开销与上下文丢失。
  • 不透明 resume token。 host 不理解 token 内容,只负责存/传——把「怎么恢复」完全留给 driver,恢复语义和 host 解耦。
  • 崩溃后自愈。 重启时把孤儿 pending 行标 error,避免界面上永远卡着一个「思考中」的气泡。

9. 边界与局限

  • agent steer 永不打断当前 turn:只能 redirect 注入或排队;想立刻硬停只有用户 Stop。
  • 工具列表按 session 快照:Claude Agent SDK 在会话开始时快照工具集,会话中途不能动态增减工具(见第 03 章「最终一致」)。
  • resume 依赖 driver 真发过 token:全新 session 第一轮若在发 token 前就崩,无锚可恢复。
  • 目前只有一个 driver(Claude Code);host/driver 的抽象是为未来留的,but as-of 本 commit 没有第二个生产 driver。

10. 代码地图

主题文件符号
host 服务src/main/ai/agentSession/AgentSessionRuntimeService.tsAgentSessionRuntimeService
通用接口/事件src/main/ai/runtime/types.tsAgentSessionRuntimeDriver, AgentRuntimeConnection, AgentRuntimeEvent
driver 注册表src/main/ai/runtime/registry.tsRuntimeDriverRegistry, runtimeDriverRegistry
Claude Code driversrc/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.tsClaudeCodeRuntimeDriver, ClaudeCodeRuntimeConnection, runQueryLoop
SDK 输入/事件队列src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.tsSdkInputQueue, AsyncEventQueue
warm query 预热src/main/ai/runtime/claudeCode/ClaudeCodeWarmQueryManager.tsClaudeCodeWarmQueryManager
agent 入口 providersrc/main/ai/streamManager/context/AgentChatContextProvider.tsAgentChatContextProvider
后台任务 agentsrc/main/ai/agents/runAgentTask.tsrunAgentTask

下一章:agent 能调的「工具」从哪来、太多了怎么办、危险动作怎么审批——第 03 章。