diff --git a/CHANGELOG.md b/CHANGELOG.md index 58dd32ecf41..5c852860411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - MiniMax/TTS: add a bundled MiniMax speech provider backed by the T2A v2 API so speech synthesis can run through MiniMax-native voices and auth. (#55921) Thanks @duncanita. - Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras. - Plugins/onboarding: add plugin config TUI prompts to onboard and configure wizards so more plugin setup can stay in the guided flow. (#60590) Thanks @odysseus0. +- Providers/OpenAI: move GPT-5 prompt tuning onto provider-owned system-prompt contributions so cache-stable guidance stays above the prompt cache boundary and embedded runner paths reuse the same provider-specific prompt behavior. - Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, and embedded image history so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny. - Providers/Amazon Bedrock: discover regional and global inference profiles, inherit their backing model capabilities, and inject the Bedrock request region automatically so cross-region Claude profiles work without manual provider overrides. (#61299) Thanks @wirjo. - Providers/Amazon Bedrock Mantle: add a bundled OpenAI-compatible Mantle provider with bearer-token discovery, automatic OSS model catalog loading, and Bedrock Mantle region detection for hosted GPT-OSS, Qwen, Kimi, GLM, and similar routes. (#61296) Thanks @wirjo. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 246244478b8..7a913687752 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -12,6 +12,18 @@ OpenClaw builds a custom system prompt for every agent run. The prompt is **Open The prompt is assembled by OpenClaw and injected into each agent run. +Provider plugins can contribute cache-aware prompt guidance without replacing +the full OpenClaw-owned prompt. The provider runtime can: + +- replace a small set of named core sections (`interaction_style`, + `tool_call_style`, `execution_bias`) +- inject a **stable prefix** above the prompt cache boundary +- inject a **dynamic suffix** below the prompt cache boundary + +Use provider-owned contributions for model-family-specific tuning. Keep legacy +`before_prompt_build` prompt mutation for compatibility or truly global prompt +changes, not normal provider behavior. + ## Structure The prompt is intentionally compact and uses fixed sections: diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index af2a2ff79ad..a951ec44de2 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -513,6 +513,13 @@ API key auth, and dynamic model resolution. | 42 | `validateReplayTurns` | Strict replay-turn validation before the embedded runner | | 43 | `onModelSelected` | Post-selection callback (e.g. telemetry) | + Prompt tuning note: + + - `resolveSystemPromptContribution` lets a provider inject cache-aware + system-prompt guidance for a model family. Prefer it over + `before_prompt_build` when the behavior belongs to one provider/model + family and should preserve the stable/dynamic cache split. + For detailed descriptions and real-world examples, see [Internals: Provider Runtime Hooks](/plugins/architecture#provider-runtime-hooks). diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index c8cdd2387c7..6570cff0cc7 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js"; import { @@ -8,7 +9,11 @@ import { } from "../../test/helpers/plugins/provider-registration.js"; import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; import plugin from "./index.js"; -import { OPENAI_FRIENDLY_PROMPT_OVERLAY } from "./prompt-overlay.js"; +import { + OPENAI_FRIENDLY_PROMPT_OVERLAY, + OPENAI_GPT5_EXECUTION_BIAS, + OPENAI_GPT5_OUTPUT_CONTRACT, +} from "./prompt-overlay.js"; const runtimeMocks = vi.hoisted(() => ({ ensureGlobalUndiciEnvProxyDispatcher: vi.fn(), @@ -40,6 +45,7 @@ const registerOpenAIPlugin = async () => async function registerOpenAIPluginWithHook(params?: { pluginConfig?: Record }) { const on = vi.fn(); + const providers: ProviderPlugin[] = []; await plugin.register( createTestPluginApi({ id: "openai", @@ -49,9 +55,12 @@ async function registerOpenAIPluginWithHook(params?: { pluginConfig?: Record { + providers.push(provider); + }, }), ); - return { on }; + return { on, providers }; } describe("openai plugin", () => { @@ -240,36 +249,51 @@ describe("openai plugin", () => { ).toBeLessThan(runtimeMocks.refreshOpenAICodexToken.mock.invocationCallOrder[0]); }); - it("registers the friendly prompt overlay by default and scopes it to OpenAI providers", async () => { - const { on } = await registerOpenAIPluginWithHook(); + it("registers GPT-5 system prompt contributions on OpenAI providers by default", async () => { + const { on, providers } = await registerOpenAIPluginWithHook(); - expect(on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function)); - const beforePromptBuild = on.mock.calls.find((call) => call[0] === "before_prompt_build")?.[1]; - const openaiResult = await beforePromptBuild?.( - { prompt: "hello", messages: [] }, - { modelProviderId: "openai", modelId: "gpt-5.4" }, - ); - expect(openaiResult).toEqual({ - appendSystemContext: OPENAI_FRIENDLY_PROMPT_OVERLAY, + expect(on).not.toHaveBeenCalledWith("before_prompt_build", expect.any(Function)); + + const openaiProvider = requireRegisteredProvider(providers, "openai"); + const codexProvider = requireRegisteredProvider(providers, "openai-codex"); + const contributionContext: Parameters< + NonNullable + >[0] = { + config: undefined, + agentDir: undefined, + workspaceDir: undefined, + provider: "openai", + modelId: "gpt-5.4", + promptMode: "full", + runtimeChannel: undefined, + runtimeCapabilities: undefined, + agentId: undefined, + }; + + expect(openaiProvider.resolveSystemPromptContribution?.(contributionContext)).toEqual({ + stablePrefix: OPENAI_GPT5_OUTPUT_CONTRACT, + sectionOverrides: { + interaction_style: OPENAI_FRIENDLY_PROMPT_OVERLAY, + execution_bias: OPENAI_GPT5_EXECUTION_BIAS, + }, }); expect(OPENAI_FRIENDLY_PROMPT_OVERLAY).toContain("This is a live chat, not a memo."); expect(OPENAI_FRIENDLY_PROMPT_OVERLAY).toContain( "Avoid walls of text, long preambles, and repetitive restatement.", ); - - const codexResult = await beforePromptBuild?.( - { prompt: "hello", messages: [] }, - { modelProviderId: "openai-codex", modelId: "gpt-5.4" }, - ); - expect(codexResult).toEqual({ - appendSystemContext: OPENAI_FRIENDLY_PROMPT_OVERLAY, + expect(codexProvider.resolveSystemPromptContribution?.(contributionContext)).toEqual({ + stablePrefix: OPENAI_GPT5_OUTPUT_CONTRACT, + sectionOverrides: { + interaction_style: OPENAI_FRIENDLY_PROMPT_OVERLAY, + execution_bias: OPENAI_GPT5_EXECUTION_BIAS, + }, }); - - const nonOpenAIResult = await beforePromptBuild?.( - { prompt: "hello", messages: [] }, - { modelProviderId: "anthropic", modelId: "sonnet-4.6" }, - ); - expect(nonOpenAIResult).toBeUndefined(); + expect( + openaiProvider.resolveSystemPromptContribution?.({ + ...contributionContext, + modelId: "gpt-image-1", + }), + ).toBeUndefined(); }); it("includes stronger execution guidance in the OpenAI prompt overlay", () => { @@ -282,13 +306,38 @@ describe("openai plugin", () => { expect(OPENAI_FRIENDLY_PROMPT_OVERLAY).toContain( "Commentary-only turns are incomplete when the next action is clear.", ); + expect(OPENAI_GPT5_EXECUTION_BIAS).toContain( + "Do prerequisite lookup or discovery before dependent actions.", + ); + expect(OPENAI_GPT5_OUTPUT_CONTRACT).toContain( + "Return the requested sections only, in the requested order.", + ); }); it("supports opting out of the prompt overlay via plugin config", async () => { - const { on } = await registerOpenAIPluginWithHook({ + const { on, providers } = await registerOpenAIPluginWithHook({ pluginConfig: { personalityOverlay: "off" }, }); expect(on).not.toHaveBeenCalledWith("before_prompt_build", expect.any(Function)); + const openaiProvider = requireRegisteredProvider(providers, "openai"); + expect( + openaiProvider.resolveSystemPromptContribution?.({ + config: undefined, + agentDir: undefined, + workspaceDir: undefined, + provider: "openai", + modelId: "gpt-5.4", + promptMode: "full", + runtimeChannel: undefined, + runtimeCapabilities: undefined, + agentId: undefined, + }), + ).toEqual({ + stablePrefix: OPENAI_GPT5_OUTPUT_CONTRACT, + sectionOverrides: { + execution_bias: OPENAI_GPT5_EXECUTION_BIAS, + }, + }); }); }); diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index e4f88334f42..c98541f43f4 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -8,9 +8,8 @@ import { import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; import { - OPENAI_FRIENDLY_PROMPT_OVERLAY, resolveOpenAIPromptOverlayMode, - shouldApplyOpenAIPromptOverlay, + resolveOpenAISystemPromptContribution, } from "./prompt-overlay.js"; import { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -22,24 +21,29 @@ export default definePluginEntry({ description: "Bundled OpenAI provider plugins", register(api) { const promptOverlayMode = resolveOpenAIPromptOverlayMode(api.pluginConfig); + const buildProviderWithPromptContribution = < + T extends + | ReturnType + | ReturnType, + >( + provider: T, + ): T => ({ + ...provider, + resolveSystemPromptContribution: (ctx) => + resolveOpenAISystemPromptContribution({ + mode: promptOverlayMode, + modelProviderId: provider.id, + modelId: ctx.modelId, + }), + }); api.registerCliBackend(buildOpenAICodexCliBackend()); - api.registerProvider(buildOpenAIProvider()); - api.registerProvider(buildOpenAICodexProviderPlugin()); + api.registerProvider(buildProviderWithPromptContribution(buildOpenAIProvider())); + api.registerProvider(buildProviderWithPromptContribution(buildOpenAICodexProviderPlugin())); api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider()); api.registerRealtimeTranscriptionProvider(buildOpenAIRealtimeTranscriptionProvider()); api.registerRealtimeVoiceProvider(buildOpenAIRealtimeVoiceProvider()); api.registerSpeechProvider(buildOpenAISpeechProvider()); api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); api.registerMediaUnderstandingProvider(openaiCodexMediaUnderstandingProvider); - if (promptOverlayMode !== "off") { - api.on("before_prompt_build", (_event, ctx) => - shouldApplyOpenAIPromptOverlay({ - mode: promptOverlayMode, - modelProviderId: ctx.modelProviderId, - }) - ? { appendSystemContext: OPENAI_FRIENDLY_PROMPT_OVERLAY } - : undefined, - ); - } }, }); diff --git a/extensions/openai/prompt-overlay.ts b/extensions/openai/prompt-overlay.ts index bfbadf3089a..d966d8d400d 100644 --- a/extensions/openai/prompt-overlay.ts +++ b/extensions/openai/prompt-overlay.ts @@ -1,4 +1,5 @@ const OPENAI_PROVIDER_IDS = new Set(["openai", "openai-codex"]); +const OPENAI_GPT5_MODEL_PREFIX = "gpt-5"; export const OPENAI_FRIENDLY_PROMPT_OVERLAY = `## Interaction Style @@ -21,6 +22,20 @@ Default to short natural replies unless the user asks for depth. Avoid walls of text, long preambles, and repetitive restatement. Keep replies concise by default; friendly does not mean verbose.`; +export const OPENAI_GPT5_OUTPUT_CONTRACT = `## GPT-5 Output Contract + +Return the requested sections only, in the requested order. +Prefer terse answers by default; expand only when depth materially helps. +Avoid restating large internal plans when the next action is already clear.`; + +export const OPENAI_GPT5_EXECUTION_BIAS = `## Execution Bias + +Start the real work in the same turn when the next step is clear. +Do prerequisite lookup or discovery before dependent actions. +If another tool call would likely improve correctness or completeness, keep going instead of stopping at partial progress. +Multi-part requests stay incomplete until every requested item is handled or clearly marked blocked. +Before the final answer, quickly verify correctness, coverage, formatting, and obvious side effects.`; + export type OpenAIPromptOverlayMode = "friendly" | "off"; export function resolveOpenAIPromptOverlayMode( @@ -30,8 +45,34 @@ export function resolveOpenAIPromptOverlayMode( } export function shouldApplyOpenAIPromptOverlay(params: { + modelProviderId?: string; + modelId?: string; +}): boolean { + if (!OPENAI_PROVIDER_IDS.has(params.modelProviderId ?? "")) { + return false; + } + const normalizedModelId = params.modelId?.trim().toLowerCase() ?? ""; + return normalizedModelId.startsWith(OPENAI_GPT5_MODEL_PREFIX); +} + +export function resolveOpenAISystemPromptContribution(params: { mode: OpenAIPromptOverlayMode; modelProviderId?: string; -}): boolean { - return params.mode === "friendly" && OPENAI_PROVIDER_IDS.has(params.modelProviderId ?? ""); + modelId?: string; +}) { + if ( + !shouldApplyOpenAIPromptOverlay({ + modelProviderId: params.modelProviderId, + modelId: params.modelId, + }) + ) { + return undefined; + } + return { + stablePrefix: OPENAI_GPT5_OUTPUT_CONTRACT, + sectionOverrides: { + execution_bias: OPENAI_GPT5_EXECUTION_BIAS, + ...(params.mode === "friendly" ? { interaction_style: OPENAI_FRIENDLY_PROMPT_OVERLAY } : {}), + }, + }; } diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 8ccf74742b9..429aba0164a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -18,7 +18,10 @@ import { import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; +import { + prepareProviderRuntimeAuth, + resolveProviderSystemPromptContribution, +} from "../../plugins/provider-runtime.js"; import type { ProviderRuntimeModel } from "../../plugins/types.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; @@ -648,6 +651,22 @@ export async function compactEmbeddedPiSessionDirect( }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; const ownerDisplay = resolveOwnerDisplaySetting(params.config); + const promptContribution = resolveProviderSystemPromptContribution({ + provider, + config: params.config, + workspaceDir: effectiveWorkspace, + context: { + config: params.config, + agentDir, + workspaceDir: effectiveWorkspace, + provider, + modelId, + promptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + }, + }); const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => createSystemPromptOverride( buildEmbeddedSystemPrompt({ @@ -678,6 +697,7 @@ export async function compactEmbeddedPiSessionDirect( userTimeFormat, contextFiles, memoryCitationsMode: params.config?.memory?.citations, + promptContribution, }), ); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 114802e8f81..7ceed1c8a08 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -22,6 +22,7 @@ import { import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js"; +import { resolveProviderSystemPromptContribution } from "../../../plugins/provider-runtime.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { buildTtsSystemPromptHint } from "../../../tts/tts.js"; import { resolveUserPath } from "../../../utils.js"; @@ -656,6 +657,22 @@ export async function runEmbeddedAttempt( }) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined; + const promptContribution = resolveProviderSystemPromptContribution({ + provider: params.provider, + config: params.config, + workspaceDir: effectiveWorkspace, + context: { + config: params.config, + agentDir: params.agentDir, + workspaceDir: effectiveWorkspace, + provider: params.provider, + modelId: params.modelId, + promptMode: effectivePromptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + }, + }); const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, @@ -684,6 +701,7 @@ export async function runEmbeddedAttempt( userTimeFormat, contextFiles, memoryCitationsMode: params.config?.memory?.citations, + promptContribution, }); const systemPromptReport = buildSystemPromptReport({ source: "run", diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index c644c9ffd97..9aea35abcc7 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -1,6 +1,10 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; +import { + applySystemPromptOverrideToSession, + buildEmbeddedSystemPrompt, + createSystemPromptOverride, +} from "./system-prompt.js"; type MutableSession = { _baseSystemPrompt?: string; @@ -61,3 +65,28 @@ describe("applySystemPromptOverrideToSession", () => { expect(mutable._rebuildSystemPrompt?.(["tool1"])).toBe("rebuild test"); }); }); + +describe("buildEmbeddedSystemPrompt", () => { + it("forwards provider prompt contributions into the embedded prompt", () => { + const prompt = buildEmbeddedSystemPrompt({ + workspaceDir: "/tmp/openclaw", + reasoningTagHint: false, + runtimeInfo: { + host: "local", + os: "darwin", + arch: "arm64", + node: process.version, + model: "gpt-5.4", + provider: "openai", + }, + tools: [], + modelAliasLines: [], + userTimezone: "UTC", + promptContribution: { + stablePrefix: "## Embedded Stable\n\nStable provider guidance.", + }, + }); + + expect(prompt).toContain("## Embedded Stable\n\nStable provider guidance."); + }); +}); diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 35ae7671346..e10e2102119 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -3,6 +3,7 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { MemoryCitationsMode } from "../../config/types.memory.js"; import type { ResolvedTimeFormat } from "../date-time.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; +import type { ProviderSystemPromptContribution } from "../system-prompt-contribution.js"; import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js"; import type { EmbeddedSandboxInfo } from "./types.js"; import type { ReasoningLevel, ThinkLevel } from "./utils.js"; @@ -51,6 +52,7 @@ export function buildEmbeddedSystemPrompt(params: { userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; memoryCitationsMode?: MemoryCitationsMode; + promptContribution?: ProviderSystemPromptContribution; }): string { return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, @@ -79,6 +81,7 @@ export function buildEmbeddedSystemPrompt(params: { userTimeFormat: params.userTimeFormat, contextFiles: params.contextFiles, memoryCitationsMode: params.memoryCitationsMode, + promptContribution: params.promptContribution, }); } diff --git a/src/agents/system-prompt-contribution.ts b/src/agents/system-prompt-contribution.ts new file mode 100644 index 00000000000..33ed23eb57f --- /dev/null +++ b/src/agents/system-prompt-contribution.ts @@ -0,0 +1,28 @@ +export type ProviderSystemPromptSectionId = + | "interaction_style" + | "tool_call_style" + | "execution_bias"; + +export type ProviderSystemPromptContribution = { + /** + * Cache-stable provider guidance inserted above the system-prompt cache boundary. + * + * Use this for static provider/model-family instructions that should preserve + * KV cache reuse across turns. + */ + stablePrefix?: string; + /** + * Provider guidance inserted below the cache boundary. + * + * Use this only for genuinely dynamic text that is expected to vary across + * runs or sessions. + */ + dynamicSuffix?: string; + /** + * Whole-section replacements for selected core prompt sections. + * + * Values should contain the complete rendered section, including any desired + * heading such as `## Tool Call Style`. + */ + sectionOverrides?: Partial>; +}; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 379ae8a6ace..37da8df2488 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -711,6 +711,56 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("# Dynamic Project Context"); }); + it("replaces provider-owned prompt sections without disturbing core ordering", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptContribution: { + sectionOverrides: { + interaction_style: "## Interaction Style\n\nCustom interaction guidance.", + execution_bias: "## Execution Bias\n\nCustom execution guidance.", + }, + }, + }); + + expect(prompt).toContain("## Interaction Style\n\nCustom interaction guidance."); + expect(prompt).toContain("## Execution Bias\n\nCustom execution guidance."); + expect(prompt).not.toContain("Bias toward action and momentum."); + }); + + it("places provider stable prefixes above the cache boundary", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptContribution: { + stablePrefix: "## Provider Stable Block\n\nStable provider guidance.", + }, + }); + + const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY); + const stableIndex = prompt.indexOf("## Provider Stable Block"); + const safetyIndex = prompt.indexOf("## Safety"); + + expect(stableIndex).toBeGreaterThan(-1); + expect(boundaryIndex).toBeGreaterThan(stableIndex); + expect(safetyIndex).toBeGreaterThan(stableIndex); + }); + + it("places provider dynamic suffixes below the cache boundary", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptContribution: { + dynamicSuffix: "## Provider Dynamic Block\n\nPer-turn provider guidance.", + }, + }); + + const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY); + const dynamicIndex = prompt.indexOf("## Provider Dynamic Block"); + const heartbeatIndex = prompt.indexOf("## Heartbeats"); + + expect(boundaryIndex).toBeGreaterThan(-1); + expect(dynamicIndex).toBeGreaterThan(boundaryIndex); + expect(heartbeatIndex).toBeGreaterThan(dynamicIndex); + }); + it("summarizes the message tool when available", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 60fe9f63a4d..7beeabaacae 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -15,6 +15,10 @@ import { } from "./prompt-cache-stability.js"; import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; +import type { + ProviderSystemPromptContribution, + ProviderSystemPromptSectionId, +} from "./system-prompt-contribution.js"; /** * Controls which hardcoded sections are included in the system prompt. @@ -269,6 +273,25 @@ function buildExecutionBiasSection(params: { isMinimal: boolean }) { ]; } +function normalizeProviderPromptBlock(value?: string): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeStructuredPromptSection(value); + return normalized || undefined; +} + +function buildOverridablePromptSection(params: { + override?: string; + fallback: string[]; +}): string[] { + const override = normalizeProviderPromptBlock(params.override); + if (override) { + return [override, ""]; + } + return params.fallback; +} + function buildExecApprovalPromptGuidance(params: { runtimeChannel?: string; inlineButtonsEnabled?: boolean; @@ -332,6 +355,7 @@ export function buildAgentSystemPrompt(params: { channel: string; }; memoryCitationsMode?: MemoryCitationsMode; + promptContribution?: ProviderSystemPromptContribution; }) { const acpEnabled = params.acpEnabled !== false; const sandboxedRuntime = params.sandboxInfo?.enabled === true; @@ -362,6 +386,17 @@ export function buildAgentSystemPrompt(params: { typeof params.extraSystemPrompt === "string" ? normalizeStructuredPromptSection(params.extraSystemPrompt) : undefined; + const promptContribution = params.promptContribution; + const providerStablePrefix = normalizeProviderPromptBlock(promptContribution?.stablePrefix); + const providerDynamicSuffix = normalizeProviderPromptBlock(promptContribution?.dynamicSuffix); + const providerSectionOverrides = Object.fromEntries( + Object.entries(promptContribution?.sectionOverrides ?? {}) + .map(([key, value]) => [ + key, + normalizeProviderPromptBlock(typeof value === "string" ? value : undefined), + ]) + .filter(([, value]) => Boolean(value)), + ) as Partial>; const ownerDisplay = params.ownerDisplay === "hash" ? "hash" : "raw"; const ownerLine = buildOwnerIdentityLine( params.ownerNumbers ?? [], @@ -476,22 +511,38 @@ export function buildAgentSystemPrompt(params: { : []), "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", "", - "## Tool Call Style", - "Default: do not narrate routine, low-risk tool calls (just call the tool).", - "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", - "Keep narration brief and value-dense; avoid repeating obvious steps.", - "Use plain human language for narration unless in a technical context.", - "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", - buildExecApprovalPromptGuidance({ - runtimeChannel: params.runtimeInfo?.channel, - inlineButtonsEnabled, + ...buildOverridablePromptSection({ + override: providerSectionOverrides.interaction_style, + fallback: [], }), - "Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.", - "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", - "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.", - "", - ...buildExecutionBiasSection({ - isMinimal, + ...buildOverridablePromptSection({ + override: providerSectionOverrides.tool_call_style, + fallback: [ + "## Tool Call Style", + "Default: do not narrate routine, low-risk tool calls (just call the tool).", + "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", + "Keep narration brief and value-dense; avoid repeating obvious steps.", + "Use plain human language for narration unless in a technical context.", + "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", + buildExecApprovalPromptGuidance({ + runtimeChannel: params.runtimeInfo?.channel, + inlineButtonsEnabled, + }), + "Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.", + "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", + "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.", + "", + ], + }), + ...buildOverridablePromptSection({ + override: providerSectionOverrides.execution_bias, + fallback: buildExecutionBiasSection({ + isMinimal, + }), + }), + ...buildOverridablePromptSection({ + override: providerStablePrefix, + fallback: [], }), ...safetySection, "## OpenClaw CLI Quick Reference", @@ -682,6 +733,9 @@ export function buildAgentSystemPrompt(params: { promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; lines.push(contextHeader, extraSystemPrompt, ""); } + if (providerDynamicSuffix) { + lines.push(providerDynamicSuffix, ""); + } // Skip heartbeats for subagent/none modes if (!isMinimal && heartbeatPrompt) { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 22aae91a0d3..2fb941c9ec7 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,5 +1,6 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; import { normalizeProviderId } from "../agents/provider-id.js"; +import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; import { resolveCatalogHookProviderPluginIds } from "./providers.js"; @@ -41,6 +42,7 @@ import type { ProviderResolveDynamicModelContext, ProviderResolveTransportTurnStateContext, ProviderResolveWebSocketSessionPolicyContext, + ProviderSystemPromptContributionContext, ProviderRuntimeModel, ProviderThinkingPolicyContext, ProviderTransportTurnState, @@ -208,6 +210,19 @@ export function runProviderDynamicModel(params: { return resolveProviderRuntimePlugin(params)?.resolveDynamicModel?.(params.context) ?? undefined; } +export function resolveProviderSystemPromptContribution(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderSystemPromptContributionContext; +}): ProviderSystemPromptContribution | undefined { + return ( + resolveProviderRuntimePlugin(params)?.resolveSystemPromptContribution?.(params.context) ?? + undefined + ); +} + export async function prepareProviderDynamicModel(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d66cc002b56..1cf9ebca70a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -13,6 +13,8 @@ import type { import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js"; +import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; +import type { PromptMode } from "../agents/system-prompt.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -1044,6 +1046,18 @@ export type ProviderDeferSyntheticProfileAuthContext = { resolvedApiKey?: string; }; +export type ProviderSystemPromptContributionContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + promptMode: PromptMode; + runtimeChannel?: string; + runtimeCapabilities?: string[]; + agentId?: string; +}; + /** Text-inference provider capability registered by a plugin. */ export type ProviderPlugin = { id: string; @@ -1401,6 +1415,15 @@ export type ProviderPlugin = { resolveDefaultThinkingLevel?: ( ctx: ProviderDefaultThinkingPolicyContext, ) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined; + /** + * Provider-owned system-prompt contribution. + * + * Use this when a provider/model family needs cache-aware prompt tuning + * without replacing the full OpenClaw-owned system prompt. + */ + resolveSystemPromptContribution?: ( + ctx: ProviderSystemPromptContributionContext, + ) => ProviderSystemPromptContribution | null | undefined; /** * Provider-owned global config defaults. *