Skip to content

Sitzungsverwaltung: SessionManager

源码版本v2026.6.11

Verantwortung

SessionManager ist der Türsteher des OpenClaw-Agent-Transcripts. Jede Nutzernachricht, Assistant-Antwort, Tool-Aufruf, Tool-Ergebnis, Thinking-Level-Wechsel, Modellwechsel, Komprimierungszusammenfassung, Verzweigungsmarke landet letztlich in der vom SessionManager gepflegten JSONL-Datei. Er bietet zudem Abfrageschnittstellen: buildSessionContext() stellt der Oberschicht den LLM-Kontext zusammen, getTree() liefert der UI eine Baumstruktur, getBranch() unterstützt Verzweigungswechsel, appendCompaction() protokolliert Komprimierungsgrenzen.

SessionManager ruft weder das Modell auf noch führt er Werkzeuge aus — er sorgt nur dafür, dass alle Produkte einer Sitzung (session) „in Reihenfolge, in Baumstruktur, in Persistenz-Semantik" sicher auf die Disk kommen. AgentSession (agent-session.ts:334) ist die semantisch höhere Hülle, die SessionManager + Agent + Werkzeug-Registrierung + Hooks zu einem vollständigen Agent-Runtime-Objekt zusammenführt.

Designmotivation

Warum einen SessionManager auslagern, anstatt den Agenten direkt an eine Datei anzuhängen? Weil die Sitzungssemantik von OpenClaw kein „lineares Log", sondern ein „verzweigter Baum" ist. In einer Session-Datei hat jeder Eintrag id und parentId und kann einen Wald bilden: branch(branchFromId) erzeugt eine Verzweigung; ein neues Blatt startet vom angegebenen Knoten, die alte Verzweigung bleibt für Rückverfolgung erhalten. Mit einfacher Append-Ginge ginge die Verzweigungsstruktur verloren.

Warum zusätzlich einen AgentSession um den SessionManager legen? Weil Werkzeug-Registrierung (toolRegistry), Modell-Registrierung (sessionModelRegistry), System-Prompt-Montage und Compaction-Steuerung andere Anliegen sind als das Transcript-Abspeichern. SessionManager konzentriert sich auf Speicherung, AgentSession auf „den veränderlichen Zustand, den das Betreiben eines Agenten erfordert". Diese Schichtung erlaubt es, SessionManager isoliert zu testen und hält das Transcript-Dateiformat frei von Runtime-Logik-Verschmutzung.

Ein weiteres Motiv ist Parallelitätssicherheit. Eine Session kann mehrere Schreibquellen haben: Die Agent-Hauptschleife hängt Assistant-Nachrichten an, Werkzeugausführungen hängen Tool-Ergebnisse an, Komprimierung schreibt Präfixe um, externe Steuerung fügt Kontextnachrichten ein. SessionManager serialisiert über sessionFileSnapshot-Cache + OwnedSessionTranscriptWriteLock auf Attempt-Ebene Schreibvorgänge und stellt sicher, dass die Datei durch verschränkte Schreibvorgänge nicht beschädigt wird. Sobald eine externe Änderung erkannt wird (Snapshot-Mismatch), verfällt der Warm-Cache und das gesamte Transcript wird neu geparst — langsam, aber korrekt.

Schlüsseldateien

Datenfluss

Einstieg in den SessionManager sind die statischen Fabriken (session-manager.ts:2894). Jede Konstruktion läuft über den privaten Konstruktor; die Fabrikmethoden entscheiden, wie Parameter gefüllt werden:

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 ist eine frische Session; open öffnet eine angegebene Datei und läuft vorher durch revalidateLoadedSessionFile, um Stale zu verhindern; continueRecent setzt die zuletzt verwendete Session fort; inMemory speichert nicht auf Disk, für Tests und temporäre Kontexte. Beachten Sie, dass open zusätzlich durch revalidateLoadedSessionFile läuft — der Kommentar sagt „a single parsed load cannot be allowed to go stale while deriving cwd/session metadata", ein zusätzlicher stat-Aufruf, um zu vermeiden, dass die Datei während des Ladens umgeschrieben wird.

Der Konstruktor (session-manager.ts:1462) pflegt Index-Maps, die das Speichermodell des SessionManagers bestimmen:

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;

Beachten Sie die drei Maps byId, opaqueParentsById, logicalParentsById — derselbe Entry liegt in der Datei linear, im Speicher aber werden drei Indizes gepflegt: id→entry, id→opaque parent, id→logical parent. Opaque parent ist die physische Dateistruktur, parent die logische Struktur (bei komprimierten oder zusammengeführten Entries kann der logische Parent abweichen). labelsById und labelTimestampsById sind getrennt, da Labels geändert werden können und ein Zeitstempel nötig ist, um das aktuellste zu erkennen.

Beim Anlegen einer neuen Session (session-manager.ts:1556) werden alle Indizes in den Anfangszustand zurückgesetzt:

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 ist der erste fileEntry und trägt parentSession — das ist die Quelle der Fork-Verzweigungskette. Wird beim Anlegen einer neuen Session parentSession übergeben, kann später aus der Eltern-Session ein gemeinsamer Präfix gezogen werden.

Jeder Entry wird über persist abgespeichert (session-manager.ts:2154):

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

persistRecord pflegt intern das fileEntries-Array, die byId-Map und den leafId-Zeiger und hängt eine JSONL-Zeile an die Disk-Datei an. Die semantischen Methoden appendMessage / appendCompaction / appendThinkingLevelChange / appendModelChange / appendCustomEntry / appendSessionInfo konstruieren alle einen Entry und rufen dann persist auf.

AgentSession fügt über dem SessionManager die Werkzeug-Registrierung hinzu (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();

Vier Maps mit getrennten Zuständigkeiten: toolRegistry enthält die tatsächlich lauffähigen AgentTool-Instanzen (mit execute), toolDefinitions die Deklarationen (Entry-Definition + sourceInfo), toolPromptSnippets die Prompt-Snippets der Werkzeuge, toolPromptGuidelines die Prompt-Richtlinien. Diese Maps werden nicht im SessionManager gespeichert — sie sind Runtime-Zustand, während SessionManager nur Persistenzzustand verwaltet.

Der Neuaufbau der Werkzeug-Registry (refreshToolRegistry, agent-session.ts:2414) ist eine der komplexesten Methoden von AgentSession. Sie sammelt zuerst alle Kandidaten-Werkzeuge, wendet Allowlist-Filter an und legt dann eine weitere Hook-Schicht außenherum:

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;

Drei Schichten isAllowedTool-Filter: disableBuiltInTools + allowedToolNames + Inline-Filter. disableBuiltInTools ist der Agent-Level-Schalter „Ich nutze nur Erweiterungswerkzeuge"; allowedToolNames ist eine feingranularere Whitelist. Zwei unabhängige Prüfungen erlauben Kombinationen wie „alle Builtins erlaubt, aber eine bestimmte Erweiterung deaktiviert".

Nach dem Neuaufbau wird nextActiveToolNames (agent-session.ts:2488) berechnet — die bisherige aktive Werkzeugmenge bleibt erhalten, neu hinzugekommene Werkzeuge werden hinzugefügt, durch Allowlist abgelehnte herausgefiltert. Das deckt das inkrementelle Szenario „Plugin hat neu ein Werkzeug installiert, die alte aktive Liste bleibt gültig, nur ein neues Item kommt hinzu".

Vor Eintritt in die Run-Schleife erfolgt noch eine Normalisierung (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;
  }

Behandelt drei Fälle: brandneue Datei (nur header id/cwd ändern), alte Datei mit Header aber ohne Assistant (direkt neu schreiben, damit der erste Flush die Sequenz header+user+assistant vollständig schreibt), Fork-Verzweigung oder Wiederherstellung nach Korruption ( ursprünglichen Baum bewahren). Beachten Sie await fs.writeFile(params.sessionFile, "", "utf-8") — leert die Datei direkt. Das ist ein Fallback für das Szenario „Nutzer hat vorher eine leere Session-Datei vorab erstellt", um zu vermeiden, dass beim ersten Assistant-Flush der Header hinter der User-Nachricht landet und die Reihenfolge verrutscht.

src/sessions/ ist die höhere Semantikschicht: Sie berührt keine Dateien direkt, sondern abstrahiert session-key, session-kind, session-lifecycle-events, transcript-events als schichtübergreifende Semantik. SessionManager verwaltet „wie abgespeichert wird", src/sessions/ verwaltet „wie Sitzungen über verschiedene Kanäle klassifiziert und identifiziert werden".

Grenzen und Fehler

  • Konstruktor ist privat: Der Konstruktor von SessionManager ist private (session-manager.ts:1462); externer Zugang nur über die vier statischen Fabriken create / open / continueRecent / inMemory. erzwingt, dass jede Konstruktion über Snapshot-Laden und cwd-Validierung läuft, und verhindert, dass extern direkt new eine inkonsistente Instanz erzeugt.
  • Beschädigter Header kann recovered werden: Das Flag recoveredCorruptHeader (session-manager.ts:1459) wird gesetzt, wenn das Header-Parsing scheitert; prepareSessionManagerForRun sieht das Flag und wählt den „Baum bewahren"-Zweig statt die Datei zu leeren — um eine beschädigte, aber wiederherstellbare Session nicht erneut auszulöschen.
  • Warm-Cache verfällt aktiv: syncSnapshotAfterHeaderRewrite (session-manager.ts:2166) wird nach externer Dateiumschreibung aufgerufen; stimmen tatsächlicher Inhalt und Snapshot nicht überein, geht rememberAppendedSessionEntry in den Snapshot-Mismatch-Zweig, verwirft den Warm-Cache und erzwingt Neuparsing. Langsam, aber garantiert korrekt.
  • Flush-Reihenfolgen-Härtung: prepareSessionManagerForRun leert im Szenario „Header vorhanden, aber kein Assistant" die Datei direkt (session-manager-init.ts:103-117), weil OpenClaw verlangt, dass beim Flush der ersten Assistant-Nachricht die Dateireihenfolge header → user → assistant sein muss. Hat der Nutzer vorher eine Datei vorab erstellt, steht die User-Nachricht bereits in der Datei ohne Anschluss; würde man direkt den Assistant flushen, ergäbe sich user → assistant → header.
  • Fork-Verzweigung bewahrt ursprünglichen Baum: Der preservesForkedBranch-Zweig (session-manager-init.ts:82) überspringt für Sessions mit nicht-leerem header.parentSession das Leeren, weil eine geforkte Verzweigung möglicherweise bewusst einen User-only- oder leeren Teilbaum als Startpunkt bewahrt; Leeren würde die Fork-Semantik zerstören.
  • toolRegistry-Neuaufbau wird nicht persistiert: refreshToolRegistry aktualisiert nur das im Speicher liegende toolRegistry (agent-session.ts:2486) und schreibt keine Datei. Werkzeug-Registrierung ist Runtime-Zustand; nach Session-Neustart wird sie aus der Agent-Konfiguration neu aufgebaut — das Transcript speichert nur Input/Output von tool_call, nicht die Tool-Definition.
  • Aktive Werkzeugmenge wird inkrementell bewahrt: Die Algorithik von nextActiveToolNames (agent-session.ts:2488) bewahrt bei Allowlist-Änderung oder Werkzeug-Erweiterung die bisherige aktive Werkzeugmenge und fügt nur neu hinzugekommene Werkzeuge hinzu. Das vermeidet das destruktive Erlebnis „aktive Werkzeugmenge nach Plugin-Reinstallation leer".
  • sessionFileSnapshot ist Cache, nicht Source of Truth: Alle Schreibpfade nehmen an, dass der Snapshot stale sein könnte; deshalb liefert rememberWrittenSessionEntries ein Flag verifiedWrite (session-manager.ts:2170) zurück, das zwischen „verifiziert" und „optimistischem Cache" unterscheidet. Das ist ein Fallback für parallele externe Schreibvorgänge — die Datei könnte von einem anderen Prozess umgeschrieben werden, SessionManager darf nicht annehmen, der einzige Schreiber zu sein.

Zusammenfassung

SessionManager ist die Persistenzschicht der OpenClaw-Sitzung: Er verwaltet Anhängen, Indizieren, Verzweigen, Komprimierungsgrenzen und Labels des Transcript-Files. AgentSession fügt darüber den Runtime-Zustand hinzu — Werkzeug-Registry, Modell-Registry, System-Prompt, Compaction-Controller. Zwei Schichten sind sauber getrennt: SessionManager speichert nur „was passiert ist", AgentSession speichert „wie jetzt zu laufen ist".

Wie die Agent-Hauptschleife den SessionManager nutzt, um Attempts auszulösen, siehe Agent-Hauptschleife: embedded-runner; wie Werkzeugdefinitionen aus baseToolDefinitions + Erweiterungswerkzeugen zur toolRegistry assembliert werden, siehe Werkzeugsystem; die tieferen Datenflüsse des Transcripts bei Komprimierung und Verzweigung siehe Context Engine.

Vergleich mit offiziellen Ressourcen: Session-Doku · README.