Gestión de sesión: SessionManager
Responsabilidad
SessionManager es el guardián del transcript del agent de OpenClaw. Cada mensaje de usuario, cada respuesta del assistant, cada llamada a herramienta, cada resultado de herramienta, cada cambio de nivel de thinking, cada cambio de modelo, cada resumen de compactación, cada marca de bifurcación acaba cayendo en el archivo JSONL que mantiene SessionManager. También ofrece interfaces de consulta: buildSessionContext() arma el contexto LLM para la capa superior, getTree() permite a la UI renderizar la estructura de bifurcaciones, getBranch() soporta cambio de rama, appendCompaction() registra el límite de compactación.
SessionManager no invoca al modelo ni ejecuta herramientas — solo se encarga de «en orden, según la estructura de árbol, según la semántica de persistencia» de llevar a disco todos los artefactos de la sesión (session) de forma segura. AgentSession (agent-session.ts:334) es la envoltura semántica superior que ata SessionManager + Agent + registro de herramientas + hooks en un objeto runtime de agent completo.
Motivación de diseño
¿Por qué un SessionManager aparte en lugar de dejar que el agent añada líneas a un archivo? Porque la semántica de sesión de OpenClaw no es un «log lineal», sino un «árbol con bifurcaciones». En un archivo de sesión, cada entrada tiene id y parentId y forma un bosque: al hacer branch(branchFromId) se crea una nueva hoja que parte del nodo indicado, y la rama vieja se conserva para su trazabilidad. Si solo se usara append, la estructura de bifurcaciones se perdería.
¿Por qué envolver además SessionManager con un AgentSession? Porque el registro de herramientas (toolRegistry), el registro de modelos (sessionModelRegistry), el montaje del system prompt y el control de compactación son preocupaciones distintas a la de volcar el transcript a disco. SessionManager se centra en almacenamiento; AgentSession se centra en el «estado mutable necesario para correr un agent». Esta separación permite testear SessionManager de forma independiente y mantiene el formato del archivo de transcript sin contaminar por la lógica runtime.
Otra motivación es la seguridad bajo concurrencia. Una misma sesión puede tener varias fuentes de escritura: el bucle principal del agent añade mensajes de assistant, la ejecución de tool añade tool result, la compactación reescribe el prefijo, el steering externo inserta context messages. SessionManager garantiza que el archivo no se corrompa por escrituras entrelazadas mediante sessionFileSnapshot cache + OwnedSessionTranscriptWriteLock que serializa las escrituras a nivel de attempt. Si detecta que el archivo fue modificado externamente (snapshot mismatch), la warm cache se invalida y se reanaliza todo el transcript — lento pero correcto.
Archivos clave
session-manager.ts— la clase SessionManager de 3000+ líneas y la definición de tipos de entry.session-manager.ts CURRENT_SESSION_VERSION:51— re-export del número de versión.session-manager.ts SessionEntry:184-216— tipo unión de entry + estructura de SessionContext.session-manager.ts constructor:1439-1487— constructor privado de SessionManager; inicializa todos los Map de índices.session-manager.ts static factories:2894-2934— cuatro fábricas estáticas:create / open / continueRecent / inMemory.session-manager.ts newSession:1556-1579— crea una sesión nueva y escribe la cabecera.session-manager.ts persist:2154-2156— entrada de volcado a disco de un entry.session-manager.ts syncSnapshotAfterHeaderRewrite:2166-2174— tras una reescritura externa del archivo, resincroniza el snapshot.agent-session.ts AgentSession:334-358— declaración de la clase AgentSession; posee SessionManager + Agent.agent-session.ts toolRegistry:395-398— los cuatro Map de herramienta / definición / prompt snippet / guidelines.agent-session.ts refreshToolRegistry:2414-2486— lógica de reconstrucción del registro de herramientas.session-manager-init.ts prepareSessionManagerForRun:47-117— normalización del archivo de sesión antes de entrar al bucle de run.src/sessions/— capa semántica: resolución de session-key, clasificación kind, eventos lifecycle, eventos transcript, etc.
Flujo de datos
La entrada de SessionManager son varias fábricas estáticas (session-manager.ts:2894). Toda construcción pasa por el constructor privado; las fábricas deciden cómo rellenar parámetros:
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 es una sesión nueva; open abre un archivo específico y primero pasa revalidateLoadedSessionFile para evitar stale; continueRecent «continúa la sesión más reciente»; inMemory no persiste, para tests y contexto temporal. Nótese que open hace un revalidateLoadedSessionFile extra — el comentario dice «un solo parsed load no debe quedar stale al derivar cwd/session metadata», un stat extra para warm open que evita que el archivo sea reescrito durante la carga.
El constructor (session-manager.ts:1462) mantiene los Map de índices que determinan el modelo de memoria de 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;Nótese los tres Map byId, opaqueParentsById, logicalParentsById: el mismo entry está linealmente en el archivo, pero en memoria se mantienen simultáneamente tres índices — id→entry, id→opaque parent, id→logical parent. El opaque parent es la estructura física del archivo; el logical parent es la estructura lógica (tras compactación o merge, el parent lógico de un entry puede ser distinto). labelsById y labelTimestampsById se almacenan por separado porque las labels se pueden modificar y se necesita timestamp para saber cuál es la más reciente.
Una sesión nueva (session-manager.ts:1556) resetea todos los índices al estado inicial:
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;Nótese que header es la primera fileEntry y lleva parentSession — ahí está el origen de la cadena de fork. Al crear una sesión nueva, si se pasa parentSession, más tarde se puede tirar del prefijo compartido desde la sesión padre.
Cada entry se vuelca (session-manager.ts:2154) por persist:
persist(entry: SessionEntry, options?: AppendPersistenceOptions): void {
this.persistRecord(entry, options);
}persistRecord mantiene internamente el array fileEntries, el Map byId, el puntero leafId, y añade la línea JSONL al archivo de disco. Los métodos semánticos appendMessage / appendCompaction / appendThinkingLevelChange / appendModelChange / appendCustomEntry / appendSessionInfo construyen un entry y luego llaman a persist.
AgentSession añade el registro de herramientas por encima de 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();Los cuatro Map tienen cada uno su rol: toolRegistry son las instancias AgentTool que de verdad se pueden ejecutar (con execute); toolDefinitions es la declaración (entry definition + sourceInfo); toolPromptSnippets son fragmentos de prompt de la herramienta; toolPromptGuidelines son las normas de prompt de la herramienta. Estos Map no se almacenan en SessionManager — son estado runtime, mientras que SessionManager solo gestiona el estado persistente.
La reconstrucción del registro de herramientas (refreshToolRegistry, agent-session.ts:2414) es uno de los métodos más complejos de AgentSession. Primero recoge todos los candidatos, filtra por allowlist, y luego envuelve cada uno con un hook de ejecución:
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;Nótese el filtro triple isAllowedTool: disableBuiltInTools + allowedToolNames + filter inline. disableBuiltInTools es el switch a nivel de agent de «solo uso extension tools»; allowedToolNames es una whitelist más fina. Las dos capas se evalúan independientemente, lo que permite combinaciones tipo «permitir todos los builtin pero deshabilitar una extensión concreta».
Tras reconstruir, además se calcula nextActiveToolNames (agent-session.ts:2488) — se conserva el conjunto de herramientas activas anterior, se añaden las nuevas herramientas aparecidas y se filtran las rechazadas por allowlist. Es para el escenario incremental de «un plugin nuevo instala una herramienta, la lista activa vieja sigue siendo válida, solo con una entrada más».
Antes de entrar al bucle de run hay una normalización (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;
}Esto trata tres situaciones: archivo nuevo (solo cambia header id/cwd); archivo viejo con header pero sin assistant (se reescribe directamente, para que el primer flush escriba la secuencia completa header+user+assistant); fork de rama o recuperación de corrupción (se conserva el árbol original). Nótese el await fs.writeFile(params.sessionFile, "", "utf-8") que vacía el archivo — es una red de seguridad para el escenario de «el usuario precreó un archivo de sesión vacío», evitando que el primer flush del assistant escriba el header detrás del user y desordene la secuencia.
src/sessions/ es la capa semántica superior: no toca archivos directamente, sino que abstrae conceptos transversales como session key, session kind, eventos lifecycle y eventos transcript. SessionManager gestiona «cómo se vuelca a disco»; src/sessions/ gestiona «cómo se clasifica e identifica una sesión según el canal».
Límites y fallos
- El constructor es privado: el constructor de
SessionManageresprivate(session-manager.ts:1462); desde fuera solo se entra por las cuatro fábricas estáticascreate / open / continueRecent / inMemory. Esto fuerza a que cada construcción pase por la carga de snapshot y la validación de cwd, evitando que un externo haganewy obtenga una instancia inconsistente. - Recuperación de header corrupto: el flag
recoveredCorruptHeader(session-manager.ts:1459) se activa cuando el header falla al parsear;prepareSessionManagerForRun, al verlo, toma la rama «conservar árbol» en lugar de vaciar el archivo — para no vaciar una segunda vez un archivo corrupto pero recuperable. - La warm cache se invalida activamente:
syncSnapshotAfterHeaderRewrite(session-manager.ts:2166) se llama cuando se reescribe el archivo desde fuera; si el contenido real del archivo no coincide con el snapshot,rememberAppendedSessionEntrycae por la rama de snapshot-mismatch, descarta la warm cache y fuerza un reanálisis. Lento, pero correcto. - Restricción dura de orden de flush:
prepareSessionManagerForRunen el escenario «con header pero sin assistant» vacía el archivo (session-manager-init.ts:103-117), porque OpenClaw exige que, al hacer el primer flush del assistant, el orden en el archivo sea header → user → assistant. Si el usuario había precreado un archivo donde el user ya estaba pero el header estaba vacío, hacer un flush directo pondría user → assistant → header. - fork de rama conserva el árbol original: la rama
preservesForkedBranch(session-manager-init.ts:82) salta el vaciado para sesiones conheader.parentSessionno vacío, porque una rama fork puede conservar intencionadamente un subárbol vacío o solo con user como punto de partida — vaciarlo destruiría la semántica de fork. - La reconstrucción de toolRegistry no se persiste:
refreshToolRegistrysolo actualizatoolRegistryen memoria (agent-session.ts:2486), no escribe archivo. El registro de herramientas es estado runtime; tras reiniciar la sesión se reconstruye desde la configuración del agent — el transcript solo registra input/output de tool_call, no la definición de tool. - El conjunto activo de herramientas se preserva de forma incremental: el algoritmo de
nextActiveToolNames(agent-session.ts:2488) conserva el conjunto activo anterior al cambiar allowlist o al añadir/quitar extension tools, y solo añade las nuevas. Evita la experiencia disruptiva de «se reinstala un plugin y se vacía el conjunto activo». - sessionFileSnapshot es cache, no fuente de verdad: todas las rutas de escritura asumen que el snapshot puede estar stale;
rememberWrittenSessionEntriesdevuelve un flagverifiedWrite(session-manager.ts:2170) que distingue «verificado» de «cache optimista». Es una red de seguridad para escrituras externas concurrentes — el archivo puede ser reescrito por otro proceso, SessionManager no puede asumir que es el único escritor.
Resumen
SessionManager es la capa de persistencia de la sesión de OpenClaw: gestiona append, índices, bifurcaciones, límites de compactación y labels del archivo de transcript. AgentSession añade por encima el estado runtime — el registro de herramientas, el registro de modelos, el system prompt y el controlador de compactación. Las dos capas tienen responsabilidades muy separadas: SessionManager solo almacena «qué ocurrió»; AgentSession almacena «cómo correr ahora».
Cómo el bucle principal del agent usa SessionManager para disparar un attempt se ve en Bucle principal del agent: embedded-runner; cómo se ensambla la definición de herramientas desde baseToolDefinitions + extension tools hasta toolRegistry se ve en Sistema de herramientas; el flujo de datos más profundo del transcript en compactación y bifurcación se ve en Context Engine.
Referencias oficiales: Documentación de sesión · README.