diff --git a/src/llm/providers/google-shared.test.ts b/src/llm/providers/google-shared.test.ts index acd0c7c4750..612047c158e 100644 --- a/src/llm/providers/google-shared.test.ts +++ b/src/llm/providers/google-shared.test.ts @@ -1,6 +1,7 @@ // Google shared provider tests cover response conversion and finish reasons. import { FinishReason, type GenerateContentResponse } from "@google/genai"; import { describe, expect, it } from "vitest"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { AssistantMessage, Model } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { @@ -146,4 +147,14 @@ describe("buildGoogleGenerateContentParams", () => { expect(params.config?.stopSequences).toEqual(["STOP"]); }); + + it("strips the internal cache boundary marker from systemInstruction", () => { + const params = buildGoogleGenerateContentParams(model, { + systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, + messages: [{ role: "user", content: "hello", timestamp: 0 }], + }); + + expect(params.config?.systemInstruction).toBe("Stable\nDynamic"); + expect(JSON.stringify(params)).not.toContain("OPENCLAW_CACHE_BOUNDARY"); + }); }); diff --git a/src/llm/providers/google-shared.ts b/src/llm/providers/google-shared.ts index 0eee2160dbf..836d31dc2e7 100644 --- a/src/llm/providers/google-shared.ts +++ b/src/llm/providers/google-shared.ts @@ -12,6 +12,7 @@ import { type Part, type ThinkingConfig, } from "@google/genai"; +import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { Api, @@ -500,7 +501,9 @@ export function buildGoogleGenerateContentParams( const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), + ...(context.systemPrompt && { + systemInstruction: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)), + }), ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), }; diff --git a/src/llm/providers/mistral.test.ts b/src/llm/providers/mistral.test.ts index 79f87312af1..8af0ec919ae 100644 --- a/src/llm/providers/mistral.test.ts +++ b/src/llm/providers/mistral.test.ts @@ -1,5 +1,6 @@ // Mistral provider tests cover request mapping and stream conversion. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { Context, Model } from "../types.js"; const mistralMockState = vi.hoisted(() => ({ @@ -214,4 +215,24 @@ describe("Mistral provider", () => { function: { name: "healthy_tool" }, }); }); + + it("strips the internal cache boundary marker from the system message", async () => { + const stream = streamSimpleMistral( + makeMistralModel(), + { + systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, + messages: [{ role: "user", content: "hello", timestamp: 0 }], + }, + { apiKey: "sk-mistral-provider" }, + ); + + await stream.result(); + + const payload = mistralMockState.payloads[0] as { + messages: Array<{ role: string; content: string }>; + }; + const systemMessage = payload.messages.find((message) => message.role === "system"); + expect(systemMessage?.content).toBe("Stable\nDynamic"); + expect(JSON.stringify(payload)).not.toContain("OPENCLAW_CACHE_BOUNDARY"); + }); }); diff --git a/src/llm/providers/mistral.ts b/src/llm/providers/mistral.ts index 0397f96f134..c8753618875 100644 --- a/src/llm/providers/mistral.ts +++ b/src/llm/providers/mistral.ts @@ -7,6 +7,7 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; +import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { @@ -309,7 +310,7 @@ function buildChatPayload( if (context.systemPrompt) { payload.messages.unshift({ role: "system", - content: sanitizeSurrogates(context.systemPrompt), + content: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)), }); } diff --git a/src/llm/providers/openai-chatgpt-responses.test.ts b/src/llm/providers/openai-chatgpt-responses.test.ts index ac8289c2a24..b7822cba405 100644 --- a/src/llm/providers/openai-chatgpt-responses.test.ts +++ b/src/llm/providers/openai-chatgpt-responses.test.ts @@ -1,6 +1,7 @@ // ChatGPT Responses provider tests cover stream handling and timeout behavior. import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { Context, Model } from "../types.js"; import { extractOpenAICodexAccountId, @@ -403,6 +404,60 @@ describe("streamOpenAICodexResponses transport", () => { expect(result.errorMessage).toContain("Request timed out after 5ms"); }); + it("strips the internal cache boundary marker from request instructions", async () => { + let capturedPayload: { instructions?: string } | undefined; + const stream = streamOpenAICodexResponses( + model, + { + systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, + messages: [{ role: "user", content: "hi", timestamp: 1 }], + }, + { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + transport: "sse", + onPayload: (payload) => { + capturedPayload = payload as typeof capturedPayload; + throw new Error("stop after payload"); + }, + }, + ); + + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(capturedPayload?.instructions).toBe("Stable\nDynamic"); + expect(JSON.stringify(capturedPayload)).not.toContain("OPENCLAW_CACHE_BOUNDARY"); + }); + + it("falls back to the default instructions when no system prompt is set", async () => { + let capturedPayload: { instructions?: string } | undefined; + const stream = streamOpenAICodexResponses( + model, + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + transport: "sse", + onPayload: (payload) => { + capturedPayload = payload as typeof capturedPayload; + throw new Error("stop after payload"); + }, + }, + ); + + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(capturedPayload?.instructions).toBe("You are a helpful assistant."); + }); + it("prefers promptCacheKey over sessionId for request cache affinity", async () => { let payload: unknown; vi.stubGlobal( diff --git a/src/llm/providers/openai-chatgpt-responses.ts b/src/llm/providers/openai-chatgpt-responses.ts index f7556c95f06..9b96c713a84 100644 --- a/src/llm/providers/openai-chatgpt-responses.ts +++ b/src/llm/providers/openai-chatgpt-responses.ts @@ -25,6 +25,7 @@ import { resolveTimerTimeoutMs, clampTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; +import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { clampThinkingLevel } from "../model-utils.js"; import { registerSessionResourceCleanup } from "../session-resources.js"; @@ -488,7 +489,8 @@ function buildRequestBody( model: model.id, store: false, stream: true, - instructions: context.systemPrompt || "You are a helpful assistant.", + instructions: + stripSystemPromptCacheBoundary(context.systemPrompt ?? "") || "You are a helpful assistant.", input: messages, text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], diff --git a/src/llm/providers/openai-responses-shared.test.ts b/src/llm/providers/openai-responses-shared.test.ts index 94696883bb4..99142070c96 100644 --- a/src/llm/providers/openai-responses-shared.test.ts +++ b/src/llm/providers/openai-responses-shared.test.ts @@ -1,6 +1,7 @@ // OpenAI Responses shared tests cover tool conversion and response item mapping. import type { Tool as OpenAIResponsesTool } from "openai/resources/responses/responses.js"; import { describe, expect, it } from "vitest"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { AssistantMessage, AssistantMessageEvent, Context, Model, Tool } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { @@ -262,6 +263,24 @@ describe("convertResponsesMessages", () => { }); }); + it("strips the internal cache boundary marker from the system prompt message", () => { + const input = convertResponsesMessages( + nativeOpenAIModel, + { + systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, + messages: [], + } satisfies Context, + allowedToolCallProviders, + ); + + expect(input[0]).toMatchObject({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Stable\nDynamic" }], + }); + expect(JSON.stringify(input)).not.toContain("OPENCLAW_CACHE_BOUNDARY"); + }); + it("omits phase-tagged assistant replay ids without reasoning", () => { const input = convertResponsesMessages( nativeOpenAIModel, diff --git a/src/llm/providers/openai-responses-shared.ts b/src/llm/providers/openai-responses-shared.ts index 799783afd3e..060d8d5f6bf 100644 --- a/src/llm/providers/openai-responses-shared.ts +++ b/src/llm/providers/openai-responses-shared.ts @@ -13,6 +13,7 @@ import type { ResponseReasoningItem, ResponseStreamEvent, } from "openai/resources/responses/responses.js"; +import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { AZURE_RESPONSES_TEXT_CONTENT_PART_TYPE, OPENAI_RESPONSES_OUTPUT_TEXT_CONTENT_PART_TYPE, @@ -254,7 +255,12 @@ export function convertResponsesMessages( messages.push({ type: "message", role, - content: [{ type: "input_text", text: sanitizeSurrogates(context.systemPrompt) }], + content: [ + { + type: "input_text", + text: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)), + }, + ], }); }