Skip to content

Gestion de session: SessionManager

源码版本v2026.6.11

Responsabilités

SessionManager est le gardien du transcript de l'agent OpenClaw. Chaque message utilisateur, réponse assistant, appel d'outil, résultat d'outil, changement de niveau de réflexion (thinking), switch de modèle, résumé de compaction, marqueur de branche — tout finit dans le fichier JSONL maintenu par SessionManager. Il expose aussi des interfaces de requête: buildSessionContext() assemble le contexte LLM pour la couche supérieure, getTree() rend la structure de branche pour l'UI, getBranch() supporte le switch de branche, appendCompaction() enregistre la frontière de compaction.

SessionManager n'appelle pas le modèle et n'exécute pas d'outils — il ne fait que persister « en ordre, en structure d'arbre, avec la sémantique de persistance voulue » tous les artefacts de la session (session) sur le disque. AgentSession (agent-session.ts:334) est l'encapsulation sémantique supérieure qui tricote SessionManager + Agent + enregistrement d'outils + hooks en un objet runtime complet.

Motivation de conception

Pourquoi extraire un SessionManager plutôt que de laisser l'agent appendre directement dans un fichier? Parce que la sémantique de session d'OpenClaw n'est pas un « log linéaire », c'est un « arbre avec branches ». Dans un fichier de session, chaque entry a un id et un parentId, ce qui forme une forêt: branch(branchFromId) crée une branche — la nouvelle feuille part du nœud spécifié, l'ancienne branche reste pour retrace. En append seul, la structure de branche serait perdue.

Pourquoi emballer un AgentSession au-dessus du SessionManager? Parce que l'enregistrement d'outils (toolRegistry), l'enregistrement de modèles (sessionModelRegistry), l'assemblage du system prompt, le contrôle de compaction sont des préoccupations différentes de la persistance du transcript. SessionManager se concentre sur le stockage; AgentSession sur « l'état mutable nécessaire pour exécuter un agent ». Cette séparation permet à SessionManager d'être testé isolément, et le format de fichier de transcript de ne pas être pollué par la logique runtime.

L'autre motivation est la sécurité concurrentielle. Une même session peut avoir plusieurs sources d'écriture: la boucle principale de l'agent append des messages assistant, l'exécution d'outils append des tool results, la compaction réécrit le préfixe, le steering externe insère des context message. SessionManager utilise un cache sessionFileSnapshot + OwnedSessionTranscriptWriteLock pour sérialiser les écritures au niveau attempt, garantissant que le fichier ne soit pas corrompu par entrelacement. Si une modification externe est détectée (snapshot mismatch), le warm cache est invalidé et tout le transcript est reparse — lent mais correct.

Fichiers clés

Flux de données

L'entrée de SessionManager est via les factories statiques (session-manager.ts:2894). Toute construction passe par le constructor privé; la factory décide comment remplir les paramètres:

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 pour une session neuve; open pour ouvrir un fichier spécifique, d'abord revalidateLoadedSessionFile pour éviter le stale; continueRecent pour « reprendre la session la plus récente »; inMemory ne persiste pas, pour tests et contexte temporaire. Notez que open fait un revalidateLoadedSessionFile supplémentaire — le commentaire explique qu'« un seul parsed load peut devenir stale en dérivant cwd/session metadata », c'est un stat supplémentaire payé pour le warm open, afin d'éviter que le fichier ne soit modifié pendant le chargement.

Le constructor (session-manager.ts:1462) maintient des Maps d'index qui déterminent le modèle d'empreinte mémoire:

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;

Notez les trois Maps byId, opaqueParentsById, logicalParentsById — une même entry est linéaire dans le fichier, mais en mémoire on maintient trois index: id→entry, id→opaque parent, id→logical parent. L'opaque parent est la structure physique du fichier; le logical parent est la structure logique (après compaction ou fusion, le logical parent d'une entry peut différer). labelsById et labelTimestampsById sont stockés séparément car un label peut être modifié; il faut son timestamp pour savoir lequel est le plus récent.

La création d'une nouvelle session (session-manager.ts:1556) réinitialise tous les index à l'état initial:

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;

Notez que header est la première fileEntry et porte parentSession — c'est l'origine de la chaîne de branche fork. En créant une session, si on passe parentSession, on pourra ensuite tirer un préfixe partagé depuis la session parent.

La persistance d'une entry (session-manager.ts:2154) via persist:

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

persistRecord maintient en interne le tableau fileEntries, la Map byId, le pointeur leafId, et append la ligne JSONL sur le disque. appendMessage / appendCompaction / appendThinkingLevelChange / appendModelChange / appendCustomEntry / appendSessionInfo sont des méthodes sémantiques qui construisent une entry puis appellent persist.

AgentSession ajoute au-dessus de SessionManager l'enregistrement d'outils (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();

Les quatre Maps ont chacune leur rôle: toolRegistry porte les instances AgentTool réellement exécutables (avec execute); toolDefinitions est la déclaration (définition d'entry + sourceInfo); toolPromptSnippets les fragments de prompt de l'outil; toolPromptGuidelines les lignes directrices de prompt. Ces Maps ne sont pas stockées dans SessionManager — elles sont l'état runtime, SessionManager ne gère que l'état persisté.

Le rebuild du registre d'outils (refreshToolRegistry, agent-session.ts:2414) est l'une des méthodes les plus complexes de AgentSession. Il collecte d'abord tous les outils candidats, filtre par allowlist, puis enveloppe d'une couche de hooks d'exécution:

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;

Notez les trois filtres isAllowedTool: disableBuiltInTools + allowedToolNames + filtre inline. disableBuiltInTools est le switch au niveau agent « je n'utilise que des outils d'extension »; allowedToolNames est une allowlist d'outils plus fine. Les deux sont jugés indépendamment, pour autoriser des combinaisons comme « permettre tous les builtin mais désactiver une extension spécifique ».

Après le rebuild, on calcule aussi nextActiveToolNames (agent-session.ts:2488) — on conserve l'ensemble d'outils actifs précédent, on ajoute les nouveaux outils, on filtre ceux rejetés par l'allowlist. C'est pour le scénario incrémental « un plugin nouvellement installé ajoute un outil; l'ancienne liste active reste valable, avec juste un outil en plus ».

Avant d'entrer dans la boucle run il y a une normalisation (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;
  }

Ce bloc gère trois cas: fichier neuf (modifier seulement header id/cwd), ancien fichier avec header mais sans assistant (réécrire pour que le premier flush produise header+user+assistant dans l'ordre), branche fork ou recovery corrompu (préserver l'arbre original). Notez await fs.writeFile(params.sessionFile, "", "utf-8") qui vide directement le fichier — c'est le filet pour le scénario « l'utilisateur a pré-créé un fichier de session vide », pour éviter qu'au premier flush assistant le header ne soit écrit après le user et désordonne la séquence.

src/sessions/ est la couche sémantique au-dessus: elle ne touche pas directement aux fichiers, elle abstrait les sémantiques transversales: session key, session kind, session lifecycle events, transcript events. SessionManager gère « comment persister », src/sessions/ gère « comment classifier et identifier les sessions à travers les canaux ».

Limites et modes d'échec

  • Constructor privé: le constructor de SessionManager est private (session-manager.ts:1462); l'extérieur ne peut passer que par les quatre factories statiques create / open / continueRecent / inMemory. C'est pour forcer chaque construction à passer par le chargement snapshot et la validation cwd, et empêcher un new externe d'instancier un objet incohérent.
  • Récupération d'un header corrompu: le flag recoveredCorruptHeader (session-manager.ts:1459) est posé quand le parsing du header échoue; prepareSessionManagerForRun en voyant ce flag prend la branche « préserver l'arbre » plutôt que de vider le fichier — pour éviter de vider une seconde fois une session corrompue mais récupérable.
  • Le warm cache s'invalidate proactivement: syncSnapshotAfterHeaderRewrite (session-manager.ts:2166) est appelé après modification externe du fichier; si le contenu réel ne correspond pas au snapshot, rememberAppendedSessionEntry prend la branche snapshot-mismatch, jette le warm cache et force un reparse. Lent, mais correct.
  • Contrainte dure d'ordre de flush: prepareSessionManagerForRun vide directement le fichier dans le scénario « header mais pas assistant » (session-manager-init.ts:103-117), car OpenClaw exige qu'au flush du premier message assistant le fichier soit dans l'ordre header → user → assistant. Si l'utilisateur a pré-créé un fichier, le message user est déjà dans le fichier mais rien ne suit le header; un flush direct d'assistant donnerait user → assistant → header.
  • Préservation de l'arbre pour une branche fork: la branche preservesForkedBranch (session-manager-init.ts:82) saute le vidage quand header.parentSession est non vide, car une branche fork peut légitimement conserver un sous-arbre user-only ou vide comme point de départ; vider perdrait la sémantique du fork.
  • Le rebuild de toolRegistry n'est pas persisté: refreshToolRegistry ne met à jour que le toolRegistry en mémoire (agent-session.ts:2486), sans écrire de fichier. L'enregistrement d'outils est un état runtime; au redémarrage de la session, il est reconstruit depuis la config de l'agent — le transcript ne conserve que les inputs/outputs des tool_call, pas les définitions d'outils.
  • L'ensemble d'outils actifs est conservé incrémentalement: l'algorithme de nextActiveToolNames (agent-session.ts:2488) conserve l'ensemble actif précédent et n'ajoute que les nouveaux outils en cas de changement d'allowlist ou d'outils d'extension. Cela évite l'expérience destructive « réinstaller un plugin vide l'ensemble d'outils actifs ».
  • sessionFileSnapshot est un cache, pas la source de vérité: tous les chemins d'écriture assument que le snapshot peut être stale; rememberWrittenSessionEntries retourne donc un flag verifiedWrite (session-manager.ts:2170) distinguant « vérifié » de « cache optimiste ». C'est un filet pour les écritures externes concurrentes — le fichier peut être modifié par un autre processus, SessionManager ne peut pas s'assumer être le seul écrivain.

Résumé

SessionManager est la couche de persistance des sessions OpenClaw: il gère l'append, l'indexation, le branching, les frontières de compaction et les labels du fichier de transcript. AgentSession ajoute par-dessus l'état runtime — registre d'outils, registre de modèles, system prompt, contrôleur de compaction. Les deux couches sont clairement séparées: SessionManager stocke « ce qui s'est passé », AgentSession stocke « comment on va exécuter maintenant ».

Comment la boucle principale de l'agent utilise SessionManager pour déclencher un attempt, voir Boucle principale de l'agent: embedded-runner; comment les définitions d'outils sont assemblées depuis baseToolDefinitions + outils d'extension dans toolRegistry, voir Système d'outils; flux de données plus profond du transcript en compaction et en branch, voir Context Engine.

Pour comparer avec la documentation officielle: Session docs · README.