From 40db3fef4915d283069327f4332237f283b4c571 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 19:04:54 +0000 Subject: [PATCH] fix(agents): cache bootstrap snapshots per session key Co-authored-by: Isis Anisoptera --- CHANGELOG.md | 1 + src/agents/bootstrap-cache.test.ts | 100 +++++++++++++++++++++++++ src/agents/bootstrap-cache.ts | 25 +++++++ src/agents/bootstrap-files.ts | 12 ++- src/gateway/server-methods/sessions.ts | 2 + 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/agents/bootstrap-cache.test.ts create mode 100644 src/agents/bootstrap-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 99325f393af..883f6b82070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bootstrap-cache.test.ts b/src/agents/bootstrap-cache.test.ts new file mode 100644 index 00000000000..ea8d0e58bfa --- /dev/null +++ b/src/agents/bootstrap-cache.test.ts @@ -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 + }); +}); diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts new file mode 100644 index 00000000000..03c4a923464 --- /dev/null +++ b/src/agents/bootstrap-cache.ts @@ -0,0 +1,25 @@ +import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js"; + +const cache = new Map(); + +export async function getOrLoadBootstrapFiles(params: { + workspaceDir: string; + sessionKey: string; +}): Promise { + 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(); +} diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 511610daaa2..a6e70a142d3 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -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 { 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, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 1c11887a8d9..8813ad065f6 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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;