mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:44:45 +00:00
fix(context): persist Codex run context maps
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user