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
三段各管一件事:
snapshotJsonValue:一次性读+校验+复制,拒绝 BigInt / function / symbol / undefined /-0/ 非有限数 / 循环引用 / 稀疏数组 / Map / Set / Date / 类实例。注释强调「a stateful getter cannot supply one value to validation and another to storage」——因为读和写用的是同一个快照。validateSessionEventData(surface.ts):语义约束。request/header不能带system字段、不能是空tools、不能是空adapterDefaults;tool/result带error时message.isError必须为true;developer/message必须与role: 'developer'同时出现,且tool-addition必须带非空toolName、不能内联工具定义、必须在有 additions 时提供headerSeq。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):
replaceGeneration:位置替换(compaction)的计数。contentGeneration:位置替换加上插件消息投影(如image/offload,见MESSAGE_PROJECTION_EVENT_TYPES)的计数。Session.deriveMessages()的缓存以它为准——generation不变时只追加新节点(O(新增)),变了就整表重建。
两条额外的结构性守卫: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,结构一变就必须写一段迁移代码。
自测题
Session.append的三段校验里,为什么第一段必须是snapshotJsonValue而不是JSON.parse(JSON.stringify(...))?请举出一个「状态化 getter」能骗过两段式校验的例子。surfaceOp只在五个SurfaceEventType上合法,assistant/message还额外禁止sourceEventSeqs。请解释这两条规则各自解决了什么重建问题。- 一条
replace的sourceEventSeqs必须覆盖每个被遮蔽节点。如果省略其中一个,会在什么时候报错?为什么不在 compaction 里就地补全而是抛错? openTurnClosers对同一个「未配对 tool-call」在forked和interrupted两种 cause 下给出的文案不同,但错误码相同。请解释文案为什么必须不同,而错误码为什么可以相同。deriveMessages()的缓存以contentGeneration为准,而不是replaceGeneration。请说明如果只用replaceGeneration,image/offload这类插件投影会造成什么错误。