Main Chain B · Step 06

Session:日志与 append

05 页的 turn/step 循环里到处是 this.session.append(...)。这一页钻进 packages/core/session/src/index.ts(1310 行)看 append 的每一行——它是「模型可见 ⟺ 已记录」这条不变量的物理实现。

05 agent.ts:304 turn() 里 append('turn/start') → session/src/index.ts:720 Session.append → (下一步) 07 Inbox.mutate 也调 append

示例本次示例:真实 JSONL 的每一行长什么样

真实会话日志(packages/test-support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json 里钉住的格式)与我们的示例消息对照:

示例轨迹 06-1 · 真实 fixture 的 session.jsonl 行
{ "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" } ] } }
来源:仓库真实 fixture(plain-turn/behavior.json)。第一行是 session 头(不在事件日志里,对应本页 header 概念);后两行是事件——注意 seq 从 0 连续、信封是 {type, seq, time, data}。0.1.5 起(SESSION_FORMAT_VERSION 2→3)system prompt 不再是 header 字段:它是 surface 节点 0 的 system/message 事件(本页 §1)。更早的 0.1.3 变更:assistant/chunk 被 assistant/attempt 取代,原始流压缩内嵌(详见 11 页 §4)
示例轨迹 06-2 · 我们的消息经过 append 变成什么
# 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" }
来源:06 页 append 行级解读 + 05 页 374-376 行(0.1.5 起 user/message 在 step 内落盘)
示例轨迹 06-3 · 为什么这个事件「模型可见」
# surface 校验(749-750 行两道校验):user/message 是五个消息生产类型之一,
# surfaceOp:'append' 合法 → 追加到 surface 尾部
# → 之后 09 页 buildRequest 的 deriveMessages() 会把它投影进模型请求
# 若换成 surfaceOp:{op:'replace',start:0,end:0}(compaction 摘要),
# 它就会替换 surface 上的节点——日志不变,模型所见变(21 页)。
来源:surface.ts 的 planSurfaceEvent + 21 页压缩章

§1SessionEvent:信封

packages/core/session/src/types.ts事件信封(判别联合)493-516
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_VERSION 2 → 3(types.ts:88):v3 起 request/header 不再有 system 字段(validateSessionEventData 显式拒绝它,surface.ts:149),system prompt 完全由 system/message 承载。
493-516

判别联合:switch (event.type) 直接收窄 event.data。整个仓库的日志处理都靠这一点。

497

seq 契约:seq = 它进日志时的 log.length,类型 SessionSeq(品牌数字)。

511

ignorable: true = 纯信息事件,不认识的读者可跳过。缺席 = 必需:遇到未识别且无标记的事件必须拒绝重建。

512-516

surfaceOp/sourceEventSeqs 只存在于五个消息生产类型(0.1.5 起含 system/message,0.1.7 起含 developer/message)。SurfaceEvent 类型也简化了:不再是「事件 & 必有 surfaceOp」,而是直接 SessionEvent<SurfaceEventType>——surfaceOp 的必填性由 append 签名与运行时校验共同保证。

§1b0.1.5 的新节点:system/message

packages/core/session/src/types.tssystem/message 的数据形状330
310'system/message': { turn: number; step: number; message: SystemMessage }
330

system/message(0.1.5 新增):loop 把首次渲染的 system prompt 作为 surface 节点 0 追加在首个 user/message 之前。能读 in-history 的路由可在同一系列内追加非空变更;无此能力的路由或新系列会把文本正规化到首个 system 节点(replace 而非新增)。空渲染会清空所有活跃 system 节点——不留旧指令给模型看。

439

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 把同一套思路推广到所有消息——插件现在可以「改写既有历史消息的呈现」,而不必新增或替换节点。

packages/core/session/src/surface.tsSessionMessageProjection 契约23-48
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}
35-48

纯解释、不改 surface 结构:project() 输入一个事件 + 它之前的历史,输出「哪些既有消息被改成了什么」——返回的是按原事件 seq 索引的新消息副本。节点列表(nodes)不变,变的只是那些节点投影出的内容。不可变:不改输入对象,只发布新副本。

23-32

context 让投影器能看到完整历史(nodes + events + 已投影的 messages),因此它能做出依赖上下文的判断,而不是逐条孤立地改写。events 的边界很关键——候选事件之后的条目尚未提交,不能作为输入。

37

注册方式是事件类型本身: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 个:

packages/core/session/src/types.tsSurfaceEventType 的五个成员439-444
439export type SurfaceEventType =
440  | 'system/message'
441  | 'developer/message'   // 0.1.7 新增第 5 个成员
442  | 'user/message'
443  | 'assistant/message'
444  | 'tool/result'
packages/core/session/src/types.tsdeveloper/message 的形状311-317
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  }
311-317

它承载「运行时的增量变更」——主要是工具的增删。注意 headerSeq 的约束:required exactly when additions are present(有工具新增时必须指向之前那条定义了它的 request/header)。这把「模型看到的工具从哪来」钉死在日志里——工具定义不在消息里重复,而是引用。

441

加入 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:唯一写入路径

packages/core/session/src/index.tsappend 主体720-771
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  }
730-737

无损 JSON 是第一道校验:snapshotJsonValue 一次迭代读、校验、拷贝每个嵌套值——BigInt/函数/symbol/undefined/负零/非有限数/循环引用/稀疏数组/Map/Set/Date/类实例全部拒绝。

738-742

防重入:appending 标志——append 的发布边界打开时不允许再 append。

743-748

构造事件:seq = SessionSeq(log.length),data 用快照(不是调用者的原对象),然后 deepFreeze——进日志的值永远不可变。

749-750

两道校验,0.1.5 起分工:validateSessionEventData(surface.ts:172)做字段级校验——拒绝空 optional header 字段、自相矛盾的工具失败元数据,并显式拒绝旧格式的 header.system(提示改用 system/message);surfaceManager.validateNext 做 surface 结构校验(§3)。两者都在 mutation 之前,失败不留半成品。

756-758

通知收集在 push 之前——同步观察者读到的是「日志尚未包含此事件」的状态。

759-760

进内存日志 = 已提交。热路径不阻塞 I/O——持久化插件走 session/event + session/flush 异步缓冲。

762

invokeContainedSessionObservers:观察者失败被逐 listener 隔离。

765-770

finally 清理 appending 标志;若发布期间有人请求 detach,等 unwind 后执行。

§3surface 校验:planSurfaceEvent

append 第 749-750 行的两道校验走 surface.ts(718 行)。核心是 planSurfaceEvent——先计划、再应用,计划失败不留下半成品。0.1.6 起它多了一件事:先看这个事件类型有没有注册的消息投影器(见 §1c):

packages/core/session/src/surface.tsplanSurfaceEvent516-553
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}
524-526

连续性断言:seq 必须是期望值。这是 seq = log.length 契约在读取侧的守卫——重放一个空洞日志会立刻在这里断掉。

527-534

0.1.7 新增 assertDeveloperHeader(surface.ts:373-401):developer 消息里的 tool 增删必须能解析回既有的 request/header——校验 headerSeq 指向更早的 request/header、工具名恰好匹配一个、定义完整。

529-534

投影优先于 surfaceOp(0.1.6 引入):先查有没有为该事件类型注册的投影器;有就交给它,不走到下面的 surfaceOp 路径。这就是 §1c 讲的插件消息改写。

542-544

append 计划:只记 seq,不改结构。

545-548

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 的校验

packages/core/session/src/index.ts私有构造器(seed 路径)548-621
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  }
556-584

seed 走与 append 完全相同的校验:无损 JSON、信封、seq 必须从 0 连续。注释点明动机:replay/fork 不能构造出「任何持久化后端都存不了」的活日志。

578-582

seed 也过 surface 校验(与 append 同一 validateNext)——候选在进 log 前计划,失败不部分 mutate surface。

588

firstLiveSeq = seed 长度:本次进程构造时点。它之前的 seq 来自构造(replay/fork/resume),从不经过 session/event 火线——所以 telemetry 这类「以重放代替订阅」的消费者从这里开始。

591-611

0.1.3-alpha.2 起 seed 与 header 的血缘分离:inheritedEventCount 是 fork 继承前缀长度,未 seeded 的会话必须为 0(否则抛,593-595);seeded 会话必须显式给出它(589-591);快照模式下它还必须恰好等于整个 seed 长度(599-601)。

616-619

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 只负责断言它。