工作階段管理:SessionManager
職責
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 都有 id 和 parentId,可以組成森林: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——慢但正確。
關鍵檔案
session-manager.ts— 3000+ 行的 SessionManager 類別,以及 entry 型別定義。session-manager.ts CURRENT_SESSION_VERSION:51— 版本號 re-export。session-manager.ts SessionEntry:184-216— entry 聯合型別 + SessionContext 結構。session-manager.ts constructor:1439-1487— SessionManager 私有建構子,初始化所有索引 Map。session-manager.ts static factories:2894-2934—create / open / continueRecent / inMemory四個靜態工廠。session-manager.ts newSession:1556-1579— 建新 session,寫 header。session-manager.ts persist:2154-2156— 單條 entry 落盤入口。session-manager.ts syncSnapshotAfterHeaderRewrite:2166-2174— 檔案被外部改寫後重新同步 snapshot。agent-session.ts AgentSession:334-358— AgentSession 類別宣告,持有 SessionManager + Agent。agent-session.ts toolRegistry:395-398— 工具 / 工具定義 / prompt snippet / guidelines 的四張 Map。agent-session.ts refreshToolRegistry:2414-2486— 工具註冊表重建邏輯。session-manager-init.ts prepareSessionManagerForRun:47-117— 進入 run 循環前的 session 檔案歸一化。src/sessions/— 語意層:session-key 解析、kind 分類、lifecycle 事件、transcript 事件等。
資料流
SessionManager 的入口是幾個靜態工廠(session-manager.ts:2894)。所有建構都走私有 constructor,工廠方法決定怎麼填參數:
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 的記憶體開銷模型:
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;注意 byId、opaqueParentsById、logicalParentsById 三張 Map——同一個 entry 在檔案裡是線性排列,但記憶體裡同時維護 id→entry、id→opaque parent、id→logical parent 三個索引。opaque parent 是檔案實體結構,parent 是邏輯結構(被壓縮或合併過的 entry 邏輯 parent 可能不同)。labelsById 和 labelTimestampsById 分開存,是因為 label 可以改,需要時間戳判斷哪個是最新。
新建 session(session-manager.ts:1556)把所有索引重置到初始態:
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:
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):
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 一層執行鉤子:
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):
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。