diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b52baf849..7b3654c986d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 54796418c0a..3f2bd931bb7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -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({ diff --git a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts index 94e76ad9d66..0a9ff170022 100644 --- a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts @@ -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 }, ); } diff --git a/src/agents/prompt-cache-stability.ts b/src/agents/prompt-cache-stability.ts new file mode 100644 index 00000000000..1bff3ad992a --- /dev/null +++ b/src/agents/prompt-cache-stability.ts @@ -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[] { + const seen = new Set(); + 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)); +} diff --git a/src/agents/system-prompt-cache-boundary.test.ts b/src/agents/system-prompt-cache-boundary.test.ts index 2a63fb57250..d71493e7b8d 100644 --- a/src/agents/system-prompt-cache-boundary.test.ts +++ b/src/agents/system-prompt-cache-boundary.test.ts @@ -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`, + ); + }); }); diff --git a/src/agents/system-prompt-cache-boundary.ts b/src/agents/system-prompt-cache-boundary.ts index 01e3558dff8..e0007ddca75 100644 --- a/src/agents/system-prompt-cache-boundary.ts +++ b/src/agents/system-prompt-cache-boundary.ts @@ -1,3 +1,5 @@ +import { normalizeStructuredPromptSection } from "./prompt-cache-stability.js"; + export const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n\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}`; } diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 35fd02aecb6..32ad8d24b5d 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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: + "\n \n demo\n \n", + 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: + "\r\n \r\n demo\t\r\n \r\n\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", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index e60ed34bbb7..d5ed68c5735 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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"}`, ]