fix(context): persist Codex run context maps

This commit is contained in:
Ayaan Zaidi
2026-05-10 21:06:18 +05:30
parent 83ccf0b7fb
commit ac5588c94a
5 changed files with 250 additions and 22 deletions

View File

@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- CLI/gateway: keep `gateway status --deep` plugin-aware so configured plugin manifest warnings, including missing channel config metadata, stay visible during install and update smoke checks.
- Doctor: stop flagging the live compatibility agent directory as orphaned when the configured default agent is not `main`. Fixes #74313. (#74438) Thanks @carlos4s.
- Auth/Claude CLI: persist fresher managed external CLI OAuth credentials back to `auth-profiles.json`, preventing stale `anthropic:claude-cli` profiles from repeatedly bootstrapping and flooding debug logs. Fixes #80129. Thanks @Caulderein.
- Context: render `/context map` only from actual run context and persist Codex app-server run reports without counting deferred tool-search schemas as prompt-loaded tool schemas.
- Codex app-server: report Codex-native tool execution to diagnostics so long-running native `bash`, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)
- Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.
- Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.

View File

@@ -740,6 +740,41 @@ describe("runCodexAppServerAttempt", () => {
expect(heartbeat?.deferLoading).toBe(true);
});
it("returns a run context report without deferred Codex dynamic tool schemas", async () => {
__testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("message"),
createRuntimeDynamicTool("web_search"),
]);
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.sourceReplyDeliveryMode = "message_tool_only";
params.toolsAllow = ["message", "web_search"];
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { mode: "yolo" } },
});
await harness.waitForMethod("turn/start", 120_000);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
const report = result.systemPromptReport;
expect(report?.source).toBe("run");
expect(report?.provider).toBe("codex");
expect(report?.model).toBe("gpt-5.4-codex");
expect(report?.systemPrompt.chars).toBeGreaterThan(0);
const message = report?.tools.entries.find((tool) => tool.name === "message");
const webSearch = report?.tools.entries.find((tool) => tool.name === "web_search");
expect(message?.schemaChars).toBeGreaterThan(0);
expect(webSearch?.schemaChars).toBe(0);
expect(report?.tools.schemaChars).toBe(message?.schemaChars);
});
it("keeps searchable Codex dynamic tools canonical in mirrored transcript snapshots", async () => {
__testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("wiki_status"),

View File

@@ -95,6 +95,7 @@ import {
type CodexUserInput,
isJsonObject,
type CodexServerNotification,
type CodexDynamicToolSpec,
type CodexDynamicToolCallParams,
type CodexDynamicToolCallResponse,
type CodexTurnStartResponse,
@@ -158,6 +159,11 @@ type OpenClawCodingToolsOptions = NonNullable<
>;
type OpenClawCodingToolsFactory =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type CodexBootstrapContext = Awaited<ReturnType<typeof resolveBootstrapContextForRun>>;
type CodexBootstrapFile = CodexBootstrapContext["bootstrapFiles"][number];
type CodexSystemPromptReport = NonNullable<EmbeddedRunAttemptResult["systemPromptReport"]>;
type CodexToolReportEntry = CodexSystemPromptReport["tools"]["entries"][number];
type CodexWorkspaceBootstrapContext = CodexBootstrapContext & { instructions?: string };
const testClientFactoryStorage = new AsyncLocalStorage<CodexAppServerClientFactory | undefined>();
const clientFactory = defaultCodexAppServerClientFactory;
@@ -571,13 +577,14 @@ export async function runCodexAppServerAttempt(
// Build the workspace bootstrap block before finalizing developer
// instructions so persona files (SOUL.md, IDENTITY.md, ...) reach Codex
// through the explicit `developerInstructions` field.
const workspaceBootstrapInstructions = await buildCodexWorkspaceBootstrapInstructions({
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
params,
resolvedWorkspace,
effectiveWorkspace,
sessionKey: sandboxSessionKey,
sessionAgentId,
});
const workspaceBootstrapInstructions = workspaceBootstrapContext.instructions;
let promptText = params.prompt;
let developerInstructions = joinPresentSections(
baseDeveloperInstructions,
@@ -639,6 +646,14 @@ export async function runCodexAppServerAttempt(
messages: historyMessages,
ctx: hookContext,
});
const systemPromptReport = buildCodexSystemPromptReport({
attempt: params,
sessionKey: sandboxSessionKey,
workspaceDir: effectiveWorkspace,
developerInstructions: promptBuild.developerInstructions,
workspaceBootstrapContext,
tools: toolBridge.specs,
});
const trajectoryRecorder = createCodexTrajectoryRecorder({
attempt: params,
cwd: effectiveWorkspace,
@@ -1528,6 +1543,7 @@ export async function runCodexAppServerAttempt(
aborted: finalAborted,
promptError: finalPromptError,
promptErrorSource: finalPromptErrorSource,
systemPromptReport,
};
} finally {
emitLifecycleTerminal({
@@ -2195,15 +2211,15 @@ async function readMirroredSessionHistoryMessages(
return messages;
}
async function buildCodexWorkspaceBootstrapInstructions(params: {
async function buildCodexWorkspaceBootstrapContext(params: {
params: EmbeddedRunAttemptParams;
resolvedWorkspace: string;
effectiveWorkspace: string;
sessionKey: string;
sessionAgentId: string;
}): Promise<string | undefined> {
}): Promise<CodexWorkspaceBootstrapContext> {
try {
const { contextFiles } = await resolveBootstrapContextForRun({
const bootstrapContext = await resolveBootstrapContextForRun({
workspaceDir: params.resolvedWorkspace,
config: params.params.config,
sessionKey: params.sessionKey,
@@ -2213,21 +2229,158 @@ async function buildCodexWorkspaceBootstrapInstructions(params: {
contextMode: params.params.bootstrapContextMode,
runKind: params.params.bootstrapContextRunKind,
});
return renderCodexWorkspaceBootstrapInstructions(
contextFiles.map((file) =>
remapCodexContextFilePath({
file,
sourceWorkspaceDir: params.resolvedWorkspace,
targetWorkspaceDir: params.effectiveWorkspace,
}),
),
const contextFiles = bootstrapContext.contextFiles.map((file) =>
remapCodexContextFilePath({
file,
sourceWorkspaceDir: params.resolvedWorkspace,
targetWorkspaceDir: params.effectiveWorkspace,
}),
);
return {
...bootstrapContext,
contextFiles,
instructions: renderCodexWorkspaceBootstrapInstructions(contextFiles),
};
} catch (error) {
embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error });
return undefined;
return { bootstrapFiles: [], contextFiles: [] };
}
}
function buildCodexSystemPromptReport(params: {
attempt: EmbeddedRunAttemptParams;
sessionKey: string;
workspaceDir: string;
developerInstructions: string;
workspaceBootstrapContext: CodexWorkspaceBootstrapContext;
tools: CodexDynamicToolSpec[];
}): CodexSystemPromptReport {
const toolEntries = params.tools.map(buildCodexToolReportEntry);
const schemaChars = toolEntries.reduce((sum, tool) => sum + tool.schemaChars, 0);
const projectContextChars = params.workspaceBootstrapContext.instructions?.length ?? 0;
const bootstrapMaxChars = readPositiveNumber(
params.attempt.config?.agents?.defaults?.bootstrapMaxChars,
);
const bootstrapTotalMaxChars = readPositiveNumber(
params.attempt.config?.agents?.defaults?.bootstrapTotalMaxChars,
);
return {
source: "run",
generatedAt: Date.now(),
sessionId: params.attempt.sessionId,
sessionKey: params.sessionKey,
provider: params.attempt.provider,
model: params.attempt.modelId,
workspaceDir: params.workspaceDir,
...(bootstrapMaxChars ? { bootstrapMaxChars } : {}),
...(bootstrapTotalMaxChars ? { bootstrapTotalMaxChars } : {}),
systemPrompt: {
chars: params.developerInstructions.length,
projectContextChars,
nonProjectContextChars: Math.max(
0,
params.developerInstructions.length - projectContextChars,
),
},
injectedWorkspaceFiles: buildCodexBootstrapInjectionStats({
bootstrapFiles: params.workspaceBootstrapContext.bootstrapFiles,
injectedFiles: params.workspaceBootstrapContext.contextFiles,
}),
skills: {
promptChars: 0,
entries: [],
},
tools: {
listChars: 0,
schemaChars,
entries: toolEntries,
},
};
}
function buildCodexToolReportEntry(tool: CodexDynamicToolSpec): CodexToolReportEntry {
const summary = tool.description.trim();
if (tool.deferLoading === true) {
return {
name: tool.name,
summaryChars: summary.length,
schemaChars: 0,
propertiesCount: null,
};
}
return {
name: tool.name,
summaryChars: summary.length,
...buildCodexToolSchemaStats(tool.inputSchema),
};
}
function buildCodexToolSchemaStats(
schema: JsonValue,
): Pick<CodexToolReportEntry, "schemaChars" | "propertiesCount"> {
const schemaChars = (() => {
try {
return JSON.stringify(schema).length;
} catch {
return 0;
}
})();
const properties =
isJsonObject(schema) && isJsonObject(schema.properties) ? schema.properties : null;
return {
schemaChars,
propertiesCount: properties ? Object.keys(properties).length : null,
};
}
function buildCodexBootstrapInjectionStats(params: {
bootstrapFiles: CodexBootstrapFile[];
injectedFiles: EmbeddedContextFile[];
}): CodexSystemPromptReport["injectedWorkspaceFiles"] {
const injectedByPath = new Map<string, string>();
const injectedByBaseName = new Map<string, string>();
for (const file of params.injectedFiles) {
const pathValue = readNonEmptyString(file.path);
if (!pathValue) {
continue;
}
if (!injectedByPath.has(pathValue)) {
injectedByPath.set(pathValue, file.content);
}
const baseName = path.posix.basename(pathValue.replaceAll("\\", "/"));
if (!injectedByBaseName.has(baseName)) {
injectedByBaseName.set(baseName, file.content);
}
}
return params.bootstrapFiles.map((file) => {
const pathValue = readNonEmptyString(file.path) ?? file.name;
const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length;
const injected =
injectedByPath.get(pathValue) ??
injectedByPath.get(file.name) ??
injectedByBaseName.get(file.name);
const injectedChars = injected?.length ?? 0;
return {
name: file.name,
path: pathValue,
missing: file.missing,
rawChars,
injectedChars,
truncated: !file.missing && injectedChars < rawChars,
};
});
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function renderCodexWorkspaceBootstrapInstructions(
contextFiles: EmbeddedContextFile[],
): string | undefined {

View File

@@ -182,4 +182,26 @@ describe("buildContextReply", () => {
await unlink(result.mediaUrl);
}
});
it("does not render context map from an estimated report", async () => {
const params = makeParams("/context map", false);
const report = params.sessionEntry?.systemPromptReport;
if (!report) {
throw new Error("missing context report");
}
params.sessionEntry = {
...params.sessionEntry,
systemPromptReport: {
...report,
source: "estimate",
},
} as SessionEntry;
const result = await buildContextReply(params);
expect(result.text).toContain("Context treemap unavailable.");
expect(result.text).toContain("No actual run context is cached for this session yet.");
expect(result.text).not.toContain("Source: estimate");
expect(result.mediaUrl).toBeUndefined();
});
});

View File

@@ -43,15 +43,21 @@ function formatListTop(
return { lines, omitted };
}
function resolveRunContextReport(params: HandleCommandsParams): SessionSystemPromptReport | null {
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
const existing = targetSessionEntry?.systemPromptReport;
return existing?.source === "run" ? existing : null;
}
async function resolveContextReport(
params: HandleCommandsParams,
): Promise<SessionSystemPromptReport> {
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
const existing = targetSessionEntry?.systemPromptReport;
if (existing && existing.source === "run") {
return existing;
const runReport = resolveRunContextReport(params);
if (runReport) {
return runReport;
}
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
const bootstrapMaxChars = resolveBootstrapMaxChars(params.cfg);
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.cfg);
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
@@ -100,7 +106,6 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
};
}
const report = await resolveContextReport(params);
const cachedContextUsageTokens = resolveFreshSessionTotalTokens(targetSessionEntry);
const session = {
totalTokens: targetSessionEntry?.totalTokens ?? null,
@@ -110,11 +115,17 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
contextTokens: params.contextTokens ?? null,
} as const;
if (sub === "json") {
return { text: JSON.stringify({ report, session }, null, 2) };
}
if (sub === "map") {
const report = resolveRunContextReport(params);
if (!report) {
return {
text: [
"Context treemap unavailable.",
"No actual run context is cached for this session yet.",
"Send a normal message, then run /context map again.",
].join("\n"),
};
}
const treemap = await renderContextTreemapPng({
report,
session: {
@@ -130,6 +141,12 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
};
}
const report = await resolveContextReport(params);
if (sub === "json") {
return { text: JSON.stringify({ report, session }, null, 2) };
}
if (sub !== "list" && sub !== "show" && sub !== "detail" && sub !== "deep") {
return {
text: [