From 87b8680ded8bbbe4daf10ff5d4adad9323ef3d44 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 5 Apr 2026 08:49:20 +0100 Subject: [PATCH] fix(cache): order stable project context before heartbeat (#61236) * fix(cache): order stable project context before heartbeat * docs(changelog): note project context cache ordering * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/agents/system-prompt.test.ts | 49 +++++++++++++ src/agents/system-prompt.ts | 115 +++++++++++++++++++++++++------ 3 files changed, 144 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326c7992318..d8fdb8817a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai - Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc. - Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan. - Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc. +- Prompt caching: order stable workspace project-context files before `HEARTBEAT.md` and keep `HEARTBEAT.md` below the system-prompt cache boundary so heartbeat churn does not invalidate the stable project-context prefix. (#58979) Thanks @yozu and @vincentkoc. - Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`. - MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch. - Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc. diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 9ab96c72a8e..c53bd872740 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { typedCases } from "../test-utils/typed-cases.js"; import { buildSubagentSystemPrompt } from "./subagent-announce.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js"; describe("buildAgentSystemPrompt", () => { @@ -622,6 +623,54 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("# Project Context"); }); + it("orders stable project context before the cache boundary and moves HEARTBEAT below it", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + contextFiles: [ + { path: "HEARTBEAT.md", content: "Check inbox." }, + { path: "MEMORY.md", content: "Long-term notes." }, + { path: "AGENTS.md", content: "Follow repo rules." }, + { path: "SOUL.md", content: "Warm but direct." }, + { path: "TOOLS.md", content: "Prefer rg." }, + ], + }); + + const agentsIndex = prompt.indexOf("## AGENTS.md"); + const soulIndex = prompt.indexOf("## SOUL.md"); + const toolsIndex = prompt.indexOf("## TOOLS.md"); + const memoryIndex = prompt.indexOf("## MEMORY.md"); + const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY); + const heartbeatHeadingIndex = prompt.indexOf("# Dynamic Project Context"); + const heartbeatFileIndex = prompt.indexOf("## HEARTBEAT.md"); + + expect(agentsIndex).toBeGreaterThan(-1); + expect(soulIndex).toBeGreaterThan(agentsIndex); + expect(toolsIndex).toBeGreaterThan(soulIndex); + expect(memoryIndex).toBeGreaterThan(toolsIndex); + expect(boundaryIndex).toBeGreaterThan(memoryIndex); + expect(heartbeatHeadingIndex).toBeGreaterThan(boundaryIndex); + expect(heartbeatFileIndex).toBeGreaterThan(heartbeatHeadingIndex); + expect(prompt).toContain( + "The following frequently-changing project context files are kept below the cache boundary when possible:", + ); + }); + + it("keeps heartbeat-only project context below the cache boundary", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + contextFiles: [{ path: "HEARTBEAT.md", content: "Check inbox." }], + }); + + const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY); + const projectContextIndex = prompt.indexOf("# Project Context"); + const heartbeatFileIndex = prompt.indexOf("## HEARTBEAT.md"); + + expect(boundaryIndex).toBeGreaterThan(-1); + expect(projectContextIndex).toBeGreaterThan(boundaryIndex); + expect(heartbeatFileIndex).toBeGreaterThan(projectContextIndex); + expect(prompt).not.toContain("# Dynamic Project Context"); + }); + it("summarizes the message tool when available", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dc291ced0df..a1d438ea1af 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -25,6 +25,81 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js" export type PromptMode = "full" | "minimal" | "none"; type OwnerIdDisplay = "raw" | "hash"; +const CONTEXT_FILE_ORDER = new Map([ + ["agents.md", 10], + ["soul.md", 20], + ["identity.md", 30], + ["user.md", 40], + ["tools.md", 50], + ["bootstrap.md", 60], + ["memory.md", 70], +]); + +const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set(["heartbeat.md"]); + +function normalizeContextFilePath(pathValue: string): string { + return pathValue.trim().replace(/\\/g, "/"); +} + +function getContextFileBasename(pathValue: string): string { + const normalizedPath = normalizeContextFilePath(pathValue); + return (normalizedPath.split("/").pop() ?? normalizedPath).toLowerCase(); +} + +function isDynamicContextFile(pathValue: string): boolean { + return DYNAMIC_CONTEXT_FILE_BASENAMES.has(getContextFileBasename(pathValue)); +} + +function sortContextFilesForPrompt(contextFiles: EmbeddedContextFile[]): EmbeddedContextFile[] { + return contextFiles.toSorted((a, b) => { + const aPath = normalizeContextFilePath(a.path); + const bPath = normalizeContextFilePath(b.path); + const aBase = getContextFileBasename(a.path); + const bBase = getContextFileBasename(b.path); + const aOrder = CONTEXT_FILE_ORDER.get(aBase) ?? Number.MAX_SAFE_INTEGER; + const bOrder = CONTEXT_FILE_ORDER.get(bBase) ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + if (aBase !== bBase) { + return aBase.localeCompare(bBase); + } + return aPath.localeCompare(bPath); + }); +} + +function buildProjectContextSection(params: { + files: EmbeddedContextFile[]; + heading: string; + dynamic: boolean; +}) { + if (params.files.length === 0) { + return []; + } + const lines = [params.heading, ""]; + if (params.dynamic) { + lines.push( + "The following frequently-changing project context files are kept below the cache boundary when possible:", + "", + ); + } else { + const hasSoulFile = params.files.some( + (file) => getContextFileBasename(file.path) === "soul.md", + ); + lines.push("The following project context files have been loaded:"); + if (hasSoulFile) { + lines.push( + "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.", + ); + } + lines.push(""); + } + for (const file of params.files) { + lines.push(`## ${file.path}`, "", file.content, ""); + } + return lines; +} + function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) { const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { @@ -653,26 +728,16 @@ export function buildAgentSystemPrompt(params: { const validContextFiles = contextFiles.filter( (file) => typeof file.path === "string" && file.path.trim().length > 0, ); - if (validContextFiles.length > 0) { - lines.push("# Project Context", ""); - if (validContextFiles.length > 0) { - const hasSoulFile = validContextFiles.some((file) => { - const normalizedPath = file.path.trim().replace(/\\/g, "/"); - const baseName = normalizedPath.split("/").pop() ?? normalizedPath; - return baseName.toLowerCase() === "soul.md"; - }); - lines.push("The following project context files have been loaded:"); - if (hasSoulFile) { - lines.push( - "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.", - ); - } - lines.push(""); - } - for (const file of validContextFiles) { - lines.push(`## ${file.path}`, "", file.content, ""); - } - } + const orderedContextFiles = sortContextFilesForPrompt(validContextFiles); + const stableContextFiles = orderedContextFiles.filter((file) => !isDynamicContextFile(file.path)); + const dynamicContextFiles = orderedContextFiles.filter((file) => isDynamicContextFile(file.path)); + lines.push( + ...buildProjectContextSection({ + files: stableContextFiles, + heading: "# Project Context", + dynamic: false, + }), + ); // Skip silent replies for subagent/none modes if (!isMinimal) { @@ -694,9 +759,17 @@ export function buildAgentSystemPrompt(params: { // Keep large stable prompt context above this seam so Anthropic-family // transports can reuse it across labs and turns. Dynamic group/session - // additions below it are the primary cache invalidators. + // additions and volatile project context below it are the primary cache invalidators. lines.push(SYSTEM_PROMPT_CACHE_BOUNDARY); + lines.push( + ...buildProjectContextSection({ + files: dynamicContextFiles, + heading: stableContextFiles.length > 0 ? "# Dynamic Project Context" : "# Project Context", + dynamic: true, + }), + ); + if (extraSystemPrompt) { // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" const contextHeader =