
会话日志唯一真源Session 是一份由类型化 SessionEvent 组成的仅追加日志。它是 agent 完整交互历史的唯一真源LLM 消息历史从日志派生而来从不单独存储。回放就是重新从同一组事件派生历史。日志里的每个事件都有单调递增的seqseq log.length和 epoch 毫秒的time。事件基于type做真正的可辨识联合因此switch (event.type)能直接收窄event.data无需类型断言。所有event.data都必须能无损序列化为 JSONSession.append会在源头强制这一点。模型可见即已记录。抵达模型请求的一切都必须能从日志重建并由一项运行时不变量断言这一点。因此新增一项模型可见输入就需要新增一个会话事件扩展 SessionEventMap 并从日志渲染。需要可回放 transcript 数据的 SDK 用户应当消费session/event事件流。deriveMessages从日志投影模型历史Session.deriveMessages()把事件日志投影成模型看到的Message[]。它是缓存的每个 surface 节点在首次出现时投影一次surface 重写时重建。它返回的是冻结的消息数组通过投影修改已记录的历史在类型上不可表达。投影规则很直接事件投影为说明user/message一条 user 消息携带确切 content可选 envelope 只作为日志展示元数据assistant/message一条 assistant 消息包含提供方、模型与可选回放状态assistant/chunk跳过属于回放/UI 数据组装后的消息才是权威tool/result一条带 tool-result 块的 user 消息工具结果以 user 角色回到模型turn/*、step/*跳过结构信息不投影为消息一个细节内容为空的assistant/message也会被跳过。因 max-tokens 截断且无内容的步骤仍会记录一条 assistant/message 来保存用量、提供方与模型但无内容的 assistant 轮次不得进入提供方 transcript。事件三域选对事件域是大多数改动的第一个决定。官方文档把事件分成三域各有各的用途事件域代表事件特性什么时候用会话事件turn/start、step/start、user/message、assistant/*、tool/call、tool/result追加进日志并广播持久事实某个事实必须在重新加载后仍然存在Agent 事件agent/pre-step、agent/request、agent/status、agent/turn-stopping携带活跃 Agent实时控制与状态观察或拦截进行中的工作能力事件tools/*、fs/*、llm/stream无导入循环地向 seam 附加策略给能力 seam 挂策略与适配器其中agent/pre-step、agent/request、llm/stream和三个tools/*事件是 waterfall监听器必须调用next()才能委托下去。agent/turn-stopping是 serial 事件没有next()。轮次与步骤的定义一个步骤step是一次模型请求加上它调用的工具。一个轮次turn包含零个或多个步骤它在领取首条输入之前打开在不再欠下任何工作时关闭。注意轮次包围一次模型循环执行而不是整个会话日志。官方时序图把完整流程画成turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end。输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒驱动器注入的上下文会留在 inbox 中直到另一条消息将其唤醒。agent/pre-step决定模型看到什么监听器可以改写已领取的消息也可以直接拒绝它们。首次领取被拒绝或被改写为空时仍会关闭一个不含步骤的持久轮次因此日志会记录这次尝试。turn 与 step 的关键事件日志里每个边界都有对应的事件下面列出最常用的一批。事件携带的数据说明turn/start{ turn }在 loop 认领排队输入或运行 pre-step 之前打开轮次turn/end{ turn, reason }以 TurnEndReason 关闭轮次completed / aborted / blocked / error / max-tokens / interruptedstep/start{ turn, step }打开某轮次里的一个步骤step/end{ turn, step }关闭该步骤user/messageUserMessage直接提示词、注入上下文、steering 与实时收件箱事件共享的带标识值assistant/chunk{ turn, step, chunk }原始流式分片token 级回放保真assistant/message{ turn, step, message, usage? }组装后的 assistant 消息派生历史用它tool/call{ turn, step, callId, name, arguments }模型请求的一次工具调用arguments 是模型产出的原始 JSON 字符串tool/result{ turn, step, message, error?, meta? }一次完成的工具调用的模型可见结果一个有用的语义assistant/message事件会记录每次成功的提供方调用包括返回空内容或以 max-tokens 结束的调用。空内容不会进入派生历史但该持久事件仍会保留用量。它通过sourceEventSeqs精确列出对应的assistant/chunk事件包括显式空列表。动手示例解析一份 JSONL 会话日志下面是一小段会话日志JSONL 的规范打包行布局来自一次「修复 runoob 仓库 typo」的任务。每一行是一个 SessionEvent包含 type、seq、time 与 data。{type:turn/start,seq:0,time:1755000000000,data:{turn:1}} {type:step/start,seq:1,time:1755000000010,data:{turn:1,step:1}} {type:user/message,seq:2,time:1755000000020,data:{role:user,content:[{type:text,text:Fix the typo in the runoob README.}]},surfaceOp:append,sourceEventSeqs:[0]} {type:assistant/chunk,seq:3,time:1755000000030,data:{turn:1,step:1,chunk:{type:text-delta,text:Ill }}} {type:assistant/chunk,seq:4,time:1755000000040,data:{turn:1,step:1,chunk:{type:tool-call-delta,name:bash,arguments:{\command\:\grep runoob README.md\}}}} {type:assistant/message,seq:5,time:1755000000050,data:{turn:1,step:1,message:{role:assistant,content:[{type:text,text:Ill search},{type:tool_use,id:call_1,name:bash,input:{command:grep runoob README.md}}]},usage:{inputTokens:12,outputTokens:4}},surfaceOp:append,sourceEventSeqs:[3,4]} {type:tool/call,seq:6,time:1755000000060,data:{turn:1,step:1,callId:call_1,name:bash,arguments:{\command\:\grep runoob README.md\}}} {type:tool/result,seq:7,time:1755000000070,data:{turn:1,step:1,message:{role:tool,toolName:bash,content:runoob,isError:false}},surfaceOp:append,sourceEventSeqs:[6]} {type:step/end,seq:8,time:1755000000080,data:{turn:1,step:1}} {type:turn/end,seq:9,time:1755000000090,data:{turn:1,reason:{kind:completed}}}注意user/message、assistant/message、tool/result三种 surface 事件带有surfaceOp标记说明它们如何加入派生 surface。turn/start、step/start等边界事件不携带 surfaceOp也不会投影成模型消息。下面用 Python 重放这份日志重建对话并标出 turn/step 边界。实例# 文件路径examples/parse_session_log.py# 解析一份 JSONL 会话日志重建模型可见的对话并标出 turn/step 边界。# 这是 Session.deriveMessages() 的一个简化教学模型真实实现是缓存的且返回冻结消息。import jsonimport sysdef derive_messages(events):只投影 surface 事件模拟 deriveMessages 的投影规则。user/message - user 消息assistant/message - assistant 消息tool/result - 携带 tool-result 块的 user 消息turn/*, step/*, assistant/chunk, tool/call 不投影为消息messages []for ev in events:t ev[type]d ev[data]if t user/message:messages.append({role: user, content: d[content]})elif t assistant/message:messages.append({role: assistant, content: d[message][content]})elif t tool/result:messages.append({role: tool, name: d[message][toolName], content: d[message][content]})return messagesdef main(path):with open(path, encodingutf-8) as f:events [json.loads(line) for line in f if line.strip()]# 第一遍打印执行边界理解 turn 与 step 的嵌套关系。for ev in events:d ev[data]if ev[type] turn/start:print(f[turn/start] turn{d[turn]})elif ev[type] turn/end:print(f[turn/end] turn{d[turn]} reason{d[reason]})elif ev[type] step/start:print(f [step/start] turn{d[turn]} step{d[step]})elif ev[type] step/end:print(f [step/end] turn{d[turn]} step{d[step]})elif ev[type] assistant/chunk:print(f chunk: {d[chunk][type]})elif ev[type] tool/call:print(f tool/call: {d[name]} args{d[arguments]})# 第二遍重建模型可见的派生历史。print(\n模型可见的派生消息)for m in derive_messages(events):if m[role] tool:print(f [tool] {m[name]}: {m[content]})else:print(f [{m[role]}] {m[content]})if __name__ __main__:main(sys.argv[1])用这份日志跑一遍输出大致是[turn/start] turn1 [step/start] turn1 step1 chunk: text-delta chunk: tool-call-delta tool/call: bash args{command:grep runoob README.md} [step/end] turn1 step1 [turn/end] turn1 reason{kind: completed} 模型可见的派生消息 [user] [{type: text, text: Fix the typo in the runoob README.}] [assistant] [{type: text, text: Ill search}, {type: tool_use, ...}] [tool] bash: runoob原始assistant/chunk在派生时被跳过组装后的assistant/message才是权威。这就是「模型可见即已记录」模型看到的每一条消息都能从这份日志原样重建。小结自测会话日志是模型所见上下文的唯一来源轮次与步骤是日志上的执行边界事件三域帮你选择正确的扩展点。自测题问题参考想保存「重新加载后仍然存在」的事实用哪类事件会话事件持久追加进日志模型看到的历史是从哪来的Session.deriveMessages()从日志派生从不单独存储一个轮次可以包含多少个步骤零个或多个领取首条输入之前打开不再欠工作时关闭