mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
refactor(agents): own system prompt assembly
This commit is contained in:
@@ -562,20 +562,6 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b
|
||||
first compaction summary exists. Auth profile or credential-epoch changes
|
||||
still never raw-reseed.
|
||||
|
||||
### `agents.defaults.systemPromptOverride`
|
||||
|
||||
Replace the entire OpenClaw-assembled system prompt with a fixed string. Set at the default level (`agents.defaults.systemPromptOverride`) or per agent (`agents.list[].systemPromptOverride`). Per-agent values take precedence; an empty or whitespace-only value is ignored. Useful for controlled prompt experiments.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
systemPromptOverride: "You are a helpful assistant.",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.promptOverlays`
|
||||
|
||||
Provider-independent prompt overlays applied by model family on OpenClaw-assembled prompt surfaces. GPT-5-family model ids receive the shared behavior contract across OpenClaw/provider routes; `personality` controls only the friendly interaction-style layer. Native Codex app-server routes keep Codex-owned base/model instructions instead of this OpenClaw GPT-5 overlay, and OpenClaw disables Codex's built-in personality for native threads.
|
||||
|
||||
@@ -230,7 +230,7 @@ Current runtime behaviour:
|
||||
- Provider-owned raw config lives under `realtime.providers.<providerId>`.
|
||||
- Voice Call exposes the shared `openclaw_agent_consult` realtime tool by default. The realtime model can call it when the caller asks for deeper reasoning, current information, or normal OpenClaw tools.
|
||||
- `realtime.consultPolicy` optionally adds guidance for when the realtime model should call `openclaw_agent_consult`.
|
||||
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity, system prompt override, and selected workspace-file capsule into the realtime provider instructions at session setup.
|
||||
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity and selected workspace-file capsule into the realtime provider instructions at session setup.
|
||||
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns those snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true.
|
||||
- If `realtime.provider` points at an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the whole plugin.
|
||||
- Consult session keys reuse the stored call session when available, then fall back to the configured `sessionScope` (`per-phone` by default, or `per-call` for isolated calls).
|
||||
@@ -278,7 +278,6 @@ for tool work, current information, memory lookups, or workspace state.
|
||||
enabled: true,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
},
|
||||
|
||||
@@ -271,8 +271,8 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
});
|
||||
|
||||
it("passes system prompt on every turn (issue #80374 — systemPromptWhen must be 'always')", () => {
|
||||
// Before fix this was hardcoded to "first", which silently dropped
|
||||
// systemPromptOverride on every resumed / compacted claude-cli session.
|
||||
// Before fix this was hardcoded to "first", which silently dropped updated
|
||||
// OpenClaw system prompt context on resumed / compacted claude-cli sessions.
|
||||
const backend = buildAnthropicCliBackend();
|
||||
expect(backend.config.systemPromptWhen).toBe("always");
|
||||
});
|
||||
|
||||
@@ -5505,7 +5505,7 @@ describe("DiscordVoiceManager", () => {
|
||||
await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("passes per-channel system prompt overrides to voice agent runs", async () => {
|
||||
it("passes per-channel system prompt context to voice agent runs", async () => {
|
||||
const client = createClient();
|
||||
client.fetchMember.mockResolvedValue({
|
||||
nickname: "Guest Nick",
|
||||
|
||||
@@ -125,7 +125,7 @@ const voiceCallConfigSchema = {
|
||||
},
|
||||
"realtime.agentContext.enabled": {
|
||||
label: "Enable Agent Voice Context",
|
||||
help: "Injects a compact agent identity, system prompt, and workspace context capsule into realtime voice instructions.",
|
||||
help: "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
|
||||
advanced: true,
|
||||
},
|
||||
"realtime.agentContext.maxChars": {
|
||||
@@ -136,10 +136,6 @@ const voiceCallConfigSchema = {
|
||||
label: "Include Agent Identity",
|
||||
advanced: true,
|
||||
},
|
||||
"realtime.agentContext.includeSystemPrompt": {
|
||||
label: "Include Agent System Prompt",
|
||||
advanced: true,
|
||||
},
|
||||
"realtime.agentContext.includeWorkspaceFiles": {
|
||||
label: "Include Agent Workspace Files",
|
||||
advanced: true,
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"realtime.agentContext.enabled": {
|
||||
"label": "Enable Agent Voice Context",
|
||||
"help": "Injects a compact agent identity, system prompt, and workspace context capsule into realtime voice instructions.",
|
||||
"help": "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
|
||||
"advanced": true
|
||||
},
|
||||
"realtime.agentContext.maxChars": {
|
||||
@@ -199,10 +199,6 @@
|
||||
"label": "Include Agent Identity",
|
||||
"advanced": true
|
||||
},
|
||||
"realtime.agentContext.includeSystemPrompt": {
|
||||
"label": "Include Agent System Prompt",
|
||||
"advanced": true
|
||||
},
|
||||
"realtime.agentContext.includeWorkspaceFiles": {
|
||||
"label": "Include Agent Workspace Files",
|
||||
"advanced": true
|
||||
@@ -617,9 +613,6 @@
|
||||
"includeIdentity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"includeSystemPrompt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"includeWorkspaceFiles": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -62,6 +62,35 @@ describe("voice-call config compatibility", () => {
|
||||
expect(streaming?.sttModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes legacy realtime agentContext system prompt toggle", () => {
|
||||
const normalized = normalizeVoiceCallLegacyConfigInput({
|
||||
realtime: {
|
||||
agentContext: {
|
||||
enabled: true,
|
||||
includeSystemPrompt: false,
|
||||
includeWorkspaceFiles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const agentContext = (
|
||||
normalized.realtime as
|
||||
| {
|
||||
agentContext?: {
|
||||
enabled?: boolean;
|
||||
includeSystemPrompt?: unknown;
|
||||
includeWorkspaceFiles?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.agentContext;
|
||||
|
||||
expect(agentContext).toEqual({
|
||||
enabled: true,
|
||||
includeWorkspaceFiles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate non-finite legacy streaming numbers", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
@@ -104,6 +133,11 @@ describe("voice-call config compatibility", () => {
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "sk-test", // pragma: allowlist secret
|
||||
},
|
||||
realtime: {
|
||||
agentContext: {
|
||||
includeSystemPrompt: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectVoiceCallLegacyConfigIssues(raw)).toEqual([
|
||||
@@ -127,6 +161,12 @@ describe("voice-call config compatibility", () => {
|
||||
replacement: "streaming.providers.openai.apiKey",
|
||||
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
|
||||
},
|
||||
{
|
||||
path: "realtime.agentContext.includeSystemPrompt",
|
||||
replacement: "realtime.agentContext",
|
||||
message:
|
||||
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
formatVoiceCallLegacyConfigWarnings({
|
||||
@@ -140,6 +180,7 @@ describe("voice-call config compatibility", () => {
|
||||
"[voice-call] plugins.entries.voice-call.config.twilio.from: Move twilio.from to fromNumber.",
|
||||
"[voice-call] plugins.entries.voice-call.config.streaming.sttProvider: Move streaming.sttProvider to streaming.provider.",
|
||||
"[voice-call] plugins.entries.voice-call.config.streaming.openaiApiKey: Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
|
||||
"[voice-call] plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt: Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -150,6 +191,11 @@ describe("voice-call config compatibility", () => {
|
||||
streaming: {
|
||||
sttProvider: "openai",
|
||||
},
|
||||
realtime: {
|
||||
agentContext: {
|
||||
includeSystemPrompt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
});
|
||||
@@ -157,6 +203,7 @@ describe("voice-call config compatibility", () => {
|
||||
expect(migration.changes).toEqual([
|
||||
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
|
||||
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
|
||||
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ function mergeProviderConfig(
|
||||
|
||||
export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLegacyConfigIssue[] {
|
||||
const raw = asObject(value) ?? {};
|
||||
const realtime = asObject(raw.realtime);
|
||||
const realtimeAgentContext = asObject(realtime?.agentContext);
|
||||
const twilio = asObject(raw.twilio);
|
||||
const streaming = asObject(raw.streaming);
|
||||
|
||||
@@ -93,6 +95,17 @@ export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLeg
|
||||
message: "Move streaming.vadThreshold to streaming.providers.openai.vadThreshold.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
realtimeAgentContext &&
|
||||
Object.prototype.hasOwnProperty.call(realtimeAgentContext, "includeSystemPrompt")
|
||||
) {
|
||||
issues.push({
|
||||
path: "realtime.agentContext.includeSystemPrompt",
|
||||
replacement: "realtime.agentContext",
|
||||
message:
|
||||
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -124,6 +137,8 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
issues: VoiceCallLegacyConfigIssue[];
|
||||
} {
|
||||
const raw = asObject(params.value) ?? {};
|
||||
const realtime = asObject(raw.realtime);
|
||||
const realtimeAgentContext = asObject(realtime?.agentContext);
|
||||
const twilio = asObject(raw.twilio);
|
||||
const streaming = asObject(raw.streaming);
|
||||
const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config";
|
||||
@@ -174,12 +189,29 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
delete normalizedTwilio.from;
|
||||
}
|
||||
|
||||
const normalizedRealtimeAgentContext = realtimeAgentContext
|
||||
? {
|
||||
...realtimeAgentContext,
|
||||
}
|
||||
: undefined;
|
||||
if (normalizedRealtimeAgentContext) {
|
||||
delete normalizedRealtimeAgentContext.includeSystemPrompt;
|
||||
}
|
||||
|
||||
const normalizedRealtime = realtime
|
||||
? {
|
||||
...realtime,
|
||||
agentContext: normalizedRealtimeAgentContext ?? realtime.agentContext,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const config = {
|
||||
...raw,
|
||||
provider: raw.provider === "log" ? "mock" : raw.provider,
|
||||
fromNumber: raw.fromNumber ?? (typeof twilio?.from === "string" ? twilio.from : undefined),
|
||||
twilio: normalizedTwilio,
|
||||
streaming: normalizedStreaming,
|
||||
realtime: normalizedRealtime,
|
||||
};
|
||||
|
||||
const changes: string[] = [];
|
||||
@@ -218,6 +250,12 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
} else if (typeof streaming?.vadThreshold === "number") {
|
||||
changes.push(`Removed invalid ${configPathPrefix}.streaming.vadThreshold.`);
|
||||
}
|
||||
if (
|
||||
realtimeAgentContext &&
|
||||
Object.prototype.hasOwnProperty.call(realtimeAgentContext, "includeSystemPrompt")
|
||||
) {
|
||||
changes.push(`Removed ${configPathPrefix}.realtime.agentContext.includeSystemPrompt.`);
|
||||
}
|
||||
|
||||
return { config, changes, issues };
|
||||
}
|
||||
|
||||
@@ -427,7 +427,6 @@ describe("normalizeVoiceCallConfig", () => {
|
||||
enabled: false,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
});
|
||||
|
||||
@@ -267,8 +267,6 @@ const VoiceCallRealtimeAgentContextConfigSchema = z
|
||||
maxChars: z.number().int().positive().default(6000),
|
||||
/** Include configured agent identity fields. */
|
||||
includeIdentity: z.boolean().default(true),
|
||||
/** Include agents.defaults/list systemPromptOverride when configured. */
|
||||
includeSystemPrompt: z.boolean().default(true),
|
||||
/** Include selected workspace files such as SOUL.md and IDENTITY.md. */
|
||||
includeWorkspaceFiles: z.boolean().default(true),
|
||||
/** Workspace-relative files to include, bounded by maxChars. */
|
||||
@@ -279,7 +277,6 @@ const VoiceCallRealtimeAgentContextConfigSchema = z
|
||||
enabled: false,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
});
|
||||
@@ -350,7 +347,6 @@ const VoiceCallRealtimeConfigSchema = z
|
||||
enabled: false,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
},
|
||||
|
||||
@@ -57,17 +57,13 @@ function createAgentRuntime(workspaceDir: string): CoreAgentDeps {
|
||||
}
|
||||
|
||||
describe("buildRealtimeVoiceInstructions", () => {
|
||||
it("injects bounded identity, system prompt, and workspace context", async () => {
|
||||
it("injects bounded identity and workspace context", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
await writeFile(path.join(workspaceDir, "SOUL.md"), "Stay quick, direct, and warm.\n");
|
||||
await writeFile(path.join(workspaceDir, "IDENTITY.md"), "Name: Claw Voice\nVibe: snappy\n");
|
||||
await writeFile(path.join(workspaceDir, "SECRET.md"), "do not include\n");
|
||||
|
||||
const coreConfig = {
|
||||
agents: {
|
||||
list: [{ id: "voice", systemPromptOverride: "Keep spoken answers short." }],
|
||||
},
|
||||
} as CoreConfig;
|
||||
const coreConfig = { agents: { list: [{ id: "voice" }] } } as CoreConfig;
|
||||
|
||||
const instructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: "Base voice instructions.",
|
||||
@@ -77,7 +73,6 @@ describe("buildRealtimeVoiceInstructions", () => {
|
||||
enabled: true,
|
||||
maxChars: 2000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "../SECRET.md"],
|
||||
},
|
||||
@@ -92,7 +87,6 @@ describe("buildRealtimeVoiceInstructions", () => {
|
||||
expect(instructions).toContain("- Agent id: voice");
|
||||
expect(instructions).toContain("- Name: Claw Voice");
|
||||
expect(instructions).toContain("- Vibe: snappy");
|
||||
expect(instructions).toContain("Keep spoken answers short.");
|
||||
expect(instructions).toContain("### SOUL.md");
|
||||
expect(instructions).toContain("Stay quick, direct, and warm.");
|
||||
expect(instructions).toContain("### IDENTITY.md");
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
|
||||
type AgentEntryLike = {
|
||||
id?: unknown;
|
||||
systemPromptOverride?: unknown;
|
||||
};
|
||||
|
||||
type VoiceIdentityLike = {
|
||||
@@ -27,18 +26,6 @@ function readAgentEntries(cfg: CoreConfig): AgentEntryLike[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function resolveAgentSystemPromptOverride(cfg: CoreConfig, agentId: string): string | undefined {
|
||||
const entries = readAgentEntries(cfg);
|
||||
const entry = entries.find((candidate) => normalizeString(candidate.id) === agentId);
|
||||
return (
|
||||
normalizeString(entry?.systemPromptOverride) ??
|
||||
normalizeString(
|
||||
(cfg as { agents?: { defaults?: { systemPromptOverride?: unknown } } }).agents?.defaults
|
||||
?.systemPromptOverride,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function limitText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
@@ -119,13 +106,6 @@ export async function buildRealtimeVoiceInstructions(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (contextConfig.includeSystemPrompt) {
|
||||
const systemPrompt = resolveAgentSystemPromptOverride(params.coreConfig, agentId);
|
||||
if (systemPrompt) {
|
||||
capsule.push(`Configured system prompt override:\n${systemPrompt}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (contextConfig.includeWorkspaceFiles) {
|
||||
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(
|
||||
params.coreConfig as OpenClawConfig,
|
||||
|
||||
@@ -64,7 +64,6 @@ export function createVoiceCallBaseConfig(params?: {
|
||||
enabled: false,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
},
|
||||
|
||||
@@ -76,7 +76,6 @@ function makeHandler(
|
||||
enabled: false,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
||||
// Live prompt probe for Anthropic setup-token and Claude CLI prompt-path debugging.
|
||||
// Usage:
|
||||
// OPENCLAW_PROMPT_TRANSPORT=direct|gateway
|
||||
// OPENCLAW_PROMPT_MODE=extra|override
|
||||
// OPENCLAW_PROMPT_MODE=extra
|
||||
// OPENCLAW_PROMPT_TEXT='...'
|
||||
// OPENCLAW_PROMPT_CAPTURE=1
|
||||
// pnpm probe:anthropic:prompt
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
} from "./lib/dev-tooling-safety.ts";
|
||||
|
||||
const TRANSPORT = process.env.OPENCLAW_PROMPT_TRANSPORT?.trim() === "direct" ? "direct" : "gateway";
|
||||
const GATEWAY_PROMPT_MODE =
|
||||
process.env.OPENCLAW_PROMPT_MODE?.trim() === "override" ? "override" : "extra";
|
||||
const GATEWAY_PROMPT_MODE = "extra";
|
||||
const PROMPT_TEXT = process.env.OPENCLAW_PROMPT_TEXT?.trim() ?? "";
|
||||
const PROMPT_LIST_JSON = process.env.OPENCLAW_PROMPT_LIST_JSON?.trim() ?? "";
|
||||
const USER_PROMPT = process.env.OPENCLAW_USER_PROMPT?.trim() || "is clawd here?";
|
||||
@@ -79,7 +78,7 @@ type PromptResult = {
|
||||
prompt: string;
|
||||
ok: boolean;
|
||||
transport: "direct" | "gateway";
|
||||
promptMode?: "extra" | "override";
|
||||
promptMode?: "extra";
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
status?: string;
|
||||
@@ -578,7 +577,6 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
|
||||
heartbeat: {
|
||||
includeSystemPromptSection: false,
|
||||
},
|
||||
...(GATEWAY_PROMPT_MODE === "override" ? { systemPromptOverride: prompt } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ export type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
systemPromptOverride?: AgentEntry["systemPromptOverride"];
|
||||
model?: AgentEntry["model"];
|
||||
thinkingDefault?: AgentEntry["thinkingDefault"];
|
||||
verboseDefault?: AgentDefaultsConfig["verboseDefault"];
|
||||
@@ -117,7 +116,6 @@ export function resolveAgentConfig(
|
||||
name: readStringValue(entry.name),
|
||||
workspace: readStringValue(entry.workspace),
|
||||
agentDir: readStringValue(entry.agentDir),
|
||||
systemPromptOverride: readStringValue(entry.systemPromptOverride),
|
||||
model:
|
||||
typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
|
||||
? entry.model
|
||||
|
||||
@@ -115,7 +115,6 @@ async function createTestMcpLoopbackServer(port = 0) {
|
||||
|
||||
function createCliBackendConfig(
|
||||
params: {
|
||||
systemPromptOverride?: string | null;
|
||||
bundleMcp?: boolean;
|
||||
reseedFromRawTranscriptWhenUncompacted?: boolean;
|
||||
} = {},
|
||||
@@ -123,9 +122,6 @@ function createCliBackendConfig(
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
...(params.systemPromptOverride !== null
|
||||
? { systemPromptOverride: params.systemPromptOverride ?? "test system prompt" }
|
||||
: {}),
|
||||
cliBackends: {
|
||||
"test-cli": {
|
||||
command: "test-cli",
|
||||
@@ -560,7 +556,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-legacy-merge",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("prompt prepend\n\nlegacy prepend\n\nlatest ask");
|
||||
@@ -595,13 +591,14 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-hook-failure",
|
||||
config: createCliBackendConfig({ systemPromptOverride: "base extra system" }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("latest ask");
|
||||
expect(context.systemPrompt).toBe(
|
||||
"base extra system\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.",
|
||||
expect(context.systemPrompt).toContain(
|
||||
"You are a personal assistant running inside OpenClaw.",
|
||||
);
|
||||
expect(context.systemPrompt).toContain("Current model identity: test-cli/test-model.");
|
||||
expect(context.systemPrompt).not.toContain("hook exploded");
|
||||
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
@@ -840,7 +837,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
sessionId: "cli-session",
|
||||
cwdHash: hashCliSessionText(dir),
|
||||
},
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.systemPrompt).toContain("## Inbound Context\nchannel=telegram");
|
||||
@@ -865,7 +862,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-cwd-prompt",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.cwd).toBe(taskDir);
|
||||
@@ -937,7 +934,6 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
|
||||
},
|
||||
config: createCliBackendConfig({
|
||||
systemPromptOverride: null,
|
||||
reseedFromRawTranscriptWhenUncompacted: true,
|
||||
}),
|
||||
});
|
||||
@@ -978,7 +974,6 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
cwdHash: hashCliSessionText(dir),
|
||||
},
|
||||
config: createCliBackendConfig({
|
||||
systemPromptOverride: null,
|
||||
reseedFromRawTranscriptWhenUncompacted: true,
|
||||
}),
|
||||
});
|
||||
@@ -1141,7 +1136,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-loopback-prompt-tools",
|
||||
config: createCliBackendConfig({ bundleMcp: true, systemPromptOverride: null }),
|
||||
config: createCliBackendConfig({ bundleMcp: true }),
|
||||
cliSessionBinding: {
|
||||
sessionId: "cli-session",
|
||||
promptToolNamesHash: "old-tool-surface",
|
||||
@@ -1231,7 +1226,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-loopback-prompt-tools-fallback",
|
||||
config: createCliBackendConfig({ bundleMcp: true, systemPromptOverride: null }),
|
||||
config: createCliBackendConfig({ bundleMcp: true }),
|
||||
});
|
||||
|
||||
expect(ensureMcpLoopbackServer).toHaveBeenCalledTimes(1);
|
||||
@@ -1435,7 +1430,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
runId: "run-77011-missing",
|
||||
cliSessionBinding: { sessionId: "stale-claude-sid" },
|
||||
cliSessionId: "stale-claude-sid",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "stale-claude-sid" });
|
||||
@@ -1483,7 +1478,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
runId: "run-77011-present",
|
||||
cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(dir) },
|
||||
cliSessionId: "live-claude-sid",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "live-claude-sid" });
|
||||
@@ -1546,7 +1541,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "opus",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-claude-plugin-skills-prompt",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
skillsSnapshot: {
|
||||
prompt: [
|
||||
"<available_skills>",
|
||||
@@ -1623,7 +1618,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "opus",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-claude-plugin-skills-prompt-fallback",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
skillsSnapshot: {
|
||||
prompt: [
|
||||
"<available_skills>",
|
||||
@@ -1716,7 +1711,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "opus",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-claude-plugin-skills-prompt-materialization-fallback",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
skillsSnapshot: {
|
||||
prompt: [
|
||||
"<available_skills>",
|
||||
@@ -1775,7 +1770,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-77011-other-provider",
|
||||
cliSessionBinding: { sessionId: "test-cli-sid", cwdHash: hashCliSessionText(dir) },
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(transcriptCheck).not.toHaveBeenCalled();
|
||||
@@ -1826,7 +1821,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "claude-haiku-3-5",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-auto-claude-reseed-history-chars",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.openClawHistoryPrompt).toBeDefined();
|
||||
@@ -1881,7 +1876,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "claude-opus-4-7",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-auto-claude-alias-reseed-history-chars",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.openClawHistoryPrompt).toBeDefined();
|
||||
@@ -1915,7 +1910,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-default-reseed-history-chars",
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.openClawHistoryPrompt).toBeDefined();
|
||||
@@ -1990,7 +1985,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-raw-reseed-cap-override",
|
||||
cliSessionBinding: { sessionId: "cli-session", cwdHash: hashCliSessionText(dir) },
|
||||
config: createCliBackendConfig({ systemPromptOverride: null }),
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
|
||||
|
||||
@@ -55,7 +55,6 @@ import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
|
||||
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
|
||||
import { resolveSkillsPromptForRun } from "../skills.js";
|
||||
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
|
||||
import { buildSystemPromptReport } from "../system-prompt-report.js";
|
||||
import { appendModelIdentitySystemPrompt } from "../system-prompt.js";
|
||||
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
|
||||
@@ -426,29 +425,24 @@ export async function prepareCliRunContext(
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const systemPromptSkillsPrompt = claudeSkillsPlugin.args.length > 0 ? "" : skillsPrompt;
|
||||
const builtSystemPrompt =
|
||||
resolveSystemPromptOverride({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
}) ??
|
||||
buildCliAgentSystemPrompt({
|
||||
workspaceDir,
|
||||
cwd,
|
||||
config: params.config,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
silentReplyPromptMode: params.silentReplyPromptMode,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
heartbeatPrompt,
|
||||
docsPath: openClawReferences.docsPath ?? undefined,
|
||||
sourcePath: openClawReferences.sourcePath ?? undefined,
|
||||
skillsPrompt: systemPromptSkillsPrompt,
|
||||
tools: promptTools,
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const builtSystemPrompt = buildCliAgentSystemPrompt({
|
||||
workspaceDir,
|
||||
cwd,
|
||||
config: params.config,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
silentReplyPromptMode: params.silentReplyPromptMode,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
heartbeatPrompt,
|
||||
docsPath: openClawReferences.docsPath ?? undefined,
|
||||
sourcePath: openClawReferences.sourcePath ?? undefined,
|
||||
skillsPrompt: systemPromptSkillsPrompt,
|
||||
tools: promptTools,
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const transformedSystemPrompt =
|
||||
backendResolved.transformSystemPrompt?.({
|
||||
config: params.config,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSystemPromptOverride } from "./embedded-agent-runner.js";
|
||||
|
||||
describe("createSystemPromptOverride", () => {
|
||||
it("returns the override prompt trimmed", () => {
|
||||
const override = createSystemPromptOverride("OVERRIDE");
|
||||
expect(override()).toBe("OVERRIDE");
|
||||
});
|
||||
|
||||
it("returns an empty string for blank overrides", () => {
|
||||
const override = createSystemPromptOverride(" \n ");
|
||||
expect(override()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ export {
|
||||
waitForEmbeddedAgentRunEnd,
|
||||
} from "./embedded-agent-runner/runs.js";
|
||||
export { buildEmbeddedSandboxInfo } from "./embedded-agent-runner/sandbox-info.js";
|
||||
export { createSystemPromptOverride } from "./embedded-agent-runner/system-prompt.js";
|
||||
export { splitSdkTools } from "./embedded-agent-runner/tool-split.js";
|
||||
export type {
|
||||
EmbeddedAgentMeta,
|
||||
|
||||
@@ -854,9 +854,8 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
}));
|
||||
|
||||
vi.doMock("./system-prompt.js", () => ({
|
||||
applySystemPromptOverrideToSession: vi.fn(),
|
||||
applySystemPromptToSession: vi.fn(),
|
||||
buildEmbeddedSystemPrompt: buildEmbeddedSystemPromptMock,
|
||||
createSystemPromptOverride: vi.fn(() => () => ""),
|
||||
}));
|
||||
|
||||
vi.doMock("./utils.js", async () => {
|
||||
|
||||
@@ -106,7 +106,6 @@ import {
|
||||
applySkillEnvOverridesFromSnapshot,
|
||||
resolveSkillsPromptForRun,
|
||||
} from "../skills.js";
|
||||
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
|
||||
import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js";
|
||||
import { logRuntimeToolSchemaQuarantine } from "../tool-schema-quarantine.js";
|
||||
import {
|
||||
@@ -156,11 +155,7 @@ import {
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "./stream-resolution.js";
|
||||
import {
|
||||
applySystemPromptOverrideToSession,
|
||||
buildEmbeddedSystemPrompt,
|
||||
createSystemPromptOverride,
|
||||
} from "./system-prompt.js";
|
||||
import { applySystemPromptToSession, buildEmbeddedSystemPrompt } from "./system-prompt.js";
|
||||
import {
|
||||
collectAllowedToolNames,
|
||||
collectRegisteredToolNames,
|
||||
@@ -960,67 +955,60 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
};
|
||||
const promptContribution =
|
||||
runtimePlan.prompt.resolveSystemPromptContribution(promptContributionContext);
|
||||
const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => {
|
||||
const builtSystemPrompt =
|
||||
resolveSystemPromptOverride({
|
||||
const buildSystemPromptText = (defaultThinkLevel: ThinkLevel) => {
|
||||
const builtSystemPrompt = buildEmbeddedSystemPrompt({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel,
|
||||
reasoningLevel: params.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
}) ??
|
||||
buildEmbeddedSystemPrompt({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel,
|
||||
reasoningLevel: params.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
defaultAgentId,
|
||||
}),
|
||||
skillsPrompt,
|
||||
docsPath: openClawReferences.docsPath ?? undefined,
|
||||
sourcePath: openClawReferences.sourcePath ?? undefined,
|
||||
promptMode,
|
||||
promptSurface,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: params.config,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
}),
|
||||
runtimeInfo,
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools: effectiveTools,
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
promptContribution,
|
||||
nativeCommandGuidanceLines,
|
||||
});
|
||||
return createSystemPromptOverride(
|
||||
transformProviderSystemPrompt({
|
||||
provider,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
context: {
|
||||
config: params.config,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
provider,
|
||||
modelId,
|
||||
promptMode,
|
||||
runtimeChannel,
|
||||
runtimeCapabilities,
|
||||
agentId: sessionAgentId,
|
||||
systemPrompt: builtSystemPrompt,
|
||||
},
|
||||
defaultAgentId,
|
||||
}),
|
||||
);
|
||||
skillsPrompt,
|
||||
docsPath: openClawReferences.docsPath ?? undefined,
|
||||
sourcePath: openClawReferences.sourcePath ?? undefined,
|
||||
promptMode,
|
||||
promptSurface,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
acpEnabled: isAcpRuntimeSpawnAvailable({
|
||||
config: params.config,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
}),
|
||||
runtimeInfo,
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools: effectiveTools,
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
promptContribution,
|
||||
nativeCommandGuidanceLines,
|
||||
});
|
||||
return transformProviderSystemPrompt({
|
||||
provider,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
context: {
|
||||
config: params.config,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
provider,
|
||||
modelId,
|
||||
promptMode,
|
||||
runtimeChannel,
|
||||
runtimeCapabilities,
|
||||
agentId: sessionAgentId,
|
||||
systemPrompt: builtSystemPrompt,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
|
||||
@@ -1152,7 +1140,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
resourceLoader,
|
||||
});
|
||||
session = createdSession.session;
|
||||
applySystemPromptOverrideToSession(session, buildSystemPromptOverride(thinkLevel)());
|
||||
applySystemPromptToSession(session, buildSystemPromptText(thinkLevel));
|
||||
session.setActiveToolsByName(sessionToolAllowlist);
|
||||
// Compaction builds the same embedded system prompt, so it must flow
|
||||
// through the same transport/payload shaping stack as normal turns.
|
||||
|
||||
@@ -23,7 +23,7 @@ const transformProviderSystemPrompt: Parameters<
|
||||
>[0]["transformProviderSystemPrompt"] = ({ context }) => context.systemPrompt;
|
||||
|
||||
describe("buildAttemptSystemPrompt", () => {
|
||||
it("injects workspace identity context without a system prompt override", () => {
|
||||
it("injects workspace identity context", () => {
|
||||
const result = buildAttemptSystemPrompt({
|
||||
isRawModelRun: false,
|
||||
transformProviderSystemPrompt,
|
||||
@@ -58,10 +58,9 @@ describe("buildAttemptSystemPrompt", () => {
|
||||
expect(result.systemPrompt).toContain("USER_CONTEXT_MARKER");
|
||||
});
|
||||
|
||||
it("preserves bootstrap Project Context when a system prompt override is configured", () => {
|
||||
it("preserves bootstrap Project Context", () => {
|
||||
const result = buildAttemptSystemPrompt({
|
||||
isRawModelRun: false,
|
||||
systemPromptOverrideText: "Custom override prompt.",
|
||||
transformProviderSystemPrompt,
|
||||
embeddedSystemPrompt: {
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
@@ -83,31 +82,42 @@ describe("buildAttemptSystemPrompt", () => {
|
||||
path: "/tmp/openclaw/BOOTSTRAP.md",
|
||||
content: "Reply with BOOTSTRAP_OK.",
|
||||
},
|
||||
{
|
||||
path: "/tmp/openclaw/SOUL.md",
|
||||
content: "SOUL_CONTEXT_MARKER",
|
||||
},
|
||||
{
|
||||
path: "/tmp/openclaw/IDENTITY.md",
|
||||
content: "IDENTITY_CONTEXT_MARKER",
|
||||
},
|
||||
{
|
||||
path: "/tmp/openclaw/USER.md",
|
||||
content: "User profile should stay in normal prompt context only.",
|
||||
content: "USER_CONTEXT_MARKER",
|
||||
},
|
||||
],
|
||||
},
|
||||
providerTransform: baseProviderTransform,
|
||||
});
|
||||
|
||||
expect(result.systemPrompt).toContain("Custom override prompt.");
|
||||
expect(result.systemPrompt).toContain("Current model identity: openai/gpt-5.5.");
|
||||
expect(result.systemPrompt).toContain("## Bootstrap Pending");
|
||||
expect(result.systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context");
|
||||
expect(result.systemPrompt).toContain("## Bootstrap Context Notice");
|
||||
expect(result.systemPrompt).toContain("Bootstrap context was truncated.");
|
||||
expect(result.systemPrompt).toContain("# Project Context");
|
||||
expect(result.systemPrompt).toContain("## /tmp/openclaw/SOUL.md");
|
||||
expect(result.systemPrompt).toContain("SOUL_CONTEXT_MARKER");
|
||||
expect(result.systemPrompt).toContain("## /tmp/openclaw/IDENTITY.md");
|
||||
expect(result.systemPrompt).toContain("IDENTITY_CONTEXT_MARKER");
|
||||
expect(result.systemPrompt).toContain("## /tmp/openclaw/USER.md");
|
||||
expect(result.systemPrompt).toContain("USER_CONTEXT_MARKER");
|
||||
expect(result.systemPrompt).toContain("## /tmp/openclaw/BOOTSTRAP.md");
|
||||
expect(result.systemPrompt).toContain("Reply with BOOTSTRAP_OK.");
|
||||
expect(result.systemPrompt).not.toContain("USER.md");
|
||||
});
|
||||
|
||||
it("preserves runtime extra system prompt context when a system prompt override is configured", () => {
|
||||
it("preserves runtime extra system prompt context", () => {
|
||||
const result = buildAttemptSystemPrompt({
|
||||
isRawModelRun: false,
|
||||
systemPromptOverrideText: "Custom override prompt.",
|
||||
transformProviderSystemPrompt,
|
||||
embeddedSystemPrompt: {
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
@@ -131,7 +141,6 @@ describe("buildAttemptSystemPrompt", () => {
|
||||
providerTransform: baseProviderTransform,
|
||||
});
|
||||
|
||||
expect(result.systemPrompt).toContain("Custom override prompt.");
|
||||
expect(result.systemPrompt).toContain("Current model identity: openai/gpt-5.5.");
|
||||
expect(result.systemPrompt).toContain("## Subagent Context");
|
||||
expect(result.systemPrompt).toContain("RUN_MODE_TASK_77950");
|
||||
@@ -167,6 +176,5 @@ describe("buildAttemptSystemPrompt", () => {
|
||||
|
||||
expect(result.baseSystemPrompt).toContain("BOOTSTRAP.md is included below in Project Context");
|
||||
expect(result.systemPrompt).toBe("");
|
||||
expect(result.systemPromptOverride()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { ProviderTransformSystemPromptContext } from "../../../plugins/types.js";
|
||||
import {
|
||||
appendAgentBootstrapSystemPromptSupplement,
|
||||
appendModelIdentitySystemPrompt,
|
||||
} from "../../system-prompt.js";
|
||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||
import { buildEmbeddedSystemPrompt } from "../system-prompt.js";
|
||||
|
||||
type EmbeddedSystemPromptParams = Parameters<typeof buildEmbeddedSystemPrompt>[0];
|
||||
type ProviderSystemPromptTransform = (params: {
|
||||
@@ -16,7 +12,6 @@ type ProviderSystemPromptTransform = (params: {
|
||||
|
||||
export type BuildAttemptSystemPromptParams = {
|
||||
isRawModelRun: boolean;
|
||||
systemPromptOverrideText?: string;
|
||||
embeddedSystemPrompt: EmbeddedSystemPromptParams;
|
||||
transformProviderSystemPrompt: ProviderSystemPromptTransform;
|
||||
providerTransform: {
|
||||
@@ -30,42 +25,12 @@ export type BuildAttemptSystemPromptParams = {
|
||||
export type AttemptSystemPrompt = {
|
||||
baseSystemPrompt: string;
|
||||
systemPrompt: string;
|
||||
systemPromptOverride: (defaultPrompt?: string) => string;
|
||||
};
|
||||
|
||||
function appendRuntimeExtraSystemPrompt(params: {
|
||||
systemPrompt: string;
|
||||
extraSystemPrompt?: string;
|
||||
promptMode?: EmbeddedSystemPromptParams["promptMode"];
|
||||
}): string {
|
||||
const extraSystemPrompt = params.extraSystemPrompt?.trim();
|
||||
if (!extraSystemPrompt || params.promptMode === "none") {
|
||||
return params.systemPrompt;
|
||||
}
|
||||
const contextHeader =
|
||||
params.promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
|
||||
return `${params.systemPrompt.trimEnd()}\n\n${contextHeader}\n${extraSystemPrompt}\n`;
|
||||
}
|
||||
|
||||
export function buildAttemptSystemPrompt(
|
||||
params: BuildAttemptSystemPromptParams,
|
||||
): AttemptSystemPrompt {
|
||||
const baseSystemPrompt = params.systemPromptOverrideText
|
||||
? appendModelIdentitySystemPrompt({
|
||||
systemPrompt: appendRuntimeExtraSystemPrompt({
|
||||
systemPrompt: appendAgentBootstrapSystemPromptSupplement({
|
||||
systemPrompt: params.systemPromptOverrideText,
|
||||
bootstrapMode: params.embeddedSystemPrompt.bootstrapMode,
|
||||
bootstrapTruncationNotice: params.embeddedSystemPrompt.bootstrapTruncationNotice,
|
||||
contextFiles: params.embeddedSystemPrompt.contextFiles,
|
||||
}),
|
||||
extraSystemPrompt: params.embeddedSystemPrompt.extraSystemPrompt,
|
||||
promptMode: params.embeddedSystemPrompt.promptMode,
|
||||
}),
|
||||
model: params.embeddedSystemPrompt.runtimeInfo.model,
|
||||
})
|
||||
: buildEmbeddedSystemPrompt(params.embeddedSystemPrompt);
|
||||
|
||||
const baseSystemPrompt = buildEmbeddedSystemPrompt(params.embeddedSystemPrompt);
|
||||
const systemPrompt = params.isRawModelRun
|
||||
? ""
|
||||
: params.transformProviderSystemPrompt({
|
||||
@@ -81,6 +46,5 @@ export function buildAttemptSystemPrompt(
|
||||
return {
|
||||
baseSystemPrompt,
|
||||
systemPrompt,
|
||||
systemPromptOverride: createSystemPromptOverride(systemPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1027,7 +1027,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
expect(JSON.stringify(seen.messages)).not.toContain("bootstrapMaxChars");
|
||||
});
|
||||
|
||||
it("preserves bootstrap system context when system prompt override is configured", async () => {
|
||||
it("preserves bootstrap system context in the assembled system prompt", async () => {
|
||||
const seen: { prompt?: string; messages?: unknown[] } = {};
|
||||
hoisted.isWorkspaceBootstrapPendingMock.mockResolvedValueOnce(true);
|
||||
hoisted.createOpenClawCodingToolsMock.mockImplementationOnce(() => [
|
||||
@@ -1037,14 +1037,14 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
bootstrapFiles: [
|
||||
{
|
||||
name: "BOOTSTRAP.md",
|
||||
path: "/tmp/openclaw-override-workspace/BOOTSTRAP.md",
|
||||
path: "/tmp/openclaw-bootstrap-workspace/BOOTSTRAP.md",
|
||||
content: "Ask who I am.",
|
||||
missing: false,
|
||||
},
|
||||
],
|
||||
contextFiles: [
|
||||
{
|
||||
path: "/tmp/openclaw-override-workspace/BOOTSTRAP.md",
|
||||
path: "/tmp/openclaw-bootstrap-workspace/BOOTSTRAP.md",
|
||||
content: "Ask who I am.",
|
||||
},
|
||||
],
|
||||
@@ -1055,13 +1055,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
sessionKey,
|
||||
tempPaths,
|
||||
attemptOverrides: {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
systemPromptOverride: "Custom override prompt.",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
disableTools: false,
|
||||
prompt: "visible ask",
|
||||
transcriptPrompt: "visible ask",
|
||||
@@ -1079,15 +1072,18 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
|
||||
expect(seen.prompt).toBe("visible ask");
|
||||
expect(JSON.stringify(seen.messages)).not.toContain("Ask who I am.");
|
||||
const systemPrompt =
|
||||
hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ??
|
||||
"";
|
||||
const promptInput = hoisted.embeddedSystemPromptInputs.at(-1) as {
|
||||
bootstrapMode?: string;
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
};
|
||||
|
||||
expect(systemPrompt).toContain("Custom override prompt.");
|
||||
expect(systemPrompt).toContain("## Bootstrap Pending");
|
||||
expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context");
|
||||
expect(systemPrompt).toContain("## /tmp/openclaw-override-workspace/BOOTSTRAP.md");
|
||||
expect(systemPrompt).toContain("Ask who I am.");
|
||||
expect(promptInput.bootstrapMode).toBe("full");
|
||||
expect(promptInput.contextFiles).toEqual([
|
||||
{
|
||||
path: "/tmp/openclaw-bootstrap-workspace/BOOTSTRAP.md",
|
||||
content: "Ask who I am.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes hook-adjusted bootstrap files preloaded before routing", async () => {
|
||||
@@ -1106,13 +1102,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
sessionKey,
|
||||
tempPaths,
|
||||
attemptOverrides: {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
systemPromptOverride: "Custom override prompt.",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompt: "visible ask",
|
||||
transcriptPrompt: "visible ask",
|
||||
trigger: "user",
|
||||
@@ -1128,14 +1117,18 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
|
||||
expect(hoisted.resolveBootstrapFilesForRunMock).toHaveBeenCalledOnce();
|
||||
expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled();
|
||||
const systemPrompt =
|
||||
hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ??
|
||||
"";
|
||||
const promptInput = hoisted.embeddedSystemPromptInputs.at(-1) as {
|
||||
bootstrapMode?: string;
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
};
|
||||
|
||||
expect(systemPrompt).toContain("## Bootstrap Pending");
|
||||
expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context");
|
||||
expect(systemPrompt).toContain(`## ${workspaceDir}/BOOTSTRAP.md`);
|
||||
expect(systemPrompt).toContain("Ask who I am before continuing.");
|
||||
expect(promptInput.bootstrapMode).toBe("full");
|
||||
expect(promptInput.contextFiles).toEqual([
|
||||
{
|
||||
path: `${workspaceDir}/BOOTSTRAP.md`,
|
||||
content: "Ask who I am before continuing.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips bootstrap preload on completed continuation-skip turns", async () => {
|
||||
|
||||
@@ -90,7 +90,8 @@ type AttemptSpawnWorkspaceHoisted = {
|
||||
>;
|
||||
limitHistoryTurnsMock: Mock<<T>(messages: T, limit: number | undefined) => T>;
|
||||
preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][];
|
||||
systemPromptOverrideTexts: string[];
|
||||
systemPromptTexts: string[];
|
||||
embeddedSystemPromptInputs: unknown[];
|
||||
sessionManager: SessionManagerMocks;
|
||||
};
|
||||
|
||||
@@ -187,7 +188,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
(messages) => messages,
|
||||
);
|
||||
const preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][] = [];
|
||||
const systemPromptOverrideTexts: string[] = [];
|
||||
const systemPromptTexts: string[] = [];
|
||||
const embeddedSystemPromptInputs: unknown[] = [];
|
||||
const sessionManager = {
|
||||
getLeafEntry: vi.fn(() => null),
|
||||
branch: vi.fn(),
|
||||
@@ -228,7 +230,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
getHistoryLimitFromSessionKeyMock,
|
||||
limitHistoryTurnsMock,
|
||||
preemptiveCompactionCalls,
|
||||
systemPromptOverrideTexts,
|
||||
systemPromptTexts,
|
||||
embeddedSystemPromptInputs,
|
||||
sessionManager,
|
||||
};
|
||||
});
|
||||
@@ -519,13 +522,13 @@ vi.mock("../system-prompt.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../system-prompt.js")>("../system-prompt.js");
|
||||
return {
|
||||
...actual,
|
||||
applySystemPromptOverrideToSession: (session: MutableSession, systemPrompt: string) => {
|
||||
applySystemPromptToSession: (session: MutableSession, systemPrompt: string) => {
|
||||
hoisted.systemPromptTexts.push(systemPrompt);
|
||||
session.agent.state.systemPrompt = systemPrompt;
|
||||
},
|
||||
buildEmbeddedSystemPrompt: () => "system prompt",
|
||||
createSystemPromptOverride: (prompt: string) => {
|
||||
hoisted.systemPromptOverrideTexts.push(prompt);
|
||||
return () => prompt;
|
||||
buildEmbeddedSystemPrompt: (params: unknown) => {
|
||||
hoisted.embeddedSystemPromptInputs.push(params);
|
||||
return "system prompt";
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -985,7 +988,8 @@ export function resetEmbeddedAttemptHarness(
|
||||
hoisted.getHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);
|
||||
hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages);
|
||||
hoisted.preemptiveCompactionCalls.length = 0;
|
||||
hoisted.systemPromptOverrideTexts.length = 0;
|
||||
hoisted.systemPromptTexts.length = 0;
|
||||
hoisted.embeddedSystemPromptInputs.length = 0;
|
||||
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
|
||||
hoisted.sessionManager.branch.mockReset();
|
||||
hoisted.sessionManager.resetLeaf.mockReset();
|
||||
|
||||
@@ -170,7 +170,6 @@ import {
|
||||
isSubagentEnvelopeSession,
|
||||
resolveSubagentCapabilityStore,
|
||||
} from "../../subagent-capabilities.js";
|
||||
import { resolveSystemPromptOverride } from "../../system-prompt-override.js";
|
||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||
import { appendModelIdentitySystemPrompt } from "../../system-prompt.js";
|
||||
@@ -246,7 +245,7 @@ import {
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "../stream-resolution.js";
|
||||
import { applySystemPromptOverrideToSession } from "../system-prompt.js";
|
||||
import { applySystemPromptToSession } from "../system-prompt.js";
|
||||
import {
|
||||
dropReasoningFromHistory,
|
||||
dropThinkingBlocks,
|
||||
@@ -1674,13 +1673,8 @@ export async function runEmbeddedAttempt(
|
||||
const bootstrapTruncationNotice = buildBootstrapPromptWarningNotice(
|
||||
bootstrapPromptWarning.lines,
|
||||
);
|
||||
const systemPromptOverrideText = resolveSystemPromptOverride({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const attemptSystemPrompt = buildAttemptSystemPrompt({
|
||||
isRawModelRun,
|
||||
systemPromptOverrideText,
|
||||
transformProviderSystemPrompt,
|
||||
embeddedSystemPrompt: {
|
||||
config: params.config,
|
||||
@@ -1767,8 +1761,7 @@ export async function runEmbeddedAttempt(
|
||||
skillsPrompt,
|
||||
tools: effectiveTools,
|
||||
});
|
||||
const systemPromptOverride = attemptSystemPrompt.systemPromptOverride;
|
||||
let systemPromptText = systemPromptOverride();
|
||||
let systemPromptText = attemptSystemPrompt.systemPrompt;
|
||||
prepStages.mark("system-prompt");
|
||||
|
||||
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
|
||||
@@ -2103,7 +2096,7 @@ export async function runEmbeddedAttempt(
|
||||
},
|
||||
});
|
||||
session = createdSession.session;
|
||||
applySystemPromptOverrideToSession(session, systemPromptText);
|
||||
applySystemPromptToSession(session, systemPromptText);
|
||||
if (!session) {
|
||||
throw new Error("Embedded agent session missing");
|
||||
}
|
||||
@@ -2117,10 +2110,10 @@ export async function runEmbeddedAttempt(
|
||||
if (isRawModelRun) {
|
||||
// Raw model probes should measure exactly the requested prompt against
|
||||
// the selected provider/model. Reset clears restored transcript state
|
||||
// and queues; the empty system override prevents the runtime from rebuilding the
|
||||
// and queues; the empty system prompt prevents the runtime from rebuilding the
|
||||
// normal OpenClaw agent/tool prompt when `session.prompt()` starts.
|
||||
activeSession.agent.reset();
|
||||
applySystemPromptOverrideToSession(activeSession, "");
|
||||
applySystemPromptToSession(activeSession, "");
|
||||
systemPromptText = "";
|
||||
}
|
||||
if (typeof activeSession.agent.convertToLlm === "function") {
|
||||
@@ -2668,7 +2661,7 @@ export async function runEmbeddedAttempt(
|
||||
try {
|
||||
if (isRawModelRun) {
|
||||
activeSession.agent.reset();
|
||||
applySystemPromptOverrideToSession(activeSession, "");
|
||||
applySystemPromptToSession(activeSession, "");
|
||||
systemPromptText = "";
|
||||
cacheTrace?.recordStage("session:raw-model-run", {
|
||||
messages: activeSession.messages,
|
||||
@@ -2750,7 +2743,7 @@ export async function runEmbeddedAttempt(
|
||||
systemPrompt: systemPromptText,
|
||||
systemPromptAddition: activeSubagentPromptAddition,
|
||||
});
|
||||
applySystemPromptOverrideToSession(activeSession, systemPromptText);
|
||||
applySystemPromptToSession(activeSession, systemPromptText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2816,7 +2809,7 @@ export async function runEmbeddedAttempt(
|
||||
systemPrompt: systemPromptText,
|
||||
systemPromptAddition: assembled.systemPromptAddition,
|
||||
});
|
||||
applySystemPromptOverrideToSession(activeSession, systemPromptText);
|
||||
applySystemPromptToSession(activeSession, systemPromptText);
|
||||
log.debug(
|
||||
`context engine: prepended system prompt addition (${assembled.systemPromptAddition.length} chars)`,
|
||||
);
|
||||
@@ -3284,9 +3277,9 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
const legacySystemPrompt = normalizeOptionalString(hookResult?.systemPrompt) ?? "";
|
||||
if (legacySystemPrompt) {
|
||||
applySystemPromptOverrideToSession(activeSession, legacySystemPrompt);
|
||||
applySystemPromptToSession(activeSession, legacySystemPrompt);
|
||||
systemPromptText = legacySystemPrompt;
|
||||
log.debug(`hooks: applied systemPrompt override (${legacySystemPrompt.length} chars)`);
|
||||
log.debug(`hooks: applied systemPrompt (${legacySystemPrompt.length} chars)`);
|
||||
}
|
||||
const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({
|
||||
baseSystemPrompt: systemPromptText,
|
||||
@@ -3300,7 +3293,7 @@ export async function runEmbeddedAttempt(
|
||||
if (prependedOrAppendedSystemPrompt) {
|
||||
const prependSystemLen = hookResult?.prependSystemContext?.trim().length ?? 0;
|
||||
const appendSystemLen = hookResult?.appendSystemContext?.trim().length ?? 0;
|
||||
applySystemPromptOverrideToSession(activeSession, prependedOrAppendedSystemPrompt);
|
||||
applySystemPromptToSession(activeSession, prependedOrAppendedSystemPrompt);
|
||||
systemPromptText = prependedOrAppendedSystemPrompt;
|
||||
log.debug(
|
||||
`hooks: applied prependSystemContext/appendSystemContext (${prependSystemLen}+${appendSystemLen} chars)`,
|
||||
@@ -3312,7 +3305,7 @@ export async function runEmbeddedAttempt(
|
||||
model: runtimeInfo.model,
|
||||
});
|
||||
if (modelAwareSystemPrompt !== systemPromptText) {
|
||||
applySystemPromptOverrideToSession(activeSession, modelAwareSystemPrompt);
|
||||
applySystemPromptToSession(activeSession, modelAwareSystemPrompt);
|
||||
systemPromptText = modelAwareSystemPrompt;
|
||||
}
|
||||
|
||||
@@ -3488,7 +3481,7 @@ export async function runEmbeddedAttempt(
|
||||
appendSystemContext: runtimeSystemContext,
|
||||
});
|
||||
if (runtimeSystemPrompt) {
|
||||
applySystemPromptOverrideToSession(activeSession, runtimeSystemPrompt);
|
||||
applySystemPromptToSession(activeSession, runtimeSystemPrompt);
|
||||
systemPromptText = runtimeSystemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { AgentSession } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js";
|
||||
import {
|
||||
applySystemPromptOverrideToSession,
|
||||
buildEmbeddedSystemPrompt,
|
||||
createSystemPromptOverride,
|
||||
} from "./system-prompt.js";
|
||||
import { applySystemPromptToSession, buildEmbeddedSystemPrompt } from "./system-prompt.js";
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
@@ -33,18 +29,16 @@ function createMockSession(): {
|
||||
return { session };
|
||||
}
|
||||
|
||||
function applyAndGetMutableSession(
|
||||
prompt: Parameters<typeof applySystemPromptOverrideToSession>[1],
|
||||
) {
|
||||
function applyAndGetMutableSession(prompt: Parameters<typeof applySystemPromptToSession>[1]) {
|
||||
const { session } = createMockSession();
|
||||
applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt);
|
||||
applySystemPromptToSession(session as unknown as AgentSession, prompt);
|
||||
return {
|
||||
mutable: session,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applySystemPromptOverrideToSession", () => {
|
||||
it("applies a string override to the session system prompt", () => {
|
||||
describe("applySystemPromptToSession", () => {
|
||||
it("applies the string to the session system prompt", () => {
|
||||
const prompt = "You are a helpful assistant with custom context.";
|
||||
const { mutable } = applyAndGetMutableSession(prompt);
|
||||
|
||||
@@ -52,20 +46,13 @@ describe("applySystemPromptOverrideToSession", () => {
|
||||
expect(mutable["_baseSystemPrompt"]).toBe(prompt);
|
||||
});
|
||||
|
||||
it("trims whitespace from string overrides", () => {
|
||||
it("trims whitespace", () => {
|
||||
const { mutable } = applyAndGetMutableSession(" padded prompt ");
|
||||
|
||||
expect(mutable.agent.state.systemPrompt).toBe("padded prompt");
|
||||
});
|
||||
|
||||
it("applies a function override to the session system prompt", () => {
|
||||
const override = createSystemPromptOverride("function-based prompt");
|
||||
const { mutable } = applyAndGetMutableSession(override);
|
||||
|
||||
expect(mutable.agent.state.systemPrompt).toBe("function-based prompt");
|
||||
});
|
||||
|
||||
it("sets _rebuildSystemPrompt that returns the override", () => {
|
||||
it("sets _rebuildSystemPrompt that returns the prompt", () => {
|
||||
const { mutable } = applyAndGetMutableSession("rebuild test");
|
||||
expect(mutable["_rebuildSystemPrompt"]?.(["tool1"])).toBe("rebuild test");
|
||||
});
|
||||
|
||||
@@ -122,18 +122,8 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemPromptOverride(
|
||||
systemPrompt: string,
|
||||
): (defaultPrompt?: string) => string {
|
||||
const override = systemPrompt.trim();
|
||||
return (_defaultPrompt?: string) => override;
|
||||
}
|
||||
|
||||
export function applySystemPromptOverrideToSession(
|
||||
session: AgentSession,
|
||||
override: string | ((defaultPrompt?: string) => string),
|
||||
) {
|
||||
const prompt = typeof override === "function" ? override() : override.trim();
|
||||
export function applySystemPromptToSession(session: AgentSession, systemPrompt: string) {
|
||||
const prompt = systemPrompt.trim();
|
||||
session.agent.state.systemPrompt = prompt;
|
||||
const mutableSession = session as unknown as {
|
||||
_baseSystemPrompt?: string;
|
||||
|
||||
@@ -250,7 +250,7 @@ describe("gateway tool", () => {
|
||||
return {
|
||||
ok: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
config: { agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } },
|
||||
config: { agents: { defaults: { reasoningDefault: "medium" } } },
|
||||
restart: { ok: true, config: "nested field preserved" },
|
||||
};
|
||||
}
|
||||
@@ -260,7 +260,7 @@ describe("gateway tool", () => {
|
||||
const tool = requireGatewayTool(sessionKey);
|
||||
|
||||
const raw =
|
||||
'{\n agents: { defaults: { systemPromptOverride: "You are a terse assistant." } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n';
|
||||
'{\n agents: { defaults: { reasoningDefault: "medium" } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n';
|
||||
const result = await tool.execute("call2", {
|
||||
action: "config.apply",
|
||||
raw,
|
||||
@@ -498,7 +498,7 @@ describe("gateway tool", () => {
|
||||
await expect(
|
||||
tool.execute("call-missing-protected", {
|
||||
action: "config.apply",
|
||||
raw: '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }',
|
||||
raw: '{ agents: { defaults: { reasoningDefault: "medium" } } }',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"gateway config.apply cannot change protected config paths: tools.exec.ask, tools.exec.security",
|
||||
@@ -651,7 +651,7 @@ describe("gateway tool", () => {
|
||||
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
const tool = requireGatewayTool(sessionKey);
|
||||
|
||||
const raw = '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }';
|
||||
const raw = '{ agents: { defaults: { reasoningDefault: "medium" } } }';
|
||||
await tool.execute("call-keep-dangerous", {
|
||||
action: "config.patch",
|
||||
raw,
|
||||
|
||||
33
src/agents/sessions/resource-loader.test.ts
Normal file
33
src/agents/sessions/resource-loader.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefaultResourceLoader } from "./resource-loader.js";
|
||||
|
||||
describe("DefaultResourceLoader", () => {
|
||||
it("keeps deprecated SDK prompt override aliases wired to prompt transforms", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-resource-loader-"));
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: root,
|
||||
agentDir: root,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
systemPrompt: "base",
|
||||
appendSystemPrompt: ["tail"],
|
||||
systemPromptOverride: (base) => `${base ?? ""} legacy`,
|
||||
appendSystemPromptOverride: (base) => [...base, "legacy"],
|
||||
});
|
||||
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getSystemPrompt()).toBe("base legacy");
|
||||
expect(loader.getAppendSystemPrompt()).toEqual(["tail", "legacy"]);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -161,7 +161,11 @@ export interface DefaultResourceLoaderOptions {
|
||||
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
|
||||
agentsFiles: Array<{ path: string; content: string }>;
|
||||
};
|
||||
systemPromptTransform?: (base: string | undefined) => string | undefined;
|
||||
appendSystemPromptTransform?: (base: string[]) => string[];
|
||||
/** @deprecated Public SDK alias. Use systemPromptTransform. */
|
||||
systemPromptOverride?: (base: string | undefined) => string | undefined;
|
||||
/** @deprecated Public SDK alias. Use appendSystemPromptTransform. */
|
||||
appendSystemPromptOverride?: (base: string[]) => string[];
|
||||
}
|
||||
|
||||
@@ -204,8 +208,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}) => {
|
||||
agentsFiles: Array<{ path: string; content: string }>;
|
||||
};
|
||||
private systemPromptOverride?: (base: string | undefined) => string | undefined;
|
||||
private appendSystemPromptOverride?: (base: string[]) => string[];
|
||||
private systemPromptTransform?: (base: string | undefined) => string | undefined;
|
||||
private appendSystemPromptTransform?: (base: string[]) => string[];
|
||||
|
||||
private extensionsResult: LoadExtensionsResult;
|
||||
private skills: Skill[];
|
||||
@@ -252,8 +256,9 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.promptsOverride = options.promptsOverride;
|
||||
this.themesOverride = options.themesOverride;
|
||||
this.agentsFilesOverride = options.agentsFilesOverride;
|
||||
this.systemPromptOverride = options.systemPromptOverride;
|
||||
this.appendSystemPromptOverride = options.appendSystemPromptOverride;
|
||||
this.systemPromptTransform = options.systemPromptTransform ?? options.systemPromptOverride;
|
||||
this.appendSystemPromptTransform =
|
||||
options.appendSystemPromptTransform ?? options.appendSystemPromptOverride;
|
||||
|
||||
this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
||||
this.skills = [];
|
||||
@@ -507,8 +512,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.systemPromptSource ?? this.discoverSystemPromptFile(),
|
||||
"system prompt",
|
||||
);
|
||||
this.systemPrompt = this.systemPromptOverride
|
||||
? this.systemPromptOverride(baseSystemPrompt)
|
||||
this.systemPrompt = this.systemPromptTransform
|
||||
? this.systemPromptTransform(baseSystemPrompt)
|
||||
: baseSystemPrompt;
|
||||
|
||||
const appendSources =
|
||||
@@ -517,8 +522,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
const baseAppend = appendSources
|
||||
.map((s) => resolvePromptInput(s, "append system prompt"))
|
||||
.filter((s): s is string => s !== undefined);
|
||||
this.appendSystemPrompt = this.appendSystemPromptOverride
|
||||
? this.appendSystemPromptOverride(baseAppend)
|
||||
this.appendSystemPrompt = this.appendSystemPromptTransform
|
||||
? this.appendSystemPromptTransform(baseAppend)
|
||||
: baseAppend;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSystemPromptOverride } from "./system-prompt-override.js";
|
||||
|
||||
describe("resolveSystemPromptOverride", () => {
|
||||
it("uses defaults when no per-agent override exists", () => {
|
||||
expect(
|
||||
resolveSystemPromptOverride({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: " default system " },
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
}),
|
||||
).toBe("default system");
|
||||
});
|
||||
|
||||
it("prefers the per-agent override", () => {
|
||||
expect(
|
||||
resolveSystemPromptOverride({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "default system" },
|
||||
list: [{ id: "main", systemPromptOverride: " agent system " }],
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
}),
|
||||
).toBe("agent system");
|
||||
});
|
||||
|
||||
it("ignores blank override values", () => {
|
||||
expect(
|
||||
resolveSystemPromptOverride({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "default system" },
|
||||
list: [{ id: "main", systemPromptOverride: " " }],
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
}),
|
||||
).toBe("default system");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
function trimNonEmpty(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveSystemPromptOverride(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): string | undefined {
|
||||
const config = params.config;
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const agentOverride = trimNonEmpty(
|
||||
params.agentId ? resolveAgentConfig(config, params.agentId)?.systemPromptOverride : undefined,
|
||||
);
|
||||
if (agentOverride) {
|
||||
return agentOverride;
|
||||
}
|
||||
return trimNonEmpty(config.agents?.defaults?.systemPromptOverride);
|
||||
}
|
||||
@@ -6,10 +6,8 @@ import { resolveAgentPromptSurfaceForSessionKey } from "./prompt-surface.js";
|
||||
import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
|
||||
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
|
||||
import {
|
||||
appendAgentBootstrapSystemPromptSupplement,
|
||||
buildAgentBootstrapSystemContext,
|
||||
buildAgentBootstrapSystemPromptSections,
|
||||
buildAgentBootstrapSystemPromptSupplement,
|
||||
buildAgentSystemPrompt,
|
||||
buildRuntimeLine,
|
||||
} from "./system-prompt.js";
|
||||
@@ -1229,13 +1227,12 @@ describe("buildAgentBootstrapSystemContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentBootstrapSystemPromptSupplement", () => {
|
||||
describe("buildAgentBootstrapSystemPromptSections", () => {
|
||||
it("can render bootstrap guidance without duplicating Project Context", () => {
|
||||
const sections = buildAgentBootstrapSystemPromptSections({
|
||||
bootstrapMode: "full",
|
||||
bootstrapTruncationNotice: "Bootstrap context was truncated.",
|
||||
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
|
||||
includeProjectContext: false,
|
||||
}).join("\n");
|
||||
|
||||
expect(sections).toContain("## Bootstrap Pending");
|
||||
@@ -1245,34 +1242,6 @@ describe("buildAgentBootstrapSystemPromptSupplement", () => {
|
||||
expect(sections).not.toContain("## /tmp/openclaw/BOOTSTRAP.md");
|
||||
expect(sections).not.toContain("Ask who I am.");
|
||||
});
|
||||
|
||||
it("adds pending bootstrap guidance and BOOTSTRAP.md contents for override prompts", () => {
|
||||
const supplement = buildAgentBootstrapSystemPromptSupplement({
|
||||
bootstrapMode: "full",
|
||||
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
|
||||
});
|
||||
|
||||
expect(supplement).toContain("## Bootstrap Pending");
|
||||
expect(supplement).toContain("BOOTSTRAP.md is included below in Project Context");
|
||||
expect(supplement).toContain("## /tmp/openclaw/BOOTSTRAP.md");
|
||||
expect(supplement).toContain("Ask who I am.");
|
||||
});
|
||||
|
||||
it("appends bootstrap supplement to configured system prompt overrides", () => {
|
||||
const prompt = appendAgentBootstrapSystemPromptSupplement({
|
||||
systemPrompt: "Custom override prompt.",
|
||||
bootstrapMode: "full",
|
||||
bootstrapTruncationNotice:
|
||||
"[Bootstrap truncation warning]\nSome workspace bootstrap files were truncated before Project Context injection.\nTreat Project Context as partial and read the relevant files directly if details seem missing.",
|
||||
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Custom override prompt.");
|
||||
expect(prompt).toContain("## Bootstrap Pending");
|
||||
expect(prompt).toContain("Ask who I am.");
|
||||
expect(prompt).toContain("## Bootstrap Context Notice");
|
||||
expect(prompt).toContain("[Bootstrap truncation warning]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSubagentSystemPrompt", () => {
|
||||
|
||||
@@ -178,6 +178,17 @@ function sortContextFilesForPrompt(contextFiles: EmbeddedContextFile[]): Embedde
|
||||
});
|
||||
}
|
||||
|
||||
function prepareContextFilesForPrompt(contextFiles: EmbeddedContextFile[] = []) {
|
||||
const ordered = sortContextFilesForPrompt(
|
||||
contextFiles.filter((file) => typeof file.path === "string" && file.path.trim().length > 0),
|
||||
);
|
||||
return {
|
||||
ordered,
|
||||
stable: ordered.filter((file) => !isDynamicContextFile(file.path)),
|
||||
dynamic: ordered.filter((file) => isDynamicContextFile(file.path)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectContextSection(params: {
|
||||
files: EmbeddedContextFile[];
|
||||
heading: string;
|
||||
@@ -308,25 +319,10 @@ export function buildAgentBootstrapSystemContext(params: {
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentBootstrapSystemPromptSupplement(params: {
|
||||
bootstrapMode?: BootstrapMode;
|
||||
bootstrapTruncationNotice?: string;
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
}): string | undefined {
|
||||
const supplement = buildAgentBootstrapSystemPromptSections({
|
||||
...params,
|
||||
includeProjectContext: true,
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
return supplement.length > 0 ? supplement : undefined;
|
||||
}
|
||||
|
||||
export function buildAgentBootstrapSystemPromptSections(params: {
|
||||
bootstrapMode?: BootstrapMode;
|
||||
bootstrapTruncationNotice?: string;
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
includeProjectContext?: boolean;
|
||||
}): string[] {
|
||||
const bootstrapFiles =
|
||||
params.bootstrapMode === "full"
|
||||
@@ -344,31 +340,9 @@ export function buildAgentBootstrapSystemPromptSections(params: {
|
||||
if (bootstrapTruncationNotice) {
|
||||
lines.push("## Bootstrap Context Notice", bootstrapTruncationNotice, "");
|
||||
}
|
||||
if (params.includeProjectContext === true && bootstrapFiles.length > 0) {
|
||||
lines.push(
|
||||
...buildProjectContextSection({
|
||||
files: bootstrapFiles,
|
||||
heading: "# Project Context",
|
||||
dynamic: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function appendAgentBootstrapSystemPromptSupplement(params: {
|
||||
systemPrompt: string;
|
||||
bootstrapMode?: BootstrapMode;
|
||||
bootstrapTruncationNotice?: string;
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
}): string {
|
||||
const supplement = buildAgentBootstrapSystemPromptSupplement(params);
|
||||
if (!supplement) {
|
||||
return params.systemPrompt;
|
||||
}
|
||||
return `${params.systemPrompt.trimEnd()}\n\n${supplement}`;
|
||||
}
|
||||
|
||||
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
|
||||
if (!ownerLine || isMinimal) {
|
||||
return [];
|
||||
@@ -967,18 +941,11 @@ export function buildAgentSystemPrompt(params: {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const contextFiles = params.contextFiles ?? [];
|
||||
const validContextFiles = contextFiles.filter(
|
||||
(file) => typeof file.path === "string" && file.path.trim().length > 0,
|
||||
);
|
||||
const orderedContextFiles = sortContextFilesForPrompt(validContextFiles);
|
||||
const stableContextFiles = orderedContextFiles.filter((file) => !isDynamicContextFile(file.path));
|
||||
const dynamicContextFiles = orderedContextFiles.filter((file) => isDynamicContextFile(file.path));
|
||||
const contextFiles = prepareContextFilesForPrompt(params.contextFiles);
|
||||
const bootstrapSystemPromptSections = buildAgentBootstrapSystemPromptSections({
|
||||
bootstrapMode: params.bootstrapMode,
|
||||
bootstrapTruncationNotice: params.bootstrapTruncationNotice,
|
||||
contextFiles: orderedContextFiles,
|
||||
includeProjectContext: false,
|
||||
contextFiles: contextFiles.ordered,
|
||||
});
|
||||
const stablePrefixCacheKey = hashStablePromptInput({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -1019,7 +986,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
memoryCitationsMode: params.memoryCitationsMode,
|
||||
memorySection,
|
||||
acpEnabled,
|
||||
stableContextFiles,
|
||||
stableContextFiles: contextFiles.stable,
|
||||
});
|
||||
const stablePrefix = cacheStablePromptPrefix(stablePrefixCacheKey, () => {
|
||||
const lines = [
|
||||
@@ -1227,7 +1194,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
|
||||
lines.push(
|
||||
...buildProjectContextSection({
|
||||
files: stableContextFiles,
|
||||
files: contextFiles.stable,
|
||||
heading: "# Project Context",
|
||||
dynamic: false,
|
||||
}),
|
||||
@@ -1258,8 +1225,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
|
||||
lines.push(
|
||||
...buildProjectContextSection({
|
||||
files: dynamicContextFiles,
|
||||
heading: stableContextFiles.length > 0 ? "# Dynamic Project Context" : "# Project Context",
|
||||
files: contextFiles.dynamic,
|
||||
heading: contextFiles.stable.length > 0 ? "# Dynamic Project Context" : "# Project Context",
|
||||
dynamic: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ function expectAllowedApply(
|
||||
|
||||
describe("gateway config mutation guard coverage", () => {
|
||||
it("keeps a narrow allowlist of agent-tunable config paths", () => {
|
||||
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.systemPromptOverride");
|
||||
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.promptOverlays");
|
||||
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.model");
|
||||
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.subagents.thinking");
|
||||
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].id");
|
||||
@@ -512,13 +512,13 @@ describe("gateway config mutation guard coverage", () => {
|
||||
expectAllowed(
|
||||
{
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "You are a helpful assistant." },
|
||||
defaults: { reasoningDefault: "low" },
|
||||
list: [{ id: "worker", model: "sonnet-4" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "You are a terse assistant." },
|
||||
defaults: { reasoningDefault: "medium" },
|
||||
list: [{ id: "worker", model: "opus-4.6" }],
|
||||
},
|
||||
},
|
||||
@@ -531,7 +531,7 @@ describe("gateway config mutation guard coverage", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all" },
|
||||
systemPromptOverride: "You are a helpful assistant.",
|
||||
reasoningDefault: "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -539,7 +539,7 @@ describe("gateway config mutation guard coverage", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "off" },
|
||||
systemPromptOverride: "You are a terse assistant.",
|
||||
reasoningDefault: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -568,13 +568,13 @@ describe("gateway config mutation guard coverage", () => {
|
||||
expectAllowedApply(
|
||||
{
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "You are a helpful assistant." },
|
||||
defaults: { reasoningDefault: "low" },
|
||||
list: [{ id: "worker", model: "sonnet-4" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
agents: {
|
||||
defaults: { systemPromptOverride: "You are a terse assistant." },
|
||||
defaults: { reasoningDefault: "medium" },
|
||||
list: [{ id: "worker", model: "opus-4.6" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000;
|
||||
// must fail closed and allow only a narrow set of agent-tunable paths.
|
||||
const ALLOWED_GATEWAY_CONFIG_PATHS = [
|
||||
// Agent prompt/model tuning.
|
||||
"agents.defaults.systemPromptOverride",
|
||||
"agents.defaults.promptOverlays",
|
||||
"agents.defaults.model",
|
||||
"agents.defaults.thinkingDefault",
|
||||
@@ -44,7 +43,6 @@ const ALLOWED_GATEWAY_CONFIG_PATHS = [
|
||||
"agents.defaults.reasoningDefault",
|
||||
"agents.defaults.fastModeDefault",
|
||||
"agents.list[].id",
|
||||
"agents.list[].systemPromptOverride",
|
||||
"agents.list[].model",
|
||||
"agents.list[].thinkingDefault",
|
||||
"agents.list[].subagents.thinking",
|
||||
|
||||
@@ -198,6 +198,45 @@ describe("legacy silent reply config migrate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy agent system prompt override config migrate", () => {
|
||||
it("removes default and per-agent system prompt overrides", () => {
|
||||
const raw = {
|
||||
agents: {
|
||||
defaults: {
|
||||
systemPromptOverride: "old default prompt",
|
||||
model: {
|
||||
primary: "openai/gpt-5.5",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "alpha",
|
||||
systemPromptOverride: "old alpha prompt",
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual([
|
||||
"agents.defaults.systemPromptOverride",
|
||||
"agents.list",
|
||||
]);
|
||||
|
||||
const res = migrateLegacyConfigForTest(raw);
|
||||
|
||||
expect(res.config?.agents?.defaults).not.toHaveProperty("systemPromptOverride");
|
||||
expect(res.config?.agents?.list?.[0]).not.toHaveProperty("systemPromptOverride");
|
||||
expect(res.config?.agents?.list?.[1]).toEqual({ id: "beta" });
|
||||
expect(res.changes).toEqual([
|
||||
"Removed agents.defaults.systemPromptOverride.",
|
||||
"Removed agents.list.0.systemPromptOverride.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile configured tool section migrate", () => {
|
||||
it("does not add grants when configured sections are the only signal", () => {
|
||||
const raw = {
|
||||
|
||||
@@ -223,6 +223,20 @@ const SILENT_REPLY_LEGACY_RULES: LegacyConfigRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SYSTEM_PROMPT_OVERRIDE_LEGACY_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["agents", "defaults", "systemPromptOverride"],
|
||||
message:
|
||||
'agents.defaults.systemPromptOverride was removed; OpenClaw owns the generated system prompt. Run "openclaw doctor --fix" to remove it.',
|
||||
},
|
||||
{
|
||||
path: ["agents", "list"],
|
||||
message:
|
||||
'agents.list[].systemPromptOverride was removed; OpenClaw owns the generated system prompt. Run "openclaw doctor --fix" to remove it.',
|
||||
match: (value) => hasAgentListSystemPromptOverride(value),
|
||||
},
|
||||
];
|
||||
|
||||
function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" {
|
||||
return perSession ? "session" : "shared";
|
||||
}
|
||||
@@ -314,6 +328,15 @@ function hasAgentListRuntimePolicy(value: unknown): boolean {
|
||||
return value.some((agent) => getRecord(getRecord(agent)?.agentRuntime) !== null);
|
||||
}
|
||||
|
||||
function hasAgentListSystemPromptOverride(value: unknown): boolean {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.some((agent) =>
|
||||
Object.prototype.hasOwnProperty.call(getRecord(agent) ?? {}, "systemPromptOverride"),
|
||||
);
|
||||
}
|
||||
|
||||
function hasOwnTimeoutMs(value: unknown): boolean {
|
||||
const record = getRecord(value);
|
||||
return Boolean(record && Object.prototype.hasOwnProperty.call(record, "timeoutMs"));
|
||||
@@ -602,6 +625,30 @@ function removeLegacySilentReplyConfig(raw: Record<string, unknown>, changes: st
|
||||
}
|
||||
}
|
||||
|
||||
function removeLegacySystemPromptOverride(raw: Record<string, unknown>, changes: string[]): void {
|
||||
const agents = getRecord(raw.agents);
|
||||
const defaults = getRecord(agents?.defaults);
|
||||
if (defaults && Object.prototype.hasOwnProperty.call(defaults, "systemPromptOverride")) {
|
||||
delete defaults.systemPromptOverride;
|
||||
changes.push("Removed agents.defaults.systemPromptOverride.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(agents?.list)) {
|
||||
return;
|
||||
}
|
||||
for (const [index, agent] of agents.list.entries()) {
|
||||
const agentRecord = getRecord(agent);
|
||||
if (
|
||||
!agentRecord ||
|
||||
!Object.prototype.hasOwnProperty.call(agentRecord, "systemPromptOverride")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
delete agentRecord.systemPromptOverride;
|
||||
changes.push(`Removed agents.list.${index}.systemPromptOverride.`);
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIGURED_TOOL_SECTION_GRANTS = [
|
||||
{ key: "exec", grants: ["exec", "process"] },
|
||||
{ key: "fs", grants: ["read", "write", "edit"] },
|
||||
@@ -1150,6 +1197,12 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[
|
||||
legacyRules: SILENT_REPLY_LEGACY_RULES,
|
||||
apply: removeLegacySilentReplyConfig,
|
||||
}),
|
||||
defineLegacyConfigMigration({
|
||||
id: "agents.systemPromptOverride-removed",
|
||||
describe: "Remove legacy agent system prompt override config",
|
||||
legacyRules: SYSTEM_PROMPT_OVERRIDE_LEGACY_RULES,
|
||||
apply: removeLegacySystemPromptOverride,
|
||||
}),
|
||||
defineLegacyConfigMigration({
|
||||
id: "agents.defaults.llm->models.providers.timeoutSeconds",
|
||||
describe: "Remove legacy agents.defaults.llm timeout config",
|
||||
|
||||
@@ -239,8 +239,6 @@ export type AgentDefaultsConfig = {
|
||||
silentReply?: SilentReplyPolicyShape;
|
||||
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
|
||||
repoRoot?: string;
|
||||
/** Optional full system prompt replacement. Primarily for prompt debugging and controlled experiments. */
|
||||
systemPromptOverride?: string;
|
||||
/** Provider-independent prompt overlays applied by model family. */
|
||||
promptOverlays?: PromptOverlaysConfig;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
|
||||
@@ -84,8 +84,6 @@ export type AgentConfig = {
|
||||
description?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
/** Optional per-agent full system prompt replacement. */
|
||||
systemPromptOverride?: AgentDefaultsConfig["systemPromptOverride"];
|
||||
model?: AgentModelConfig;
|
||||
/**
|
||||
* @deprecated Legacy raw config accepted only by doctor/migration repair.
|
||||
|
||||
@@ -66,7 +66,6 @@ export const AgentDefaultsSchema = z
|
||||
skills: z.array(z.string()).optional(),
|
||||
silentReply: SilentReplyPolicyConfigSchema.optional(),
|
||||
repoRoot: z.string().optional(),
|
||||
systemPromptOverride: z.string().optional(),
|
||||
promptOverlays: z
|
||||
.object({
|
||||
gpt5: z
|
||||
|
||||
@@ -1018,7 +1018,6 @@ export const AgentEntrySchema = z
|
||||
description: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
agentDir: z.string().optional(),
|
||||
systemPromptOverride: z.string().optional(),
|
||||
model: AgentModelSchema.optional(),
|
||||
models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(),
|
||||
thinkingDefault: z
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
/**
|
||||
* gateway-cli-backend.system-prompt-resume.live.test.ts
|
||||
*
|
||||
* End-to-end behavioral before/after proof for issue #80374. Designed to
|
||||
* actually distinguish pre-fix from post-fix code at the model-response level,
|
||||
* not just the argv level.
|
||||
*
|
||||
* Approach:
|
||||
*
|
||||
* Turn 1: system prompt instructs the model to append `MARKER_ALPHA`.
|
||||
* Server restart (kills the live claude process).
|
||||
* Config rewritten: system prompt instructs the model to append `MARKER_BRAVO`.
|
||||
* Turn 2: resume the prior session. Assert reply contains `MARKER_BRAVO`.
|
||||
*
|
||||
* Pre-fix (systemPromptWhen="first"): on Turn 2 the new claude process is
|
||||
* spawned with `--resume <id>` but WITHOUT `--append-system-prompt-file`.
|
||||
* The model has no way to know `MARKER_BRAVO` exists — it only sees the
|
||||
* resumed conversation where Turn 1's user message was tagged with
|
||||
* `MARKER_ALPHA`. Reply contains `MARKER_ALPHA`, not `MARKER_BRAVO`.
|
||||
* ASSERTION FAILS.
|
||||
*
|
||||
* Post-fix (systemPromptWhen="always"): Turn 2's claude process IS spawned
|
||||
* with `--append-system-prompt-file <new-file>` and reads the BRAVO
|
||||
* instruction. Reply contains `MARKER_BRAVO`. ASSERTION PASSES.
|
||||
*
|
||||
* This is paired with the argv-level unit tests in
|
||||
* `src/agents/cli-runner/helpers.system-prompt-resume.test.ts`. The unit tests
|
||||
* are the cheap, deterministic before/after proof. This live test is the
|
||||
* expensive end-to-end proof that the argv-level fix actually changes
|
||||
* observable model behavior on resumed sessions.
|
||||
*
|
||||
* Run (after `pnpm build`):
|
||||
* OPENCLAW_LIVE_TEST=1 \
|
||||
* OPENCLAW_LIVE_USE_REAL_HOME=1 \
|
||||
* OPENCLAW_LIVE_CLI_BACKEND=true \
|
||||
* OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-haiku-4-5 \
|
||||
* pnpm vitest run --config test/vitest/vitest.live.config.ts \
|
||||
* src/gateway/gateway-cli-backend.system-prompt-resume.live.test.ts
|
||||
*/
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliBackendConfig } from "../agents/cli-backends.js";
|
||||
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
|
||||
import { resolveShellFromPath } from "../agents/shell-utils.js";
|
||||
import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
applyCliBackendLiveEnv,
|
||||
connectTestGatewayClient,
|
||||
ensurePairedTestGatewayClientIdentity,
|
||||
getFreeGatewayPort,
|
||||
parseJsonStringArray,
|
||||
resolveCliBackendLiveArgs,
|
||||
resolveCliBackendLiveModelSelection,
|
||||
restoreCliBackendLiveEnv,
|
||||
snapshotCliBackendLiveEnv,
|
||||
} from "./gateway-cli-backend.live-helpers.js";
|
||||
import { startGatewayServer } from "./server.js";
|
||||
import { extractPayloadText } from "./test-helpers.agent-results.js";
|
||||
|
||||
const LIVE = isLiveTestEnabled();
|
||||
const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND);
|
||||
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
|
||||
|
||||
// Two distinct markers, used to distinguish "model saw the new system prompt"
|
||||
// from "model is just carrying forward Turn 1's instruction by conversation
|
||||
// context." Random suffixes prevent the model from inferring one from the
|
||||
// other or from training data.
|
||||
const RAND_TAG = randomBytes(4).toString("hex").toUpperCase();
|
||||
const MARKER_ALPHA = `SP-PROOF-ALPHA-${RAND_TAG}`;
|
||||
const MARKER_BRAVO = `SP-PROOF-BRAVO-${RAND_TAG}`;
|
||||
|
||||
const DEFAULT_PROVIDER = "claude-cli";
|
||||
const DEFAULT_MODEL = "claude-cli/claude-haiku-4-5";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
||||
const AGENT_TIMEOUT_SECONDS = Math.max(1, Math.ceil(REQUEST_TIMEOUT_MS / 1000) - 10);
|
||||
|
||||
async function isExecutableCommandAvailable(command: string): Promise<boolean> {
|
||||
if (command.includes("/") || command.includes(path.sep)) {
|
||||
try {
|
||||
await fs.access(command, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return Boolean(resolveShellFromPath(command));
|
||||
}
|
||||
|
||||
describeLive("system-prompt-override on resumed cli sessions (issue #80374)", () => {
|
||||
it(
|
||||
"resumed session honors NEW systemPromptOverride (changing-marker proof, server restart between turns)",
|
||||
async () => {
|
||||
const preservedEnv = new Set(
|
||||
parseJsonStringArray(
|
||||
"OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV",
|
||||
process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV,
|
||||
) ?? [],
|
||||
);
|
||||
const rawModel = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
|
||||
const modelSelection = resolveCliBackendLiveModelSelection({
|
||||
rawModel,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
modelSwitchTarget: undefined,
|
||||
});
|
||||
const { providerId, configModelKey } = modelSelection;
|
||||
|
||||
const backendResolved = resolveCliBackendConfig(providerId);
|
||||
const providerDefaults = backendResolved?.config;
|
||||
|
||||
const explicitCliCommand = process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND;
|
||||
const cliCommand = explicitCliCommand ?? providerDefaults?.command;
|
||||
if (!cliCommand) {
|
||||
throw new Error(
|
||||
`OPENCLAW_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`,
|
||||
);
|
||||
}
|
||||
if (!(await isExecutableCommandAvailable(cliCommand))) {
|
||||
if (explicitCliCommand) {
|
||||
throw new Error(
|
||||
`OPENCLAW_LIVE_CLI_BACKEND_COMMAND is not executable or not on PATH: ${cliCommand}`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[sp-resume-proof] skip: CLI backend command "${cliCommand}" is not executable or not on PATH; set OPENCLAW_LIVE_CLI_BACKEND_COMMAND to run this live proof.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousEnv = snapshotCliBackendLiveEnv();
|
||||
clearRuntimeConfigSnapshot();
|
||||
applyCliBackendLiveEnv(preservedEnv);
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
const port = await getFreeGatewayPort();
|
||||
|
||||
const { args: cliArgs, resumeArgs: cliResumeArgs } = resolveCliBackendLiveArgs({
|
||||
providerId,
|
||||
defaultArgs: providerDefaults?.args,
|
||||
defaultResumeArgs: providerDefaults?.resumeArgs,
|
||||
});
|
||||
|
||||
const cliClearEnv =
|
||||
parseJsonStringArray(
|
||||
"OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV",
|
||||
process.env.OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV,
|
||||
) ??
|
||||
providerDefaults?.clearEnv ??
|
||||
[];
|
||||
const filteredCliClearEnv = cliClearEnv.filter((name) => !preservedEnv.has(name));
|
||||
const preservedCliEnv = Object.fromEntries(
|
||||
[...preservedEnv]
|
||||
.map((name) => [name, process.env[name]])
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sp-resume-"));
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
type ConfigWithRawCliBackends = OpenClawConfig & {
|
||||
agents?: {
|
||||
defaults?: {
|
||||
cliBackends?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const buildConfig = (marker: string): OpenClawConfig => {
|
||||
const cfg: ConfigWithRawCliBackends = {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
port,
|
||||
auth: { mode: "token", token },
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: path.join(tempDir, "workspace"),
|
||||
model: { primary: configModelKey },
|
||||
models: {
|
||||
[configModelKey]: { agentRuntime: modelSelection.agentRuntime },
|
||||
},
|
||||
agentRuntime: modelSelection.agentRuntime,
|
||||
// THE KEY FIELD. The marker changes between turns so we can
|
||||
// detect whether the new system prompt actually reaches the
|
||||
// model on the resumed turn — not just whether Turn 1's marker
|
||||
// was carried forward through conversation context.
|
||||
// Phrasing avoids ambiguity vs the user message and forbids the
|
||||
// model from emitting the OTHER marker, so a clean "BRAVO not
|
||||
// ALPHA" assertion is possible on the second turn.
|
||||
systemPromptOverride: [
|
||||
`You are a helpful assistant in a regression test.`,
|
||||
`Mandatory rule, overrides any user request for brevity:`,
|
||||
`you MUST end every single reply with a newline followed by`,
|
||||
`the exact literal token: ${marker}`,
|
||||
`(this token is required for harness validation).`,
|
||||
`Do NOT emit any other ${RAND_TAG} token; only emit ${marker}.`,
|
||||
].join(" "),
|
||||
cliBackends: {
|
||||
[providerId]: {
|
||||
command: cliCommand,
|
||||
args: cliArgs,
|
||||
resumeArgs: cliResumeArgs,
|
||||
clearEnv: filteredCliClearEnv.length > 0 ? filteredCliClearEnv : undefined,
|
||||
env: Object.keys(preservedCliEnv).length > 0 ? preservedCliEnv : undefined,
|
||||
// The live proof targets the fixed Claude contract directly.
|
||||
// Pre-fix argv construction ignored this on resume; post-fix
|
||||
// passes the prompt file again.
|
||||
systemPromptWhen: "always",
|
||||
},
|
||||
},
|
||||
sandbox: { mode: "off" },
|
||||
compaction: { mode: "safeguard" },
|
||||
},
|
||||
},
|
||||
};
|
||||
return cfg as OpenClawConfig;
|
||||
};
|
||||
|
||||
await fs.mkdir(path.join(tempDir, "workspace"), { recursive: true });
|
||||
|
||||
const writeConfig = async (marker: string) => {
|
||||
const cfg = buildConfig(marker);
|
||||
const tempConfigPath = path.join(tempDir, "openclaw.json");
|
||||
await fs.writeFile(tempConfigPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.OPENCLAW_CONFIG_PATH = tempConfigPath;
|
||||
};
|
||||
|
||||
const deviceIdentity = await ensurePairedTestGatewayClientIdentity();
|
||||
const sessionKey = "agent:dev:sp-resume-proof";
|
||||
|
||||
let server1 = null as Awaited<ReturnType<typeof startGatewayServer>> | null;
|
||||
let client1 = null as Awaited<ReturnType<typeof connectTestGatewayClient>> | null;
|
||||
let server2 = null as Awaited<ReturnType<typeof startGatewayServer>> | null;
|
||||
let client2 = null as Awaited<ReturnType<typeof connectTestGatewayClient>> | null;
|
||||
|
||||
try {
|
||||
// ── Turn 1: fresh session, system prompt instructs MARKER_ALPHA ──────
|
||||
clearRuntimeConfigSnapshot();
|
||||
await writeConfig(MARKER_ALPHA);
|
||||
server1 = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
client1 = await connectTestGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
deviceIdentity,
|
||||
});
|
||||
|
||||
const nonce1 = randomBytes(3).toString("hex").toUpperCase();
|
||||
console.log(`\n[sp-resume-proof] Turn 1 (fresh) nonce=${nonce1} marker=${MARKER_ALPHA}`);
|
||||
const payload1 = await client1.request(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${randomUUID()}`,
|
||||
// Prompt avoids "and nothing else" so it does not conflict with
|
||||
// the system-prompt instruction to append the marker.
|
||||
message: `Acknowledge with the exact token SP-T1-${nonce1}.`,
|
||||
deliver: false,
|
||||
timeout: AGENT_TIMEOUT_SECONDS,
|
||||
},
|
||||
{ expectFinal: true, timeoutMs: REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (!payload1) {
|
||||
return;
|
||||
}
|
||||
if (payload1.status !== "ok") {
|
||||
throw new Error(`Turn 1 status=${String(payload1.status)}`);
|
||||
}
|
||||
const text1 = extractPayloadText(payload1.result);
|
||||
console.log(`[sp-resume-proof] Turn 1 response: ${text1}`);
|
||||
|
||||
// Sanity check: Turn 1 (fresh session) must apply the ALPHA system
|
||||
// prompt. If this fails, something is broken upstream of the
|
||||
// resume/swap mechanism we're actually trying to test.
|
||||
expect(
|
||||
text1,
|
||||
`Turn 1 (fresh): expected "${MARKER_ALPHA}" in response — system prompt was not applied at all`,
|
||||
).toContain(MARKER_ALPHA);
|
||||
|
||||
// ── Restart the server AND swap the system prompt to MARKER_BRAVO ───
|
||||
// This kills the live process and forces Turn 2 to spawn a NEW claude
|
||||
// with `--resume <sessionId>`. The config now requests MARKER_BRAVO.
|
||||
//
|
||||
// Pre-fix: the new process is spawned without `--append-system-prompt-file`,
|
||||
// so the model never sees the BRAVO instruction. It only sees Turn 1's
|
||||
// resumed conversation history where ALPHA was instructed, so it emits
|
||||
// ALPHA again. THE BRAVO ASSERTION BELOW FAILS.
|
||||
// Post-fix: the new process IS spawned with `--append-system-prompt-file`
|
||||
// pointing at the new file (BRAVO instruction). The model emits BRAVO.
|
||||
console.log(
|
||||
`[sp-resume-proof] Restarting server, swapping system prompt to ${MARKER_BRAVO}...`,
|
||||
);
|
||||
await client1.stopAndWait({ timeoutMs: 5_000 }).catch(() => {});
|
||||
client1 = null;
|
||||
await server1.close();
|
||||
server1 = null;
|
||||
|
||||
// ── Turn 2: resumed session, NEW system prompt instructs MARKER_BRAVO ─
|
||||
clearRuntimeConfigSnapshot();
|
||||
await writeConfig(MARKER_BRAVO);
|
||||
server2 = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
client2 = await connectTestGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
deviceIdentity,
|
||||
});
|
||||
|
||||
const nonce2 = randomBytes(3).toString("hex").toUpperCase();
|
||||
console.log(
|
||||
`[sp-resume-proof] Turn 2 (resume after restart) nonce=${nonce2} marker=${MARKER_BRAVO}`,
|
||||
);
|
||||
const payload2 = await client2.request(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${randomUUID()}`,
|
||||
message: `Acknowledge with the exact token SP-T2-${nonce2}.`,
|
||||
deliver: false,
|
||||
timeout: AGENT_TIMEOUT_SECONDS,
|
||||
},
|
||||
{ expectFinal: true, timeoutMs: REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (!payload2) {
|
||||
return;
|
||||
}
|
||||
if (payload2.status !== "ok") {
|
||||
throw new Error(`Turn 2 status=${String(payload2.status)}`);
|
||||
}
|
||||
const text2 = extractPayloadText(payload2.result);
|
||||
console.log(`[sp-resume-proof] Turn 2 response: ${text2}`);
|
||||
|
||||
// THE CRITICAL BEHAVIORAL ASSERTION:
|
||||
// Pre-fix: model has no way to know BRAVO exists (the new system
|
||||
// prompt file is never sent on resume), so it emits ALPHA from
|
||||
// Turn 1's conversation history. This assertion FAILS.
|
||||
// Post-fix: the new system prompt is delivered, so the model emits
|
||||
// BRAVO. This assertion PASSES.
|
||||
expect(
|
||||
text2,
|
||||
`Turn 2 (resume): expected "${MARKER_BRAVO}" in response (the NEW system prompt's marker). ` +
|
||||
`If the model emitted "${MARKER_ALPHA}" instead, the resumed session is using the OLD ` +
|
||||
`system prompt from Turn 1's conversation context — the new override was never delivered ` +
|
||||
`(issue #80374).`,
|
||||
).toContain(MARKER_BRAVO);
|
||||
|
||||
console.log(
|
||||
`\n[sp-resume-proof] ✓ Resumed turn honored the NEW system prompt (${MARKER_BRAVO}).`,
|
||||
);
|
||||
console.log(` systemPromptWhen = always`);
|
||||
} finally {
|
||||
await client1?.stopAndWait({ timeoutMs: 5_000 }).catch(() => {});
|
||||
await client2?.stopAndWait({ timeoutMs: 5_000 }).catch(() => {});
|
||||
await server1?.close().catch(() => {});
|
||||
await server2?.close().catch(() => {});
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
clearRuntimeConfigSnapshot();
|
||||
restoreCliBackendLiveEnv(previousEnv);
|
||||
}
|
||||
},
|
||||
10 * 60_000,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user