From 3f80f889fab71cb6abfbc481287aa4aaaaf6715a Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 14 May 2026 15:10:42 -0700 Subject: [PATCH] fix: align Codex cron bootstrap context (#81822) * fix: align Codex cron bootstrap context * fix: address Codex cron review comments * fix: suppress Codex project docs for lightweight context * fix: note Codex cron lightweight context --- CHANGELOG.md | 1 + .../codex/src/app-server/run-attempt.test.ts | 11 +--- .../src/app-server/thread-lifecycle.test.ts | 53 +++++++++++++++++++ .../codex/src/app-server/thread-lifecycle.ts | 27 ++++++++-- src/cron/isolated-agent/run-executor.ts | 7 +-- .../run.session-key-isolation.test.ts | 26 +++++++++ 6 files changed, 109 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddae8cc2207..228831d094c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - ACP/Codex: surface redacted Codex wrapper stderr for generic ACP internal failures and preserve safe Codex model/provider routing in isolated `CODEX_HOME`, making `sessions_spawn(runtime="acp", agentId="codex")` failures actionable. Fixes #80079. (#80718) Thanks @leoge007. - ACP: treat rejected timeout config options as best-effort hints so ACP turns continue with adapters that do not support `session/set_config_option` timeout keys. Fixes #81250. (#81603) Thanks @qkal. - Cron/Codex: default exact-command scheduled agent turns to lightweight bootstrap context so automation runs the command before loading workspace identity or memory context. +- Codex cron: disable native Codex project-doc loading for lightweight app-server cron turns so scheduled jobs avoid project-doc injection after OpenClaw suppresses bootstrap context. (#81822) Thanks @jalehman. - Codex plugin/Gateway: strip unpaired UTF-16 surrogates from Codex app-server JSON-RPC payloads and let stale reply-work recovery abort stalled reply runs, preventing malformed media turns from wedging gateway lanes. - Codex app server: force OAuth refresh requests to perform a real token refresh instead of reusing unchanged inherited auth-profile tokens after refresh failures. (#80738) Thanks @simplyclever914. - Control UI/WebChat: render `/tts audio` replies as playable audio attachments through the assistant-media ticket path, with structured-audio compatibility for older live payloads. (#81722) Thanks @Conan-Scott. diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 5cd7818bb04..74cc777c90a 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -2516,24 +2516,17 @@ describe("runCodexAppServerAttempt", () => { const threadStart = harness.requests.find((request) => request.method === "thread/start"); const threadStartParams = threadStart?.params as { developerInstructions?: string; + config?: Record; }; + expect(threadStartParams.config?.project_doc_max_bytes).toBe(0); expect(threadStartParams.developerInstructions).not.toContain("Soul voice goes here."); expect(threadStartParams.developerInstructions).not.toContain("Follow AGENTS guidance."); const turnStart = harness.requests.find((request) => request.method === "turn/start"); const turnStartParams = turnStart?.params as { - collaborationMode?: { - settings?: { developer_instructions?: string | null }; - }; input?: Array<{ text?: string }>; }; expect(turnStartParams.input?.[0]?.text).toBe(exactCommand); - expect(turnStartParams.collaborationMode?.settings?.developer_instructions).toContain( - "This is an OpenClaw cron automation turn", - ); - expect(turnStartParams.collaborationMode?.settings?.developer_instructions).toContain( - "run that command before doing any investigation", - ); }); it("fires llm_input, llm_output, and agent_end hooks for codex turns", async () => { diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 6001056471d..52b1bc7384d 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -11,6 +11,8 @@ function createAttemptParams(params: { authProfileId?: string; authProfileProvider?: string; authProfileProviders?: Record; + bootstrapContextMode?: "full" | "lightweight"; + bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; }): EmbeddedRunAttemptParams { const authProfileProviders = params.authProfileProviders ?? @@ -21,6 +23,10 @@ function createAttemptParams(params: { provider: params.provider, modelId: "gpt-5.4", authProfileId: params.authProfileId, + ...(params.bootstrapContextMode ? { bootstrapContextMode: params.bootstrapContextMode } : {}), + ...(params.bootstrapContextRunKind + ? { bootstrapContextRunKind: params.bootstrapContextRunKind } + : {}), authProfileStore: { version: 1, profiles: Object.fromEntries( @@ -80,6 +86,53 @@ describe("Codex app-server native code mode config", () => { "features.code_mode_only": true, }); }); + + it("disables native Codex project docs for lightweight context threads", () => { + const request = buildThreadStartParams( + createAttemptParams({ + provider: "openai", + bootstrapContextMode: "lightweight", + bootstrapContextRunKind: "cron", + }), + { + cwd: "/repo", + dynamicTools: [], + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + config: { + project_doc_max_bytes: 64_000, + "features.codex_hooks": true, + }, + }, + ); + + expect(request.config).toEqual({ + project_doc_max_bytes: 0, + "features.codex_hooks": true, + "features.code_mode": true, + "features.code_mode_only": true, + }); + }); + + it("keeps native Codex project docs enabled when context is not lightweight", () => { + const request = buildThreadResumeParams( + createAttemptParams({ provider: "openai", bootstrapContextRunKind: "cron" }), + { + threadId: "thread-1", + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + config: { + project_doc_max_bytes: 64_000, + }, + }, + ); + + expect(request.config).toEqual({ + project_doc_max_bytes: 64_000, + "features.code_mode": true, + "features.code_mode_only": true, + }); + }); }); describe("Codex app-server model provider selection", () => { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 8af6a8b5d31..23bdb6f9ee4 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -65,6 +65,10 @@ export const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = { "features.code_mode_only": true, }; +const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = { + project_doc_max_bytes: 0, +}; + export async function startOrResumeThread(params: { client: CodexAppServerClient; params: EmbeddedRunAttemptParams; @@ -472,7 +476,7 @@ export function buildThreadStartParams( sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), serviceName: "OpenClaw", - config: buildCodexRuntimeThreadConfig(options.config), + config: buildCodexRuntimeThreadConfigForRun(params, options.config), developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), dynamicTools: options.dynamicTools, experimentalRawEvents: true, @@ -505,16 +509,31 @@ export function buildThreadResumeParams( approvalsReviewer: options.appServer.approvalsReviewer, sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), - config: buildCodexRuntimeThreadConfig(options.config), + config: buildCodexRuntimeThreadConfigForRun(params, options.config), developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), persistExtendedHistory: true, }; } export function buildCodexRuntimeThreadConfig(config: JsonObject | undefined): JsonObject { + const runtimeConfig = mergeCodexThreadConfigs(config, CODEX_CODE_MODE_THREAD_CONFIG) ?? { + ...CODEX_CODE_MODE_THREAD_CONFIG, + }; + return runtimeConfig; +} + +function buildCodexRuntimeThreadConfigForRun( + params: EmbeddedRunAttemptParams, + config: JsonObject | undefined, +): JsonObject { + const runtimeConfig = buildCodexRuntimeThreadConfig(config); + if (params.bootstrapContextMode !== "lightweight") { + return runtimeConfig; + } return ( - mergeCodexThreadConfigs(config, CODEX_CODE_MODE_THREAD_CONFIG) ?? { - ...CODEX_CODE_MODE_THREAD_CONFIG, + mergeCodexThreadConfigs(runtimeConfig, CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG) ?? { + ...runtimeConfig, + ...CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG, } ); } diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index ad565adac2b..0fd2f2a9289 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -152,6 +152,7 @@ export function createCronPromptExecutor(params: { let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( params.cronSession.sessionEntry.systemPromptReport, ); + const bootstrapContextMode = resolveCronBootstrapContextMode(params.agentPayload); const runPrompt = async (promptText: string) => { const fallbackResult = await runWithModelFallback({ @@ -202,10 +203,10 @@ export function createCronPromptExecutor(params: { abortSignal: params.abortSignal, onExecutionStarted: params.onExecutionStarted, onExecutionPhase: params.onExecutionPhase, + bootstrapContextMode, + bootstrapContextRunKind: "cron", bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature, - bootstrapContextMode: resolveCronBootstrapContextMode(params.agentPayload), - bootstrapContextRunKind: "cron", senderIsOwner: params.senderIsOwner, }); bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( @@ -260,7 +261,7 @@ export function createCronPromptExecutor(params: { verboseLevel: params.resolvedVerboseLevel, timeoutMs: params.timeoutMs, runTimeoutOverrideMs: params.runTimeoutOverrideMs, - bootstrapContextMode: resolveCronBootstrapContextMode(params.agentPayload), + bootstrapContextMode, bootstrapContextRunKind: "cron", toolsAllow: params.agentPayload?.toolsAllow, execOverrides: params.suppressExecNotifyOnExit diff --git a/src/cron/isolated-agent/run.session-key-isolation.test.ts b/src/cron/isolated-agent/run.session-key-isolation.test.ts index 74aaf857b26..ba4ad582bb0 100644 --- a/src/cron/isolated-agent/run.session-key-isolation.test.ts +++ b/src/cron/isolated-agent/run.session-key-isolation.test.ts @@ -41,6 +41,13 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { const result = await runCronIsolatedAgentTurn( makeIsolatedAgentTurnParams({ sessionKey: "cron:daily-monitor", + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test", + lightContext: true, + }, + }), }), ); @@ -56,10 +63,14 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { const runRequest = requireFirstMockArg(runEmbeddedPiAgentMock, "runEmbeddedPiAgentMock") as { sessionId?: string; sessionKey?: string; + bootstrapContextMode?: string; + bootstrapContextRunKind?: string; }; expect(runRequest.sessionId).toBe("isolated-run-1"); expect(runRequest.sessionKey).toBe("agent:default:cron:daily-monitor:run:isolated-run-1"); expect(runRequest.sessionKey).not.toBe("agent:default:cron:daily-monitor"); + expect(runRequest.bootstrapContextMode).toBe("lightweight"); + expect(runRequest.bootstrapContextRunKind).toBe("cron"); }); it("keeps explicit session-bound cron execution on the requested session key", async () => { @@ -88,9 +99,13 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { const runRequest = requireFirstMockArg(runEmbeddedPiAgentMock, "runEmbeddedPiAgentMock") as { sessionId?: string; sessionKey?: string; + bootstrapContextMode?: string; + bootstrapContextRunKind?: string; }; expect(runRequest.sessionId).toBe("bound-run-1"); expect(runRequest.sessionKey).toBe("agent:default:project-alpha-monitor"); + expect(runRequest.bootstrapContextMode).toBeUndefined(); + expect(runRequest.bootstrapContextRunKind).toBe("cron"); }); it("uses a run-scoped key for CLI isolated cron execution", async () => { @@ -112,6 +127,13 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { const result = await runCronIsolatedAgentTurn( makeIsolatedAgentTurnParams({ sessionKey: "cron:cli-monitor", + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test", + lightContext: true, + }, + }), }), ); @@ -122,11 +144,15 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { sessionId?: string; sessionKey?: string; senderIsOwner?: boolean; + bootstrapContextMode?: string; + bootstrapContextRunKind?: string; }; expect(runRequest.sessionId).toBe("isolated-cli-run-1"); expect(runRequest.sessionKey).toBe("agent:default:cron:cli-monitor:run:isolated-cli-run-1"); expect(runRequest.sessionKey).not.toBe("agent:default:cron:cli-monitor"); expect(runRequest.senderIsOwner).toBe(true); + expect(runRequest.bootstrapContextMode).toBe("lightweight"); + expect(runRequest.bootstrapContextRunKind).toBe("cron"); }); it("runs externally sourced CLI hook turns without owner tool authority", async () => {