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
This commit is contained in:
Vincent Koc
2026-04-05 08:49:20 +01:00
committed by GitHub
parent 2d2824874e
commit 87b8680ded
3 changed files with 144 additions and 21 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<string, number>([
["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 =