Session 文件与回放边界

对应源码

主题源码
Session 内存对象与回放nanobot/session/manager.py::Session、Session.get_history
JSONL 加载、修复和原子保存nanobot/session/manager.py::SessionManager
Session Key 规范nanobot/session/keys.py
Turn 读取与保存调用点nanobot/agent/loop.py::AgentLoop._state_build、_save_turn、_state_save

学习目标

读完后,你应当能够:

  • 读懂 Session JSONL;
  • 解释 metadata、messages 和 last_consolidated;
  • 说明 Session 为什么不会把磁盘内容原样全部回放;
  • 理解原子保存和损坏恢复的基本策略。

前置知识

1. Session 对象

定义:

  • nanobot/session/manager.py::Session

核心字段:

字段含义
keySession 唯一标识
messages已持久化消息
created_at创建时间
updated_at最近更新时间
metadataGoal、checkpoint、UI 等 Session 级状态
last_consolidated已归档到 Memory history 的消息边界

last_consolidated 如果损坏或越界,会在 __post_init__ 中重置,避免错误 offset 隐藏全部历史。

2. 文件位置

默认位于:

<workspace>/sessions/*.jsonl

SessionManager.safe_key() 将 Session key 转换为安全文件名。文件名不是理解归属的唯一依据,metadata 记录中仍保存原 key。

3. JSONL 结构

保存时第一行是 metadata record,后面每行是一条消息。示意:

{"_type":"metadata","key":"cli:direct","created_at":"...","updated_at":"...","metadata":{},"last_consolidated":0}
{"role":"user","content":"你好","timestamp":"..."}
{"role":"assistant","content":"你好!","timestamp":"..."}

实际字段以当前 SessionManager.save() 为准。

这种格式的优点:

  • 可逐行读取;
  • 损坏时可尝试保留合法行;
  • 列表页只读 metadata 和少量 preview;
  • 不必一次解析一个巨大 JSON 数组。

4. add_message() 只负责追加

Session.add_message():

  • 添加 role;
  • 添加 content;
  • 添加 timestamp;
  • 合并附加 kwargs;
  • 更新 updated_at。

它不负责:

  • schema 完整性;
  • Tool Call 边界;
  • 上下文预算;
  • 磁盘写入。

合法化和保存由上层负责。

5. get_history() 返回可回放视图

Session 可能保存很多消息,但 get_history() 会根据:

  • last_consolidated;
  • max_messages;
  • max_tokens;
  • user/tool 边界;
  • tool call 配对;
  • command/内部消息过滤;
  • 时间戳选项;
  • media/CLI/MCP breadcrumb

返回适合 ContextBuilder 的历史。

Session.messages
  → 排除已 consolidation 的前缀
  → 截取最近消息/token
  → 找到合法 user 起点
  → 修复 Provider replay
  → 可选添加 user timestamp
  → history

6. 为什么只给 user 消息加时间

_annotate_message_time() 只为 user turn 添加时间前缀。

原因写在源码注释中:

  • 如果每条 assistant 历史都带 [Message Time: ...];
  • 模型可能通过 in-context imitation 在自己的回复中重复该格式;
  • 这会把内部 metadata 泄露给用户。

用户时间已经足够为相邻 assistant 回复提供相对时间锚点。

这是一个典型的“上下文内容会反过来训练当前行为”的工程取舍。

7. 合法后缀

retain_recent_legal_suffix() 用于硬裁剪后保留可回放的最近片段。合法后缀不能:

  • 从孤立 tool result 开始;
  • 留下没有 tool result 的 assistant Tool Call;
  • 破坏 user/assistant/tool 的协议边界。

因此“保留最后 N 条”不是简单切片。

8. 原子保存

SessionManager.save() 使用:

写入 .jsonl.tmp
  → flush
  → 可选 fsync 文件
  → os.replace
  → 可选 fsync 目录

正常运行保存默认不一定每次 fsync;优雅关闭或明确要求 durable save 时可以启用。

这保证:

  • 目标文件不会被普通写入过程截断;
  • replace 在同一文件系统中是原子的;
  • 临时文件写失败时清理。

Windows 上目录 fsync 可能因权限而跳过。

9. 损坏恢复

SessionManager 读取时会:

  • 逐行解析;
  • 跳过或记录损坏;
  • 尝试恢复合法内容;
  • 在只读列表/metadata 路径中避免因为一处损坏丢失所有条目。

但恢复不代表可以忽视磁盘损坏。调试时应保留原文件,不要先手工清空。

10. Session metadata 中可能有什么

常见内部状态:

  • goal_state
  • runtime_checkpoint
  • pending_user_turn
  • _last_summary
  • workspace scope;
  • WebUI title/显示状态;
  • continuation 计数。

这些键多数是内部契约。扩展不应随意写入同名字段。

观察任务

使用隔离学习 workspace:

  1. 完成两轮 CLI 对话;
  2. 找到对应 Session JSONL;
  3. 对照 Session.messages 和文件行;
  4. 调用 get_history(max_messages=...) 的相关测试;
  5. 观察 user 时间戳何时添加;
  6. 不修改文件,记录 metadata 键。

源码与测试

  • nanobot/session/manager.py
  • nanobot/session/keys.py
  • tests/agent/test_session_manager_history.py
  • tests/agent/test_session_atomic.py
  • tests/agent/test_cursor_recovery.py
  • tests/agent/test_session_delete.py
  • tests/agent/test_max_messages_config.py

自检

  1. 为什么 Session JSONL 第一行单独保存 metadata?
  2. 为什么 get_history() 不能只是 messages[-120:]?
  3. 为什么 assistant 时间戳可能污染未来回复?
  4. os.replace 解决什么问题,fsync 又解决什么问题?
  5. last_consolidated 为什么必须验证边界?

下一步

阅读 Compaction、Memory 与 Dream。