mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(codex): forward workspace bootstrap context
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user