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