fix(codex): forward workspace bootstrap context

This commit is contained in:
Peter Steinberger
2026-05-02 23:21:21 +01:00
parent 1f03b629be
commit 9fdc0e7030
6 changed files with 199 additions and 3 deletions

View File

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

View File

@@ -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`.
<Note>
`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.
</Note>

View File

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

View File

@@ -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<void>((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": [],
},
}),
}),
);
});

View File

@@ -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<string, number>([
["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<string | undefined> {
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;

View File

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