会话事件流与持久化投影
AgentRuntime 怎样对外交代它做了什么:七十九种会话事件的谱系,只在内存里、按回合窗口淘汰的事件存储,appendEvent 的四步管线,EventReducer 投影出的会话状态,消息与 part 怎样与事件并行落库,合成通知的语义标注,标题生成的旁路请求,以及会话驻留。
AgentRuntime 跑起来之后,外界靠什么知道它在做什么?靠两条并行的输出。一条是会话事件 SessionEvent:经 appendEvent 分配序号,交给订阅者,驱动 TUI、桌面端与 Web 的实时界面。另一条是持久化写入:用户消息、助手消息、各类 part、会话条目与输入账本,经 SessionStorePort 写进 SQLite,供冷恢复与历史回放。这一篇讲这两条输出怎么产生、怎么分工。
事件的类型、事件存储与核心 reducer 定义在 apps/zcode-cli/packages/contracts/src/events;运行时一侧的写入集中在 apps/zcode-cli/packages/core/src/runtime/methods 下的 events.ts、message-persistence.ts、timeline-persistence.ts 等文件。SQLite 的表结构见 SQLite 会话库,协议层怎样把事件归约成界面状态、推给客户端并支持重放,见 ZCode Protocol V4。
不只是事件
说运行时“对外只发事件”并不准确,它有三种对外通道:
- 方法返回值。受理回执、
TurnResult这些直接交给调用方;TurnResult里带着本回合的事件数组与一份会话投影(apps/zcode-cli/packages/core/src/runtime/types.ts:412)。 - 会话事件。所有订阅者都实现同一个一行接口
onSessionEvent(event)(apps/zcode-cli/packages/contracts/src/interfaces/session.port.ts:76)。订阅有两种方式:构造时经deps.eventSink传入,或事后调subscribeEvents(apps/zcode-cli/packages/core/src/runtime/methods/config.ts:161)。仓库里的三个主要订阅者是协议层(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3417)、TUI 的事件中继(apps/zcode-cli/packages/cli/src/tui-session-event-relay.ts:42)与-p无头模式(apps/zcode-cli/packages/cli/src/prompt-command.ts:291)。 - 持久化写入。经
SessionStorePort落库,由协议层在冷恢复时读回。
另外还有反向请求:需要审批或提问时,运行时经 PermissionBrokerPort.requestPermission 主动问宿主(apps/zcode-cli/packages/contracts/src/interfaces/permission.port.ts:95),详见权限模式与规则。
事件与事件存储
一条事件有八个字段:id、sessionId、turnId、type、timestamp、traceId、sequenceNumber 与 payload(apps/zcode-cli/packages/contracts/src/events/session.events.ts:72)。payload 的类型是 unknown,消费方按 type 自行断言。工厂函数创建事件时序号一律为 0(session.events.ts:1133),真正的序号由事件存储在 append 时分配(apps/zcode-cli/packages/contracts/src/events/in-memory-session-event-store.ts:61)。
事件存储的接口只有五个必选方法:追加、全量读、按序号之后读、取最新序号、删除会话(session.port.ts:57)。仓库里唯一的实现是进程内的 InMemorySessionEventStore(in-memory-session-event-store.ts:33),协议服务为每条会话记录新建一个(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:239、server-operations.ts:3300)。它的类注释说明了定位(in-memory-session-event-store.ts:26):它是实时推送、重放与快照三者序号的唯一来源,但只驻留在内存里。事件因此不跨进程存活,重启之后能恢复的只有 SQLite 里的消息与条目。
长会话里流式事件与 token 同频,全部留着内存会线性增长,所以存储按“回合窗口”淘汰四种瞬态事件(apps/zcode-cli/packages/contracts/src/events/session-event-retention.ts:4):
/**
* 与消息流同频的瞬态事件。
* 它们仍经 event store 分配 seq 并交给 live sink,但 turn 结束并被下一 turn 取代后即可从内存淘汰:
* 已完成 turn 的文本由持久化消息重新合成,reducer / rewind / fork / checkpoint 不消费这些类型。
*/
export const TRANSIENT_SESSION_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set([
SessionEventType.ModelStreaming,
SessionEventType.ToolCallProgress,
SessionEventType.StreamingToolLedgerUpdated,
SessionEventType.ModelNetworkStatus,
]);回合收到 turn_complete 或 turn_error 只是被标记为已封口,等下一个 turn_started 到来才一次性淘汰;滞后一个回合,是因为刚结束的回合消息可能还没落盘(session-event-retention.ts:49)。子 Agent 这种只有一个回合的会话等不到下一回合,就靠 60 秒一次的采样节拍兜底,封口超过 120 秒即淘汰(session-event-retention.ts:22、server.ts:298)。序号计数器只增不减,淘汰不会造成重复序号(in-memory-session-event-store.ts:65)。
事件类型谱系
SessionEventType 一共 79 种(session.events.ts:83),按用途归纳:
| 类别 | 事件(行号均在 session.events.ts) | 主要产生方 |
|---|---|---|
| 会话 | session_created、session_resumed、session_forked、session_compacted、session_title_updated、session_mode_changed、session_ended(84–90) | core;session_created 只在冷恢复时由协议层合成 |
| 回合 | turn_started、turn_complete、turn_error、turn_input_received(91–92、109–110) | core |
| 输入队列 | 七个 turn_steer_* 与 session_input_promoted、queue_auto_drain_changed、followup_mode_changed(93–108) | core,见输入受理 |
| 消息 | user_message、assistant_message、assistant_feedback_updated、system_message(111–114) | 只有反馈由协议层产生 |
| 模型 | model_request、model_selected、model_streaming、model_complete、model_error、model_network_status、model_anomaly_warning、network_request_status、streaming_tool_ledger_updated 与六个 stream_recovery_*(115–129) | core,见一次模型请求 |
| 工具 | tool_call_scheduled、_started、_progress、_result、_error 与 tool_batch_complete(130–135) | 工具执行器 |
| 后台与工作流 | 三个 background_task_* 与 dynamic_workflow_run_progress(136–143) | core |
| 权限与交互 | permission_requested、_resolved、_denied 与 user_input_auto_resolution_updated(144–147) | core |
| 钩子 | 四个 workspace_hook_* 与五个 hook_run_*(148–157) | 工作区钩子审阅由 bootstrap 发,钩子运行由 HookRunner 发 |
| 压缩 | compact_started、_completed、_failed 与 compact_boundary、microcompact_boundary(158–162) | core,见上下文压缩 |
| 回退与检查点 | rewind_triggered、checkpoint_created(163–164) | core |
| 目标 | target_changed、target_completion_verification(165–166) | core |
| 子 Agent | subagent_spawned、subagent_message、subagent_stopped(167–169) | core |
| 控制 | interrupt、cancel、resume、error(170–173) | 无 |
不少类型只剩定义。按 SessionEventType.X 全仓搜索,turn_input_received、user_message、system_message、session_compacted、session_ended、model_error、hook_run_progress、stream_recovery_blocked 与四个控制类事件都找不到产生方,只有少数消费方还留着对应的分支;assistant_message 只在 CLI 登录流程里临时拼一条给 TUI 显示(apps/zcode-cli/packages/cli/src/command-center/login-flow.ts:141)。换句话说,消息内容并不走事件,它走下面讲的持久化写入。
appendEvent 的四步
所有事件都从 appendEvent 出去(apps/zcode-cli/packages/core/src/runtime/methods/events.ts:86):
// live sink 以前拿到的是 createSessionEvent 默认的 sequenceNumber=0,
// 而 replay/read 路径拿到的是 eventStore 补号后的事件,导致同一 session 有两套顺序事实。
// 这里只发布已落库事件,让 live、replay、snapshot 的 eventSeq 全部来自同一个 event store。
// ...
let phase = "event_store.append";
try {
const storedEvent = await this.eventStore.append(event);
phase = "session_event.persist_durable";
await persistDurableSessionEvent.call(this, storedEvent, traceContext);
phase = "session_event.record_usage";
await recordToolUsageFromEvent(this, storedEvent, traceContext);
phase = "session_event.notify_sinks";
await this.notifyEventSinks(storedEvent, traceContext);第二步是事件到持久层的一小段投影,只处理七种事件(events.ts:263)。注释解释了为什么放在事件汇统一处理:像 turn_steer_queued 这类事件有五个以上的发射点,单点接线才保证不漏(events.ts:316):
| 事件 | 写到哪里 |
|---|---|
checkpoint_created | 会话条目 runtime/workspace_checkpoint(events.ts:270) |
rewind_triggered | 会话条目 runtime/workspace_file_rewind(events.ts:275) |
user_input_auto_resolution_updated | 会话条目 runtime/user_input_auto_resolution,按交互 id 覆写(events.ts:280) |
turn_steer_queued | 输入账本登记为 admitted(events.ts:320) |
turn_steer_delivery_changed | 账本改投递方式(events.ts:357) |
turn_steer_discarded | 账本收口为 cancelled 或 discarded,原因是 promoted 时不动(events.ts:386) |
target_completion_verification | 会话条目,外加一条目标校验分隔线(events.ts:420) |
这些写入失败都只记警告,不打断事件发布。第四步依次等待每个订阅者,单个订阅者抛错只记日志(events.ts:528)。日志也按频率区别对待:回合开始、完成、出错与标题更新这类生命周期事件记 info(events.ts:44),流式与进度类事件则按回合聚合,每满 100 条记一条摘要(events.ts:33)。
EventReducer:会话投影
核心 reducer 住在 contracts 包(apps/zcode-cli/packages/contracts/src/events/event-reducer.ts:74),经 deps.ts 再导出给 core,每个 runtime 构造时各建一个(apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:255)。它是一张以事件类型为键的处理表,覆盖 30 种事件,其余类型只更新 updatedAt(event-reducer.ts:87)。投影结果 SessionProjection 是一份“此刻的会话状态”(session.port.ts:84):状态、回合数、token 累计与上下文占用、待审批请求、排队输入、进行中的工具调用、后台任务、最近一次压缩、检查点、回退与错误、当前目标与完成校验记录。
rebuildProjection 每次都从头归约该会话的全部事件(apps/zcode-cli/packages/core/src/runtime/methods/message-persistence.ts:385)。它的主要用户是队列操作:回合结束后排队输入只剩投影里的 pendingSteerInputs,编辑、删除、重排都要先查它。回合结束时的 TurnResult 也附上一份,bootstrap 观察子 Agent 时读父会话的投影(apps/zcode-cli/packages/bootstrap/src/app/subagent-observation.ts:71)。有个细节保证了旁路请求不污染主会话:model_complete 只有在 querySource 为 main_turn 时才更新上下文占用(event-reducer.ts:60)。
这个 reducer 不生产消息,也不生产 part。界面上看到的对话行来自协议层的“第二个 reducer” ProductProjection(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:2),它按事件类型把事件归约成界面增量(product-projection.ts:1313)。冷恢复时事件早已不在内存,协议层反过来把 SQLite 里的消息合成一串等价事件,再喂给同一个 reducer,文件头把这个原则写成“reduce(transcript) ≡ reduce(events)”(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/transcript-hydration.ts:1)。两层投影的细节归 ZCode Protocol V4。
消息与 part:和事件并排落库
消息与 part 不是从事件推导出来的,而是由运行时在同一段代码里与事件并排写出,最终落到 saveMessage 与 savePart(message-persistence.ts:336、message-persistence.ts:354)。part 共 13 种(apps/zcode-cli/packages/contracts/src/interfaces/session-store.port.ts:764),与 OpenCode 的 12 种 Part 一一对应,另多一种 timeline。几类写入的时机:
| 写入 | 时机 | 要点 |
|---|---|---|
| 用户消息与 text、file part | 回合开始、guide 注入 | persistUserPrompt(message-persistence.ts:29);带账本 id 时与“已提升”同一事务,提交后才发 session_input_promoted(message-persistence.ts:142) |
| 助手消息 | 每个模型步 | persistAssistantMessage(message-persistence.ts:270);记录实际生成它的 provider 与 model、cwd 与 root、token 用量 |
| 工具 part | 模型声明调用、批次开跑、结果返回 | 同一个 part id 依次写成 pending、running、completed 或 error(apps/zcode-cli/packages/core/src/runtime/methods/tool-part-persistence.ts:30、apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:198、turn-tools.ts:302) |
| timeline part | 切换模型、压缩、目标校验、分叉 | 挂在一条 timeline_event 语义的助手消息上,界面可见、模型不可见(apps/zcode-cli/packages/core/src/runtime/methods/timeline-persistence.ts:86) |
| 合成通知 | 回退、后台结果、子 Agent 回话、目标续跑等 | 见下一节 |
工具 part 完成时的元数据带 schema 版本、展示载荷、序列化信息(是否截断、原始与返回字节数、落盘路径),以及模型当时读到的文件快照;注释说恢复会话时要据此重建“已读文件”状态,不能依赖工具结果的文本格式(apps/zcode-cli/packages/core/src/runtime/methods/tool-part-metadata.ts:19、tool-part-metadata.ts:37)。模型把工具名写成空串时,part 里记为 empty_tool_name,原名放进元数据(tool-part-persistence.ts:12)。
每条消息还有两套标注。semantics 回答“这是什么”:来源(真实用户、运行时、系统、迁移、导入)、种类,以及界面、模型、transcript 三个面上各自是否可见(session-store.port.ts:91)。anchor 是给 v4 投影用的锚点:回合 id、输入来源与命令 id,以附加 JSON 的方式演进,旧数据没有也能读(apps/zcode-cli/packages/core/src/runtime/methods/projection-anchor.ts:8)。
模型切换的分隔线是延迟写的:切换时只在内存里记一笔,连续切换会合并,切回原模型则抵消(timeline-persistence.ts:15、timeline-persistence.ts:27),等下一个回合开始才落成 timeline part(apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:476)。
合成通知
运行时经常要以 user 角色往对话里塞一段上下文:回退说明、后台任务结果、子 Agent 的回话、目标续跑提示、插件引用提醒等。它们统一经 persistSyntheticUserNoticeForSession 落库,标记 synthetic: true,缺省只给模型看(message-persistence.ts:198)。来源共 12 种(session-store.port.ts:51),语义标注按可见性推出(apps/zcode-cli/packages/core/src/runtime/methods/synthetic-notice-metadata.ts:46):
const providerVisible = visibility === "model-only";
return {
origin: "agent_runtime",
kind: syntheticUserNoticeKind(source),
source,
uiVisibility: providerVisible ? "hidden" : "visible",
providerVisibility: providerVisible ? "visible" : "hidden",
// provider-visible 的 user-role runtime context 不是用户 transcript。
// fork/goal/background 等 synthetic notice 如果标成 transcript visible,
// v4 hydration 会把它们误当作真实用户 turn。
transcriptVisibility: providerVisible ? "hidden" : "visible",
};各来源的 kind(synthetic-notice-metadata.ts:60):
| 来源 | kind |
|---|---|
background_task | background_notification |
subagent、subagent_message | subagent_notification |
goal-continuation、goal_state_change、plugin_reference、selection_side_chat | system_reminder |
fork、rewind、todo_reminder | fork_notice、rewind_notice、todo_reminder |
shared_context | shared_context:来源记为导入,界面隐藏,模型与 transcript 可见 |
workflow_launch | user_prompt:只为穷举登记,实际另有专用落盘,见控制类回合 |
锚点里的来源另有一张映射:后台任务与子 Agent 记为 backgroundResult,目标续跑记为 goalContinuation,其余一律 synthetic(projection-anchor.ts:27)。
会话标题
标题有四种来源:default、first_input、generated、custom(session-store.port.ts:45)。会话第一次落库时,标题取首条输入压缩空白后的前 60 个字符,超长截到 57 个字符加省略号,空输入记为“Untitled session”(apps/zcode-cli/packages/core/src/runtime/helpers/project.ts:4),并立刻补发一条 source: "first_input" 的 session_title_updated。注释说之前只写库不发事件,侧边栏会一直显示“New session”,直到模型生成的标题回来(events.ts:622)。用户重命名则写成 custom(apps/zcode-cli/packages/core/src/runtime/methods/session-title.ts:264),此后自动标题再也不会覆盖它。
模型生成的标题在首条用户消息落库之后立即发起,不等主回合结束,这样即使用户中途停止首轮,标题请求也已经发出(turn.ts:530)。例外是需要在请求前刷新运行时请求头的 provider:标题请求会先占用刷新窗口、让主消息失败,所以推迟到回合结束后再发(turn.ts:677)。能否发起由一组条件决定(session-title.ts:138):
function shouldAttemptSessionTitleGeneration(
runtime: AgentRuntimeInternal,
input: string,
options: { bypassShortInputGuard?: boolean } = {},
): boolean {
if (runtime.sessionTitleGenerationAttempted) return false;
if (runtime.config.titleGeneration?.enabled === false) return false;
if (!runtime.config.titleGeneration) return false;
if (!runtime.sessionStore) return false;
if (runtime.config.parentSessionId) return false;
if (runtime.config.taskType && runtime.config.taskType !== "interactive") return false;
if (runtime.turnNumber !== 0) return false;
const normalizedInput = normalizeTitleInput(input);
if (normalizedInput.length === 0) return false;
// 短首发输入本身已经是可读标题,继续走 generated title sidecar
// 会把 "hi" 这类标题稳定覆盖成泛化的 "New Coding Session"。
return (
options.bypassShortInputGuard ||
Array.from(normalizedInput).length >= MIN_GENERATED_TITLE_INPUT_CHARS
);
}MIN_GENERATED_TITLE_INPUT_CHARS 是 10(session-title.ts:27)。/goal 走控制类回合,调的也是这个普通入口(apps/zcode-cli/packages/core/src/runtime/methods/control-only-turn.ts:241);另有一个跳过长度门槛的外部入口,注释说是为 /goal 这类命令准备的,但仓库里找不到调用方(session-title.ts:59)。titleGeneration 配置缺席等于关闭,TUI 与协议会话都显式传入空对象来开启(apps/zcode-cli/packages/cli/src/tui-command-state.ts:35),自动化任务的会话则传 enabled: false,免得回答内容覆盖原始问题当标题(server-operations.ts:3354)。
所谓 sidecar,是一次挂在主回合旁边、独立于主回合生命周期的模型请求(apps/zcode-cli/packages/core/src/runtime/methods/title-generation-sidecar.ts:86):
- 模型:
titleGeneration.modelSelection,缺省用会话当前的模型;再绑定辅助调用参数,推理档位取最低一档,输出上限取 5000 与模型上限中的较小者(apps/zcode-cli/packages/core/src/model/auxiliary-model-options.ts:9)。 - 请求:一条系统提示词加一条用户消息,输入截到 1200 个字符,不给任何工具,超时 60 秒(
title-generation-sidecar.ts:25)。系统提示词强调这是命名任务而不是对话,要求只回{"title":"..."},开头一句是 “Generate a concise title for this coding session.”(title-generation-sidecar.ts:31)。bootstrap 入口层也补了一个 60 秒的缺省,注释说旧的 15 秒在慢模型或代理链路下容易超时(apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:78)。 - 观测:它照样发
model_request与model_complete事件,querySource记为session_title,也照样记用量(title-generation-sidecar.ts:113、title-generation-sidecar.ts:172);遥测是单独的后台 trace,入队时冻结触发它的 span 作为因果链接(session-title.ts:100)。 - 清洗与写回:去掉思考标签,优先解析 JSON,也认 Markdown 代码块包着的 JSON,再去掉首尾引号与句末标点,最长 100 个字符(
title-generation-sidecar.ts:231)。写回时带上“期望来源仍是 default、first_input 或 generated”的条件,期间被用户改名就放弃(session-title.ts:313);首条问题已被编辑也放弃(session-title.ts:377)。
第一次设置目标时,同一个 sidecar 的结果还会兼作目标的摘要标题;失败或没有资格生成时,目标摘要退回到截断后的目标原文(apps/zcode-cli/packages/core/src/runtime/methods/goal-summary-title.ts:13),目标本身见目标模式。
会话驻留
协议服务一个进程要托管许多会话,每个活着的会话都占着一个 ZCodeApp、一个 runtime、一个内存事件存储和可能的 MCP 连接。常驻池负责把不再需要的会话从内存里请出去:默认目标保留 8 个、上限 16 个,连续空闲 10 分钟即可回收(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-resident-pool.ts:7)。回收先按空闲超时,超过上限时再按最近使用时间淘汰到目标数;每次动手前都重新读一遍事实,只有已落库、没有在飞工作、没有待回答的交互、没有排队命令、没有订阅者的会话才有资格(session-resident-pool.ts:226)。回收做的是摘除记录、关闭 app、清空该会话的内存事件,注释说去激活之后必须与“从未加载”等价(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-residency.ts:53);下次打开时从 SQLite 冷恢复。
难点在“在飞工作”怎么判定。标题 sidecar、MCP 启动、账本写入这些脱离调用栈的 Promise,既不算活动回合,也不在后台任务表里,常驻池过去看不见它们,会把仍在读写 runtime 的会话当成空闲关掉。runtime 于是自己维护一个计数(apps/zcode-cli/packages/core/src/runtime/methods/residency.ts:10):
export function trackResidencyBlockingWork<T>(
this: AgentRuntimeInternal,
work: Promise<T>,
): Promise<T> {
this.residencyBlockingWorkCount += 1;
return work.finally(() => {
this.residencyBlockingWorkCount = Math.max(0, this.residencyBlockingWorkCount - 1);
});
}
/** Session 常驻池只消费这一项,新增 sidecar 时不再修改 bootstrap 的猜测列表。 */
export function hasResidencyBlockingWork(this: AgentRuntimeInternal): boolean {
return (
this.hasActiveOrQueuedTurnWork() ||
this.hasRunningBackgroundTasks() ||
this.residencyBlockingWorkCount > 0 ||
(this.memoryExtractionScheduler?.hasPendingWork() ?? false)
);
}计数必须在 Promise 启动的同一同步片里增加(residency.ts:8)。登记的地方有标题与目标摘要 sidecar(session-title.ts:126、goal-summary-title.ts:62)、MCP 启动(apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:111)、后台通知的账本写入(apps/zcode-cli/packages/core/src/runtime/methods/background-notifications.ts:73)、目标回合的心跳(turn.ts:347),以及 bootstrap 里在飞的动态工作流引擎。公开接口上的注释记下了最后这个的来由:工作流引擎跑在会话 app 里、不进任务表,曾出现“引擎在飞时会话被按 idle 关掉”,从此约定新增的旁路工作一律到这里登记(agent-runtime.ts:402、apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:697)。协议层读驻留事实时,把这一项与自己的命令收尾计数合在一起(session-residency.ts:85)。
下一篇:系统提示词、上下文与提醒——每次请求模型之前,系统提示词由哪些段落拼成,各类提醒在什么时机注入。