会话管理: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。