セッション管理: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 の会話セマンティクスは「線形ログ」ではなく「ブランチ付きツリー」だからです。1 つの 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の 4 つの静的ファクトリ。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 の 4 枚の 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 の 3 枚の Map に注意——同じ entry はファイル内では線形配置ですが,メモリ内では id→entry、id→opaque parent、id→logical parent の 3 つのインデックスを同時保守します。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();4 枚の 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;3 層の isAllowedTool フィルタに注意:disableBuiltInTools + allowedToolNames + インライン filter。disableBuiltInTools は agent レベルの「私は拡張ツールだけを使う」スイッチ,allowedToolNames はより細粒度のツールホワイトリスト。2 層を独立判定し,「すべての builtin を許可するが特定拡張は無効」のような組み合わせを許します。
再構築後に nextActiveToolNames(agent-session.ts:2488)を計算します——以前の active ツールセットを保持し,新たに出現したツールを追加し,allowlist で拒否されたものをフィルタします。これは「拡張プラグインが新しくツールを追加したとき,旧 active リストは依然有効で,1 項目増えるだけ」という増分シナリオのためです。
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;
}この部分は 3 つの状況を処理します:新規ファイル(header id/cwd だけ変更)、header はあるが assistant のない旧ファイル(直接書き直し,初回 flush で header+user+assistant の完全シーケンスを書く)、fork ブランチや破損復旧(元のツリーを保持)。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の 4 つの静的ファクトリからしか入れません。これは毎回の構築が snapshot ロードと cwd 検証を経ることを強制し,外部が直接newで不整合なインスタンスを作るのを防ぐためです。 - 破損 header は復旧可能:
recoveredCorruptHeaderマーカー(session-manager.ts:1459)は header 解析失敗時に立ち,prepareSessionManagerForRunはこのマーカーを見ると「ツリーを保持」分岐に入り,ファイルを空にしません——破損でも復旧可能な 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 の後に何もない状態で直接 assistant を flush すると,順序が user → assistant → header になります。 - fork ブランチは元のツリーを保持:
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 コントローラ——を加えます。2 層の責務は明確に分かれています:SessionManager は「何が起きたか」を保存し,AgentSession は「今どう走るか」を保存します。
agent メインループがどう SessionManager を使って attempt をトリガーするかは Agent メインループ:embedded-runner,ツール定義が baseToolDefinitions + 拡張ツールからどう toolRegistry に組み立てられるかは ツールシステム,transcript の圧縮時のより深いデータフローは コンテキストエンジン を参照。
公式資料:Session ドキュメント · README。