fix(agents): cache bootstrap snapshots per session key

Co-authored-by: Isis Anisoptera <github@lotuswind.net>
This commit is contained in:
Peter Steinberger
2026-02-23 19:04:54 +00:00
parent 8b3eee71ec
commit 40db3fef49
5 changed files with 136 additions and 4 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc.
- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc.
- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed.
- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera.
- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg.
- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn.
- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc.

View File

@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearAllBootstrapSnapshots,
clearBootstrapSnapshot,
getOrLoadBootstrapFiles,
} from "./bootstrap-cache.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
vi.mock("./workspace.js", () => ({
loadWorkspaceBootstrapFiles: vi.fn(),
}));
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
const mockLoad = vi.mocked(loadWorkspaceBootstrapFiles);
function makeFile(name: string, content: string): WorkspaceBootstrapFile {
return {
name: name as WorkspaceBootstrapFile["name"],
path: `/ws/${name}`,
content,
missing: false,
};
}
describe("getOrLoadBootstrapFiles", () => {
const files = [makeFile("AGENTS.md", "# Agent"), makeFile("SOUL.md", "# Soul")];
beforeEach(() => {
clearAllBootstrapSnapshots();
mockLoad.mockResolvedValue(files);
});
afterEach(() => {
clearAllBootstrapSnapshots();
vi.clearAllMocks();
});
it("loads from disk on first call and caches", async () => {
const result = await getOrLoadBootstrapFiles({
workspaceDir: "/ws",
sessionKey: "session-1",
});
expect(result).toBe(files);
expect(mockLoad).toHaveBeenCalledTimes(1);
});
it("returns cached result on second call", async () => {
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
const result = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
expect(result).toBe(files);
expect(mockLoad).toHaveBeenCalledTimes(1);
});
it("different session keys get independent caches", async () => {
const files2 = [makeFile("AGENTS.md", "# Agent v2")];
mockLoad.mockResolvedValueOnce(files).mockResolvedValueOnce(files2);
const r1 = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-1" });
const r2 = await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "session-2" });
expect(r1).toBe(files);
expect(r2).toBe(files2);
expect(mockLoad).toHaveBeenCalledTimes(2);
});
});
describe("clearBootstrapSnapshot", () => {
beforeEach(() => {
clearAllBootstrapSnapshots();
mockLoad.mockResolvedValue([makeFile("AGENTS.md", "content")]);
});
afterEach(() => {
clearAllBootstrapSnapshots();
vi.clearAllMocks();
});
it("clears a single session entry", async () => {
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk" });
clearBootstrapSnapshot("sk");
// Next call should hit disk again.
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk" });
expect(mockLoad).toHaveBeenCalledTimes(2);
});
it("does not affect other sessions", async () => {
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk1" });
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
clearBootstrapSnapshot("sk1");
// sk2 should still be cached.
await getOrLoadBootstrapFiles({ workspaceDir: "/ws", sessionKey: "sk2" });
expect(mockLoad).toHaveBeenCalledTimes(2); // sk1 x1, sk2 x1
});
});

View File

@@ -0,0 +1,25 @@
import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js";
const cache = new Map<string, WorkspaceBootstrapFile[]>();
export async function getOrLoadBootstrapFiles(params: {
workspaceDir: string;
sessionKey: string;
}): Promise<WorkspaceBootstrapFile[]> {
const existing = cache.get(params.sessionKey);
if (existing) {
return existing;
}
const files = await loadWorkspaceBootstrapFiles(params.workspaceDir);
cache.set(params.sessionKey, files);
return files;
}
export function clearBootstrapSnapshot(sessionKey: string): void {
cache.delete(sessionKey);
}
export function clearAllBootstrapSnapshots(): void {
cache.clear();
}

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import {
@@ -49,10 +50,13 @@ export async function resolveBootstrapFilesForRun(params: {
warn?: (message: string) => void;
}): Promise<WorkspaceBootstrapFile[]> {
const sessionKey = params.sessionKey ?? params.sessionId;
const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(params.workspaceDir),
sessionKey,
);
const rawFiles = params.sessionKey
? await getOrLoadBootstrapFiles({
workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey,
})
: await loadWorkspaceBootstrapFiles(params.workspaceDir);
const bootstrapFiles = filterBootstrapFilesForSession(rawFiles, sessionKey);
const updated = await applyBootstrapHookOverrides({
files: bootstrapFiles,

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
@@ -185,6 +186,7 @@ async function ensureSessionRuntimeCleanup(params: {
queueKeys.add(params.sessionId);
}
clearSessionQueues([...queueKeys]);
clearBootstrapSnapshot(params.target.canonicalKey);
stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: params.target.canonicalKey });
if (!params.sessionId) {
return undefined;