Main Chain B · Step 06
Session:日志与 append
05 页的 turn/step 循环里到处是 this.session.append(...)。这一页钻进 packages/core/session/src/index.ts(1310 行)看 append 的每一行——它是「模型可见 ⟺ 已记录」这条不变量的物理实现。
示例本次示例:真实 JSONL 的每一行长什么样
真实会话日志(packages/test-support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json 里钉住的格式)与我们的示例消息对照:
{ "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}", "delegationDepth": 0 }
{ "type": "system/message", "seq": 0, "time": 5, "data": {
"turn": 1, "step": 1, "message": { "role": "system", "content": [...] } },
"surfaceOp": "append" }
{ "type": "request/header", "seq": 1, "time": 5, "data": { "header": {
"config": { "model": "fake" },
"tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] },
"reason": "initial" } }
{ "type": "assistant/attempt", "seq": 2, "time": 5, "data": {
"turn": 1, "step": 1, "stream": [ { "type": "text-delta", "index": 0, "text": "hi" } ] } }
system/message 事件(本页 §1)。更早的 0.1.3 变更:assistant/chunk 被 assistant/attempt 取代,原始流压缩内嵌(详见 11 页 §4)# turn() 里调用:session.append('user/message', message, {surfaceOp:'append'})(11 页 step:374-376)
# 输入:{ role:'user', content:[{type:'text',text:'帮我列一下当前目录'}], source:{kind:'user'} }
# append 的变换(本页 720-771 行):
# ① snapshotJsonValue 无损 JSON 校验 → 通过(纯文本)
# ② deepFreeze 冻结 → 事件对象不可变
# ③ seq = log.length(此时假设 log 已有 2 条:turn/start=0、step/start=1)→ seq=2
# ④ log.push → 内存日志第 3 条;session/event 通知持久化插件
# 落盘事件:
{ "type":"user/message", "seq":2, "time":..., "data":{...原样...}, "surfaceOp":"append" }
# surface 校验(749-750 行两道校验):user/message 是五个消息生产类型之一,
# surfaceOp:'append' 合法 → 追加到 surface 尾部
# → 之后 09 页 buildRequest 的 deriveMessages() 会把它投影进模型请求
# 若换成 surfaceOp:{op:'replace',start:0,end:0}(compaction 摘要),
# 它就会替换 surface 上的节点——日志不变,模型所见变(21 页)。
§1SessionEvent:信封
493export type SessionEvent<T extends SessionEventType = SessionEventType> = {
494 [K in SessionEventType]: {
495 type: K
497 seq: SessionSeq // 单调序号:seq = log.length(品牌数字)
499 time: number // Unix epoch ms
500 data: SessionEventMap[K]
511 ignorable?: true // 读者不认识此 type 时可安全跳过
512 } & (K extends SurfaceEventType ? {
516}[T]
0.1.5 的两个结构变化:
SurfaceEventType从三类型扩到四类型(types.ts:412):'system/message'加入user/message、assistant/message、tool/result。system prompt 不再是 header 里的字符串字段,而是 surface 上的节点 0——一个system/message事件(types.ts:310)。SESSION_FORMAT_VERSION2 → 3(types.ts:88):v3 起request/header不再有system字段(validateSessionEventData显式拒绝它,surface.ts:149),system prompt 完全由system/message承载。
判别联合:switch (event.type) 直接收窄 event.data。整个仓库的日志处理都靠这一点。
seq 契约:seq = 它进日志时的 log.length,类型 SessionSeq(品牌数字)。
ignorable: true = 纯信息事件,不认识的读者可跳过。缺席 = 必需:遇到未识别且无标记的事件必须拒绝重建。
surfaceOp/sourceEventSeqs 只存在于五个消息生产类型(0.1.5 起含 system/message,0.1.7 起含 developer/message)。SurfaceEvent 类型也简化了:不再是「事件 & 必有 surfaceOp」,而是直接 SessionEvent<SurfaceEventType>——surfaceOp 的必填性由 append 签名与运行时校验共同保证。
§1b0.1.5 的新节点:system/message
310'system/message': { turn: number; step: number; message: SystemMessage }
system/message(0.1.5 新增):loop 把首次渲染的 system prompt 作为 surface 节点 0 追加在首个 user/message 之前。能读 in-history 的路由可在同一系列内追加非空变更;无此能力的路由或新系列会把文本正规化到首个 system 节点(replace 而非新增)。空渲染会清空所有活跃 system 节点——不留旧指令给模型看。
SurfaceEventType 的成员之一。这一加入让 surface 的规则保持单一:system prompt 与其他消息走同一套 planSurfaceEvent / surfaceOp 机制(§3),而不是作为请求头的特例存在。
这是格式断点:SESSION_FORMAT_VERSION 从 2 升到 3。0.1.7-rc.1 又升到 4(types.ts:89)。dsh 的持久化默认不做隐式迁移(20 页:版本不匹配直接拒绝)——但每个断点都配了显式的迁移包(session-format-v2-to-v3/、session-format-v3-to-v4/),旧日志可在明确同意下升级。
§1c0.1.6 新增:插件的消息投影
0.1.5 让 system prompt 变成了 surface 节点;0.1.6 把同一套思路推广到所有消息——插件现在可以「改写既有历史消息的呈现」,而不必新增或替换节点。
23export interface SessionMessageProjectionContext {
25 nodes: readonly SessionSeq[] // 当前产生消息的事件序(模型可见顺序)
27 events: readonly SessionEvent[] // 连续事件窗口;candidate 及其后的条目不是已提交输入
29 baseSeq: SessionLogOffset // 窗口首事件的绝对序号
31 messages: ReadonlyMap<SessionSeq, Message> // 已投影的消息,按原事件 seq 索引
32}
35export interface SessionMessageProjection<T extends SessionEventType = SessionEventType> {
37 type: T // 由 @messageProjection 在 SessionEventMap 里声明事件类型即注册键
47 project(event: SessionEvent<T>, context: SessionMessageProjectionContext): ReadonlyMap<SessionSeq, Message>
48}
纯解释、不改 surface 结构:project() 输入一个事件 + 它之前的历史,输出「哪些既有消息被改成了什么」——返回的是按原事件 seq 索引的新消息副本。节点列表(nodes)不变,变的只是那些节点投影出的内容。不可变:不改输入对象,只发布新副本。
context 让投影器能看到完整历史(nodes + events + 已投影的 messages),因此它能做出依赖上下文的判断,而不是逐条孤立地改写。events 的边界很关键——候选事件之后的条目尚未提交,不能作为输入。
注册方式是事件类型本身:type 字段声明这个投影器服务于哪个事件类型,事件类型在 SessionEventMap 里用 @messageProjection 标注。MESSAGE_PROJECTION_EVENT_TYPES(known-event-types.ts)是它的运行时清单——0.1.6 首个成员是 'image/offload'(图片卸载:把历史里的大图替换成占位文本,而日志原样保留)。
为什么这比「再加一种 surfaceOp」好?replace 会改变节点列表——它表达的是「这条历史不再存在」。而投影表达的是「这条历史还在,但模型看到的样子变了」:日志不变、节点不变、事件序不变,只有 message 内容变。而且它是可逆的——移除投影器定义,历史立刻回到原样(SurfaceManager 的构造器注释说「live borrowed definitions;移除一个用过的定义会让后续读取失效」)。这正是 21 页 compaction 做不到的事。
两个 generation 别混用:replaceGeneration(surface.ts:228)只统计位置性替换;contentGeneration(230 行)额外统计投影改写。缓存重建(本页 §5)看前者就够;但 loop 判断「要不要重发 system prompt」必须看后者——否则插件改写了一条历史消息,模型侧不会有任何反应。
§1d0.1.7 新增:developer/message 与 forked 标记
0.1.7-rc.1 把 SESSION_FORMAT_VERSION 从 3 升到 4,带来两类新事件。
① developer/message:一等公民的运行时变更
surface 的消息生产类型从 4 个扩到 5 个:
439export type SurfaceEventType =
440 | 'system/message'
441 | 'developer/message' // 0.1.7 新增第 5 个成员
442 | 'user/message'
443 | 'assistant/message'
444 | 'tool/result'
311 'developer/message': {
312 turn: number
313 step: number
314 message: DeveloperMessage
315 /** Earlier request/header defining every tool addition; required exactly when additions are present. */工具增删必须指向定义它们的 header
316 headerSeq?: SessionSeq
317 }
它承载「运行时的增量变更」——主要是工具的增删。注意 headerSeq 的约束:required exactly when additions are present(有工具新增时必须指向之前那条定义了它的 request/header)。这把「模型看到的工具从哪来」钉死在日志里——工具定义不在消息里重复,而是引用。
加入 SurfaceEventType 意味着它和其他消息一样走 surface 规则(surfaceOp、可被 projection 改写、进 deriveMessages())。模型能直接看到 developer 消息——这正是 OpenAI developer 角色的语义。
对应的校验在 surface.ts(validateSessionEventData:177-205,见 §3):① developer 事件必须配 role === 'developer';② 非 developer role 不得带 tool-addition/tool-removal 块;③ tool-addition 不得内联工具定义(定义必须来自 header);④ headerSeq 与 tool additions 必须同时出现或缺席。
② forked:fork 不再拒绝「开着的 turn」
旧版 fork 一个正处在 turn 中间的会话会被拒绝(SessionForkErrorCode 里的 OPEN_TURN)。0.1.7 改为合成闭合事件:
| 0.1.6 及以前 | 0.1.7-rc.1 | |
|---|---|---|
| turn 结局类型 | completed / max-tokens / blocked / error / aborted / interrupted | 六种 + forked |
| fork 落在 open turn 内 | 拒绝(OPEN_TURN 错误码) | 接受——由 buildForkSeed(session/src/fork.ts)合成 closers |
| 新字段 | — | firstLifecycleSeq(index.ts:492)区分「继承前缀」与「本生命周期」 |
forked 的注释说明了它的特殊性:「Only fork seeds carry this marker — the loop never emits it」——和 interrupted 一样,它是持久化层专用标记,loop 自己从不发。TurnEndReasonMap.forked 定义在 types.ts:228。
§2append:唯一写入路径
720 append<T extends SessionEventType>(
721 type: T,
722 data: SessionEventMap[T],
723 ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []
724 ): SessionEvent<T> {
725 const surfaceOpts: SurfaceIntent | undefined = opts[0]
726 const surfaceMetadata = {
727 ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: ... },
728 ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: ... },
729 }
730 const dataSnapshot = snapshotJsonValue(data)
732 if (dataSnapshot === undefined) {
733 throw new Error(`session event "${type}" carries non-JSON-serializable data`)
734 }
735 assertSupportedRequestHeader(type, dataSnapshot, ...)
736 const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
740 if (entry?.appending) {
741 throw new Error('session append cannot reenter while another append is being published')
742 }
743 const event = deepFreeze({
743 type,
744 seq: SessionSeq(this.log.length), // ← seq 契约的落点(品牌化)
745 time: Date.now(),
746 data: dataSnapshot,
747 ...(surfaceMetadataSnapshot as ...),
748 } as unknown as SessionEvent<T>)
749 validateSessionEventData(event, ...) // 0.1.5 新增:字段级校验0.1.7 起还管 developer/tool 变更
750 this.surfaceManager.validateNext(event as SessionEvent)
752 if (entry !== undefined) entry.appending = true
753 try {
754 let callbacks: SessionCallback[] | undefined
755 const callbackArgs: unknown[] = [this, event]
756 if (entry !== undefined) {
757 callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
758 }
759 this.log.push(event as SessionEvent) // ① 进日志 = 已提交
760 this.eventsSnapshot = undefined // ② 快照失效
761 if (callbacks !== undefined && entry !== undefined) {
762 invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks) // ③ 通知
763 }
764 return event
765 } finally {
766 if (entry !== undefined) {
767 entry.appending = false
768 if (entry.detachRequested && !entry.announcing) entry.detach()
769 }
770 }
771 }
无损 JSON 是第一道校验:snapshotJsonValue 一次迭代读、校验、拷贝每个嵌套值——BigInt/函数/symbol/undefined/负零/非有限数/循环引用/稀疏数组/Map/Set/Date/类实例全部拒绝。
防重入:appending 标志——append 的发布边界打开时不允许再 append。
构造事件:seq = SessionSeq(log.length),data 用快照(不是调用者的原对象),然后 deepFreeze——进日志的值永远不可变。
两道校验,0.1.5 起分工:validateSessionEventData(surface.ts:172)做字段级校验——拒绝空 optional header 字段、自相矛盾的工具失败元数据,并显式拒绝旧格式的 header.system(提示改用 system/message);surfaceManager.validateNext 做 surface 结构校验(§3)。两者都在 mutation 之前,失败不留半成品。
通知收集在 push 之前——同步观察者读到的是「日志尚未包含此事件」的状态。
进内存日志 = 已提交。热路径不阻塞 I/O——持久化插件走 session/event + session/flush 异步缓冲。
invokeContainedSessionObservers:观察者失败被逐 listener 隔离。
finally 清理 appending 标志;若发布期间有人请求 detach,等 unwind 后执行。
§3surface 校验:planSurfaceEvent
append 第 749-750 行的两道校验走 surface.ts(718 行)。核心是 planSurfaceEvent——先计划、再应用,计划失败不留下半成品。0.1.6 起它多了一件事:先看这个事件类型有没有注册的消息投影器(见 §1c):
516function planSurfaceEvent(
517 state: SurfaceFoldState,
518 event: SessionEvent,
519 expectedSeq: SessionSeq,
520 events: readonly SessionEvent[],
521 baseSeq: SessionLogOffset,
522 projections: readonly SessionMessageProjection[],
523): SurfacePlan | undefined {
524 if (event.seq !== expectedSeq) {
525 throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
526 }
527 const surfaceOp = validateSurfaceMetadata(event)
528 assertDeveloperHeader(event, events, baseSeq) // 0.1.7 新增校验 tool 变更引用
529 const projection = projections.find(item => item.type === event.type)0.1.6:投影优先
530 if (projection !== undefined) {
531 return { kind: 'project', projection, messages: projection.project(event, {
532 nodes: state.nodes, events, baseSeq, messages: state.projectedMessages,
533 }) }
534 }
541 if (surfaceOp === undefined) return
542 if (surfaceOp === 'append') {
543 return { kind: 'append', seq: event.seq }
544 }
545 const range = replacementRange(state, surfaceOp)
546 assertSourceEventReferences(event, range.shadowedSeqs)
547 assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
548 assertSystemHeadRewrite(event, state, range.startIdx, range.shadowedSeqs, events, baseSeq)
549 return { kind: 'replace', seq: event.seq, start: surfaceOp.startSeq, end: surfaceOp.endSeq, ...range }
553}
连续性断言:seq 必须是期望值。这是 seq = log.length 契约在读取侧的守卫——重放一个空洞日志会立刻在这里断掉。
0.1.7 新增 assertDeveloperHeader(surface.ts:373-401):developer 消息里的 tool 增删必须能解析回既有的 request/header——校验 headerSeq 指向更早的 request/header、工具名恰好匹配一个、定义完整。
投影优先于 surfaceOp(0.1.6 引入):先查有没有为该事件类型注册的投影器;有就交给它,不走到下面的 surfaceOp 路径。这就是 §1c 讲的插件消息改写。
append 计划:只记 seq,不改结构。
replace 计划的三道断言,各有专名:assertSourceEventReferences(provenance 必须覆盖每个被遮蔽节点)、assertToolResultRewrite(405 行,tool/result 的替换只能改 content——见下)、assertSystemHeadRewrite(0.1.5 起,system 节点 0 的改写规则)。
assertToolResultRewrite(462-486 行)为什么这么细?它要求替换一个 tool/result 时,除了 content 之外的每个字段都必须逐字相同(把 content 置 null 后做深比较)。理由:工具结果的 callId/sourceEventSeqs 是模型历史与日志事件之间的对应关系;允许改写它们就等于允许「伪造一条工具调用的出处」。所以只放开 content——那才是「呈现」。
§4构造器:seed 的校验
548 private constructor(
549 id: SessionId,
550 seed?: readonly SessionEvent[],
551 header?: SessionHeader,
552 mode: 'snapshot' | SessionSeedEventState = 'snapshot',
553 suppliedInheritedEventCount?: SessionLogOffset,
554 ) {
555 const restoredHeader = mode === 'snapshot' ? undefined : validateRestoredSessionHeader(id, header)
556 if (seed !== undefined) {
564 for (const [index, source] of seed.entries()) {
567 const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source
568 if (snapshot === undefined) {
569 throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
570 }
571 assertSessionEventEnvelope(snapshot, index)
572 if (snapshot.seq !== index) {
573 throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
574 }
579 this.surfaceManager.validateNext(snapshot)
584 this.log.push(mode === 'snapshot' ? deepFreeze(snapshot) : snapshot)
586 this.firstLiveSeq = SessionLogOffset(this.log.length)
587 this.header = restoredHeader ?? snapshotSessionHeader(id, header)
588 if (this.header.isSeeded && seed === undefined) {
589 throw new Error('seeded session requires an explicit constructor seed')
590 }
594 const inheritedEventCount = SessionLogOffset(suppliedInheritedEventCount ?? 0)
595 if (!this.header.isSeeded && inheritedEventCount !== 0) {
596 throw new Error('unseeded session inherited event count must be 0')
597 }
606 this.inheritedEventCount = inheritedEventCount
610 if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {0.1.3-alpha.2 起
617 this.append('session/end-seed', { inherited: true }) // 带 inherited 标记
614 } else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
613 this.append('session/end-seed', {})
614 }
615 }
seed 走与 append 完全相同的校验:无损 JSON、信封、seq 必须从 0 连续。注释点明动机:replay/fork 不能构造出「任何持久化后端都存不了」的活日志。
seed 也过 surface 校验(与 append 同一 validateNext)——候选在进 log 前计划,失败不部分 mutate surface。
firstLiveSeq = seed 长度:本次进程构造时点。它之前的 seq 来自构造(replay/fork/resume),从不经过 session/event 火线——所以 telemetry 这类「以重放代替订阅」的消费者从这里开始。
0.1.3-alpha.2 起 seed 与 header 的血缘分离:inheritedEventCount 是 fork 继承前缀长度,未 seeded 的会话必须为 0(否则抛,593-595);seeded 会话必须显式给出它(589-591);快照模式下它还必须恰好等于整个 seed 长度(599-601)。
session/end-seed 标记:firstLiveSeq 的持久投影(log-only 事件)。0.1.3-alpha.2 起带 { inherited: true } 的变体标记「这个 seed 来自父会话继承」(607 行);普通 resume 走 609 行的空标记。只写一次——seed 已以它结尾就不再标记(608 的守卫)。
§5deriveMessages 与 firstLiveSeq
第 09 页 buildRequest 会调 deriveMessages(),这里提前看它的缓存结构(index.ts:701-729):
- 投影规则是一个函数:
deriveEventMessage(surface.ts:92-125)——user/message 原样返回;assistant/message 空内容返回 null(只为承载 max-tokens usage 的事件不进 transcript);tool/result 返回 message;其他类型(边界、chunk、log-only)投影为 null。 - 增量缓存:每个 surface 节点只投影一次(O(新节点));surface 重写(replace)使
replaceGeneration变化,缓存重建。 - 共享冻结:返回的数组每次是全新快照(后追加不会撑大已持有的数组),其中的 Message 对象与日志事件共享同一批深冻结对象——投递、持久历史、模型请求三界共用,无第二份深拷贝。
为什么模型历史必须来自日志投影?因为 09 页的 buildRequest 把 deriveMessages() 的结果直接放进请求。任何想给模型塞内容的路径都只能先 append 到日志。「模型可见 ⟺ 已记录」从一条纪律变成结构上不可能违反的约束——invariant 只负责断言它。