mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user