mirror of https://github.com/openclaw/openclaw.git
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:
parent
0660bef81e
commit
d75a8933e7
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}`,
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue