From f7b71abf48c755c4d6a71e03c5cd84a4571c4a2c Mon Sep 17 00:00:00 2001 From: skylee-01 <497627264@qq.com> Date: Mon, 20 Apr 2026 13:34:23 +0800 Subject: [PATCH] fix(agents): pass Claude system prompt via file --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 8 ++-- docs/gateway/cli-backends.md | 2 + docs/gateway/config-agents.md | 1 + extensions/anthropic/cli-backend.ts | 2 +- src/agents/cli-backends.test.ts | 4 +- src/agents/cli-runner.helpers.test.ts | 16 +++++++ src/agents/cli-runner.spawn.test.ts | 49 ++++++++++++++++++-- src/agents/cli-runner/claude-live-session.ts | 45 +++++++----------- src/agents/cli-runner/helpers.ts | 13 +++++- src/config/schema.base.generated.ts | 3 ++ src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.core.ts | 1 + 13 files changed, 107 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1daf1b5e34f..57ad2e32291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai - Active Memory: keep silent recall sub-agent billing/auth failures out of shared auth-profile cooldown state, so a Claude CLI extra-usage rejection cannot disable normal Claude-backed turns. Fixes #71284. (#71539) Thanks @vishutdhar and @obviyus. - Auth/Claude CLI: sync refreshed Claude CLI OAuth credentials into the managed auth profile so long-running Claude CLI runs stop falling back to stale OpenClaw snapshots. (#70902) Thanks @starvex. - Sessions: make `sessions_spawn(mode="session")` errors name usable alternatives when the current channel cannot bind subagent threads. Fixes #67400. (#67790) Thanks @stainlu. +- Agents/Claude CLI: pass the OpenClaw system prompt through Claude's prompt-file flag so Windows runs avoid argv length failures without changing system prompt semantics. Fixes #69158. (#69211) Thanks @skylee-01, @cassioanorte, @Syu0, and @Stache73. ## 2026.4.25 (Unreleased) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 7ba99141fb7..9cac01eab02 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -dae9ece3ac683a0bed2835d96d4373f65ab955b8b901df0bcdeedc565ade6ed6 config-baseline.json -7cd52f77b1e0ecb50d2119b4c21d6d51d336a0c752a44cbaf8df1efa9ef538c0 config-baseline.core.json -d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json -0504c4f38d4c753fffeb465c93540d829df6b0fcef921eb0e2226ac16bdbbe07 config-baseline.plugin.json +445663bd6907368befbfd76f6fcc58f9dc282244697f44e9860391e51e6f2f83 config-baseline.json +f54f808dc85123a5ba788618a6dff7f2c869ced639dd0db34a86802985730dc6 config-baseline.core.json +7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json +7825b56a5b3fcdbe2e09ef8fe5d9f12ac3598435afebe20413051e45b0d1968e config-baseline.plugin.json diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 72bc541df76..b8b169cd633 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -122,6 +122,8 @@ The provider id becomes the left side of your model ref: sessionMode: "existing", sessionIdFields: ["session_id", "conversation_id"], systemPromptArg: "--system", + // For CLIs with a dedicated prompt-file flag: + // systemPromptFileArg: "--system-file", // Codex-style CLIs can point at a prompt file instead: // systemPromptFileConfigArg: "-c", // systemPromptFileConfigKey: "model_instructions_file", diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 31f4a583f4a..e6bc928bc96 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -443,6 +443,7 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b sessionArg: "--session", sessionMode: "existing", systemPromptArg: "--system", + // Or use systemPromptFileArg when the CLI accepts a prompt file flag. systemPromptWhen: "first", imageArg: "--image", imageMode: "repeat", diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 6f4b9eb7c13..682f3bf6bc1 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -62,7 +62,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { sessionArg: "--session-id", sessionMode: "always", sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS], - systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", systemPromptMode: "append", systemPromptWhen: "first", clearEnv: [...CLAUDE_CLI_CLEAR_ENV], diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 3716ee37cf0..d8fff20e037 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -357,7 +357,7 @@ beforeEach(() => { ...claudeBackend.config, sessionArg: "--session-id", sessionMode: "always", - systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "first", }, }, @@ -874,7 +874,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { "--permission-mode", "bypassPermissions", ]); - expect(resolved?.config.systemPromptArg).toBe("--append-system-prompt"); + expect(resolved?.config.systemPromptFileArg).toBe("--append-system-prompt-file"); expect(resolved?.config.systemPromptWhen).toBe("first"); expect(resolved?.config.sessionArg).toBe("--session-id"); expect(resolved?.config.sessionMode).toBe("always"); diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index c1ddbe92345..07c917cb96d 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -161,6 +161,22 @@ describe("buildCliArgs", () => { ).toEqual(["exec", "--json", "-c", 'model_instructions_file="/tmp/openclaw/system-prompt.md"']); }); + it("passes Claude system prompts through its file flag", () => { + expect( + buildCliArgs({ + backend: { + command: "claude", + systemPromptFileArg: "--append-system-prompt-file", + }, + baseArgs: ["-p"], + modelId: "claude-sonnet-4-6", + systemPrompt: "Stable prefix", + systemPromptFilePath: "/tmp/openclaw/system-prompt.md", + useResume: false, + }), + ).toEqual(["-p", "--append-system-prompt-file", "/tmp/openclaw/system-prompt.md"]); + }); + it("replaces prompt placeholders before falling back to a trailing positional prompt", () => { expect( buildCliArgs({ diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 096639a4e00..7732c7a5962 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -71,7 +71,7 @@ function buildPreparedCliRunContext(params: { modelArg: "--model", sessionArg: "--session-id", sessionMode: "always" as const, - systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "first" as const, serialize: true, } @@ -244,6 +244,41 @@ describe("runCliAgent spawn path", () => { expect(input.argv).not.toContain("Explain this diff"); }); + it("passes Claude system prompts through a file instead of argv", async () => { + let systemPromptPath = ""; + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { argv?: string[] }; + const systemPromptArgIndex = input.argv?.indexOf("--append-system-prompt-file") ?? -1; + expect(systemPromptArgIndex).toBeGreaterThanOrEqual(0); + systemPromptPath = input.argv?.[systemPromptArgIndex + 1] ?? ""; + expect(systemPromptPath).toContain("openclaw-cli-system-prompt-"); + await expect(fs.readFile(systemPromptPath, "utf-8")).resolves.toBe( + "You are a helpful assistant.", + ); + expect(input.argv).not.toContain("You are a helpful assistant."); + return createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "ok", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }); + + await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-claude-system-prompt-file", + }), + ); + + await expect(fs.access(systemPromptPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("passes --session-id for new Claude sessions", async () => { mockSuccessfulCliRun(); @@ -990,10 +1025,9 @@ describe("runCliAgent spawn path", () => { await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); releaseSpawn?.(); - await expect(Promise.all([first, second])).resolves.toEqual([ - expect.objectContaining({ text: "one" }), - expect.objectContaining({ text: "two" }), - ]); + const results = await Promise.all([first, second]); + expect(results.map((result) => result.text).sort()).toEqual(["one", "two"]); + expect(stdin.write).toHaveBeenCalledTimes(2); expect(supervisorSpawnMock).toHaveBeenCalledOnce(); }); @@ -1087,6 +1121,7 @@ describe("runCliAgent spawn path", () => { input: "stdin", sessionArg: "--session-id", systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", }; const args = buildClaudeLiveArgs({ @@ -1100,6 +1135,8 @@ describe("runCliAgent spawn path", () => { "openclaw-session", "--append-system-prompt", "old prompt", + "--append-system-prompt-file", + "/tmp/system-prompt.md", ], backend, systemPrompt: "current prompt", @@ -1110,6 +1147,8 @@ describe("runCliAgent spawn path", () => { expect(args).toContain("claude-session"); expect(args).not.toContain("--session-id"); expect(args).not.toContain("openclaw-session"); + expect(args).not.toContain("--append-system-prompt-file"); + expect(args).not.toContain("/tmp/system-prompt.md"); expect(args).not.toContain("--append-system-prompt"); expect(args).not.toContain("old prompt"); expect(args).not.toContain("current prompt"); diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index d507b4aea2e..93ae54c8ed1 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -10,7 +10,6 @@ import { } from "../cli-output.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; import { classifyFailoverReason } from "../pi-embedded-helpers.js"; -import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import { cliBackendLog } from "./log.js"; import type { PreparedCliRunContext } from "./types.js"; @@ -111,11 +110,18 @@ function appendArg(args: string[], flag: string): string[] { return args.includes(flag) ? args : [...args, flag]; } -function stripLiveProcessArgs(args: string[], backend: CliBackendConfig): string[] { +function stripLiveProcessArgs( + args: string[], + backend: CliBackendConfig, + stripSystemPrompt: boolean, +): string[] { const liveProcessFlags = new Set( - [backend.sessionArg, backend.systemPromptArg, "--session-id"].filter( - (entry): entry is string => typeof entry === "string" && entry.length > 0, - ), + [ + backend.sessionArg, + "--session-id", + stripSystemPrompt ? backend.systemPromptArg : undefined, + stripSystemPrompt ? backend.systemPromptFileArg : undefined, + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), ); const stripped: string[] = []; for (let i = 0; i < args.length; i += 1) { @@ -132,18 +138,6 @@ function stripLiveProcessArgs(args: string[], backend: CliBackendConfig): string return stripped; } -function appendSystemPromptArg( - args: string[], - backend: CliBackendConfig, - systemPrompt: string, -): string[] { - const prompt = systemPrompt.trim(); - if (!backend.systemPromptArg || !prompt) { - return args; - } - return upsertArgValue(args, backend.systemPromptArg, stripSystemPromptCacheBoundary(prompt)); -} - export function buildClaudeLiveArgs(params: { args: string[]; backend: CliBackendConfig; @@ -153,13 +147,7 @@ export function buildClaudeLiveArgs(params: { return appendArg( upsertArgValue( upsertArgValue( - params.useResume - ? stripLiveProcessArgs(params.args, params.backend) - : appendSystemPromptArg( - stripLiveProcessArgs(params.args, params.backend), - params.backend, - params.systemPrompt, - ), + stripLiveProcessArgs(params.args, params.backend, params.useResume), "--input-format", "stream-json", ), @@ -207,9 +195,12 @@ function buildClaudeLiveFingerprint(params: { : undefined; const normalizePluginDir = Boolean(skillsFingerprint); const omittedValueFlags = new Set( - [params.context.preparedBackend.backend.systemPromptArg, "--resume", "-r"].filter( - (entry): entry is string => typeof entry === "string" && entry.length > 0, - ), + [ + params.context.preparedBackend.backend.systemPromptArg, + params.context.preparedBackend.backend.systemPromptFileArg, + "--resume", + "-r", + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), ); const unstableValueFlags = new Set( [ diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index e2c80bd082a..a2123200198 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -160,6 +160,7 @@ export function resolveSystemPromptUsage(params: { } if ( !params.backend.systemPromptArg?.trim() && + !params.backend.systemPromptFileArg?.trim() && !params.backend.systemPromptFileConfigKey?.trim() ) { return null; @@ -292,7 +293,10 @@ export async function writeCliSystemPromptFile(params: { backend: CliBackendConfig; systemPrompt: string; }): Promise<{ filePath?: string; cleanup: () => Promise }> { - if (!params.backend.systemPromptFileConfigKey?.trim()) { + if ( + !params.backend.systemPromptFileArg?.trim() && + !params.backend.systemPromptFileConfigKey?.trim() + ) { return { cleanup: async () => {} }; } const tempDir = await fs.mkdtemp( @@ -369,6 +373,13 @@ export function buildCliArgs(params: { args.push(params.backend.modelArg, params.modelId); } if ( + !params.useResume && + params.systemPrompt && + params.systemPromptFilePath && + params.backend.systemPromptFileArg + ) { + args.push(params.backend.systemPromptFileArg, params.systemPromptFilePath); + } else if ( !params.useResume && params.systemPrompt && params.systemPromptFilePath && diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index a93d1bc8562..46cfb0e890a 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3866,6 +3866,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { systemPromptArg: { type: "string", }, + systemPromptFileArg: { + type: "string", + }, systemPromptFileConfigArg: { type: "string", }, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index aaa9cfd8542..d19e1fd7b90 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -127,6 +127,8 @@ export type CliBackendConfig = { sessionIdFields?: string[]; /** Flag used to pass system prompt. */ systemPromptArg?: string; + /** Flag used to pass a system prompt file. */ + systemPromptFileArg?: string; /** Config override flag used to pass a system prompt file (e.g. -c). */ systemPromptFileConfigArg?: string; /** Config override key used to pass a system prompt file. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 1785030d27f..36bbf4346e5 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -565,6 +565,7 @@ export const CliBackendSchema = z .optional(), sessionIdFields: z.array(z.string()).optional(), systemPromptArg: z.string().optional(), + systemPromptFileArg: z.string().optional(), systemPromptFileConfigArg: z.string().optional(), systemPromptFileConfigKey: z.string().optional(), systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(),