fix(agents): route per-turn media task hints below the cache boundary (#87998)

* fix(agents): route media task hints below the system-prompt cache boundary

Per-turn image/video/music generation task hints were injected into the
static prependSystemContext slot, landing above the cache boundary inside the
cacheable prefix. The hints are present only on user/manual turns and vary
with active media tasks, so the cacheable prefix shifted turn-to-turn and
defeated Anthropic/OpenAI prompt caching (#85203).

Split the per-turn media hints out of the prepend resolver into
resolveAttemptMediaTaskSystemPromptAddition and route them below the boundary
via the existing prependSystemPromptAddition helper, matching how subagent and
context-engine system-prompt additions are already routed. The static plugin
prependSystemContext / appendSystemContext hook fields are unchanged and
remain in the cacheable prefix. Applied at both consumers (embedded agent
runner and CLI runner).

* fix(agents): keep media task hints below the cache boundary for hook systemPrompt overrides

A before_prompt_build hook that returns a full systemPrompt override replaces
the base prompt with marker-free text. Per-turn media-generation task hints
were then front-prepended into that marker-free prompt, which providers cache
as a single block, so the cached prefix still shifted turn-to-turn on the
override path (#85203).

Wrap the base with ensureSystemPromptCacheBoundary at both media-routing sites
(embedded agent runner and CLI runner) so a marker-free override gets an
appended boundary and the hint routes into the uncached suffix. The helper is
idempotent, so marker-bearing prompts are unchanged. The shared
prependSystemPromptAddition wrapper and the static prependSystemContext /
appendSystemContext hook fields are untouched.

* fix(agents): keep marker-free idle prompts cacheable below the boundary

A marker-free hook systemPrompt override only had the cache boundary
ensured on turns with an active media task. On idle turns the later
appendModelIdentitySystemPrompt landed above the absent boundary, so the
idle cached system prefix diverged from active turns and prompt caching
broke across active/idle transitions. Ensure the boundary regardless of
media state in both the embedded and CLI runners, and extend the
regression to cover the model-identity append across active->idle.

* fix(agents): scope cache-boundary ensure to the model-identity append

Ensuring the boundary unconditionally on media-idle turns appended a
boundary marker to empty raw/gateway system prompts (turning "" into a
marker-only prompt) and to prompts with nothing below the boundary.
Instead ensure the boundary only when a model identity line is actually
appended to a non-empty prompt, in both the embedded and CLI runners.
This still keeps the identity below the boundary for marker-free hook
systemPrompt overrides (the #85203 idle-cache regression) while leaving
empty and identity-less prompts untouched.

* test: refresh stale type and lint expectations

* test: stabilize CI timeout checks

* test: satisfy channel entry lint

* fix(agents): skip cache boundary for blank prompts

* fix(channels): keep draft flush timer referenced

* test(agents): tolerate failed exec timeout setup

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
David
2026-05-31 10:35:53 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent 75ea8b5094
commit 778c4f90b9
11 changed files with 300 additions and 55 deletions
@@ -23,7 +23,7 @@ const defaultShell = isWin
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
describe("exec foreground failures", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
beforeEach(() => {
vi.useRealTimers();
@@ -41,7 +41,8 @@ describe("exec foreground failures", () => {
afterEach(() => {
vi.useRealTimers();
envSnapshot.restore();
envSnapshot?.restore();
envSnapshot = undefined;
});
it("returns a failed text result when the default timeout is exceeded", async () => {
@@ -82,16 +83,16 @@ describe("exec foreground failures", () => {
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect((supervisorMock.spawn.mock.calls[0]?.[0] as SpawnInput | undefined)?.timeoutMs).toBe(50);
expect(result.content[0]?.type).toBe("text");
expect((result.content[0] as { text?: string }).text).toMatch(/timed out/i);
expect((result.content[0] as { text?: string }).text).toMatch(/re-run with a higher timeout/i);
const details = result.details as {
status?: string;
exitCode?: number | null;
aggregated?: string;
durationMs?: number;
timedOut?: boolean;
};
expect(details.status).toBe("failed");
expect(details.exitCode).toBeNull();
expect(details.timedOut).toBe(true);
expect(details.aggregated).toBe("");
expect(details.durationMs).toBeTypeOf("number");
expect(details.durationMs).toBeGreaterThanOrEqual(0);
+4 -3
View File
@@ -17,6 +17,7 @@ import { hashCliSessionText } from "../cli-session.js";
import { resetContextWindowCacheForTest } from "../context.js";
import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js";
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js";
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
import {
prepareCliRunContext,
@@ -350,7 +351,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.params.prompt).toBe("history:2\n\nlatest ask");
expect(context.contextEngineTurnPrompt).toBe("latest ask");
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("prepend system")}\n\nhook system\n\n${wrappedPluginSystemContext("append system")}\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
`${wrappedPluginSystemContext("prepend system")}\n\nhook system\n\n${wrappedPluginSystemContext("append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
@@ -632,7 +633,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.params.prompt).toBe("prompt prepend\n\nlegacy prepend\n\nlatest ask");
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("prompt prepend system")}\n\n${wrappedPluginSystemContext("legacy prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}\n\n${wrappedPluginSystemContext("legacy append system")}\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
`${wrappedPluginSystemContext("prompt prepend system")}\n\n${wrappedPluginSystemContext("legacy prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}\n\n${wrappedPluginSystemContext("legacy append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
expect(hookRunner.runBeforeAgentStart).toHaveBeenCalledOnce();
@@ -1091,7 +1092,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
});
expect(context.systemPrompt).toBe(
`active image task\n\nactive video task\n\n${wrappedPluginSystemContext("hook prepend system")}\n\nhook system\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
`${wrappedPluginSystemContext("hook prepend system")}\n\nhook system${SYSTEM_PROMPT_CACHE_BOUNDARY}active image task\n\nactive video task\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
"agent:main:test",
+29 -8
View File
@@ -53,13 +53,17 @@ import {
resolveBootstrapTotalMaxChars,
} from "../embedded-agent-helpers.js";
import { resolvePromptBuildHookResult } from "../embedded-agent-runner/run/attempt.prompt-helpers.js";
import { resolveAttemptPrependSystemContext } from "../embedded-agent-runner/run/attempt.prompt-helpers.js";
import {
prependSystemPromptAddition,
resolveAttemptMediaTaskSystemPromptAddition,
} from "../embedded-agent-runner/run/attempt.prompt-helpers.js";
import { composeSystemPromptWithHookContext } from "../embedded-agent-runner/run/attempt.thread-helpers.js";
import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { ensureSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js";
import { buildSystemPromptReport } from "../system-prompt-report.js";
import { appendModelIdentitySystemPrompt } from "../system-prompt.js";
import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js";
@@ -506,13 +510,19 @@ export async function prepareCliRunContext(
systemPrompt =
composeSystemPromptWithHookContext({
baseSystemPrompt: systemPrompt,
prependSystemContext: resolveAttemptPrependSystemContext({
sessionKey: params.sessionKey,
trigger: params.trigger,
hookPrependSystemContext: hookResult.prependSystemContext,
}),
prependSystemContext: hookResult.prependSystemContext,
appendSystemContext: hookResult.appendSystemContext,
}) ?? systemPrompt;
const mediaTaskSystemPromptAddition = resolveAttemptMediaTaskSystemPromptAddition({
sessionKey: params.sessionKey,
trigger: params.trigger,
});
if (mediaTaskSystemPromptAddition) {
systemPrompt = prependSystemPromptAddition({
systemPrompt: ensureSystemPromptCacheBoundary(systemPrompt),
systemPromptAddition: mediaTaskSystemPromptAddition,
});
}
} catch (error) {
cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`);
}
@@ -553,8 +563,19 @@ export async function prepareCliRunContext(
maxHistoryChars: autoReseedHistoryChars,
})
: undefined;
const systemPromptWithReplacements = applyPluginTextReplacements(
systemPrompt,
backendResolved.textTransforms?.input,
);
// Ensure the cache boundary before appending the model identity so the identity lands in the
// dynamic suffix, not the cached prefix, for marker-free hook overrides — otherwise an idle
// turn's prefix (O + identity) diverges from an active media turn's prefix (O) and breaks
// prompt caching. Skip empty prompts and turns with no identity line, which need no boundary.
systemPrompt = appendModelIdentitySystemPrompt({
systemPrompt: applyPluginTextReplacements(systemPrompt, backendResolved.textTransforms?.input),
systemPrompt:
buildModelIdentityPromptLine(modelDisplay) && systemPromptWithReplacements.trim().length > 0
? ensureSystemPromptCacheBoundary(systemPromptWithReplacements)
: systemPromptWithReplacements,
model: modelDisplay,
});
const systemPromptReport = buildSystemPromptReport({
@@ -0,0 +1,149 @@
// Regression guard for #85203: per-turn media-generation task hints must sit BELOW
// the system-prompt cache boundary so the cacheable prefix stays byte-identical
// turn-to-turn. Mirrors the composition order used at attempt.ts (embedded runner)
// and cli-runner/prepare.ts: hook prependSystemContext stays in the cacheable prefix,
// media task hints are routed below the boundary via prependSystemPromptAddition.
import { describe, expect, it, vi } from "vitest";
const imageGenerationTaskStatusMocks = vi.hoisted(() => ({
buildActiveImageGenerationTaskPromptContextForSession: vi.fn(),
buildImageGenerationTaskStatusDetails: vi.fn(() => ({})),
buildImageGenerationTaskStatusText: vi.fn(() => "Image generation task status"),
findActiveImageGenerationTaskForSession: vi.fn(),
getImageGenerationTaskProviderId: vi.fn(),
isActiveImageGenerationTask: vi.fn(() => false),
IMAGE_GENERATION_TASK_KIND: "image_generation",
}));
const videoGenerationTaskStatusMocks = vi.hoisted(() => ({
buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(),
buildVideoGenerationTaskStatusDetails: vi.fn(() => ({})),
buildVideoGenerationTaskStatusText: vi.fn(() => "Video generation task status"),
findActiveVideoGenerationTaskForSession: vi.fn(),
getVideoGenerationTaskProviderId: vi.fn(),
isActiveVideoGenerationTask: vi.fn(() => false),
VIDEO_GENERATION_TASK_KIND: "video_generation",
}));
const musicGenerationTaskStatusMocks = vi.hoisted(() => ({
buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(),
buildMusicGenerationTaskStatusDetails: vi.fn(() => ({})),
buildMusicGenerationTaskStatusText: vi.fn(() => "Music generation task status"),
findActiveMusicGenerationTaskForSession: vi.fn(),
MUSIC_GENERATION_TASK_KIND: "music_generation",
}));
vi.mock("../../image-generation-task-status.js", () => imageGenerationTaskStatusMocks);
vi.mock("../../music-generation-task-status.js", () => musicGenerationTaskStatusMocks);
vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks);
import {
ensureSystemPromptCacheBoundary,
SYSTEM_PROMPT_CACHE_BOUNDARY,
splitSystemPromptCacheBoundary,
} from "../../system-prompt-cache-boundary.js";
import {
appendModelIdentitySystemPrompt,
buildModelIdentityPromptLine,
} from "../../system-prompt.js";
import {
prependSystemPromptAddition,
resolveAttemptMediaTaskSystemPromptAddition,
} from "./attempt.prompt-helpers.js";
import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js";
const MEDIA_HINT = "Active image generation task in progress";
const HOOK = "Static plugin guidance"; // documented static-cacheable hook field, constant per turn
const BASE = `Stable workspace prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic channel guidance`;
const MODEL = "test-model-x"; // any non-empty model yields a "Current model identity:" line
const MODEL_IDENTITY_FRAGMENT = "Current model identity:";
// Mirror the production composition order at attempt.ts / cli-runner/prepare.ts:
// 1) compose base with the static hook prepend/append (above-boundary, cacheable),
// 2) route the per-turn media task hints below the cache boundary (when a task is active),
// 3) before appending the model identity line, ensure a cache boundary exists (covers
// marker-free hook systemPrompt overrides) so the identity lands below it, not in the
// cached prefix.
function composeTurn(opts: { activeImageTask: boolean; base?: string; hook?: string }): string {
imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(
opts.activeImageTask ? MEDIA_HINT : undefined,
);
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
undefined,
);
musicGenerationTaskStatusMocks.buildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(
undefined,
);
const base = opts.base ?? BASE;
const composed =
composeSystemPromptWithHookContext({
baseSystemPrompt: base,
prependSystemContext: opts.hook ?? HOOK,
}) ?? base;
const mediaTaskSystemPromptAddition = resolveAttemptMediaTaskSystemPromptAddition({
sessionKey: "agent:main:discord:direct:123",
trigger: "user",
});
const routed = mediaTaskSystemPromptAddition
? prependSystemPromptAddition({
systemPrompt: ensureSystemPromptCacheBoundary(composed),
systemPromptAddition: mediaTaskSystemPromptAddition,
})
: composed;
// Production appends the model identity line after media routing; ensure the boundary first
// (when an identity line will be added) so it lands below the boundary, not in the cached
// prefix — the regression the marker-free idle case caught.
const withIdentityBoundary =
buildModelIdentityPromptLine(MODEL) && routed.trim().length > 0
? ensureSystemPromptCacheBoundary(routed)
: routed;
return appendModelIdentitySystemPrompt({ systemPrompt: withIdentityBoundary, model: MODEL });
}
describe("#85203 media task hints stay below the system-prompt cache boundary", () => {
it("cached stablePrefix is identical across a media-active turn and a media-idle turn", () => {
const withMedia = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
const withoutMedia = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: false }));
expect(withMedia?.stablePrefix).toBe(withoutMedia?.stablePrefix);
});
it("documented static hook guidance stays in the cacheable prefix (use-case coverage)", () => {
const split = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
expect(split?.stablePrefix).toContain(HOOK);
});
it("media hint lands below the boundary (dynamic suffix), not in the cached prefix", () => {
const split = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
expect(split?.dynamicSuffix).toContain(MEDIA_HINT);
expect(split?.stablePrefix ?? "").not.toContain(MEDIA_HINT);
});
// A hook that returns a full systemPrompt override produces a marker-free base; the
// ensureSystemPromptCacheBoundary wrap inserts a boundary so media still routes below it.
it("inserts a boundary for a marker-free hook systemPrompt override so media stays uncached", () => {
const OVERRIDE = "Custom hook system prompt override without a cache boundary";
const split = splitSystemPromptCacheBoundary(
composeTurn({ activeImageTask: true, base: OVERRIDE, hook: "" }),
);
expect(split).toBeDefined();
expect(split?.stablePrefix).toBe(OVERRIDE);
expect(split?.stablePrefix ?? "").not.toContain(MEDIA_HINT);
expect(split?.dynamicSuffix).toContain(MEDIA_HINT);
});
// Without ensuring the boundary on idle turns too, a marker-free override has the later
// model-identity append land above the (absent) boundary, so the idle cached prefix
// diverges from the active turn and prompt caching breaks across active/idle transitions.
it("marker-free override: idle cached prefix matches the active turn after model identity is appended", () => {
const OVERRIDE = "Custom hook system prompt override without a cache boundary";
const active = splitSystemPromptCacheBoundary(
composeTurn({ activeImageTask: true, base: OVERRIDE, hook: "" }),
);
const idle = splitSystemPromptCacheBoundary(
composeTurn({ activeImageTask: false, base: OVERRIDE, hook: "" }),
);
expect(active?.stablePrefix).toBe(OVERRIDE);
expect(idle).toBeDefined();
expect(idle?.stablePrefix).toBe(active?.stablePrefix);
expect(idle?.stablePrefix ?? "").not.toContain(MODEL_IDENTITY_FRAGMENT);
expect(idle?.dynamicSuffix).toContain(MODEL_IDENTITY_FRAGMENT);
});
});
@@ -40,12 +40,12 @@ vi.mock("../../../plugins/host-hook-state.js", () => hostHookStateMocks);
import {
forgetPromptBuildDrainCacheForRun,
resolvePromptSubmissionSkipReason,
resolveAttemptPrependSystemContext,
resolveAttemptMediaTaskSystemPromptAddition,
resolvePromptBuildHookResult,
} from "./attempt.prompt-helpers.js";
describe("resolveAttemptPrependSystemContext", () => {
it("prepends active video task guidance ahead of hook system context", () => {
describe("resolveAttemptMediaTaskSystemPromptAddition", () => {
it("joins active media task guidance for user triggers", () => {
imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(
"Image task hint",
);
@@ -56,10 +56,9 @@ describe("resolveAttemptPrependSystemContext", () => {
"Music task hint",
);
const result = resolveAttemptPrependSystemContext({
const result = resolveAttemptMediaTaskSystemPromptAddition({
sessionKey: "agent:main:discord:direct:123",
trigger: "user",
hookPrependSystemContext: "Hook system context",
});
expect(
@@ -71,12 +70,10 @@ describe("resolveAttemptPrependSystemContext", () => {
expect(
musicGenerationTaskStatusMocks.buildActiveMusicGenerationTaskPromptContextForSession,
).toHaveBeenCalledWith("agent:main:discord:direct:123");
expect(result).toBe(
"Image task hint\n\nActive task hint\n\nMusic task hint\n\nHook system context",
);
expect(result).toBe("Image task hint\n\nActive task hint\n\nMusic task hint");
});
it("skips active video task guidance for non-user triggers", () => {
it("returns undefined (no media guidance) for non-user/manual triggers", () => {
imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReset();
imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(
"Should not be used",
@@ -90,10 +87,9 @@ describe("resolveAttemptPrependSystemContext", () => {
"Should not be used",
);
const result = resolveAttemptPrependSystemContext({
const result = resolveAttemptMediaTaskSystemPromptAddition({
sessionKey: "agent:main:discord:direct:123",
trigger: "heartbeat",
hookPrependSystemContext: "Hook system context",
});
expect(
@@ -105,7 +101,7 @@ describe("resolveAttemptPrependSystemContext", () => {
expect(
musicGenerationTaskStatusMocks.buildActiveMusicGenerationTaskPromptContextForSession,
).not.toHaveBeenCalled();
expect(result).toBe("Hook system context");
expect(result).toBeUndefined();
});
});
@@ -500,22 +500,21 @@ export function prependSystemPromptAddition(params: {
return prependSystemPromptAdditionAfterCacheBoundary(params);
}
export function resolveAttemptPrependSystemContext(params: {
// Per-turn media-generation task hints depend on live session state, so they must
// be routed BELOW the system-prompt cache boundary (via prependSystemPromptAddition)
// rather than placed in the static prepend slot — keeping them above the boundary
// shifted the cacheable prefix turn-to-turn and broke prompt caching (#85203).
export function resolveAttemptMediaTaskSystemPromptAddition(params: {
sessionKey?: string;
trigger?: EmbeddedRunAttemptParams["trigger"];
hookPrependSystemContext?: string;
}): string | undefined {
const activeMediaTaskPromptContexts =
params.trigger === "user" || params.trigger === "manual"
? [
buildActiveImageGenerationTaskPromptContextForSession(params.sessionKey),
buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey),
buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey),
]
: [];
if (params.trigger !== "user" && params.trigger !== "manual") {
return undefined;
}
return joinPresentTextSegments([
...activeMediaTaskPromptContexts,
params.hookPrependSystemContext,
buildActiveImageGenerationTaskPromptContextForSession(params.sessionKey),
buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey),
buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey),
]);
}
@@ -177,9 +177,13 @@ import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
} from "../../subagent-capabilities.js";
import { ensureSystemPromptCacheBoundary } from "../../system-prompt-cache-boundary.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { appendModelIdentitySystemPrompt } from "../../system-prompt.js";
import {
appendModelIdentitySystemPrompt,
buildModelIdentityPromptLine,
} from "../../system-prompt.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import {
buildEmptyExplicitToolAllowlistError,
@@ -330,7 +334,7 @@ import {
buildAfterTurnRuntimeContextFromUsage,
prependSystemPromptAddition,
resolveAttemptFsWorkspaceOnly,
resolveAttemptPrependSystemContext,
resolveAttemptMediaTaskSystemPromptAddition,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
resolvePromptSubmissionSkipReason,
@@ -439,7 +443,7 @@ export {
mergeOrphanedTrailingUserPrompt,
prependSystemPromptAddition,
resolveAttemptFsWorkspaceOnly,
resolveAttemptPrependSystemContext,
resolveAttemptMediaTaskSystemPromptAddition,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
shouldWarnOnOrphanedUserRepair,
@@ -3347,11 +3351,7 @@ export async function runEmbeddedAttempt(
}
const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({
baseSystemPrompt: systemPromptText,
prependSystemContext: resolveAttemptPrependSystemContext({
sessionKey: params.sessionKey,
trigger: params.trigger,
hookPrependSystemContext: hookResult?.prependSystemContext,
}),
prependSystemContext: hookResult?.prependSystemContext,
appendSystemContext: hookResult?.appendSystemContext,
});
if (prependedOrAppendedSystemPrompt) {
@@ -3362,9 +3362,29 @@ export async function runEmbeddedAttempt(
`hooks: applied prependSystemContext/appendSystemContext (${prependSystemLen}+${appendSystemLen} chars)`,
);
}
const mediaTaskSystemPromptAddition = resolveAttemptMediaTaskSystemPromptAddition({
sessionKey: params.sessionKey,
trigger: params.trigger,
});
if (mediaTaskSystemPromptAddition) {
setActiveSessionSystemPrompt(
prependSystemPromptAddition({
systemPrompt: ensureSystemPromptCacheBoundary(systemPromptText),
systemPromptAddition: mediaTaskSystemPromptAddition,
}),
);
}
}
// The model identity line is appended below; for a marker-free hook systemPrompt
// override ensure the cache boundary first so the identity lands in the dynamic
// suffix, not the cached prefix — otherwise an idle turn's prefix (O + identity)
// diverges from an active media turn's prefix (O) and breaks prompt caching. Skip
// empty prompts (raw/gateway runs) and turns with no identity line, which need none.
const modelAwareSystemPrompt = appendModelIdentitySystemPrompt({
systemPrompt: systemPromptText,
systemPrompt:
buildModelIdentityPromptLine(runtimeInfo.model) && systemPromptText.trim().length > 0
? ensureSystemPromptCacheBoundary(systemPromptText)
: systemPromptText,
model: runtimeInfo.model,
});
if (modelAwareSystemPrompt !== systemPromptText) {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
ensureSystemPromptCacheBoundary,
prependSystemPromptAdditionAfterCacheBoundary,
splitSystemPromptCacheBoundary,
stripSystemPromptCacheBoundary,
@@ -42,3 +43,46 @@ describe("system prompt cache boundary helpers", () => {
);
});
});
describe("ensureSystemPromptCacheBoundary", () => {
it("returns a marker-bearing prompt unchanged", () => {
const prompt = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
expect(ensureSystemPromptCacheBoundary(prompt)).toBe(prompt);
});
it("appends the boundary to a marker-free prompt", () => {
expect(ensureSystemPromptCacheBoundary("Marker-free override")).toBe(
`Marker-free override${SYSTEM_PROMPT_CACHE_BOUNDARY}`,
);
});
it("does not add a boundary for an empty prompt", () => {
expect(ensureSystemPromptCacheBoundary("")).toBe("");
expect(ensureSystemPromptCacheBoundary(" \n\t ")).toBe(" \n\t ");
});
it("uses a per-turn addition directly when the base prompt is empty", () => {
expect(
prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: ensureSystemPromptCacheBoundary(""),
systemPromptAddition: "Per-turn media task hint",
}),
).toBe("Per-turn media task hint");
});
it("is idempotent for a marker-free prompt", () => {
const once = ensureSystemPromptCacheBoundary("Marker-free override");
expect(ensureSystemPromptCacheBoundary(once)).toBe(once);
});
it("lets a per-turn addition split into the uncached suffix for a marker-free prompt", () => {
const result = prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: ensureSystemPromptCacheBoundary("Marker-free override"),
systemPromptAddition: "Per-turn media task hint",
});
expect(splitSystemPromptCacheBoundary(result)).toEqual({
stablePrefix: "Marker-free override",
dynamicSuffix: "Per-turn media task hint",
});
});
});
@@ -6,6 +6,17 @@ export function stripSystemPromptCacheBoundary(text: string): string {
return text.replaceAll(SYSTEM_PROMPT_CACHE_BOUNDARY, "\n");
}
// Append the cache boundary when a prompt has none (e.g. a hook systemPrompt override),
// so dynamic additions route into an uncached suffix instead of the cached prefix (#85203).
export function ensureSystemPromptCacheBoundary(systemPrompt: string): string {
if (systemPrompt.trim().length === 0) {
return systemPrompt;
}
return systemPrompt.includes(SYSTEM_PROMPT_CACHE_BOUNDARY)
? systemPrompt
: `${systemPrompt}${SYSTEM_PROMPT_CACHE_BOUNDARY}`;
}
export function splitSystemPromptCacheBoundary(
text: string,
): { stablePrefix: string; dynamicSuffix: string } | undefined {
@@ -30,6 +41,9 @@ export function prependSystemPromptAdditionAfterCacheBoundary(params: {
if (!systemPromptAddition) {
return params.systemPrompt;
}
if (params.systemPrompt.trim().length === 0) {
return systemPromptAddition;
}
const split = splitSystemPromptCacheBoundary(params.systemPrompt);
if (!split) {
@@ -197,7 +197,7 @@ describe("bundled channel entry shape guards", () => {
const bundledPluginRoots = listSourceBundledPluginRoots();
let realBundledSourceTreeProbe: {
hasAccountInspect: boolean;
pluginIds: string[];
pluginIds: readonly string[];
};
beforeAll(async () => {
@@ -1,4 +1,4 @@
import { spawnSync } from "node:child_process";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -296,7 +296,7 @@ async function expectBuiltArtifactNodeRequireFastPath(
}
}
function runCompiledEsmSidecarFastPathProbe(): ReturnType<typeof spawnSync> {
function runCompiledEsmSidecarFastPathProbe(): SpawnSyncReturns<string> {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-"));
tempDirs.push(tempRoot);
const probePath = path.join(tempRoot, "probe.mjs");
@@ -358,7 +358,7 @@ function runCompiledEsmSidecarFastPathProbe(): ReturnType<typeof spawnSync> {
}
describe("loadBundledEntryExportSync", () => {
let compiledEsmSidecarFastPathResult: ReturnType<typeof spawnSync>;
let compiledEsmSidecarFastPathResult: SpawnSyncReturns<string>;
beforeAll(() => {
compiledEsmSidecarFastPathResult = runCompiledEsmSidecarFastPathProbe();
@@ -573,9 +573,9 @@ describe("loadBundledEntryExportSync", () => {
const result = compiledEsmSidecarFastPathResult;
expect(result.status).toBe(0);
expect(String(result.stdout).trim()).toBe("adapter");
expect(String(result.stderr)).toMatch(/sourceLoaderCreateMs=0(?:\.0+)?(?:\s|$)/u);
expect(String(result.stderr)).toMatch(/sourceLoaderCallMs=0(?:\.0+)?(?:\s|$)/u);
expect(result.stdout.trim()).toBe("adapter");
expect(result.stderr).toMatch(/sourceLoaderCreateMs=0(?:\.0+)?(?:\s|$)/u);
expect(result.stderr).toMatch(/sourceLoaderCallMs=0(?:\.0+)?(?:\s|$)/u);
});
it("can disable source-tree fallback for dist bundled entry checks", () => {