feat(agents): add provider-owned system prompt contributions

This commit is contained in:
Peter Steinberger
2026-04-05 14:05:35 +01:00
parent 1a7c2a9bc8
commit 760c4be438
15 changed files with 413 additions and 59 deletions

View File

@@ -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,
}),
);

View File

@@ -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",

View File

@@ -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.");
});
});

View File

@@ -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,
});
}

View File

@@ -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<Record<ProviderSystemPromptSectionId, string>>;
};

View File

@@ -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",

View File

@@ -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<Record<ProviderSystemPromptSectionId, string>>;
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) {

View File

@@ -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;

View File

@@ -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.
*