跳到主要内容

11 · 进程退出后 Session 如何继续

让 Agent 在进程退出、上下文压缩或切换入口后恢复到可验证状态

本章任务

要回答的问题

恢复 Session 时,怎样同时还原 cwd、权限、工具副作用和验证进度?

读完你能

  • 定义消息历史之外必须恢复的状态不变量
  • 选择 JSONL、索引、Checkpoint 与生命周期 Hook 的组合
  • 为尾部截断、进程重启、重复事件和跨入口恢复写测试
适合现在读
正在实现持久会话、断点续跑、跨入口恢复或多平台路由的工程师
先修知识
理解事件日志、幂等性和 Agent Loop
实践产物
一份 Session Restore 规范、状态清单与故障注入套件
证据边界
源码展示恢复载荷和顺序,不能替代真实文件系统与副作用下的恢复实验

场景:Agent 在 /repo-a 的分支 fix/payment 修改了两个文件,测试 7/9 通过,随后 IDE 崩溃。用户第二天在 /repo-b 打开同一个对话并点击 Resume。消息历史完全存在,但如果系统没有校验仓库、分支和权限,模型会把昨天的推理继续应用到今天的错误工作区。

恢复契约先回答六个问题:

恢复不变量验收方式
逻辑任务是谁thread_id 保持稳定,新的进程实例拥有新的 session_id
在哪里执行cwd、仓库标识、Git SHA/分支不匹配时阻止静默继续
能做什么恢复 approval mode、sandbox policy 和 tool profile,不从历史 Prompt 猜权限
已经做过什么工具事件带 operation_id、参数摘要、提交状态和结果持久化状态
已验证到哪一步Todo、测试、Reviewer finding 与 verifier state 能恢复或明确失效
为什么继续或重置startup、resume、clear、compact、idle reset 等生命周期原因可追踪

最小 SessionMeta 可以很小,但不能只有消息:

{
"thread_id": "thread_abc",
"session_id": "session_002",
"resumed_from": "session_001",
"cwd": "/repo-a",
"git_sha": "4f8c...",
"approval_mode": "on-request",
"tool_profile": "coding-read-write",
"last_durable_event": 184,
"verifier_checkpoint": "tests:7/9"
}

故障注入:损坏 JSONL 最后一行、删除 SQLite 索引、改变当前 Git 分支、重复投递最后一个工具事件,然后验证系统是安全恢复、重建索引还是进入人工检查,而不是“看起来还能聊天”。

用恢复契约比较四种 Session 模型

Section titled “用恢复契约比较四种 Session 模型”
四系统的 session 模型:JSONL rollout、4 hook 事件、极简 id、多平台路由
同一个恢复契约,四家分别把真相源放在事件文件、子系统状态、上层存储或平台路由中。
恢复决策 CodexClaude CodeOpenClawHermes
真相源 Append-only JSONL rollout;SQLite 只做索引消息、成本、文件、Todo、Worktree 等多子系统状态定义 session id 与 transcript contract,存储由上层决定按 platform + chat_id 持久化 SessionContext
身份模型 ThreadId 对比 SessionId,可 fork 与 archivesession 加 worktree、task、sub-agent 维度agent scope key 加 session idSessionSource 加平台、chat_id 与 reset policy
恢复触发 Create / Resume 两条 recorder 入口startup / resume / clear / compact 四类 lifecycle source调用方按 id 加载并切换idle / daily / both / none reset
主要风险 事件文件与索引迁移不一致跨子系统恢复顺序复杂上层若不定义语义,只有身份没有恢复平台能力、PII 与 reset 规则持续分叉
先比较恢复契约,再比较存储格式

源码证据:身份、存储与生命周期

Section titled “源码证据:身份、存储与生命周期”

Codex · 把 session 当数据库工程化:JSONL 持久化加 SQLite 索引加 Thread/Session ID 分离

Section titled “Codex · 把 session 当数据库工程化:JSONL 持久化加 SQLite 索引加 Thread/Session ID 分离”

恢复问题先从崩溃开始:进程可能在写入一行 JSON、更新索引或执行工具后被杀。Codex 用 append-only JSONL 保存事件,再用 SQLite 只做查询索引,并把 ThreadId 与 SessionId 分开。

第一个决策是用 JSONL append-only 文件而不是单个 JSON 文件。这个选择背后是 agent 进程的现实:它可能被 IDE 杀掉、被 OOM killer 干掉、被用户 Ctrl-C、被 OS 重启,崩溃点随机分布在写入过程中的任何位置。如果用整个文件 JSON,一旦崩在写到一半的时候,整个文件就 corrupt 了不能 parse,下次启动 resume 直接失败,整个 session 丢光。

JSONL 的设计是每行独立 parse:如果一次 append 在第 N 行中途失败,完整的前 N-1 行通常仍可解析;实际能恢复多少还取决于 write、flush/fsync 和文件系统语义,不能承诺固定只丢一行。另一个好处是写性能:一个长 session 可能有几百轮,整文件 JSON 每轮都要把几 MB 的内容全量序列化再 atomic write,IO 压力随轮次线性上升。

JSONL 每次只追加一个事件,避免重新序列化整段历史;实际 syscall 数量和延迟由 buffering 与 durability 策略决定。还有第三个好处是流式消费:Codex 的 TUI 想实时显示「agent 在干什么」,JSONL 可以 tail -f 式逐行读取,每个新行就是一个事件,整文件 JSON 很难这样消费。

第二个决策是文件名格式:rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl。前缀是 ISO 时间戳,让列目录就是按时间排序。后缀是 UUID,防止同一秒内创建多个 session 时碰撞。中间用连字符分隔,让肉眼也能粗读时间。

这种「文件名本身就编码所有路由信息」的设计让 session 管理工具几乎不用数据库就能跑:找最近的 session 就是按文件名排序取最后几个,找特定时间段就是文件名前缀匹配,归档老 session 就是把文件移到 archived_sessions/ 子目录。

第三个决策是每个 session 的第一行强制写 SessionMeta:

Codex codex/codex-rs/rollout/src/recorder.rs:80-105 RolloutRecorder 接收 Create、Resume 两种入参,JSONL 落盘并通过 mpsc 异步写
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
#[derive(Clone)]
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
writer_task: Arc<RolloutWriterTask>,
pub(crate) rollout_path: PathBuf,
event_persistence_mode: EventPersistenceMode,
}
#[derive(Clone)]
pub enum RolloutRecorderParams {
Create {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
},
Resume {
// ...
},
}

这个 SessionMeta 包含 resume 时需要的所有元数据:

Codex codex/codex-rs/rollout/src/metadata.rs:39-65 从 SessionMeta 还原 ThreadMetadataBuilder,包括 cwd / model / agent / git 信息
pub(crate) fn builder_from_session_meta(
session_meta: &SessionMetaLine,
rollout_path: &Path,
) -> Option<ThreadMetadataBuilder> {
let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?;
let mut builder = ThreadMetadataBuilder::new(
session_meta.meta.id,
rollout_path.to_path_buf(),
created_at,
session_meta.meta.source.clone(),
);
builder.model_provider = session_meta.meta.model_provider.clone();
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
builder.agent_role = session_meta.meta.agent_role.clone();
builder.agent_path = session_meta.meta.agent_path.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
builder.git_branch = git.branch.clone();
builder.git_origin_url = git.repository_url.clone();
}
Some(builder)
}

Resume 时为什么需要这么多元数据?因为单纯重放消息历史是不够的:模型看到一条「请把 foo.py 里的 bar 函数改成异步」消息时,它需要知道当时的工作目录在哪(不然找不到 foo.py)、当时跑的是哪个模型(不同模型表现不同,混着复读会让对话不连贯)、当时的 approval mode 是什么(之前是 --accept-edits,resume 后变 interactive 会卡在每个 edit)、当时的 git commit 是哪个(agent 之前基于某个 commit 推理,如果 resume 时已经在不同 branch 上,agent 给的建议就会错)。

这些隐藏前提如果不恢复,agent 就会表现得「换了个人」,用户会觉得 resume 功能没用。

第四个工程决策是把 session 数量增长时的性能问题前置考虑:当 session 累积到几百几千个时,每次启动列目录扫所有 JSONL 第一行解析出 SessionMeta 会变得很慢(IO 开销加 JSON parse 开销)。

Codex 在 JSONL 之外再维护一个 SQLite state.db 做线程索引:每个 thread 的元数据(cwd、model、created_at、last_message_at、archived 标记)落进表里,列 thread 直接 SQL 查询,毫秒级响应。JSONL 文件依然是真相之源(SQLite 损坏了可以从 JSONL 重建),但日常查询走 SQLite。

启动时不会扫所有文件,只在 SQLite 找不到对应 thread_id 时才 backfill 一次。这种「文件作为持久层加数据库作为索引层」的双层设计是数据库系统的起点。

第五个工程决策是把「逻辑对话」和「具体运行实例」拆成两个独立 ID。ThreadId 是逻辑对话单位:用户说「我那个关于 refactoring 的对话」指的是 thread,thread 可以跨多次进程启动存在、可以被 fork(基于历史开新分支)、可以被 archive(归档到 archived_sessions/)。SessionId 是一次具体的运行实例:进程启动一次就是一个 session,跟进程同生命周期,用户不关心也看不到具体的 session_id。

一个 thread 可能跨多个 session(每次 resume 是新 session,但继承同一个 thread_id)。这种拆分让用户视角(持久 thread)和系统视角(瞬态 session)各自独立演化,避免混淆。

Session 在内存里的运行时结构是一个带锁的状态机:

Codex codex/codex-rs/core/src/session/session.rs:11-37 Session 是一个带锁的状态机:state Mutex 加 active_turn Mutex 加 Mailbox 加服务集合
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
pub(crate) conversation_id: ThreadId,
pub(crate) installation_id: String,
pub(super) tx_event: Sender<Event>,
pub(super) agent_status: watch::Sender<AgentStatus>,
pub(super) out_of_band_elicitation_paused: watch::Sender<bool>,
pub(super) state: Mutex<SessionState>,
pub(super) managed_network_proxy_refresh_lock: Semaphore,
pub(super) features: ManagedFeatures,
pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(super) mailbox: Mailbox,
pub(super) mailbox_rx: Mutex<MailboxReceiver>,
pub(super) idle_pending_input: Mutex<Vec<ResponseInputItem>>,
pub(crate) goal_runtime: GoalRuntimeState,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
pub(super) next_internal_sub_id: AtomicU64,
}

注释里这句「A session has at most 1 running task at a time, and can be interrupted by user input」是核心约束:一个 session 同时只能跑一个 turn,用户输入可以打断当前 turn,但不能让两个 turn 并发跑。这个约束防止 race condition:如果两个 turn 同时往 message history 里写东西、同时调用工具、同时改文件,状态会乱套。

代价是单 session 不能并行处理多个用户请求,但 Codex 用更激进的策略弥补:用户想并行就开新 thread(fork 当前 thread),新 thread 是独立 session 跑独立 turn,互不干扰。

Claude Code · 把 session 拆成 22 个子系统加 4 种生命周期事件触发 hook

Section titled “Claude Code · 把 session 拆成 22 个子系统加 4 种生命周期事件触发 hook”

IDE session 不是一份消息文件。Claude Code 把 history、cost、todo、worktree 等状态拆开,并让 startup、resume、clear、compact 触发不同 hooks;代价是恢复顺序需要维护。

所以 Claude Code 把 session 拆成了 22 个文件,每个名字里带 session 关键字:sessionStart 管启动、sessionRestore 管恢复、sessionStorage 管持久化、sessionState 管运行时状态、sessionMemory 管内存中的 message 缓冲、sessionMemoryCompact 管上下文压缩、sessionRunner 管 turn 执行、sessionIngress 管入口(IDE、CLI 等不同接入点)、sessionEnvVars 管环境变量、sessionEnvironment 管运行环境、sessionActivity 管活动检测(idle 判定)、sessionHistory 管历史日志、sessionFileAccessHooks 管文件访问钩子、sessionHooks 管生命周期 hook 注册、sessionTracing 管 OTEL 追踪、sessionUrl 管 IDE 跳转 URL、sessionTitle 管显示标题、sessionIngressAuth 管 ingress 鉴权、sessionIdCompat 管旧版 ID 兼容、sessionStoragePortable 管跨设备存储、SessionsWebSocket 管 IDE WebSocket。

这种拆法的好处是每个子系统可以独立演化:加新功能(比如「记录用户用过的 skill」)只需要新增一个 sessionSkillUsage 子系统,不会动其他文件。坏处是 resume 时变得复杂:要把 22 个子系统的状态按正确顺序全部恢复,错一步 agent 就会表现失常。

核心抽象是把所有 session 生命周期事件归纳成 4 种 source,每种 source 触发一组 plugin hook 加 user hook:

Claude Code claude-code/src/utils/sessionStart.ts:34-66 processSessionStartHooks 4 种 source:startup、resume、clear、compact
// Note to CLAUDE: do not add ANY "warmup" logic. It is **CRITICAL** that you do not add extra work on startup.
export async function processSessionStartHooks(
source: 'startup' | 'resume' | 'clear' | 'compact',
{
sessionId,
agentType,
model,
forceSyncExecution,
}: SessionStartHooksOptions = {},
): Promise<HookResultMessage[]> {
// --bare skips all hooks. executeHooks already early-returns under --bare
// (hooks.ts:1861), but this skips the loadPluginHooks() await below too —
// no point loading plugin hooks that'll never run.
if (isBareMode()) {
return []
}
const hookMessages: HookResultMessage[] = []
const additionalContexts: string[] = []
const allWatchPaths: string[] = []
// Skip loading plugin hooks if restricted to managed hooks only
// Plugin hooks are untrusted external code that should be blocked by policy
if (shouldAllowManagedHooksOnly()) {
logForDebugging('Skipping plugin hooks - allowManagedHooksOnly is enabled')
} else {
// Ensure plugin hooks are loaded before executing SessionStart hooks.
// ...
try {
await withDiagnosticsTiming('load_plugin_hooks', () => loadPluginHooks())
} catch (error) {
// Log error but don't crash - continue with session start without plugin hooks

这 4 种 source 表达 4 种语义完全不同的场景。startup 是「全新对话」:用户第一次输入 claude 启动,没有任何历史,hook 应该做的事是加载 CLAUDE.md 项目说明、设置工作目录、初始化 cost tracker、按 plugin 配置注入 system prompt 的某些 section。不应该做的是从 archive 拉历史(根本没有)、恢复 worktree state(用户没要 worktree)。

resume 是「从历史 session 继续」:用户输入 claude --resume 选了一个历史会话,hook 应该恢复 cost state、attribution snapshot、file history、todos、model override、worktree state。不应该重置 cost tracker(resume 是要继续不是从零开始)、不应该重新加载 CLAUDE.md(已经在 history 里了)。

clear 是「用户主动 /clear」:在对话过程中用户想重置上下文但保留 session 元数据,hook 应该清掉 message history、保留 cost tracker(计费不该清)、保留 model override(用户偏好不变)。不应该清掉 session 文件(用户可能后面 resume)。

compact 是「上下文超阈值触发压缩」:系统判断 context tokens 大于 limit 触发 compact subagent,hook 应该 snapshot 关键信息(避免压缩后丢失)、暂停 cost tracker 写入(compact 自己 LLM 调用的计费要分离)。不应该清 message history(compact 是「精简」不是「丢弃」)。

把这 4 种合并是诱惑很大的:startup 和 resume 都是「开始 session」,clear 和 compact 都是「中途事件」,看起来合并成 2 种更简洁。但实际上每种 source 下「该做什么、不该做什么」完全不同,合并之后 hook 就得在每个分支里手写 if-else 判断当前情况,反而比分成 4 种 source 更繁琐。

引用快照把这 4 种 source 固定成联合类型;源码能证明它们走不同 hook 语义,不能证明 4 是所有产品的最小数量。新增或合并事件前,应先列出每种事件不同的副作用。

代码注释里有一行关键的铁律值得单独拎出来讲:「do not add ANY “warmup” logic. It is CRITICAL that you do not add extra work on startup.」 直译是「不要加任何热身逻辑,启动时不要做额外工作,这一点是硬性要求」。

这条注释能证明维护者明确禁止把非必要工作放进启动路径,但没有给出历史 warmup 清单或启动耗时。可验证的做法是把目录扫描、plugin 加载、git 查询和版本检查逐项加入冷、热启动基准,记录每项对目标设备和代表性仓库的影响,再决定同步执行、后台执行还是 lazy load。

resume 时不是简单重载 JSONL 就完事,要按顺序恢复 7 类状态:

Claude Code claude-code/src/utils/sessionRestore.ts:1-58 sessionRestore 跨 7 个子系统:cost、attribution、fileHistory、todos、model、worktree、systemPrompt
import { feature } from 'bun:bundle'
import type { UUID } from 'crypto'
import { dirname } from 'path'
import {
getMainLoopModelOverride,
getSessionId,
setMainLoopModelOverride,
setMainThreadAgentType,
setOriginalCwd,
switchSession,
} from '../bootstrap/state.js'
import { clearSystemPromptSections } from '../constants/systemPromptSections.js'
import { restoreCostStateForSession } from '../cost-tracker.js'
import type { AppState } from '../state/AppState.js'
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
import {
type AgentDefinition,
type AgentDefinitionsResult,
getActiveAgentsFromList,
getAgentDefinitionsWithOverrides,
} from '../tools/AgentTool/loadAgentsDir.js'
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
import { asSessionId } from '../types/ids.js'
import type {
AttributionSnapshotMessage,
ContextCollapseCommitEntry,
ContextCollapseSnapshotEntry,
PersistedWorktreeSession,
} from '../types/logs.js'
import type { Message } from '../types/message.js'
import { renameRecordingForSession } from './asciicast.js'
import { clearMemoryFileCaches } from './claudemd.js'
import {
type AttributionState,
attributionRestoreStateFromLog,
restoreAttributionStateFromSnapshots,
} from './commitAttribution.js'
import { updateSessionName } from './concurrentSessions.js'
import { getCwd } from './cwd.js'

每个 import 都对应一类需要恢复的状态:cost-tracker 是花了多少钱、commitAttribution 是哪些改动算用户写的哪些算 agent 写的、AgentTool/agentColorManager 是子 agent 的颜色编码、TodoWriteTool 是待办清单、AppState 是应用级别的 UI 状态、claudemd 缓存清理、worktree-related types 是 git worktree session 状态。

少恢复一个,agent 就会在某个维度上表现不一致:比如 cost 没恢复,用户会看到「从零开始计费」的错觉。attribution 没恢复,git commit 上的「Co-authored-by Claude」会缺。todos 没恢复,用户上次提的待办事项被忘了。worktree state 没恢复,agent 不知道自己应该在哪个 worktree 操作。

这种「resume 复杂度」是 IDE 级 session 的代价:状态被故意分散到多个子系统让每个子系统独立演化,恢复时就得跨子系统编排。

OpenClaw · 把 session 退化成一个身份概念:只校验 ID,存储交给上层

Section titled “OpenClaw · 把 session 退化成一个身份概念:只校验 ID,存储交给上层”

框架只需定义身份时,存储不该被强行绑定。OpenClaw 的 session 模块校验 UUID、生成 scope key、序列化 transcript,resume 和持久化留给上层。

OpenClaw openclaw/src/sessions/session-id.ts:1-6 session id 就是一个 UUID 正则校验
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeSessionId(value: string): boolean {
return SESSION_ID_RE.test(value.trim());
}

就这么一个正则加一个 helper 函数。整个 session 模块剩下的文件也都是这种「轻量工具」级别:session-key-utils.ts 负责把 agent scope 拼到 session key 上({agentId}:{sessionId} 格式,让同一个 session 在不同 agent 视角下可以区分)、session-label.ts 负责生成人类可读的 label(用于 UI 显示)、transcript-events.ts 负责会话 transcript 的事件序列化、model-overrides.tslevel-overrides.ts 提供 per-session 的配置覆盖、send-policy.ts 管消息发送策略。

整个模块没有任何 rollout 文件、没有 SQLite 索引、没有 lifecycle hook,连 session 应该存哪里都不规定。

这种极简看起来像「没做完」,但是 OpenClaw 定位决定的。OpenClaw 是 framework(agent 平台),不是 product(agent 产品):它的用户是写 agent 的开发者,不是用 agent 的终端用户。

这两种用户对 session 的需求完全不同:终端用户需要「列出过去 7 天的会话」「resume 任意 session」「自动 archive 旧 session」这些产品功能,开发者却各自有自己的存储栈:写 Slack bot 的人要把 session 存到 Slack thread、写 IDE 插件的人要存到 IDE workspace state、写 SaaS 的人要存到 PostgreSQL、Redis、写 CLI 工具的人要存本地 JSONL。

如果 OpenClaw 在框架层强制一种存储方案,所有这些场景都会被绑死。所以 OpenClaw 选择只校验 ID 格式合规、只提供 session_key 的命名空间工具、只提供 transcript 事件的序列化格式,把「存哪里、什么时候 reset、怎么 resume」全部留给上层调用方。

这是一种「定义 contract,不定义 implementation」的框架设计哲学:类似数据库的 query layer 不该决定数据存在哪里(SQLite、PostgreSQL、MySQL 的存储后端可以变,但 query layer 一致)。代价是 out-of-box 不够好(要 demo 一个较全 agent 要先选个 session backend)、生态可能分裂(不同 plugin 存到不同地方)。

好处是 OpenClaw 可以适配任何部署形态而不需要改框架代码。

Hermes · 为多平台聊天而生:SessionSource 记录消息从哪来加 4 种 reset 模式

Section titled “Hermes · 为多平台聊天而生:SessionSource 记录消息从哪来加 4 种 reset 模式”

多平台聊天要先回答消息来自哪个平台、哪个会话、是否可脱敏。Hermes 用 SessionSource 保存这些字段,再按 reset policy 处理跨天或闲置会话。

这些对话在用户感受里是「跟同一个 agent 的不同子线程」,但在系统层面需要是完全独立的 session:不能让 Telegram 私聊的内容混进 Slack 工作群(隐私和合规问题),也不能让 Discord 公开 channel 的内容污染 Telegram 私聊(context 混乱)。

所以 Hermes 的 session 设计有两个核心抽象。第一个是 SessionSource,记录每条消息「从哪里来」:

Hermes hermes-agent/gateway/session.py:65-106 SessionSource 等于 platform 加 chat_id 加 user、chat 元数据,覆盖 DM、group、channel、thread 4 种聊天类型
@dataclass
class SessionSource:
"""
Describes where a message originated from.
This information is used to:
1. Route responses back to the right place
2. Inject context into the system prompt
3. Track origin for cron job delivery
"""
platform: Platform
chat_id: str
chat_name: Optional[str] = None
chat_type: str = "dm" # "dm", "group", "channel", "thread"
user_id: Optional[str] = None
user_name: Optional[str] = None
thread_id: Optional[str] = None
chat_topic: Optional[str] = None
user_id_alt: Optional[str] = None # Signal UUID
chat_id_alt: Optional[str] = None # Signal group internal ID
is_bot: bool = False
@property
def description(self) -> str:
"""Human-readable description of the source."""
if self.platform == Platform.LOCAL:
return "CLI terminal"
# ...

这个数据类要回答三个问题:消息走哪个 routing 路径回去(platform 加 chat_id 决定回复发到哪)、要给 system prompt 注入什么上下文(让 agent 知道「现在你在 Slack 工作群里,应该用更专业的语气;现在你在 Telegram 私聊里,可以更随意」)、cron 任务的输出该投递到哪里(用户问「明早 8 点提醒我开会」,第二天 8 点 agent 主动发消息要发到对的平台对的 chat)。

注意 chat_type 字段覆盖了 4 种聊天形态:dm(私聊)、group(普通群)、channel(公开频道)、thread(线程化对话),不同 chat_type 下 agent 的行为应该不同:dm 里可以畅所欲言,group 里要克制不要刷屏,channel 里要更正式。

还有 Signal 特有的 user_id_altchat_id_alt 字段:Signal 协议在群组里用电话号码做用户 ID 但有时候又用 UUID,需要两个都存才能正确路由。

第二个核心抽象是 SessionResetPolicy 提供的 4 种 reset 模式:

Hermes hermes-agent/gateway/config.py:100-141 SessionResetPolicy 4 模式:daily、idle、both、none,可按 platform、chat_type 覆盖
@dataclass
class SessionResetPolicy:
"""
Controls when sessions reset (lose context).
Modes:
- "daily": Reset at a specific hour each day
- "idle": Reset after N minutes of inactivity
- "both": Whichever triggers first (daily boundary OR idle timeout)
- "none": Never auto-reset (context managed only by compression)
"""
mode: str = "both" # "daily", "idle", "both", or "none"
at_hour: int = 4 # Hour for daily reset (0-23, local time)
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
notify: bool = True # Send a notification to the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook")

这 4 种模式对应 4 类真实用户群。daily 是「每天定时 reset」:典型用户是用 agent 当个人助理的人,作息规律,每天用一段时间晚上不用,凌晨 4 点(默认 at_hour)自动 reset 让第二天从干净状态开始。好处是每天一个清爽 session 不积累冗余上下文,坏处是熬夜用户可能 4 点突然失忆。

idle 是「闲置超时 reset」:典型用户是项目协作场景,跟 agent 在 Slack 工作群讨论一个项目可能几天才回一次消息,但只要还在讨论就保持上下文。它以「有活动」为颗粒,连续讨论同一个项目时 context 持续保留;不过一旦超过 idle 阈值就被强制 reset,这点可能让用户不爽。both 是「任一触发就 reset」:这是 Hermes 默认模式,既有每天清盘的稳定性又有工作日连续的连贯性;是否适合你的用户,要用实际会话间隔验证。

none 是「永不 auto-reset」:典型用户是维护长期项目的人(小说创作、知识库整理),context 永远保留 agent 还「记得」用户在干什么。代价是 context 会无限增长所以需要依赖 compact 机制兜底。

notify_exclude_platforms 字段是个很实用的细节:reset 时默认会给用户发通知(「agent 已重置上下文」),让用户知道为什么 agent 突然不记得之前的事。但对 api_server 和 webhook 这种程序调用方,发通知毫无意义反而是噪音,所以默认排除这两个平台。

Hermes 还有一个其他三家都没有的独有设计:PII(个人身份信息)按平台脱敏:

Hermes hermes-agent/gateway/session.py:176-209 安全平台清单加按需 PII redaction:发给 LLM 前替换手机号、用户 ID 为 hash
_PII_SAFE_PLATFORMS = frozenset({
Platform.WHATSAPP,
Platform.SIGNAL,
Platform.TELEGRAM,
Platform.BLUEBUBBLES,
})
"""Platforms where user IDs can be safely redacted (no in-message mention system
that requires raw IDs). Discord is excluded because mentions use ``<@user_id>``
and the LLM needs the real ID to tag users."""
def build_session_context_prompt(
context: SessionContext,
*,
redact_pii: bool = False,
) -> str:
"""
Build the dynamic system prompt section that tells the agent about its context.
This is injected into the system prompt so the agent knows:
- Where messages are coming from
- What platforms are connected
- Where it can deliver scheduled task outputs
When *redact_pii* is True **and** the source platform is in
``_PII_SAFE_PLATFORMS``, phone numbers are stripped and user/chat IDs
are replaced with deterministic hashes before being sent to the LLM.
Platforms like Discord are excluded because mentions need real IDs.
Routing still uses the original values (they stay in SessionSource).
"""

注释里把「为什么 Discord 不能 redact」讲得很清楚:Discord 的 mention 语法是 <@user_id>(需要用数字 ID),Slack 的 mention 语法是 <@U12345678>(需要用 Slack member ID),这两个平台如果把 user_id 替换成 hash 发给 LLM,LLM 生成回复时就没办法正确 mention 用户了,agent 跟用户的「我在跟你说话」的产品信号会丢失。

而 WhatsApp、Signal、Telegram、BlueBubbles 这几个平台的 mention 都用自然语言(@用户名、电话号码),不依赖内部 ID,所以可以放心 redact。这是产品需求和安全需求两端拉锯后落地的具体结论,不是抽象设计:把「为什么这样」写进代码注释里是一种很值得学习的工程透明度。

整个 PII 系统的关键设计是「路由路径和 LLM 路径分离」:SessionSource 永远保留原始 user_id 和 chat_id 用于路由(Hermes 系统层知道真实 ID 才能把回复发到正确的地方),但发给 LLM 的 prompt 里这些 ID 被替换成确定性 hash(hash_user_001、hash_chat_001)。

LLM 生成回复时引用 hash_user_001,Hermes 把回复路由出去之前把 hash 映射回真实 user_id 再发送。这种「LLM 不知道真实 ID 但系统知道」的设计在企业部署场景很有用。

虽然四家在 session 的实现深度差异极大,但以下 3 个问题在源码快照中反复出现;它们是本文的工程归纳,不是所有产品都采用同一套强制规则。

第一件是每个 session 需要有全局单一的 ID。Codex 用 UUIDv4(128 位足够防碰撞)、Claude Code 用 UUIDv4 加上 worktree 维度的额外限定、OpenClaw 严格校验 UUID 格式、Hermes 用 platform+chat_id 组合作为天然单一标识。为什么需要单一?因为 session 数据要持久化到文件系统、数据库、远端 KV,碰撞会导致一个 session 的数据覆盖另一个,bug 极难排查。另外多个 session 可能在不同进程或不同设备上同时活着,没有全局单一 ID 就没法正确 routing。

第二件是 session 需要跟 user/scope 绑定,不能是全局的。Codex 把 agent_path 和 agent_nickname 写进 SessionMeta(同一台机器上 Codex 给不同 agent role 用的 session 要能区分)、Claude Code 用 worktreeSession 让 git worktree 各自有独立 session(一个项目开多个 worktree 同时工作时 session 不能混)、OpenClaw 用 {agentId}:{sessionId} 拼接 key(同一个 sessionId 在不同 agent 视角下完全隔离)、Hermes 用 platform+chat_id 自然实现按平台和聊天上下文分隔。这个原则的反面是「全局 session pool」:如果所有用户、所有 agent、所有项目共享一个 session 集合,那 session 之间会互相污染,agent 在用户 A 那里学到的偏好会被错误地应用到用户 B 上。

第三点,resume 不能只重放消息历史,需要把隐藏的状态也恢复。这是 resume 这件事看似简单但很容易做错的地方。最容易踩的坑是:开发者实现 resume 时想着「把 messages 列表加载回来塞给模型就行了」,结果发现 agent resume 后表现得「像换了个人」:因为模型读对话历史时虽然能看到「用户让我改 foo.py」这类内容,但不知道当时的 cwd、approval mode 或 git state。引用的实现处理方式并不相同:Codex 把这些字段写进 SessionMeta,Claude Code 的 sessionRestore 恢复多个子系统,OpenClaw 只提供 transcript_events 契约并把存储决策留给上层,Hermes 重新构建 SessionContext。目标系统应明确哪些隐藏前提必须持久化。

四家 session 模型在存储工程化深度加生命周期颗粒度上的相对位置
OpenClaw 极简 id 最左下,Codex JSONL 加 SQLite 偏右中,Claude Code 22 工具加 4 hook 最右上,Hermes 多平台路由居中偏上。

虽然共同点确立了 session 设计的基本盘,但四家在「session 应该做多深」这件事上的分歧才是实际决定他们各自适合什么场景的核心。换一个角度看,「想做什么样的 agent」决定了你应该参考哪家的实现。

如果想做一个长期使用的开发者工具型 agent,用户希望能列出过去几个月所有的对话历史、能 resume 任意一次会话、能给 session 命名归档分类,那么 Codex 的 JSONL 加 SQLite 双层架构可以作为候选起点。 这种场景的核心需求是「持久化较全、性能可控、跨时间引用」,Codex 的设计每一条都对得上:JSONL 给崩溃恢复能力、SQLite 索引给毫秒级查询、ThreadId 给跨进程的稳定身份、archived_sessions/ 子目录给老 session 归档。代价也实在:工程复杂度高,要维护 JSONL 格式、SQLite schema 以及两者之间的一致性;应在目标仓库上验证恢复和查询边界。

如果想做一个 IDE 集成的 agent,需要跟 cost tracker、file change tracking、todo list、worktree 这些 IDE 一等公民子系统深度协作,那么 Claude Code 的 22 文件分层加 4 种 lifecycle source 可以作为候选起点。 这种场景的核心需求是「让 session 跟 IDE 的其他状态系统配合而不是替代它们」,Claude Code 的设计每个子系统独立演化、4 种 source hook 让 plugin 精确选择什么时候介入、warmup 禁令保证启动延迟可控。换来的是长期维护负担:22 个文件要养,sessionRestore 7 类状态的恢复顺序敏感,加新状态容易踩坑;先用恢复顺序和启动 trace 验证这层复杂度是否值得。

如果想做一个 agent 框架而不是 agent 产品,不希望强加存储方案给用户,那么 OpenClaw 的极简 session-id 可以作为候选起点。 这种场景的核心需求是「定义清晰的 contract,把 implementation 留给用户」,OpenClaw 只校验 UUID 格式加提供 session_key 命名空间加提供 transcript 序列化,剩下的全交给上层。换来的是 out-of-box 体验偏弱,用户上手前得先挑一个 backend;需要由上层补齐存储、resume 和一致性契约。

如果想做一个多平台聊天 agent,要同时服务 Telegram、Slack、Discord 等多个平台,那么 Hermes 的 SessionSource 加 4 种 reset 模式加 PII 安全平台清单可以作为候选起点。 这种场景的核心需求是「精确建模消息从哪来、按平台和场景定制 reset 策略、按平台能力区分能不能脱敏」,Hermes 的设计每一条都贴合:SessionSource 编码消息来源的 4 种 chat_type、SessionResetPolicy 提供 4 种 reset 模式可 per-platform 覆盖、_PII_SAFE_PLATFORMS 按 mention 语法精确划分。担子落在 6 个以上平台的兼容性维护上,还要把 reset 策略和 compact 之间的边界划清楚;应按实际平台 fixture 逐项验收。

这里不做星级评分。先定义“恢复后需要保持什么不变”,再选择存储和生命周期层。

恢复约束可先读代价与边界
要跨进程列出、resume、fork 历史Codex 的 JSONL、SQLite index、Thread/Session 分离文件格式和索引要一起迁移与校验
Session 要和 IDE 子系统及 hooks 协同Claude Code 的 source hooks 与 restore 分层状态恢复顺序会影响结果,维护面较大
只想给框架定义身份 contractOpenClaw 的 session id、scope key 和 transcript存储、resume 语义交给上层
多平台聊天需要按来源 reset 和脱敏Hermes 的 SessionSource、reset policy 和 PII 清单每个平台能力不同,策略要持续核对

先定义 resume 后必须保持的状态,再按恢复风险决定是否增加 JSONL、索引和 lifecycle 层;每层都用损坏尾部、重启和跨平台 fixture 验收。

复刻方案

最小可行

  • session_id 用 UUID v4(参考 OpenClaw 的正则校验):它提供足够大的随机空间;再加格式校验和存储层唯一约束,避免脏 ID 或重复 ID 覆盖已有记录
  • 存一个 JSONL 文件(参考 Codex 格式:rollout-{ts}-{uuid}.jsonl):一行一个事件、append-only 写入。恢复器可跳过损坏尾行,但持久性仍取决于 flush、fsync、文件系统和写入协议
  • session 第一行写 SessionMeta:cwd、model、agent、git_sha、timestamp。这是 resume 时恢复运行环境的单一来源,模型不需要看(meta 是给 harness 看的)
  • resume 时按 session_id 找 JSONL,从 SessionMeta 恢复 cwd、model:不只是恢复 messages,环境(工作目录、模型选择、approval mode)也要跟着回到当时。不然 agent 会「失忆」

进阶

  • JSONL 之外加 SQLite state DB 做线程索引(参考 Codex):先测目录扫描的会话数量与延迟;超过产品预算后再加索引,并把回填和 schema migration 作为恢复路径测试
  • 区分 ThreadId 对比 SessionId(参考 Codex):thread 是逻辑对话(用户视角的「这次聊天」),session 是具体运行实例(一次启动加退出的物理周期)。resume 时同一 thread 可对应多个 session
  • 4 种 lifecycle source(参考 Claude Code):startup、resume、clear、compact,各自触发 hook。不同生命周期事件需要不同处理(startup 加载用户偏好、resume 恢复 cwd、clear 清空 message、compact 压缩历史)
  • sessionRestore 跨子系统恢复:不只 messages,还要恢复 cost(继续累加而非清零)、attribution(哪些操作是这个用户的)、file history(编辑历史)、todos(任务清单)、worktree(git 分支)、model(模型选择)。任何一项漏恢复就「断片」
  • archived_sessions/ 独立子目录归档历史 session(参考 Codex):当前 session 跟历史 session 物理分开,list current 时不扫历史。archive 既能控制文件数量也能保留历史可查
  • 多平台路由用 SessionSource(参考 Hermes 的 platform 加 chat_id):routing 信息跟 LLM 输入分离(不让模型看到「我在 telegram 还是 slack」)。同一会话跨平台时知道当时在哪平台
  • SessionResetPolicy 4 模式(参考 Hermes):daily(每日重置)、idle(空闲 30min 重置)、both(两个条件之一触发)、none(永不自动重置),可 per-platform 覆盖。不同场景需要不同策略(客服 daily、长跑助理 none)
  • PII redaction 看 platform 能力(参考 Hermes 的 _PII_SAFE_PLATFORMS):mention-based 平台不能 redact(@Alice 改成 [REDACTED] 用户就找不到 Alice 了),name-based 平台可以 redact。安全策略要按平台特性调
  • 在注释里禁止无基准的 warmup(参考 Claude Code 的 do not add warmup):为启动路径设冷、热缓存预算;新增工作需要 trace,并优先考虑后台执行或 lazy load

一开始别做

  • 别把 session 元数据塞 message history 里:模型 resume 时看到一堆 meta 信息会困惑,meta 应该走 SessionMeta(独立字段)。混在一起既污染 prompt 又难做单独修改
  • 别在未测量时用单个 JSON 文件反复重写整个 session:文件越大,全量序列化和原子替换成本越高;比较整文件与 JSONL 的写入、损坏恢复和 schema 演化后再选
  • 别假设 session_id 一定单一:恶意调用方可能传重复 ID 试图覆盖别人 session。正则校验加数据库单一约束都要
  • 别让 resume 只重放 messages:cwd、model、approval mode 不恢复,agent 会「失忆」(用户问「你刚才在哪个目录?」答不上来)
  • 别在 startup hook 里做网络、文件大扫描:Claude Code 的「do not add warmup」是反复踩坑的结论。启动慢用户体验毁,warmup 都该走 lazy loading 而非 startup
四种 session 模型流程并列对照
Codex JSONL 加 SQLite 持久化,Claude Code 4 hook 加 22 工具子系统分层,OpenClaw 极简 id,Hermes 多平台路由加 4 reset 模式。

把 4 种放一起,工程化方向的差异一眼可见:文件级持久化(Codex)走 子系统分层(Claude Code)走 极简 ID(OpenClaw)走 多平台路由(Hermes)。

下一步实验:损坏尾部、切换分支、重复事件

Section titled “下一步实验:损坏尾部、切换分支、重复事件”

不要只测一次正常 Resume。建立四个 fixture:

  1. 损坏尾部:截断最后一条 JSONL,期望保留此前完整事件,并把尾部标成待检查。
  2. 索引丢失:删除 SQLite 索引,期望从事件真相源重建,而不是丢失 Thread。
  3. 环境漂移:把当前仓库或 Git 分支切走,期望阻止静默继续并显示差异。
  4. 重复事件:再次投递最后一个工具 operation_id,期望不重复副作用。

验收报告至少记录:恢复到哪个事件、哪些状态被判失效、是否重做工具、是否重跑 Verifier、何时转人工。只有消息能继续显示,不算恢复通过。

按需展开练习和十道复盘题
  1. 🟢 写 SessionMeta:定义一个结构记录 session 启动元数据:cwd、model、git_sha、agent_role、timestamp。会话开始时落 JSONL 第一行。
  2. 🟠 加 SQLite 索引:先测目录扫描随 session 数量增长的延迟;超过产品预算后,写一个 sqlite 表存 thread_id / cwd / timestamp / last_message_at,并在启动时按需 backfill。
  3. 🟠 4 种 lifecycle hook:实现 processSessionLifecycle(source),source ∈ {startup, resume, clear, compact}。每种 source 调用一组 hook。验证:clear 时清除 cost tracker,resume 时不清。
  4. 🔴 SessionResetPolicy:实现 4 种模式(daily / idle / both / none)。idle_minutes 用 last_message_at 比较;daily 看本地时间是否过了 at_hour。reset 时发通知(exclude api_server / webhook)。
Q1 · 概念:为什么 Codex 用 JSONL append-only 而非单个 JSON 文件存 session?

JSONL 比整文件 JSON 在 agent 场景下三个具体优势:

1. 崩溃恢复

Agent 进程可能:被 SIGKILL、断电、OOM、被 IDE 杀掉。如果是整文件 JSON:进程崩在写到一半的时候,整个文件 corrupt 不能 parse。下次启动 resume 失败,整个 session 丢。

JSONL append-only:每行独立 parse。恢复器可以跳过未写完或无法解析的尾行,保留此前已完整落盘的事件;是否只损失一行仍取决于缓冲、flush、fsync 和文件系统语义。

Codex 的快照叙述过「每周有几个 corrupt 文件、99.9% 数据可恢复」;本站没有复测这个比例,应把它当来源方观察,并在目标文件系统记录损坏率。

2. 写性能

整文件 JSON:每个 turn 后要序列化整个 session(可能几 MB)再 atomic write。turn 多了之后每次都是 IO 高峰。

JSONL:每个事件只追加一行,避免每轮重新序列化全部历史。具体 syscall 数量和延迟取决于缓冲与 durability 策略,应在目标文件系统分别测吞吐和断电恢复。

3. 流式消费

Codex 的 TUI 想实时显示「agent 在干什么」。JSONL 可以 tail -f 流式读,每个新行就是一个事件。整文件 JSON 没法这样消费,读到文件中间 parse 就会失败。

JSONL 的代价

  1. 没有”修正历史”能力:append-only,写错了改不了。Codex 的解法:写错了再 append 一个「修正」事件,consumer 自己合并。
  2. 文件大小膨胀:长 session 文件大,cat 起来累。Codex 加 archived_sessions/ 子目录归档老文件。
  3. schema 演化复杂:每行的 schema 可能跨版本。Codex 用 discriminator: "type" + per-type 反序列化让旧版本 row 还能 parse 出来。

对比四家

  • Codex: JSONL + SessionMeta first line。最工程化。
  • Claude Code: multi-file(rollout / cost / attribution 各自落盘)。本质也是 append-only 思想,但分多个文件。
  • OpenClaw: session-id 校验,存哪交给上层。
  • Hermes: gateway/session JSON 持久化。整文件,因为 session 量小(每个 chat_id 一个)+ reset 频繁(每天)。

工程教训:当会话需要逐事件恢复、流式消费和审计时,append-only log 是值得优先验证的候选;如果数据量很小或需要频繁修订历史,整文件或数据库也可能更简单。

源码codex/codex-rs/rollout/src/recorder.rs:80-105(RolloutRecorder)+ metadata.rs:39-65(SessionMeta 解析)。

追问:「为什么 Hermes 不用 JSONL?」Hermes 是聊天 agent,每个 chat session 短(几十轮),reset 频繁(每天)。整文件 JSON 几十 KB,整体序列化够快。+ 多平台场景,每个平台一个 chat_id,文件数太多 JSONL 不便于管理。两边场景驱动设计不同。

Q2 · 架构:Claude Code 4 种 lifecycle source(startup / resume / clear / compact)为什么不合并成 2 种或 5 种?

4 种是合适的数量。每种对应根本不同的语义。

startup · 全新对话

  • 用户 claude 第一次启动,没有任何历史。
  • Hook 应该:加载 CLAUDE.md、设置工作目录、初始化 cost tracker / git state、按 plugin 配置注入 system prompt。
  • 不应该:从 archive 拉历史(没有历史)、恢复 worktree session(用户没要 worktree)。

resume · 恢复历史 session

  • 用户 claude --resume 选了一个 session。
  • Hook 应该:恢复 cost state、attribution snapshot、file history、todos、model override、worktree state。
  • 不应该:重置 cost tracker(resume 是要继续,不是从 0 开始)、重新加载 CLAUDE.md(已经在 history 里)。

clear · 用户主动 /clear

  • 用户在对话中 /clear 想重置上下文但保留 session 元数据。
  • Hook 应该:清 message history、保留 cost tracker(计费不重置)、保留 model override(用户喜好不变)、可能保留 todos。
  • 不应该:清 session 文件(用户后面可能想 resume)、清 plugin state(plugin 有自己的生命周期)。

compact · 上下文超阈值触发压缩

  • 系统判断 context tokens > limit,触发 compact subagent。
  • Hook 应该:snapshot 关键信息(避免压缩后丢失)、暂停 cost tracker 写入(compact 自己的 LLM 调用计费要分离)、更新 systemPrompt(compact 结果是新的 baseline)。
  • 不应该:clear message history(compact 是「精简」不是「丢弃」)、reset model(用户没改)。

为什么不合并?

合并为 2 种(new / restore):

  • 把 clear 算进 new:但 clear 不应该重新加载 CLAUDE.md(已加载),plugin state 应保留,cost tracker 不清。new 的 hook 不知道这些细节。
  • 把 compact 算进 restore:但 compact 时 session 还在活着,hook 想做的不是 restore 状态,而是 snapshot + 重启计费。

合并为 5+ 种(add: “fork” / “convert”):

  • fork 是新 session ID,但 inherits 部分历史。本质就是 startup 加一段 initial messages。复用 startup 路径加 initial_messages 参数足够,不需要新 source。
  • convert(agent → agent)也类似,inherits messages 但 reset model。

Claude Code 实测发现 4 种是「最小够用」的颗粒。每种都有清晰的「应该做什么 / 不应该做什么」。

实现细节

type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact';
async function processSessionStartHooks(source: SessionStartSource) {
for (const hook of hooks) {
if (hook.appliesTo.includes(source)) {
await hook.execute({ source, ... });
}
}
}

Hook 可以声明 appliesTo: ['startup', 'resume'](不在 clear / compact 时跑)。颗粒度让 hook 写作更精确。

工程教训lifecycle 事件的颗粒度,需要反映”hook 应该做什么不同事情”。如果两种事件下 hook 干的事一样,应该合并;不一样,需要区分。

源码claude-code/src/utils/sessionStart.ts:34-66processSessionStartHooks + 4 种 source 类型)。

追问:「Codex 没有 clear / compact source 吗?」Codex 的 lifecycle 是 RolloutRecorderParams 的 Create / Resume 两种。compact 在 Codex 是 sub-agent(chapter 10),不是 lifecycle event。clear 不存在,Codex 不鼓励 /clear,而是鼓励开新 thread(cheap 操作)。两种产品定位不同。

Q3 · 概念:OpenClaw 只做 UUID 正则校验把 session 存储交给上层。这是「不较全」还是「正确的边界」?

正确的边界。OpenClaw 是 agent 平台 (framework),不是 agent 产品 (application)。两者关心 session 的事完全不同:

产品视角(Codex / Claude Code / Hermes)

用户开 agent 是为了完成具体任务。产品要:

  1. 让用户能列出”过去 7 天的 session”
  2. 让用户 resume 任意 session
  3. 自动 archive 旧 session 防止文件过多
  4. 跨设备同步 session(高级产品功能)

每一条都需要 session 持久化层(JSONL / multi-file / SQLite)。

平台视角(OpenClaw)

OpenClaw 是给开发者写 agent 用的框架。开发者:

  • 写 Slack bot 的:要把 session 存到 Slack 平台的 thread,不要写本地
  • 写 IDE 插件的:要把 session 存到 IDE workspace state
  • 写 SaaS 的:要把 session 存到 PostgreSQL / Redis
  • 写 CLI 工具的:要本地 JSONL(学 Codex 模式)

OpenClaw 的取舍

如果 OpenClaw 也提供「JSONL session 持久化」,会出现两个问题:

  1. 架构绑定:用 OpenClaw + Slack bot 时,session 既存本地 JSONL 又存 Slack thread,两份数据可能不一致。
  2. 扩展困难:每加一种存储后端(PostgreSQL / Redis / S3 / cloud KV),都要在 OpenClaw 内核加 if-else。一个 platform 框架最不应该的就是 hardcode 存储。

所以 OpenClaw 选择:

  • 提供 session_id 校验(确保 ID 格式合规)
  • 提供 session_key 工具({agentId}:{sessionId} 拼接)
  • 提供 transcript-events 序列化(事件转 JSON)
  • 存哪里 / 什么时候 reset / 怎么 resume 全交给 plugin / 上层调用方

类比

数据库的 query layer 不该决定「数据存在哪」。SQLite / PostgreSQL / MySQL 的存储后端可以变,但 query layer 一致。OpenClaw 让 session 存储成为可插拔后端。

代价

OpenClaw 用户要自己处理 session 存储。这意味着:

  1. out-of-box 不够:要 demo 一个较全 agent,需要先选个 session backend。
  2. 生态分裂:不同插件可能存到不同地方,跨插件查询不便。
  3. 新手门槛:「session 怎么存?」是新人第一问,OpenClaw 答「你决定」。

OpenClaw 用文档 + 几个 plugin 示例(一个 file-based、一个 in-memory)减轻这些问题。

对比标准

判断「这个抽象是不是合理」用一个问题:业务场景下,是不是足够多样

  • session 存储后端:部署可能覆盖本地、Slack、DB 或 S3;框架不绑定后端能保留 adapter 选择,但只有单一受控后端的产品也可以固定实现。
  • session_id 格式:UUID 有成熟工具链、较大随机空间和跨语言互操作优势,但不是唯一行业格式。OpenClaw 的 UUID 正则是当前快照的协议选择,仍应配合存储层唯一约束;需要可排序 ID 或数据库主键的系统可能选择 ULID 或整数 ID。
  • session_key 命名空间:agentId + sessionId 适合一个 agent 对应一个 session 的路由;多租户、多平台或群聊场景可能还要加入 tenant、platform 或 chat 维度。util 函数只有在命名契约一致时才适用。

这个问题可以作为抽象评估入口,但最终仍要用目标部署的互操作、迁移和冲突测试验证。

源码openclaw/src/sessions/session-id.ts:1-6(极简 UUID 正则)+ session-key-utils.ts

追问:「但 Codex 也是『可扩展』的,怎么 Codex 还是绑定 JSONL?」Codex 不是 framework,是 product。Codex 团队决定 JSONL 是好选择,强制所有 Codex 用户用这个。OpenClaw 给的不是「Codex 的灵活版」,是「让开发者自己做 Codex 的工具」。定位不同。

Q4 · 概念:Hermes 的 SessionResetPolicy 有 4 种模式(daily / idle / both / none)。为什么是 4 种而不是 1 种?

每种对应一个真实用户群:

daily(每天 4 点 reset)· 个人助理

用户:早上让 agent 帮做事,晚上接着聊。

  • 优点:每天一个清爽 session,不积累冗余上下文
  • 缺点:如果用户深夜还在用,可能 4 点突然失忆

适合:用户作息规律的私人助理(Notion AI assistant、Telegram bot)

idle(24 小时无活动 reset)· 项目协作

用户:跟 agent 在 Slack 工作群讨论项目,可能几天才回一次。

  • 优点:以”有活动”为粒度。如果用户连续 7 天在讨论同一个项目,context 保持
  • 缺点:超过 idle 阈值后强制 reset,可能让用户体验不佳

适合:项目协作场景(Slack agent、Linear assistant)

both(任一触发就 reset)· 默认推荐

实际 Hermes 默认是 both:每天 4 点 OR idle 24 小时,任一先到的触发 reset。

  • 优点:既有”每天清盘”的稳定性,又有”工作日连续”的连贯性
  • 缺点:策略叠加,规则复杂

适合:只有在两种 reset 触发条件都符合产品策略时才设为默认;不要把 Hermes 的默认值当成多数场景证据。

none(永不 auto-reset)· 长期记忆 agent

用户:跟 agent 维护一个长期项目(小说创作、知识库整理)。

  • 优点:context 永远保留,agent 还「记得」用户在干什么
  • 缺点:context 会无限增长,需要依赖 compact 不然爆。Hermes 默认 compact threshold 触发后会自动压缩,不影响这个模式。

适合:长期项目 agent、creative writing assistant

配置层次

@dataclass
class SessionResetPolicy:
mode: str = "both"
at_hour: int = 4
idle_minutes: int = 1440
notify: bool = True
notify_exclude_platforms: tuple = ("api_server", "webhook")

注意 notify_exclude_platforms:reset 时发通知给用户(“agent 已重置上下文”)。但对 api_server / webhook 这种程序调用方,不发通知(程序不需要收到这个消息)。

为什么不让用户自己写 reset 逻辑?

如果只给 hook:

def custom_reset_logic(session):
if some_condition:
reset(session)

用户要自己实现 daily / idle 逻辑,每次都重新发明轮子。Hermes 直接给 4 种 enum 模式 + 配置,多数用户不用写代码。

per-platform 覆盖

reset_by_platform = {
Platform.SLACK: SessionResetPolicy(mode="idle", idle_minutes=240), # 4 小时
Platform.TELEGRAM: SessionResetPolicy(mode="both"),
Platform.LOCAL: SessionResetPolicy(mode="none"), # CLI 永不 reset
}

工作 Slack 群短 idle、私人 Telegram 长 idle、本地 CLI 永久。一个 agent 服务多种场景,per-platform 配置很必要。

工程教训reset 不是”全局策略”,是”per-platform / per-context 策略”。给固定 enum + per-context override 比让用户写代码合理。

源码hermes-agent/gateway/config.py:100-145SessionResetPolicy 定义)。

追问:「reset 跟 compact 怎么区分?」reset 是”对话归零”(清 message history,保留 plugin state);compact 是”压缩”(保留 message history 摘要,原始消息丢)。reset 触发是策略+时间;compact 触发是 token 数。两个独立机制并存。

Q5 · 概念:Codex 区分 ThreadId 和 SessionId。看起来重复,为什么不合并?

两个 ID 表达完全不同的概念:

ThreadId · 逻辑对话单位

  • 一个 thread 可以:fork(基于历史开新分支)、resume(继续)、archive(归档)
  • thread 跨时间存在:今天开始的 thread,明天 resume 继续,下周 archive
  • thread 有人类语义:用户说「我那个关于 refactoring 的对话」

SessionId · 一次具体运行实例

  • 一个 session 是:进程启动 → 用户交互 → 进程退出 的一次运行
  • session 短期存在:跟进程同生命周期
  • session 没有用户语义:用户不关心「session 12345」

两者关系

ThreadId = "thread-refactor-foo"
├─ Session 1 (Monday 10am-11am)
├─ Session 2 (Monday 3pm-4pm, resumed from Session 1)
├─ Session 3 (Tuesday 9am-10am, resumed from Session 2)
└─ Session 4 (Wednesday, archived)

一个 thread 可能跨多个 session(每次 resume 是新 session)。

为什么不能合并?

合并为单个 ID(比如都叫 thread_id):

  • 用户主动 resume 同一个 thread 两次会不会撞 ID?需要新 ID。
  • 但 thread 又是同一个逻辑对话,从用户视角不应该改名。

合并为单个 ID(都叫 session_id):

  • 那 fork 出来的新对话 ID 是什么?跟原来 fork 自的 session 什么关系?
  • 长期 archive 时需要稳定 ID,session 频繁生成新 ID 不利于跨时间引用。

在需要一个稳定逻辑 thread 跨多次运行、同时让每次运行拥有独立 lifecycle 的产品里,Codex 的拆分能表达两种所有权。若产品没有 resume、fork 或多次运行的区别,单一 ID 也可能足够;是否拆分应由生命周期与引用需求决定。

实现细节

struct Session {
pub(crate) conversation_id: ThreadId, // 逻辑 thread ID
pub(crate) session_id: SessionId, // 本次运行 ID
// ...
}

conversation_id 是稳定的;session_id 每次启动新生成。

resume 时:

fn resume_thread(thread_id: ThreadId) -> Session {
let history = load_jsonl_by_thread(thread_id);
let session_id = SessionId::new(); // 新 session ID
Session {
conversation_id: thread_id, // 同一个 thread
session_id,
// ...
}
}

对比四家

  • Codex:ThreadId + SessionId,两个明确概念
  • Claude Code:sessionId 一个概念,但 worktree 有独立的 worktreeSessionId
  • OpenClaw:sessionId 一个,但 agent scope 引入 {agentId}:{sessionId} 命名空间
  • Hermes:session_id 一个,但 platform + chat_id 组合作为 stable identifier

每家本质都遇到「短期运行 vs 长期对话」的区分,只是命名不同。

工程教训用户视角的 ID(持久)和系统视角的 ID(运行)应该是两个东西。混淆会导致:用户找不到自己的对话、archive / migration / metric 都做不对。

源码codex/codex-rs/protocol/src/protocol.rs ThreadId/SessionId 定义 + core/src/session/session.rs:11-37(Session struct)。

追问:「fork 怎么处理 ID?」fork 是基于历史 thread 创建新 thread。Codex 的 RolloutRecorderParams::Create.forked_from_id: Option<ThreadId> 记录”从哪个 thread fork”。新 thread 有自己的 ThreadId(独立演化),但记得自己的 forked-from origin(便于追溯)。

Q6 · 实战:你要给自己的 agent 加 session 持久化。最小可用 → 生产就绪要走多远?

六个阶段;按前置依赖和验收门槛推进,不把时间标签当成交付承诺:

阶段 1 · 单文件 JSON

def save_session(session_id: str, messages: list, meta: dict):
path = f"~/.youragent/sessions/{session_id}.json"
with open(path, 'w') as f:
json.dump({"meta": meta, "messages": messages}, f)
def load_session(session_id: str):
path = f"~/.youragent/sessions/{session_id}.json"
return json.load(open(path))

先用它验证 resume 契约。问题:每个 turn 全量写盘,慢;崩了丢全部;不能流式 tail。

阶段 2 · 切到 JSONL append-only

前置:单文件版本的字段和 resume 语义已经被 fixture 固定。

def append_event(session_id: str, event: dict):
path = f"~/.youragent/sessions/{session_id}.jsonl"
with open(path, 'a') as f:
f.write(json.dumps(event) + "\n")
def load_session(session_id: str):
path = f"~/.youragent/sessions/{session_id}.jsonl"
return [json.loads(line) for line in open(path) if line.strip()]

参考 Codex。第一行写 SessionMeta,后续 append 每个 message / event。

验收门槛:模拟截断尾行、重启和并发追加;恢复器保留完整前缀,且不会把损坏数据静默当成有效事件。

阶段 3 · 加 SessionMeta + 区分 ThreadId/SessionId

前置:JSONL 事件可恢复,且产品已区分逻辑 thread 与一次运行实例。

@dataclass
class SessionMeta:
thread_id: str # 稳定,跨 session
session_id: str # 本次运行
cwd: str
model: str
git_sha: str | None
cli_version: str
created_at: str
forked_from: str | None # ThreadId
def start_session(thread_id: str | None = None):
if thread_id is None:
thread_id = uuid4()
session_id = uuid4() # 总是新生成
meta = SessionMeta(thread_id, session_id, ...)
rollout_path = f"~/.youragent/sessions/rollout-{ts}-{session_id}.jsonl"
append_event(rollout_path, asdict(meta))
return Session(meta, rollout_path)

参考 Codex。resume 走 thread_id,启动走 session_id。

验收门槛:同一 thread 的多次 session 可重放、fork 来源可追溯,启动不会复用旧 session_id。

阶段 4 · SQLite 索引

当目录扫描和解析 JSONL 的延迟超过产品预算时,再建索引:

def init_db():
conn = sqlite3.connect("~/.youragent/state.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS threads (
thread_id TEXT PRIMARY KEY,
cwd TEXT,
model TEXT,
created_at TEXT,
last_message_at TEXT,
archived BOOLEAN DEFAULT FALSE
)
""")
def on_session_start(meta: SessionMeta):
conn.execute(
"INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT REPLACE",
(meta.thread_id, meta.cwd, meta.model, meta.created_at, meta.created_at, False)
)
def list_threads_for_cwd(cwd: str):
return conn.execute(
"SELECT * FROM threads WHERE cwd = ? AND NOT archived ORDER BY last_message_at DESC LIMIT 50",
(cwd,)
).fetchall()

参考 Codex state.db。启动时 backfill:扫所有 JSONL 文件,找新增的写入 DB。

验收门槛:固定 session fixture 中,索引查询与 JSONL source of truth 一致;重建、迁移和索引损坏都能回退或被明确报告。

阶段 5 · 4 种 lifecycle hook

前置:插件或子系统确实需要在 startup、resume、clear、compact 介入。

class SessionLifecycle:
def on_startup(self, session): pass # 新 session
def on_resume(self, session): pass # resume 历史
def on_clear(self, session): pass # 用户 /clear
def on_compact(self, session): pass # 上下文压缩
def trigger_lifecycle(source: str, session):
for hook in registered_hooks:
getattr(hook, f"on_{source}")(session)

参考 Claude Code 4 source。每种触发对应的 hook。让 plugin 能在生命周期事件挂自己的逻辑。

验收门槛:事件顺序、重复触发和 hook 失败行为有契约测试;hook 不能改变核心 resume 结果。

阶段 6 · Reset 策略(仅多平台 agent 需要)

前置:消息来源和平台能力已经进入 SessionSource,且产品明确 reset 的业务含义。

@dataclass
class ResetPolicy:
mode: Literal["daily", "idle", "both", "none"] = "both"
at_hour: int = 4
idle_minutes: int = 1440
def should_reset(session: Session, policy: ResetPolicy) -> bool:
if policy.mode == "none":
return False
daily = is_past_at_hour(session.last_reset, policy.at_hour) if policy.mode in ("daily", "both") else False
idle = minutes_since(session.last_message_at) > policy.idle_minutes if policy.mode in ("idle", "both") else False
return daily or idle

参考 Hermes。如果你的 agent 服务多平台,per-platform 覆盖。

验收门槛:对 daily、idle、both、none 及平台覆盖分别跑边界 fixture,确认 reset 不会误删仍需恢复的上下文。

关键经验

  1. 优先采用 JSONL,不要单文件 JSON:先验证尾部损坏和恢复语义
  2. 定义 ThreadId/SessionId 分离:用户视角和系统视角需要分
  3. SQLite 索引等性能问题真出现再加:先用目录扫描建立基线,再按 session 数量和查询耗时决定
  4. lifecycle hook 是平台化路径:单产品可以不要
  5. Reset 策略只在 chat agent 上必要:编程助手 / IDE 不需要

源码组合:Codex rollout/src/recorder.rs + metadata.rs + state_db.rs(基础三件套)→ Claude Code sessionStart.ts + sessionRestore.ts(生命周期)→ Hermes gateway/session.py + config.py(多平台 + reset)。从 1 到生产化的源码地图。

追问:「跨设备同步要怎么做?」存储后端切到 cloud(S3 / DynamoDB / Firebase);rollout JSONL 改成 stream upload;用户登录后 sync 本地 cache。架构变化大,建议从 cloud-first 起步,不要先 local 再迁移。

Q7 · 架构:Claude Code 注释里强调”do not add ANY warmup logic”。为什么这条铁律重要?

启动路径是 agent UX 的命脉。延迟在这里失控会传到所有用户。

源码能证明什么

引用的 sessionStart.ts 注释只明确要求不要把非必要工作塞进启动路径;它没有给出下面这些历史耗时数据。把它们当成待验证的实验向量更稳妥:

  1. startup 时扫 ~/.claude 目录:找过去 session,准备 quick-resume 列表。
  2. startup 时加载所有 plugins:避免后续 lazy load 延迟。
  3. startup 时 git status:预填 git context,耗时随 repo 和 filesystem 变化。
  4. startup 时 fetch latest version:检查更新,通常可以放到后台。

是否拖慢启动,应在同一机器、冷/热缓存和代表性仓库上记录总耗时;本文没有复现出固定的 11 秒。

为什么会这样发生?

每个 warmup 单独来看都合理:

  • “扫历史 session 加速 resume”:帮用户更快进入工作
  • “加载 plugins”:避免后续延迟
  • “git status”:context 准备
  • “fetch version”:安全 / 修 bug

多个看似很小的 warmup 叠加后可能侵蚀启动预算;具体回归应从 CI 的历史 trace 计算,不能归因于一段未经核验的 PR 故事。

铁律由来

可执行的防线包括:

  1. 代码注释直接禁止:source 里写明”do not add ANY warmup”,新 PR 看到这条要解释。
  2. startup time SLA:为 claude --version 设产品预算,并在 CI 超预算时提示回归;预算值应由目标设备测量。
  3. defer-by-default:所有非需要的初始化 lazy load(plugins / sessions / git)。

如何设启动预算?

把交互目标、设备分布和冷启动/热启动数据写进预算;不要把某个通用的 200ms 阈值当成所有 CLI 的事实。

应该 lazy load 什么?

  • 历史 session 列表:用户 --resume 时才扫,不是 startup
  • plugins:要用时才加载(每个 plugin 注册自己的 trigger)
  • git context:第一次需要 git info 时再 status
  • version check:后台异步,启动不阻塞

什么需要 startup 做?

  • 解析 CLI args
  • 验证 API key(不验后续每次调用都会失败)
  • 设置 logger
  • 注册 signal handler

总预算需要在目标设备上测量,并将网络、插件和首次认证等可变路径单独计入。

Codex 也学了

Codex 的引用路径把 session 列表和 state.db 查询放到需要时再做;源码快照本身没有提供可跨设备复用的启动毫秒数。

Hermes 反例

Hermes 的启动路径还包含多平台连接、插件和 cron 初始化;这些依赖是否在冷启动发生,应按部署配置测量:

  • 连接已启用的 messaging platform(可能包含 OAuth handshake)
  • 加载所有 plugin
  • 初始化 cron scheduler

server 类应用和 CLI agent 的启动约束不同:前者可以把一次冷启动摊到长期运行,后者更直接暴露给交互用户;是否可接受要看进程生命周期。

工程教训:把 CLI agent 的启动延迟当作产品契约。每加一个 warmup,先问”能不能 lazy”,再用真实设备和仓库的 trace 证明它值得占用预算。

源码claude-code/src/utils/sessionStart.ts:34(注释里的禁令)。

追问:「但用户希望 --resume 快,怎么做?」--resume 时才查 SQLite 索引(state.db),并把非必要初始化放到后台或按需加载。等待多久应由 --resume 的专项 trace 决定。

Q8 · 实战:用户报告”resume 之后 agent 表现得像换了一个人”。系统化排查。

resume 失忆症状本质是「状态恢复不较全」。分 4 层排查:

第一层 · 消息历史(最常见)

检查:

session = load_session(thread_id)
print(f"加载了 {len(session.messages)} 条消息")
print(f"最后一条: {session.messages[-1]}")

如果 messages 数量不对(少了 / 截断了),是 JSONL 解析错或文件损坏。Claude Code 见过的 bug:

  • archived_sessions/ 没正确读,只读了 active 目录
  • 跨版本 schema 不兼容,新版本 parser 跳过老 row
  • 文件被外部修改(用户手动编辑了 JSONL 想 debug)

第二层 · 系统元数据

哪怕 messages 全对,agent 行为可能因为:

# 检查 resume 后这些是不是恢复了
print(f"cwd: {session.cwd}") # 工作目录
print(f"model: {session.model}") # 模型选择
print(f"approval_mode: {session.approval_mode}") # 审批策略
print(f"git_sha: {session.git_sha}") # git 状态

典型 bug:

  • resume 时 cwd 没恢复,agent 不在原项目目录(找不到 file)
  • model override 没恢复,从 sonnet 切回 opus(行为不同)
  • approval mode 没恢复,原来 --accept-edits 现在变 interactive(卡在每个 edit)

第三层 · 子系统状态

Claude Code 7 类要恢复:

sessionRestore({
cost: ..., # 计费状态
attribution: ..., # 用户归因
file_history: ..., # 改过哪些文件
todos: ..., # 待办
model_override: ...,
worktree_state: ...,
system_prompt: ..., # 上下文额外注入
})

最容易漏的是 system_prompt sections。Claude Code 的 system_prompt 由多个 section 组成(CLAUDE.md + plugin 注入 + 工具描述 + 用户自定义)。resume 时如果只恢复了一部分,agent 不知道某些工具能用、不知道项目约定。

第四层 · 上下文窗口管理

如果消息太多被 compact 过,resume 时可能:

  • compact 后的 summary 没保留,agent 不知道历史发生过什么
  • 原始消息保留但 compact summary 也保留,导致重复(context bloat)

典型修复流程

  1. 让用户复现,记下 thread_id
  2. 看 rollout JSONL 文件,确认行数 + SessionMeta 较全
  3. 加 debug log 在 sessionRestore 每一步:「准备恢复 cost」「准备恢复 attribution」…
  4. resume 后 dump 实际 session state,对比 SessionMeta 期望状态
  5. 找出哪一类没恢复,写测试

预防

  • resume 端到端测试:每个 release 跑 fixture session(已知历史)做 resume,对比 100 个 assertion
  • schema versioning:SessionMeta 加 schema_version,旧版本数据走兼容路径
  • resume metric:埋点 resume 成功率、用户 resume 后 N 分钟内主动 /clear 的比例(侧面 indicator)

对比四家

  • Codex 的 resume 比较稳:JSONL line-by-line replay,state DB 只是索引不影响 session 内容。
  • Claude Code 的 resume 复杂:7 类状态分布在 7 个子系统。每个都要小心。
  • OpenClaw 的 resume 取决于上层实现:framework 不管。
  • Hermes 的 resume 是 chat session:状态少(只有 message history + SessionContext),出错少。

工程教训resume 不是”重放 messages”,是”重建较全 session state”。多少子系统就有多少东西要恢复。每加一个子系统,resume path 要更新。

源码claude-code/src/utils/sessionRestore.ts:1-58(7 类状态导入列表,每个都要恢复)。

追问:「resume 失败时 fallback 策略?」分级:(1) message 解析失败 → 跳过损坏行;(2) cwd 不存在 → 提示用户选新 cwd;(3) model 失效 → fallback 到默认模型;(4) 整个 session 不能恢复 → 提示用户「session 损坏,开新对话还是导出旧消息?」让用户决定。

Q9 · 工程:Hermes 把 _PII_SAFE_PLATFORMS 列出 4 个平台允许 PII redaction。这个清单怎么维护?

清单不是猜的,是从两个具体约束逆推:

约束 1 · 平台的 mention 语法

不同平台 mention 用户的方式:

  • WhatsApp:自然语言 @用户名(不需要内部 ID)
  • Signal:电话号码 / UUID(用户级别 ID)
  • Telegram:自然语言 + @username(不需要内部 ID)
  • BlueBubbles:phone number(人可读)
  • Discord<@user_id> 语法(需要用内部数字 ID)
  • Slack:<@U12345678> 语法(需要用 Slack member ID)

如果平台 mention 需要内部 ID,那 LLM 需要看到原始 user_id 才能生成正确 mention。redact 掉 = mention 失败。

所以:

  • 能 redact:WhatsApp / Signal / Telegram / BlueBubbles → 在 _PII_SAFE_PLATFORMS
  • 不能 redact:Discord / Slack → 不在清单里

约束 2 · routing 跟 LLM input 分离

Hermes 设计上 SessionSource 永远保留原始 ID(用于路由),LLM 看到的是 redact 后的版本:

session_source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="123456789",
user_id="987654321",
user_name="Alice",
)
# 路由:原始 ID
route_response(session_source.chat_id, session_source.user_id, response)
# LLM input:redact 后
prompt = build_session_context_prompt(session_source, redact_pii=True)
# prompt 里 user_id 变成 hash_user_001

LLM 不知道真实 user_id,但 Hermes 系统知道。LLM 生成的回复要发给”hash_user_001”时,Hermes 内部映射回真实 user_id 再发送。

清单更新规则

  • 新增平台支持:先研究 mention 语法。如果用 internal ID,加入「不能 redact」;如果用自然语言,加入 _PII_SAFE_PLATFORMS
  • 平台改协议:罕见。但比如 Telegram 5.0 开始要求 user_id mention,得移出 safe 列表。
  • 法律 / 合规改变:如果 GDPR / CCPA 要求所有 user 数据脱敏发给 LLM,那 redact 不再是可选,所有平台都强制(即使 mention 失败也接受)。

为什么不让所有平台都 redact?

mention 是产品的”agent 能跟你互动”的核心信号:

  • Slack 群里 agent 回复时 @你:你知道是给你的
  • Discord channel 里 agent 提到你:你能看到

如果 LLM 看不到真实 ID 没法生成 mention,agent 的回复变成”普通消息”,UX 大幅退步。

所以 Hermes 的选择是:

  • 默认 redact_pii=False(不脱敏,保证功能)
  • 用户配置 redact_pii=True 时,只对 PII_SAFE_PLATFORMS 生效
  • Discord / Slack 上要 redact 时,强制 fallback 不 redact + 加 audit log(让用户知道这些平台没 redact)

对比工业实践

OpenAI 的 ChatGPT 商业版:

  • 默认所有 user input 都 redact(公司怕泄密)
  • 但同一个 user 在不同 chat 用 hash_user_001 不一致,agent 不能跨 chat 记得用户
  • 产品力受损但符合合规要求

Hermes 是个人 / 私人 agent,UX 优先;ChatGPT 是企业 agent,合规优先。设计取向不同。

工程教训安全决策一定要追到产品 / 平台 / 法律的具体需求。不能”为了安全而安全”,会损失产品力。文档里把 trade-off 讲清楚(“Discord 不能 redact,因为 mention 要 raw ID”)让维护者理解 why。

源码hermes-agent/gateway/session.py:176-209_PII_SAFE_PLATFORMS 定义 + 注释里讲清楚 Discord 为什么 excluded)。

追问:「如果用户不在乎 mention 失败,要全 redact 怎么办?」配置项 force_redact_all_platforms=True。Hermes 不直接给(怕 UX 退步),但 enterprise / regulated 部署可以打开。

Q10 · 开放:设计一个「通用 session 框架」,按约束组合四家中选定的模式。给出最小较全 API + 实现 outline。

分层设计,按需启用:

Layer 1 · 核心 ID(需要)

type ThreadId = string; // 稳定,跨 session
type SessionId = string; // 本次运行
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function newThread(): ThreadId { return crypto.randomUUID(); }
function newSession(): SessionId { return crypto.randomUUID(); }
function isValidSessionId(id: string): boolean { return SESSION_ID_RE.test(id); }

参考 OpenClaw UUID 正则 + Codex thread/session 分离。

Layer 2 · SessionMeta(需要)

interface SessionMeta {
thread_id: ThreadId;
session_id: SessionId;
forked_from?: ThreadId;
cwd: string;
model: string;
git_sha?: string;
cli_version: string;
created_at: string;
agent_role?: string;
agent_nickname?: string;
}

参考 Codex SessionMeta。落 JSONL 第一行。

Layer 3 · JSONL Rollout(推荐)

interface RolloutRecorder {
appendEvent(event: RolloutItem): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
}
type RolloutItem =
| { type: 'session_meta'; meta: SessionMeta }
| { type: 'response_item'; item: ResponseItem }
| { type: 'turn_context'; ctx: TurnContext }
| { type: 'compacted'; summary: string }
| { type: 'event_msg'; event: EventMsg };
class FileSystemRollout implements RolloutRecorder {
// ~/.youragent/sessions/rollout-{ts}-{session_id}.jsonl
// append-only, mpsc async write
}

参考 Codex RolloutRecorder + 5 种 RolloutItem。

Layer 4 · SQLite 索引(生产推荐)

interface SessionIndex {
saveThread(meta: SessionMeta): Promise<void>;
listThreads(filter: ThreadFilter): Promise<ThreadSummary[]>;
findThread(id: ThreadId): Promise<ThreadSummary | null>;
archiveThread(id: ThreadId): Promise<void>;
}
class SqliteSessionIndex implements SessionIndex {
// ~/.youragent/state.db
// schema: threads(thread_id, cwd, model, created_at, last_message_at, archived)
// backfill from rollout files on startup (lazy)
}

参考 Codex state.db。listThreads 不扫文件用 SQL。

Layer 5 · 生命周期 Hook(推荐)

type SessionSource = 'startup' | 'resume' | 'clear' | 'compact';
interface SessionLifecycleHook {
appliesTo: SessionSource[];
execute(source: SessionSource, session: Session): Promise<void>;
}
class SessionLifecycle {
private hooks: SessionLifecycleHook[] = [];
register(hook: SessionLifecycleHook) {
this.hooks.push(hook);
}
async trigger(source: SessionSource, session: Session) {
for (const hook of this.hooks) {
if (hook.appliesTo.includes(source)) {
await hook.execute(source, session);
}
}
}
}

参考 Claude Code 4 种 source + plugin/user hook trust 边界。

Layer 6 · SessionRestore(推荐)

interface RestoreableSubsystem<T> {
name: string;
snapshot(session: Session): T;
restore(state: T, session: Session): Promise<void>;
}
class SessionRestore {
private subsystems: RestoreableSubsystem<any>[] = [];
register<T>(sub: RestoreableSubsystem<T>) {
this.subsystems.push(sub);
}
async restoreAll(state: Record<string, any>, session: Session) {
for (const sub of this.subsystems) {
const subState = state[sub.name];
if (subState !== undefined) {
await sub.restore(subState, session);
}
}
}
}

参考 Claude Code 多子系统恢复。每个子系统自己实现 snapshot/restore。

Layer 7 · 多平台 SessionSource(可选 · chat agent 需要)

interface SessionSource {
platform: 'cli' | 'slack' | 'telegram' | 'discord' | ...;
chat_id: string;
chat_type: 'dm' | 'group' | 'channel';
user_id?: string;
user_name?: string;
thread_id?: string;
}
interface BuildContextOptions {
redact_pii?: boolean;
}
function buildSessionContextPrompt(
source: SessionSource,
opts: BuildContextOptions = {}
): string {
// 注入 system prompt:where messages come from, what platforms connected
// redact_pii: only for safe platforms
}

参考 Hermes SessionSource + PII redaction。

Layer 8 · ResetPolicy(可选 · chat agent 需要)

interface ResetPolicy {
mode: 'daily' | 'idle' | 'both' | 'none';
at_hour?: number;
idle_minutes?: number;
notify?: boolean;
notify_exclude_platforms?: string[];
}
class ResetEngine {
shouldReset(session: Session, policy: ResetPolicy): boolean {
if (policy.mode === 'none') return false;
const daily = policy.mode === 'daily' || policy.mode === 'both' ?
this.isPastResetHour(session, policy.at_hour!) : false;
const idle = policy.mode === 'idle' || policy.mode === 'both' ?
this.isIdleTimeout(session, policy.idle_minutes!) : false;
return daily || idle;
}
}

参考 Hermes 4 种 reset 模式 + per-platform override。

总 API

import { SessionManager } from '@your-org/session';
const sm = new SessionManager({
storage: new FileSystemRollout('~/.myagent'),
index: new SqliteSessionIndex('~/.myagent/state.db'),
resetPolicy: { mode: 'both', at_hour: 4, idle_minutes: 1440 },
});
// Start new
const session = await sm.startSession({ cwd: '/foo', model: 'opus' });
// Resume by thread_id
const resumed = await sm.resume(threadId);
// Lifecycle hook
sm.lifecycle.register({
appliesTo: ['startup', 'resume'],
execute: async (source, session) => {
if (source === 'startup') {
// 加载 CLAUDE.md
} else {
// 恢复 cost tracker
}
},
});
// Multi-platform routing (optional)
const platformSession = await sm.fromSource({
platform: 'telegram',
chat_id: '123',
user_id: '456',
});

vs 四家

  • Codex:Layer 1-4(核心 ID + Meta + JSONL + SQLite)
  • Claude Code:Layer 1-6(+ lifecycle + restore)
  • OpenClaw:Layer 1(只校验 ID)
  • Hermes:Layer 1, 2, 7, 8(+ 多平台 + reset)

按 scope 评估

  • Layers 1-3 · 身份与持久化:固定 Thread/Session、SessionMeta 和 JSONL 事件契约。验收:截断尾部、重启、fork 和重复 ID fixture 都能给出确定结果。
  • Layers 4-6 · 索引与生命周期:仅在查询预算或插件协作需要时增加 SQLite 与 lifecycle hooks。前置:已有目录扫描基线和恢复顺序;验收:索引可从 JSONL 重建,hook 顺序与失败语义有契约测试。
  • Layers 7-8 · 多平台 reset/脱敏:仅在 chat agent 需要按来源路由时增加。前置:平台能力和 PII 规则明确;验收:各 reset 模式、平台覆盖和 redaction fixture 可重放。

关键决策

  1. JSONL 是默认:不要单文件 JSON
  2. Thread/Session 分开:UID 不要混用
  3. SQLite 索引等数据量大才上:先测目录扫描和查询延迟,再决定阈值
  4. Lifecycle hook 是平台化路径:单产品可以不要
  5. 多平台 / Reset 只在 chat agent 上必要:编程 agent 不需要

追问:「跨设备同步怎么加?」Layer 9:CloudSync 层。把 RolloutRecorder 实现切到 cloud storage(S3 / GCS / Azure Blob),index 切到 cloud DB(DynamoDB / Firestore)。用户登录后 sync 本地 cache。架构改动大,建议从一开始就设计 cloud-first。

源码组合:Codex rollout/ + core/session/ (基础三层) → Claude Code utils/sessionStart.ts + utils/sessionRestore.ts (生命周期) → OpenClaw sessions/session-id.ts (极简校验) → Hermes gateway/session.py + gateway/config.py (多平台 + reset)。四家代码拼一起 = session 框架 v0.1。