fix(agents): stabilize prompt cache fingerprints (#60731)

* fix(agents): stabilize prompt cache fingerprints

* chore(changelog): note prompt cache fingerprint stability

* refactor(agents): simplify capability normalization

* refactor(agents): simplify prompt capability normalization helper
This commit is contained in:
Vincent Koc
2026-04-04 16:20:36 +09:00
committed by GitHub
parent 0660bef81e
commit d75a8933e7
8 changed files with 150 additions and 24 deletions

View File

@@ -22,7 +22,8 @@ Docs: https://docs.openclaw.ai
- Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge that reuses gateway tool policy, honors session/account/channel scoping, and only advertises the bridge when the local runtime is actually live. (#35676) Thanks @mylukin.
- Prompt caching: keep prompt prefixes more reusable across transport fallback, compaction, and embedded image history so follow-up turns hit cache more reliably. (#59054, #60603, #60691)
- Agents/cache: stabilize cache-relevant system prompt fingerprints by normalizing equivalent structured prompt whitespace, line endings, hook-added system context, and runtime capability ordering so semantically unchanged prompts reuse KV/cache more reliably. Thanks @vincentkoc.
- Agents/cache: keep prompt prefixes more reusable across transport fallback, compaction, and embedded image history so follow-up turns hit cache more reliably. (#59054, #60603, #60691)
### Fixes

View File

@@ -164,6 +164,16 @@ describe("composeSystemPromptWithHookContext", () => {
).toBe("prepend\n\nbase system\n\nappend");
});
it("normalizes hook system context line endings and trailing whitespace", () => {
expect(
composeSystemPromptWithHookContext({
baseSystemPrompt: " base system ",
prependSystemContext: " prepend line \r\nsecond line\t\r\n",
appendSystemContext: " append \t\r\n",
}),
).toBe("prepend line\nsecond line\n\nbase system\n\nappend");
});
it("avoids blank separators when base system prompt is empty", () => {
expect(
composeSystemPromptWithHookContext({

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
import { normalizeStructuredPromptSection } from "../../prompt-cache-stability.js";
export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl";
@@ -8,13 +9,19 @@ export function composeSystemPromptWithHookContext(params: {
prependSystemContext?: string;
appendSystemContext?: string;
}): string | undefined {
const prependSystem = params.prependSystemContext?.trim();
const appendSystem = params.appendSystemContext?.trim();
const prependSystem =
typeof params.prependSystemContext === "string"
? normalizeStructuredPromptSection(params.prependSystemContext)
: "";
const appendSystem =
typeof params.appendSystemContext === "string"
? normalizeStructuredPromptSection(params.appendSystemContext)
: "";
if (!prependSystem && !appendSystem) {
return undefined;
}
return joinPresentTextSegments(
[params.prependSystemContext, params.baseSystemPrompt, params.appendSystemContext],
[prependSystem, params.baseSystemPrompt, appendSystem],
{ trim: true },
);
}

View File

@@ -0,0 +1,17 @@
export function normalizeStructuredPromptSection(text: string): string {
return text.replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trim();
}
export function normalizePromptCapabilityIds(capabilities: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const capability of capabilities) {
const value = normalizeStructuredPromptSection(capability).toLowerCase();
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -30,4 +30,15 @@ describe("system prompt cache boundary helpers", () => {
}),
).toBe(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\n\nDynamic suffix`);
});
it("normalizes structured additions and dynamic suffix whitespace", () => {
expect(
prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix \r\n\r\nMore detail \t\r\n`,
systemPromptAddition: " Per-turn lab context \r\nSecond line\t\r\n",
}),
).toBe(
`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\nSecond line\n\nDynamic suffix\n\nMore detail`,
);
});
});

View File

@@ -1,3 +1,5 @@
import { normalizeStructuredPromptSection } from "./prompt-cache-stability.js";
export const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
export function stripSystemPromptCacheBoundary(text: string): string {
@@ -21,18 +23,25 @@ export function prependSystemPromptAdditionAfterCacheBoundary(params: {
systemPrompt: string;
systemPromptAddition?: string;
}): string {
if (!params.systemPromptAddition) {
const systemPromptAddition =
typeof params.systemPromptAddition === "string"
? normalizeStructuredPromptSection(params.systemPromptAddition)
: "";
if (!systemPromptAddition) {
return params.systemPrompt;
}
const split = splitSystemPromptCacheBoundary(params.systemPrompt);
if (!split) {
return `${params.systemPromptAddition}\n\n${params.systemPrompt}`;
return `${systemPromptAddition}\n\n${params.systemPrompt}`;
}
if (!split.dynamicSuffix) {
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${params.systemPromptAddition}`;
const dynamicSuffix = split.dynamicSuffix
? normalizeStructuredPromptSection(split.dynamicSuffix)
: "";
if (!dynamicSuffix) {
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}`;
}
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${params.systemPromptAddition}\n\n${split.dynamicSuffix}`;
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}\n\n${dynamicSuffix}`;
}

View File

@@ -697,10 +697,56 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("model=anthropic/claude");
expect(line).toContain("default_model=anthropic/claude-opus-4-6");
expect(line).toContain("channel=telegram");
expect(line).toContain("capabilities=inlineButtons");
expect(line).toContain("capabilities=inlinebuttons");
expect(line).toContain("thinking=low");
});
it("normalizes runtime capability ordering and casing for cache stability", () => {
const line = buildRuntimeLine(
{
agentId: "work",
},
"telegram",
[" React ", "inlineButtons", "react"],
"low",
);
expect(line).toContain("capabilities=inlinebuttons,react");
});
it("keeps semantically equivalent structured prompt inputs byte-stable", () => {
const clean = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: ["inlinebuttons", "react"],
},
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
extraSystemPrompt: "Group chat context\nSecond line",
workspaceNotes: ["Reminder: keep commits scoped."],
modelAliasLines: ["- Sonnet: anthropic/claude-sonnet-4-5"],
});
const noisy = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: [" react ", "inlineButtons", "react"],
},
skillsPrompt:
"<available_skills>\r\n <skill> \r\n <name>demo</name>\t\r\n </skill>\r\n</available_skills>\r\n",
heartbeatPrompt: " ping \r\n",
extraSystemPrompt: " Group chat context \r\nSecond line \t\r\n",
workspaceNotes: [" Reminder: keep commits scoped. \t\r\n"],
modelAliasLines: [" - Sonnet: anthropic/claude-sonnet-4-5 \t\r\n"],
});
expect(noisy).toBe(clean);
expect(noisy).not.toContain("\r");
expect(noisy).not.toMatch(/[ \t]+$/m);
});
it("describes sandboxed runtime and elevated when allowed", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",

View File

@@ -9,6 +9,10 @@ import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type { EmbeddedSandboxInfo } from "./pi-embedded-runner/types.js";
import {
normalizePromptCapabilityIds,
normalizeStructuredPromptSection,
} from "./prompt-cache-stability.js";
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
@@ -347,7 +351,10 @@ export function buildAgentSystemPrompt(params: {
const readToolName = resolveToolName("read");
const execToolName = resolveToolName("exec");
const processToolName = resolveToolName("process");
const extraSystemPrompt = params.extraSystemPrompt?.trim();
const extraSystemPrompt =
typeof params.extraSystemPrompt === "string"
? normalizeStructuredPromptSection(params.extraSystemPrompt)
: undefined;
const ownerDisplay = params.ownerDisplay === "hash" ? "hash" : "raw";
const ownerLine = buildOwnerIdentityLine(
params.ownerNumbers ?? [],
@@ -368,14 +375,22 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const skillsPrompt =
typeof params.skillsPrompt === "string"
? normalizeStructuredPromptSection(params.skillsPrompt)
: undefined;
const heartbeatPrompt =
typeof params.heartbeatPrompt === "string"
? normalizeStructuredPromptSection(params.heartbeatPrompt)
: undefined;
const runtimeInfo = params.runtimeInfo;
const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase();
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
.map((cap) => String(cap).trim())
.filter(Boolean);
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const runtimeCapabilities = runtimeInfo?.capabilities ?? [];
const runtimeCapabilitiesLower = new Set(
runtimeCapabilities
.map((cap) => String(cap).trim().toLowerCase())
.filter(Boolean),
);
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full";
@@ -414,7 +429,12 @@ export function buildAgentSystemPrompt(params: {
isMinimal,
readToolName,
});
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
const workspaceNotes = (params.workspaceNotes ?? [])
.map((note) => normalizeStructuredPromptSection(note))
.filter(Boolean);
const modelAliasLines = (params.modelAliasLines ?? [])
.map((line) => normalizeStructuredPromptSection(line))
.filter(Boolean);
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
@@ -499,16 +519,16 @@ export function buildAgentSystemPrompt(params: {
hasGateway && !isMinimal ? "" : "",
"",
// Skip model aliases for subagent/none modes
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
modelAliasLines.length > 0 && !isMinimal
? "## Model Aliases"
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
modelAliasLines.length > 0 && !isMinimal
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? params.modelAliasLines.join("\n")
modelAliasLines.length > 0 && !isMinimal
? modelAliasLines.join("\n")
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
modelAliasLines.length > 0 && !isMinimal ? "" : "",
userTimezone
? "If you need the current date, time, or day of week, run session_status (📊 session_status)."
: "",
@@ -705,6 +725,7 @@ export function buildRuntimeLine(
runtimeCapabilities: string[] = [],
defaultThinkLevel?: ThinkLevel,
): string {
const normalizedRuntimeCapabilities = normalizePromptCapabilityIds(runtimeCapabilities);
return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
@@ -720,7 +741,11 @@ export function buildRuntimeLine(
runtimeInfo?.shell ? `shell=${runtimeInfo.shell}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
? `capabilities=${
normalizedRuntimeCapabilities.length > 0
? normalizedRuntimeCapabilities.join(",")
: "none"
}`
: "",
`thinking=${defaultThinkLevel ?? "off"}`,
]