diff --git a/CHANGELOG.md b/CHANGELOG.md index 61391b2f56e..edf34f856ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Setup/TUI: bound the Terminal hatch bootstrap run so a stalled provider request times out instead of leaving first-run hatching stuck behind the watchdog. (#76241) Thanks @joshavant. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. - Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. +- Codex harness: forward OpenClaw workspace bootstrap files such as `SOUL.md` through native Codex config instructions while leaving `AGENTS.md` to Codex project-doc discovery. Fixes #76273. Thanks @zknicker. ## 2026.5.2 diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 66dafe7a0b9..e8fd9d02eae 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -160,6 +160,13 @@ heartbeats are disabled for the default agent or files concise — especially `MEMORY.md`, which can grow over time and lead to unexpectedly high context usage and more frequent compaction. +When a session runs on the native Codex harness, Codex loads `AGENTS.md` +through its own project-doc discovery. OpenClaw still resolves the remaining +bootstrap files and forwards them as Codex config instructions, so `SOUL.md`, +`TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, and +`MEMORY.md` keep the same workspace-context role without duplicating +`AGENTS.md`. + `memory/*.md` daily files are **not** part of the normal bootstrap Project Context. On ordinary turns they are accessed on demand via the `memory_search` and `memory_get` tools, so they do not count against the context window unless the model explicitly reads them. Bare `/new` and `/reset` turns are the exception: the runtime can prepend recent daily memory as a one-shot startup-context block for that first turn. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 47d91aafaa3..1c23e86c76f 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -259,6 +259,20 @@ For live and Docker smoke tests, auth usually comes from the Codex CLI account or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present. +## Workspace bootstrap files + +Codex handles `AGENTS.md` itself through native project-doc discovery. OpenClaw +does not write synthetic Codex project-doc files or depend on Codex fallback +filenames for persona files, because Codex fallbacks only apply when +`AGENTS.md` is missing. + +For OpenClaw workspace parity, the Codex harness resolves the other bootstrap +files (`SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, +`BOOTSTRAP.md`, and `MEMORY.md` when present) and forwards them through Codex +config instructions on `thread/start` and `thread/resume`. This keeps +`SOUL.md` and related workspace persona/profile context visible without +duplicating `AGENTS.md`. + ## Add Codex alongside other models Do not set `agentRuntime.id: "codex"` globally if the same agent should freely switch diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 6e49a7e558d..3c31b1249ef 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -674,6 +674,31 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("passes OpenClaw bootstrap files through Codex config instructions", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "Follow AGENTS guidance."); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice goes here."); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const threadStart = harness.requests.find((request) => request.method === "thread/start"); + const config = (threadStart?.params as { config?: { instructions?: string } }).config; + expect(config).toEqual( + expect.objectContaining({ + instructions: expect.stringContaining("Soul voice goes here."), + }), + ); + expect(config?.instructions).toContain("Codex loads AGENTS.md natively"); + expect(config?.instructions).not.toContain("Follow AGENTS guidance."); + }); + it("fires llm_input, llm_output, and agent_end hooks for codex turns", async () => { const llmInput = vi.fn(); const llmOutput = vi.fn(); @@ -996,13 +1021,13 @@ describe("runCodexAppServerAttempt", () => { const startRequest = harness.requests.find((request) => request.method === "thread/start"); expect(startRequest?.params).toEqual( expect.objectContaining({ - config: { + config: expect.objectContaining({ "features.codex_hooks": false, "hooks.PreToolUse": [], "hooks.PostToolUse": [], "hooks.PermissionRequest": [], "hooks.Stop": [], - }, + }), }), ); }); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index ba7d528fa35..2f3a0dfbbe1 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; +import path from "node:path"; import { assembleHarnessContextEngine, bootstrapHarnessContextEngine, @@ -26,12 +27,14 @@ import { runAgentHarnessLlmOutputHook, runHarnessContextEngineMaintenance, registerNativeHookRelay, + resolveBootstrapContextForRun, setActiveEmbeddedRun, supportsModelTools, runAgentCleanupStep, type AgentMessage, type EmbeddedRunAttemptParams, type EmbeddedRunAttemptResult, + type EmbeddedContextFile, type NativeHookRelayEvent, type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; @@ -101,6 +104,16 @@ const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000; const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000; const CODEX_STEER_ALL_DEBOUNCE_MS = 500; const LOG_FIELD_MAX_LENGTH = 160; +const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]); +const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([ + ["soul.md", 10], + ["identity.md", 20], + ["user.md", 30], + ["tools.md", 40], + ["bootstrap.md", 50], + ["memory.md", 60], + ["heartbeat.md", 70], +]); type OpenClawCodingToolsOptions = NonNullable< Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0] @@ -472,6 +485,13 @@ export async function runCodexAppServerAttempt( messages: historyMessages, ctx: hookContext, }); + const workspaceBootstrapInstructions = await buildCodexWorkspaceBootstrapInstructions({ + params, + resolvedWorkspace, + effectiveWorkspace, + sessionKey: sandboxSessionKey, + sessionAgentId, + }); const trajectoryRecorder = createCodexTrajectoryRecorder({ attempt: params, cwd: effectiveWorkspace, @@ -506,6 +526,10 @@ export async function runCodexAppServerAttempt( : options.nativeHookRelay?.enabled === false ? buildCodexNativeHookRelayDisabledConfig() : undefined; + const threadConfig = mergeCodexConfigInstructions( + nativeHookRelayConfig, + workspaceBootstrapInstructions, + ); ({ client, thread } = await withCodexStartupTimeout({ timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, @@ -533,7 +557,7 @@ export async function runCodexAppServerAttempt( dynamicTools: toolBridge.specs, appServer, developerInstructions: promptBuild.developerInstructions, - config: nativeHookRelayConfig, + config: threadConfig, }); return { client: startupClient, thread: startupThread }; }; @@ -1597,6 +1621,129 @@ async function readMirroredSessionHistoryMessages( return messages; } +async function buildCodexWorkspaceBootstrapInstructions(params: { + params: EmbeddedRunAttemptParams; + resolvedWorkspace: string; + effectiveWorkspace: string; + sessionKey: string; + sessionAgentId: string; +}): Promise { + try { + const { contextFiles } = await resolveBootstrapContextForRun({ + workspaceDir: params.resolvedWorkspace, + config: params.params.config, + sessionKey: params.sessionKey, + sessionId: params.params.sessionId, + agentId: params.params.agentId ?? params.sessionAgentId, + warn: (message) => embeddedAgentLog.warn(message), + contextMode: params.params.bootstrapContextMode, + runKind: params.params.bootstrapContextRunKind, + }); + return renderCodexWorkspaceBootstrapInstructions( + contextFiles.map((file) => + remapCodexContextFilePath({ + file, + sourceWorkspaceDir: params.resolvedWorkspace, + targetWorkspaceDir: params.effectiveWorkspace, + }), + ), + ); + } catch (error) { + embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error }); + return undefined; + } +} + +function renderCodexWorkspaceBootstrapInstructions( + contextFiles: EmbeddedContextFile[], +): string | undefined { + const files = contextFiles + .filter((file) => { + const baseName = getCodexContextFileBasename(file.path); + return baseName && !CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName); + }) + .toSorted(compareCodexContextFiles); + if (files.length === 0) { + return undefined; + } + const hasSoulFile = files.some((file) => getCodexContextFileBasename(file.path) === "soul.md"); + const lines = [ + "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.", + "", + "# Project Context", + "", + "The following project context files have been loaded:", + ]; + if (hasSoulFile) { + lines.push( + "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.", + ); + } + lines.push(""); + for (const file of files) { + lines.push(`## ${file.path}`, "", file.content, ""); + } + return lines.join("\n").trim(); +} + +function mergeCodexConfigInstructions( + config: JsonObject | undefined, + instructions: string | undefined, +): JsonObject | undefined { + if (!instructions?.trim()) { + return config; + } + const merged: JsonObject = { ...config }; + const existingInstructions = + typeof merged.instructions === "string" ? merged.instructions.trim() : undefined; + merged.instructions = joinPresentSections(existingInstructions, instructions); + return merged; +} + +function remapCodexContextFilePath(params: { + file: EmbeddedContextFile; + sourceWorkspaceDir: string; + targetWorkspaceDir: string; +}): EmbeddedContextFile { + const relativePath = path.relative(params.sourceWorkspaceDir, params.file.path); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) || + params.sourceWorkspaceDir === params.targetWorkspaceDir + ) { + return params.file; + } + return { + ...params.file, + path: path.join(params.targetWorkspaceDir, relativePath), + }; +} + +function compareCodexContextFiles(left: EmbeddedContextFile, right: EmbeddedContextFile): number { + const leftPath = normalizeCodexContextFilePath(left.path); + const rightPath = normalizeCodexContextFilePath(right.path); + const leftBase = getCodexContextFileBasename(left.path); + const rightBase = getCodexContextFileBasename(right.path); + const leftOrder = CODEX_BOOTSTRAP_CONTEXT_ORDER.get(leftBase) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = CODEX_BOOTSTRAP_CONTEXT_ORDER.get(rightBase) ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + if (leftBase !== rightBase) { + return leftBase.localeCompare(rightBase); + } + return leftPath.localeCompare(rightPath); +} + +function normalizeCodexContextFilePath(filePath: string): string { + return filePath.trim().replaceAll("\\", "/").toLowerCase(); +} + +function getCodexContextFileBasename(filePath: string): string { + return normalizeCodexContextFilePath(filePath).split("/").pop() ?? ""; +} + async function mirrorTranscriptBestEffort(params: { params: EmbeddedRunAttemptParams; agentId?: string; diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index fda963b4bbb..e31a7f95cae 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -105,6 +105,8 @@ export { } from "../agents/runtime-plan/tools.js"; export { normalizeProviderToolSchemas } from "../agents/pi-embedded-runner/tool-schema-runtime.js"; export { resolveSandboxContext } from "../agents/sandbox.js"; +export { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js"; +export type { EmbeddedContextFile } from "../agents/pi-embedded-helpers/types.js"; export { isSubagentSessionKey } from "../routing/session-key.js"; export { acquireSessionWriteLock,