Skip to content

会话管理:SessionManager

源码版本v2026.6.11

职责

SessionManager 是 OpenClaw agent 的 transcript 守门人。每一条用户消息、assistant 回复、tool 调用、tool 结果、思考级别变更、模型切换、压缩摘要、分支标记,最终都落到 SessionManager 维护的 JSONL 文件里。它同时提供查询接口:buildSessionContext() 给上层组装 LLM 上下文,getTree() 给 UI 渲染分支结构,getBranch() 支持分支切换,appendCompaction() 记录压缩边界。

SessionManager 不调模型,也不执行工具——它只负责"按顺序、按树结构、按持久化语义"把会话 (session) 的所有产物安全落到磁盘。AgentSession(agent-session.ts:334)是更上层的语义封装,把 SessionManager + Agent + 工具注册 + 钩子串成一个完整的 agent 运行时对象。

设计动机

为什么单拎一个 SessionManager 而不是直接让 agent 往文件追加?因为 OpenClaw 的会话语义不是"线性日志",而是"带分支的树"。一个 session 文件里,每条 entry 都有 idparentId,可以组成森林:branch(branchFromId) 创建分支时,新的 leaf 从指定节点出发,旧分支保留可回溯。如果只用 append,分支结构就丢了。

为什么还要在 SessionManager 之上再包一个 AgentSession?因为工具注册 (toolRegistry)、模型注册 (sessionModelRegistry)、system prompt 拼装、compaction 控制这些事情跟 transcript 落盘是不同的关注点。SessionManager 专心做存储,AgentSession 专心做"运行一个 agent 所需的可变状态"。这种分层让 SessionManager 可以被独立测试,也让 transcript 文件格式不被运行时逻辑污染。

另一个动机是并发安全。同一个 session 可能有多个写入源:agent 主循环在追加 assistant 消息、tool 执行在追加 tool result、compaction 在改写前缀、外部 steering 在插入 context message。SessionManager 通过 sessionFileSnapshot 缓存 + OwnedSessionTranscriptWriteLock 在 attempt 层串行化写入,保证文件不会因为交错写入而损坏。一旦检测到文件被外部改写(snapshot mismatch),warm cache 失效,重新解析整个 transcript——慢但正确。

关键文件

数据流

SessionManager 的入口是几个静态工厂(session-manager.ts:2894)。所有构造都走私有 constructor,工厂方法决定怎么填参数:

typescript
static create(cwd: string, sessionDir?: string): SessionManager {
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
  return new SessionManager(cwd, dir, undefined, true);
}

static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
  const loaded = revalidateLoadedSessionFile(path, loadEntriesFromFileWithSnapshot(path));
  const header = loaded.entries.find((e) => e.type === "session");
  const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
  const dir = sessionDir ?? resolve(path, "..");
  return new SessionManager(cwd, dir, path, true, loaded);
}

static continueRecent(cwd: string, sessionDir?: string): SessionManager {
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
  const mostRecent = findMostRecentSession(dir);
  if (mostRecent) {
    return new SessionManager(cwd, dir, mostRecent, true);
  }
  return new SessionManager(cwd, dir, undefined, true);
}

static inMemory(cwd: string = process.cwd()): SessionManager {
  return new SessionManager(cwd, "", undefined, false);
}

create 是全新 session;open 是打开指定文件,先 revalidateLoadedSessionFile 防 stale;continueRecent 是"接着上次最近的那个 session 继续";inMemory 不落盘,用于测试和临时上下文。注意 open 多走了一次 revalidateLoadedSessionFile——注释里说"单次 parsed load 不能在派生 cwd/session 元数据时变 stale",这是为 warm open 多付一次 stat,避免文件在加载途中被改写。

constructor(session-manager.ts:1462)维护的索引 Map 决定了 SessionManager 的内存开销模型:

typescript
private sessionId = "";
private sessionFile: string | undefined;
private sessionDir: string;
private cwd: string;
private shouldPersist: boolean;
private flushed = false;
private fileEntries: FileEntry[] = [];
private opaqueFileEntries: PreservedOpaqueFileEntry[] = [];
private byId: Map<string, SessionEntry> = new Map();
private opaqueParentsById: Map<string, string | null> = new Map();
private logicalParentsById: Map<string, string | null> = new Map();
private invalidLeafControlIds: Set<string> = new Set();
private labelsById: Map<string, string> = new Map();
private labelTimestampsById: Map<string, string> = new Map();
private leafId: string | null = null;
private appendParentId: string | null = null;
private promptReleasedSideBranchId: string | null | undefined;
private recoveredCorruptHeader = false;
private sessionFileSnapshot: SessionFileSnapshot | undefined;

注意 byIdopaqueParentsByIdlogicalParentsById 三张 Map——同一个 entry 在文件里是线性排列,但内存里同时维护 id→entry、id→opaque parent、id→logical parent 三个索引。opaque parent 是文件物理结构,parent 是逻辑结构(被压缩或合并过的 entry 逻辑 parent 可能不同)。labelsByIdlabelTimestampsById 分开存,是因为 label 可以改,需要时间戳判断哪个是最新。

新建 session(session-manager.ts:1556)把所有索引重置到初始态:

typescript
newSession(options?: NewSessionOptions): string | undefined {
  this.recoveredCorruptHeader = false;
  this.sessionFileSnapshot = undefined;
  this.sessionId = options?.id ?? createSessionId();
  const timestamp = new Date().toISOString();
  const header: SessionHeader = {
    type: "session",
    version: CURRENT_SESSION_VERSION,
    id: this.sessionId,
    timestamp,
    cwd: this.cwd,
    parentSession: options?.parentSession,
  };
  this.fileEntries = [header];
  this.opaqueFileEntries = [];
  this.byId.clear();
  this.opaqueParentsById.clear();
  this.logicalParentsById.clear();
  this.invalidLeafControlIds.clear();
  this.labelsById.clear();
  this.leafId = null;
  this.appendParentId = null;
  this.promptReleasedSideBranchId = undefined;
  this.flushed = false;

注意 header 是第一条 fileEntry,它携带 parentSession——这就是 fork 分支链的源头。新建 session 时如果传了 parentSession,后续可以从父 session 拉取共享前缀。

每条 entry 落盘(session-manager.ts:2154)走 persist:

typescript
persist(entry: SessionEntry, options?: AppendPersistenceOptions): void {
  this.persistRecord(entry, options);
}

persistRecord 内部维护 fileEntries 数组、byId Map、leafId 指针,并把 JSONL 行追加到磁盘文件。appendMessage / appendCompaction / appendThinkingLevelChange / appendModelChange / appendCustomEntry / appendSessionInfo 这些语义方法都是构造 entry 后调 persist

AgentSession 在 SessionManager 之上加了工具注册 (agent-session.ts:395):

typescript
private toolRegistry: Map<string, AgentTool> = new Map();
private toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
private toolPromptSnippets: Map<string, string> = new Map();
private toolPromptGuidelines: Map<string, string[]> = new Map();

四张 Map 各司其职:toolRegistry 是真正能跑的 AgentTool 实例(带 execute),toolDefinitions 是声明(entry 定义 + sourceInfo),toolPromptSnippets 是工具的 prompt 片段,toolPromptGuidelines 是工具的 prompt 准则。这些 Map 不存进 SessionManager——它们是运行时态,而 SessionManager 只管持久化态。

工具注册表的重建(refreshToolRegistry,agent-session.ts:2414)是 AgentSession 里最复杂的方法之一。它先收集所有候选工具,做 allowlist 过滤,再 wrap 一层执行钩子:

typescript
private refreshToolRegistry(options?: {
  activeToolNames?: string[];
  includeAllExtensionTools?: boolean;
}): void {
  const previousRegistryNames = new Set(this.toolRegistry.keys());
  const previousActiveToolNames = this.getActiveToolNames();
  const allowedToolNames = this.allowedToolNames;
  const isDisabledBuiltInToolName = (name: string): boolean =>
    this.disableBuiltInTools && this.baseToolDefinitions.has(name);
  const isAllowedTool = (name: string): boolean =>
    !isDisabledBuiltInToolName(name) && (!allowedToolNames || allowedToolNames.has(name));

  const registeredTools = this.currentExtensionRunner.getAllRegisteredTools();
  const allCustomTools = [
    ...registeredTools,
    ...this.customTools.map((definition) => ({
      definition,
      sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
    })),
  ].filter((tool) => isAllowedTool(tool.definition.name));
  const definitionRegistry = new Map<string, ToolDefinitionEntry>(
    Array.from(this.baseToolDefinitions.entries())
      .filter(([name]) => isAllowedTool(name))
      .map(([name, definition]) => [
        name,
        {
          definition,
          sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
        },
      ]),
  );
  for (const tool of allCustomTools) {
    definitionRegistry.set(tool.definition.name, {
      definition: tool.definition,
      sourceInfo: tool.sourceInfo,
    });
  }
  this.toolDefinitions = definitionRegistry;
  ...
  const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
  for (const tool of wrappedExtensionTools) {
    toolRegistry.set(tool.name, tool);
  }
  this.toolRegistry = toolRegistry;

注意三层 isAllowedTool 过滤:disableBuiltInTools + allowedToolNames + 内联 filter。disableBuiltInTools 是 agent 级别"我只用扩展工具"的开关;allowedToolNames 是更细粒度的工具白名单。两层独立判断,允许"允许所有 builtin 但禁用某个具体扩展"这种组合。

重建后还要算 nextActiveToolNames(agent-session.ts:2488)——保留之前的 active 工具集,加上新出现的工具,过滤掉被 allowlist 拒绝的。这是为了"扩展插件新装了个工具,旧 active 列表仍然有效,只是多了一项"这种增量场景。

进入 run 循环前还有一道归一化(session-manager-init.ts:47):

typescript
export async function prepareSessionManagerForRun(params: {
  sessionManager: unknown;
  sessionFile: string;
  hadSessionFile: boolean;
  sessionId: string;
  cwd: string;
}): Promise<void> {
  const sm = params.sessionManager as {
    sessionId: string;
    cwd: string;
    flushed: boolean;
    fileEntries: Array<SessionHeaderEntry | SessionMessageEntry | { type: string }>;
    ...
  };

  const header = sm.fileEntries.find((e): e is SessionHeaderEntry => e.type === "session");
  const hasAssistant = sm.fileEntries.some(
    (e) => e.type === "message" && (e as SessionMessageEntry).message?.role === "assistant",
  );

  if (!params.hadSessionFile && header) {
    header.id = params.sessionId;
    header.cwd = params.cwd;
    sm.sessionId = params.sessionId;
    sm.cwd = params.cwd;
    return;
  }

  if (params.hadSessionFile && header && !hasAssistant) {
    const preservesForkedBranch =
      typeof header.parentSession === "string" && header.parentSession.length > 0;
    if (sm.wasRecoveredFromCorruptHeader?.() || preservesForkedBranch) {
      ...
      return;
    }

    // Reset file so the first assistant flush includes header+user+assistant in order.
    await assertExistingHeaderIsReadable(params.sessionFile);
    await fs.writeFile(params.sessionFile, "", "utf-8");
    invalidateSessionFileRepairCache(params.sessionFile);
    header.id = params.sessionId;
    ...
    sm.fileEntries = [header];
    sm.flushed = false;
    return;
  }

这段处理三种情况:全新文件(只改 header id/cwd)、有 header 但无 assistant 的旧文件(直接重写,让首次 flush 写出 header+user+assistant 完整序列)、fork 分支或损坏恢复(保留原 tree)。注意 await fs.writeFile(params.sessionFile, "", "utf-8") 直接清空文件——这是为"用户先前预创建了一个空 session 文件"的场景兜底,避免第一次 assistant flush 时 header 写在 user 后面导致顺序错乱。

src/sessions/ 是更上层的语义层:它不直接碰文件,而是把 session key、session kind、session lifecycle events、transcript events 这些跨组件语义抽出来。SessionManager 管"怎么落盘",src/sessions/ 管"会话在不同渠道下怎么分类、怎么标识"。

边界与失败

  • 构造是私有的:SessionManager 的 constructor 是 private(session-manager.ts:1462),外部只能通过 create / open / continueRecent / inMemory 四个静态工厂进入。这是为了强制每次构造都经过 snapshot 加载和 cwd 校验,防止外部直接 new 出一个不一致的实例。
  • 损坏 header 能恢复:recoveredCorruptHeader 标记(session-manager.ts:1459)在 header 解析失败时被置位,prepareSessionManagerForRun 看到这个标记会走"保留 tree"分支而不是清空文件——这是为了避免把损坏但可恢复的 session 二次清空。
  • warm cache 会主动失效:syncSnapshotAfterHeaderRewrite(session-manager.ts:2166)在外部改写文件后被调用,如果文件实际内容和 snapshot 不一致,rememberAppendedSessionEntry 走 snapshot-mismatch 分支,丢掉 warm cache 强制重解析。慢,但保证正确。
  • flush 顺序的硬约束:prepareSessionManagerForRun 在"有 header 但无 assistant"的场景下直接清空文件(session-manager-init.ts:103-117),因为 OpenClaw 要求第一条 assistant 消息 flush 时,文件里的顺序必须是 header → user → assistant。如果之前用户预创建过文件,user 消息已经在文件里但 header 后面什么都没有,直接 flush assistant 会让顺序变 user → assistant → header。
  • fork 分支保留原 tree:preservesForkedBranch 分支(session-manager-init.ts:82)对 header.parentSession 非空的 session 跳过清空,因为 fork 出来的分支可能有意保留了 user-only 或空的子树作为起点,清空就丢了 fork 语义。
  • toolRegistry 重建不持久化:refreshToolRegistry 只更新内存里的 toolRegistry(agent-session.ts:2486),不写文件。工具注册是运行时态,session 重启后从 agent 配置重新构建——transcript 里只记 tool_call 的输入输出,不记 tool 定义。
  • active 工具集增量保留:nextActiveToolNames 的算法(agent-session.ts:2488)在 allowlist 改变或扩展工具增减时,保留之前的 active 工具集,只追加新出现的工具。这避免了"重装插件后 active 工具集清空"这种破坏性体验。
  • sessionFileSnapshot 是缓存而不是真相源:所有写入路径都假设 snapshot 可能 stale,所以 rememberWrittenSessionEntries 返回 verifiedWrite 标志(session-manager.ts:2170)区分"已验证"和"乐观缓存"。这是为并发外部写入兜底——文件可能被其他进程改写,SessionManager 不能假设自己是唯一写入者。

小结

SessionManager 是 OpenClaw 会话的持久化层:它管 transcript 文件的追加、索引、分支、压缩边界、label。AgentSession 在它之上加了运行时态——工具注册表、模型注册表、system prompt、compaction 控制器。两层职责分得很清:SessionManager 只存"什么发生了",AgentSession 存"现在要怎么跑"。

agent 主循环怎么使用 SessionManager 触发 attempt,见 Agent 主循环:embedded-runner;工具定义怎么从 baseToolDefinitions + 扩展工具组装到 toolRegistry,见 工具系统;transcript 在压缩和分支时的更深层数据流,见 上下文引擎

对照官方资料:Session 文档 · README