KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · Session 事件流:append-only 真相与 surface 投影 — keel 龙骨

packages/core/session/src/types.ts: SessionEventMap 的文档注释是这个子系统唯一的宪法:

packages/core/session/src/types.ts: SessionEventMap 的文档注释是这个子系统唯一的宪法:

The merge-extensible, append-only source of truth for an agent interaction. Message history is derived from this log. Every event is lossless JSON and sequence numbers stay contiguous.

两句关键:日志是唯一真相,消息历史是派生结果。这一章讲清楚派生是怎么做的、以及为什么必须有一个独立的 surface 层。

一、事件形状与三处可选项

SessionEvent<T> 是一个按 type 判别的联合(discriminated union),所以 switch (event.type) 能直接收窄 event.data:

{ type, seq, time, data }
  + ignorable?: true                       // 可选,只对「读不懂也能安全跳过」的事件
  + surfaceOp / sourceEventSeqs            // 仅当 type ∈ SurfaceEventType

第三行是编译器强制的:SurfaceEventType 只有五个成员——system/message、developer/message、user/message、assistant/message、tool/result。产生模型消息的事件必须声明 surfaceOp,非消息事件禁止声明。assistant/message 例外,它自带 provider 原始流,不允许引用 sourceEventSeqs。

二、59 种事件类型:核心 14 + 插件 45

known-event-types.ts: KNOWN_SESSION_EVENT_TYPES 由 scripts/gen-persistence-catalog.ts 生成,是「本构建理解的词汇表」。当前 59 项,其中 14 项在 core/session/src/types.ts: SessionEventMap 里就地声明:

turn/start · turn/end · step/start · step/end
user/message · developer/message · system/message · assistant/message · assistant/attempt
tool/call · tool/result · request/header · request/context · session/end-seed

剩下 45 项全部由各自的 生产包通过模块合并补进同一张表(见下节):approval/asked|decided、plan/mode、permission/preset、sandbox/mode、model/selection、agent-preset/selected、compaction/start|end|prune|summary、tool/ptc-dispatch(-start)、hook/invoked|result、command/run|done、llm/retry|retry-started、image/offload、todo/write、goal/change、schedule/change、workspace/changes、deliverables/presented、subagent/catalog|descriptor|model-selection-policy、team/*、feedback/*、tool-workflow/*、web/deepseek-search-llm-request、session/title|title-llm-request、session-log-deepseek/delivery-accepted、agent/inbox/spliced。

注意 known-event-types.ts 是生成物,手写的那份是 types.ts。packages/core/session/tests/gen-persistence-catalog.spec.ts 会在测试里断言生成结果与源码一致(也是 doc-sync 的一部分)。

三、append 的三段校验

session/index.ts: Session.append 在入日志之前做三段校验,任一失败即在调用点抛出:

const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) throw new Error(`session event "${type}" carries non-JSON-serializable data`)
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
// …reentrancy 检查…
const event = deepFreeze({ type, seq: SessionSeq(this.log.length), time: Date.now(), data: dataSnapshot, ... })
validateSessionEventData(event, `session event "${type}" at seq ${event.seq}`)
this.surfaceManager.validateNext(event as SessionEvent)
this.log.push(event as SessionEvent)     // 到这里才算 committed

三段各管一件事:

  1. snapshotJsonValue:一次性读+校验+复制,拒绝 BigInt / function / symbol / undefined / -0 / 非有限数 / 循环引用 / 稀疏数组 / Map / Set / Date / 类实例。注释强调「a stateful getter cannot supply one value to validation and another to storage」——因为读和写用的是同一个快照。
  2. validateSessionEventData(surface.ts):语义约束。request/header 不能带 system 字段、不能是空 tools、不能是空 adapterDefaults;tool/result 带 error 时 message.isError 必须为 true;developer/message 必须与 role: 'developer' 同时出现,且 tool-addition 必须带非空 toolName、不能内联工具定义、必须在有 additions 时提供 headerSeq。
  3. surfaceManager.validateNext:surface 契约。marker 形状与资格、sourceEventSeqs 必须全为更早且不重复的 seq、replace 的 startSeq/endSeq 必须已在当前 surface 中、必须覆盖全部被遮蔽节点。

「在 append 点就抛」是被刻意选择的:注释说 The event log is the durable source of truth, so a bad event fails at the append site rather than later during a backend flush。

append 还带一个重入闸:if (entry?.appending) throw new Error('session append cannot reenter while another append is being published')。entry.appending 在 log.push 前后翻转,因此事件观察者在回调里 append 会失败。

四、跨包扩展:declare module 合并

插件往 SessionEventMap 加自己的事件类型,靠的是 TypeScript 的模块合并。模块说明符是 '@deepseek-ai/dsh-session/types',不是 '@deepseek-ai/dsh-session':

// packages/interaction/user-approval/src/types.ts
declare module '@deepseek-ai/dsh-session/types' {
  interface SessionEventMap {
    'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: ToolCallId; reason?: string }
    'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
  }
}

同类还有 packages/plan/plan-mode/src/index.ts:47(plan/mode)、packages/interaction/permission-presets/src/index.ts:52(permission/preset)、packages/sandbox/sandbox-policy/src/session-mode.ts:25(sandbox/mode)、packages/core/tools/src/types.ts:28(tool/ptc-dispatch*)、packages/api/session-controller/src/types.ts:35(model/selection、subagent/catalog|descriptor)、packages/hooks/hook-protocol/src/types.ts:9、packages/llm/llm-retry/src/types.ts:7、packages/core/agent/src/types.ts:90。

同目录还有第二个可合并的 map:SessionProjectionStateMap(宿主投影状态,见 packages/session/session-projection)。事件日志建模「发生过什么」,投影建模「UI 需要看到什么」。

五、surface:为什么需要第二层

如果直接遍历日志取消息,compaction 就无法表达「这段历史被摘要替换了」。所以 surface 是独立的一层:它维护一个有序节点列表(SessionSurface.nodes: readonly SessionSeq[]),只含五个消息产生类型的事件 seq。

SurfaceOp 只有两种:

type SurfaceOp =
  | 'append'
  | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }

replace 的语义是「用我这个节点替换 startSeq..endSeq 这段(含两端)的现有 surface 节点」,并要求 sourceEventSeqs 覆盖全部被遮蔽节点。压缩就是这么写的(packages/compaction/compaction-basic/src/region.ts:507):先写 compaction/start 与 log-only 的 compaction/summary,再写一条 user/message 承载摘要,sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs]。

surface 提供两个代数(surface.ts: SessionSurface):

两条额外的结构性守卫:assertToolResultRewrite 限制工具结果替换只能改 content(其余字段必须深相等),assertSystemHeadRewrite 保护 node 0 的系统提示词只能被一条恰好覆盖它的 system/message 替换。

六、未知类型:ignorable 是唯一的兼容机制

读路径遇到不在 KNOWN_SESSION_EVENT_TYPES 里的类型,必须拒绝解释整个 session,除非该事件带 ignorable: true:

Absent means required: a reader meeting an unrecognized type without this marker
MUST refuse to reconstruct the session instead of silently dropping the event.

注释还记录了被否决的方案:事件名注册(event-name registration)——理由是它不区分「漏读是否安全」,且会让读的行为依赖组合。默认「必需」的取向也很明确:忘记标 ignorable 会过度拒绝(麻烦),而不是静默恢复一个残缺 session。SessionEventType 的类型默认因此是保守的。

七、fork / resume / replay

fork:packages/core/session/src/fork.ts: buildForkSeed(events, boundary) 复制 0..boundary 的精确前缀,立刻推入一条 session/end-seed 带 { inherited: true },再用 repair.ts: openTurnClosers(prefix, { kind: 'forked' }) 合成尾部闭合。openTurnClosers 扫描最后一个未闭合 turn:为每个未配对的 tool-call 补一条 isError 的 tool/result(错误码 TOOL_NOT_STARTED 或 TOOL_OUTCOME_UNKNOWN),然后补 step/end 与 turn/end: forked。已闭合的 step 里的调用一律不动。

repair.ts 还有第二个入口 interruptedTurnClosers(events),同一套机制但 cause 是 interrupted——供持久化崩溃恢复用。两条合成路径共享 CLOSER_TEXT(文案),但 turn/end 的 reason 和合成 message-id 前缀由 cause 决定。

resume:packages/api/session-controller/src/agent.ts: resume / resumeObserved 负责拿回一个已有 session。循环侧不知道「这是 resume」——它只写 request/header 的 reason:本 loop 第一次请求且 log 里没有 header → initial;本 loop 第一次请求但 log 里已有 header → resume;header 与基线不等 → change;header 相同但显式开启新 series(或刚发生过 surface 替换)→ series。Session.requestHeader() 是对日志的增量 fold(headerFoldSeq),每次只折新事件。

replay:assistant/message 自带 stream: AssistantStreamRecord[](带时间戳的原始 chunk 序列,不合并 delta 边界)。llm-deepseek/src/replay.ts: replayState(model, blocks) 与 llm-pi-ai/src/replay.ts 用它还原 provider 侧需要的签名/推理元数据,replayState 是 { response: { kind, version, model }, blocks } 形态的版本化信封。失败或取消的尝试没有消息可承载流,改写成 assistant/attempt。

八、持久化与格式迁移链

session-persistence 是契约,session-persistence-jsonl 是实现:format.ts: logSuffix(compression) 返回 .jsonl 或 .jsonl.zstd;zstd 有 public/private 两个 decoder(zstd-public-decoder.ts / zstd-private-decoder.ts),另有 lease.ts、win32.ts(Windows 下的重命名/锁差异)、worker.ts(编解码放到 worker)。

格式版本是单个单调整数,当前 SESSION_FORMAT_VERSION = 4(types.ts)。注释花了大段篇幅论证「什么时候该 bump」:由写入方能写出什么决定,而不是由读方能接受什么决定;只有结构变化(header 形状、event 信封、核心事件语义、surface 机制)才够格;新增一个普通事件类型不 bump,由 per-event 的 ignorable 承担。

迁移链在 packages/session/session-format/src/chain.ts:defineSessionFormatMigration 强制 to === from + 1(相邻),createSessionFormatChain 编译出一条唯一且完整的链。四段实现分别是 session-format-v0-to-v1…v3-to-v4,历史版本目录由 session-format-catalog(historical.ts 明确说「never publishes or completes parent catalogs」)提供。

代码地图

机制 位置 要点
事件表与宪法注释 packages/core/session/src/types.ts: SessionEventMap 「append-only source of truth,Message history is derived」
格式版本 packages/core/session/src/types.ts: SESSION_FORMAT_VERSION 值 4;注释定义什么算需要 bump
事件词汇表 packages/core/session/src/known-event-types.ts: KNOWN_SESSION_EVENT_TYPES 59 项,生成物;含 MESSAGE_PROJECTION_EVENT_TYPES
追加三段校验 packages/core/session/src/index.ts: Session.append snapshotJsonValue → validateSessionEventData → validateNext
语义校验 packages/core/session/src/surface.ts: validateSessionEventData header 空字段、developer role 配对、tool error 一致性
surface 元数据校验 packages/core/session/src/surface.ts: validateSurfaceMetadata marker 形状、资格、更早 seq 引用
surface 折叠 packages/core/session/src/surface.ts: foldSurface 全量重放得到 nodes / replacements / projectedMessages
增量 surface packages/core/session/src/surface.ts: SurfaceManager validateNext 先校验后提交;contentGeneration 驱动缓存失效
消息派生 packages/core/session/src/surface.ts: deriveEventMessage 非穷尽 switch;空 content 的 system/developer/assistant 派生为 null
消息历史缓存 packages/core/session/src/index.ts: Session.deriveMessages 只在 contentGeneration 变化时重建
header 折叠 packages/core/session/src/index.ts: Session.requestHeader headerFoldSeq 增量,返回深冻结值
fork seed packages/core/session/src/fork.ts: buildForkSeed 精确前缀 + session/end-seed{inherited:true} + forked closers
尾部闭合 packages/core/session/src/repair.ts: openTurnClosers 补合成错误结果 → step/end → turn/end
崩溃恢复入口 packages/core/session/src/repair.ts: interruptedTurnClosers 同机制,cause 为 interrupted
resume 入口 packages/api/session-controller/src/agent.ts: resumeObserved 拿回已有 session 的观察状态
JSONL 后缀 packages/session/session-persistence-jsonl/src/format.ts: logSuffix .jsonl / .jsonl.zstd
迁移相邻性 packages/session/session-format/src/chain.ts: defineSessionFormatMigration 强制 to === from + 1,名字唯一
审批审计事件 packages/interaction/user-approval/src/types.ts approval/asked/decided 明确 log-only、无 surfaceOp

关键取舍

日志与消息历史分离成两层,代价是每次读历史都要走一遍 surface 折叠。
好处是 compaction 不需要改写历史——它只是往日志里再写一条 replace 事件,被遮蔽的节点仍在日志里可查。代价是 deriveMessages() 必须维护缓存与 generation 计数,且任何位置替换都会让整个派生表重建。

sourceEventSeqs 要求覆盖全部被遮蔽节点,代价是压缩要枚举几十个 seq。
assertSourceEventReferences 会算出 missing 并报出具体缺哪些 seq。好处是「一条摘要到底替代了哪几条」在日志里是自证完备的,repair/审计不需要额外推导。代价是替换的 payload 体积随被遮蔽区间线性增长。

ignorable 默认缺席(即必需),代价是跨版本读会过度拒绝。
注释明确把两个方向的错误做了不对称权衡:忘记标 ignorable → 读拒绝(可见、可修);错标 ignorable → 静默丢一个影响重建的事件(不可见、难查)。选前者。代价是任何新增的纯信息事件如果忘了标,会让旧读方直接拒绝整个 session。

surface 只能被「消息产生事件」改写,代价是压缩必须伪装成 user message。
SurfaceEventType 是封闭五元组,所以 compaction 的摘要必须包成一条 user/message(frameSummary 渲染)而不是自定义类型。好处是模型看到的历史与普通用户输入同构,provider 侧无需特判。代价是「这是压缩产物」这一事实只能通过 source/compaction/summary 事件反查。

格式版本用单一整数而不是 major.minor,代价是每个升级都要写一段真实迁移。
SESSION_FORMAT_VERSION = 4,defineSessionFormatMigration 强制相邻链。好处是「v2 读到 v4 日志」这类组合不存在。代价是没有「向后兼容的小改」这一档——新增事件类型不 bump,结构一变就必须写一段迁移代码。

自测题

  1. Session.append 的三段校验里,为什么第一段必须是 snapshotJsonValue 而不是 JSON.parse(JSON.stringify(...))?请举出一个「状态化 getter」能骗过两段式校验的例子。
  2. surfaceOp 只在五个 SurfaceEventType 上合法,assistant/message 还额外禁止 sourceEventSeqs。请解释这两条规则各自解决了什么重建问题。
  3. 一条 replace 的 sourceEventSeqs 必须覆盖每个被遮蔽节点。如果省略其中一个,会在什么时候报错?为什么不在 compaction 里就地补全而是抛错?
  4. openTurnClosers 对同一个「未配对 tool-call」在 forked 和 interrupted 两种 cause 下给出的文案不同,但错误码相同。请解释文案为什么必须不同,而错误码为什么可以相同。
  5. deriveMessages() 的缓存以 contentGeneration 为准,而不是 replaceGeneration。请说明如果只用 replaceGeneration,image/offload 这类插件投影会造成什么错误。

进入 keel 阅读