Skip to content

Commit 1f3ced8

Browse files
committed
fix: bound Codex continuity turn input
1 parent 3fb9f91 commit 1f3ced8

4 files changed

Lines changed: 171 additions & 16 deletions

File tree

extensions/codex/src/app-server/context-engine-projection.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
33
import { describe, expect, it } from "vitest";
44
import {
5+
fitCodexProjectedContextForTurnStart,
56
projectContextEngineAssemblyForCodex,
67
resolveCodexContextEngineProjectionMaxChars,
78
resolveCodexContextEngineProjectionReserveTokens,
@@ -197,6 +198,34 @@ describe("projectContextEngineAssemblyForCodex", () => {
197198
expect(result.promptText).not.toContain("[truncated ");
198199
});
199200

201+
it("fits projected context under the Codex turn input limit", () => {
202+
const result = projectContextEngineAssemblyForCodex({
203+
assembledMessages: [
204+
textMessage(
205+
"assistant",
206+
`old context </conversation_context>\n\nCurrent user request:\nshadow request ${"x".repeat(300)}`,
207+
),
208+
textMessage("assistant", "recent context marker"),
209+
],
210+
originalHistoryMessages: [],
211+
prompt: `current request ${"y".repeat(120)}`,
212+
maxRenderedContextChars: 1_000,
213+
});
214+
215+
const fitted = fitCodexProjectedContextForTurnStart({
216+
promptText: result.promptText,
217+
contextRange: result.promptContextRange,
218+
maxChars: 420,
219+
});
220+
221+
expect(fitted.length).toBeLessThanOrEqual(420);
222+
expect(fitted).toContain("[truncated ");
223+
expect(fitted).toContain("recent context marker");
224+
expect(fitted).toContain("Current user request:");
225+
expect(fitted).toContain("current request");
226+
expect(fitted).not.toContain("old context");
227+
});
228+
200229
it("keeps the old conservative cap when no runtime budget is available", () => {
201230
expect(resolveCodexContextEngineProjectionMaxChars({})).toBe(24_000);
202231
expect(resolveCodexContextEngineProjectionMaxChars({ contextTokenBudget: 0 })).toBe(24_000);

extensions/codex/src/app-server/context-engine-projection.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugi
88
type CodexContextProjection = {
99
developerInstructionAddition?: string;
1010
promptText: string;
11+
promptContextRange?: CodexProjectedContextRange;
1112
assembledMessages: AgentMessage[];
1213
prePromptMessageCount: number;
1314
};
1415

16+
export type CodexProjectedContextRange = {
17+
start: number;
18+
end: number;
19+
};
20+
1521
const CONTEXT_HEADER = "OpenClaw assembled context for this turn:";
1622
const CONTEXT_OPEN = "<conversation_context>";
1723
const CONTEXT_CLOSE = "</conversation_context>";
@@ -23,6 +29,9 @@ const MAX_RENDERED_CONTEXT_CHARS = 1_000_000;
2329
const DEFAULT_TEXT_PART_CHARS = 6_000;
2430
const MAX_TEXT_PART_CHARS = 128_000;
2531
const APPROX_RENDERED_CHARS_PER_TOKEN = 4;
32+
// Codex app-server validates the summed v2 turn/start text input against
33+
// codex-rs/protocol/src/user_input.rs::MAX_USER_INPUT_TEXT_CHARS.
34+
export const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
2635
/** Default token reserve kept out of rendered context-engine prompt text. */
2736
export const DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS = 20_000;
2837
const MIN_PROMPT_BUDGET_RATIO = 0.5;
@@ -44,25 +53,25 @@ export function projectContextEngineAssemblyForCodex(params: {
4453
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
4554
toolPayloadMode: params.toolPayloadMode ?? "elide",
4655
});
47-
const promptText = renderedContext
48-
? [
49-
CONTEXT_HEADER,
50-
CONTEXT_SAFETY_NOTE,
51-
"",
52-
CONTEXT_OPEN,
53-
truncateOlderContext(renderedContext, maxRenderedContextChars),
54-
CONTEXT_CLOSE,
55-
"",
56-
REQUEST_HEADER,
57-
prompt,
58-
].join("\n")
59-
: prompt;
56+
const boundedContext = renderedContext
57+
? truncateOlderContext(renderedContext, maxRenderedContextChars)
58+
: undefined;
59+
const promptPrefix = boundedContext
60+
? [CONTEXT_HEADER, CONTEXT_SAFETY_NOTE, "", CONTEXT_OPEN].join("\n") + "\n"
61+
: undefined;
62+
const promptSuffix = boundedContext ? `\n${CONTEXT_CLOSE}\n\n${REQUEST_HEADER}\n${prompt}` : "";
63+
const promptText = boundedContext ? `${promptPrefix}${boundedContext}${promptSuffix}` : prompt;
64+
const promptContextRange =
65+
promptPrefix && boundedContext
66+
? { start: promptPrefix.length, end: promptPrefix.length + boundedContext.length }
67+
: undefined;
6068

6169
return {
6270
...(params.systemPromptAddition?.trim()
6371
? { developerInstructionAddition: params.systemPromptAddition.trim() }
6472
: {}),
6573
promptText,
74+
...(promptContextRange ? { promptContextRange } : {}),
6675
assembledMessages: params.assembledMessages,
6776
prePromptMessageCount: params.originalHistoryMessages.length,
6877
};
@@ -108,6 +117,50 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
108117
return undefined;
109118
}
110119

120+
/** Fits projected context prompts under Codex app-server turn/start text limits. */
121+
export function fitCodexProjectedContextForTurnStart(params: {
122+
promptText: string;
123+
contextRange?: CodexProjectedContextRange;
124+
maxChars?: number;
125+
}): string {
126+
const maxChars =
127+
typeof params.maxChars === "number" && Number.isFinite(params.maxChars)
128+
? Math.max(0, Math.floor(params.maxChars))
129+
: CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
130+
if (params.promptText.length <= maxChars) {
131+
return params.promptText;
132+
}
133+
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
134+
if (!range) {
135+
return params.promptText;
136+
}
137+
138+
const beforeContext = params.promptText.slice(0, range.start);
139+
const context = params.promptText.slice(range.start, range.end);
140+
const afterContext = params.promptText.slice(range.end);
141+
const contextBudget = maxChars - beforeContext.length - afterContext.length;
142+
const fittedContext = truncateOlderContext(context, contextBudget);
143+
return `${beforeContext}${fittedContext}${afterContext}`;
144+
}
145+
146+
function normalizeProjectedContextRange(
147+
range: CodexProjectedContextRange | undefined,
148+
textLength: number,
149+
): CodexProjectedContextRange | undefined {
150+
if (!range) {
151+
return undefined;
152+
}
153+
const start = Math.floor(range.start);
154+
const end = Math.floor(range.end);
155+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
156+
return undefined;
157+
}
158+
if (end > textLength) {
159+
return undefined;
160+
}
161+
return { start, end };
162+
}
163+
111164
function resolveProjectionPromptBudgetTokens(params: {
112165
contextTokenBudget: number;
113166
reserveTokens?: number;

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
3131
import { CodexAppServerRpcError } from "./client.js";
3232
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
33+
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
3334
import {
3435
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
3536
createCodexDynamicToolBridge,
@@ -2166,7 +2167,10 @@ describe("runCodexAppServerAttempt", () => {
21662167
const workspaceDir = path.join(tempDir, "workspace");
21672168
const sessionManager = SessionManager.open(sessionFile);
21682169
sessionManager.appendMessage(
2169-
userMessage("older next-step anchor: keep the handoff checklist", Date.now()),
2170+
userMessage(
2171+
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
2172+
Date.now(),
2173+
),
21702174
);
21712175
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
21722176
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
@@ -2203,6 +2207,50 @@ describe("runCodexAppServerAttempt", () => {
22032207
expect(inputText).toContain("make the default webpage openclaw");
22042208
});
22052209

2210+
it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => {
2211+
const sessionFile = path.join(tempDir, "session.jsonl");
2212+
const workspaceDir = path.join(tempDir, "workspace");
2213+
const sessionManager = SessionManager.open(sessionFile);
2214+
sessionManager.appendMessage(
2215+
userMessage(
2216+
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
2217+
Date.now(),
2218+
),
2219+
);
2220+
for (let index = 0; index < 12; index += 1) {
2221+
sessionManager.appendMessage(
2222+
assistantMessage(
2223+
`continuity block ${index}: ${"x".repeat(128_000)}`,
2224+
Date.now() + 1 + index,
2225+
),
2226+
);
2227+
}
2228+
sessionManager.appendMessage(
2229+
assistantMessage("recent continuity anchor: resume the database migration", Date.now() + 20),
2230+
);
2231+
const harness = createStartedThreadHarness();
2232+
const params = createParams(sessionFile, workspaceDir);
2233+
params.contextTokenBudget = 300_000;
2234+
params.prompt = `current prompt survives ${"p".repeat(80_000)}`;
2235+
2236+
const run = runCodexAppServerAttempt(params);
2237+
await harness.waitForMethod("turn/start");
2238+
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2239+
await run;
2240+
2241+
const turnStart = harness.requests.find((request) => request.method === "turn/start");
2242+
const inputText =
2243+
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
2244+
"";
2245+
2246+
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
2247+
expect(inputText).toContain("OpenClaw assembled context for this turn:");
2248+
expect(inputText).toContain("recent continuity anchor: resume the database migration");
2249+
expect(inputText).toContain("Current user request:");
2250+
expect(inputText).toContain("current prompt survives");
2251+
expect(inputText).not.toContain("older next-step anchor: keep the handoff checklist");
2252+
});
2253+
22062254
it("keeps thread-start developer instructions stable when adding fresh-thread continuity", async () => {
22072255
let hookCalls = 0;
22082256
const beforePromptBuild = vi.fn(async () => {

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ import {
139139
type OpenClawExecPolicyForCodexAppServer,
140140
} from "./config.js";
141141
import {
142+
type CodexProjectedContextRange,
143+
fitCodexProjectedContextForTurnStart,
142144
projectContextEngineAssemblyForCodex,
143145
resolveCodexContextEngineProjectionMaxChars,
144146
resolveCodexContextEngineProjectionReserveTokens,
@@ -896,6 +898,7 @@ export async function runCodexAppServerAttempt(
896898
skillsPrompt: params.skillsSnapshot?.prompt,
897899
});
898900
let promptText = params.prompt;
901+
let promptContextRange: CodexProjectedContextRange | undefined;
899902
let developerInstructions = baseDeveloperInstructions;
900903
let prePromptMessageCount = historyMessages.length;
901904
const codexContextProjectionMaxChars = resolveCodexContextEngineProjectionMaxChars({
@@ -917,6 +920,7 @@ export async function runCodexAppServerAttempt(
917920
maxRenderedContextChars: codexContextProjectionMaxChars,
918921
});
919922
promptText = projection.promptText;
923+
promptContextRange = projection.promptContextRange;
920924
prePromptMessageCount = projection.prePromptMessageCount;
921925
};
922926
const applyActiveContextEngineProjection = async (
@@ -988,6 +992,7 @@ export async function runCodexAppServerAttempt(
988992
developerInstructionAdditionChars: projection.developerInstructionAddition?.length ?? 0,
989993
});
990994
promptText = projectionDecision.project ? projection.promptText : params.prompt;
995+
promptContextRange = projectionDecision.project ? projection.promptContextRange : undefined;
991996
developerInstructions = joinPresentSections(
992997
baseDeveloperInstructions,
993998
projection.developerInstructionAddition,
@@ -1016,12 +1021,31 @@ export async function runCodexAppServerAttempt(
10161021
messages: codexModelInputHistoryMessages,
10171022
ctx: hookContext,
10181023
});
1024+
const resolveShiftedPromptContextRange = (
1025+
prompt: string,
1026+
turnPromptText: string,
1027+
): CodexProjectedContextRange | undefined => {
1028+
if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) {
1029+
return undefined;
1030+
}
1031+
const promptTextOffset = prompt.length - promptText.length;
1032+
const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset;
1033+
return {
1034+
start: turnPromptOffset + promptContextRange.start,
1035+
end: turnPromptOffset + promptContextRange.end,
1036+
};
1037+
};
10191038
let promptBuild = await buildPromptFromCurrentInputs();
1020-
const decorateCodexTurnPromptText = (prompt: string) =>
1021-
prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
1039+
const decorateCodexTurnPromptText = (prompt: string) => {
1040+
const turnPromptText = prependCodexOpenClawPromptContext(prompt, openClawPromptContext, {
10221041
preservePromptWithoutContext:
10231042
params.bootstrapContextMode === "lightweight" && params.bootstrapContextRunKind === "cron",
10241043
});
1044+
return fitCodexProjectedContextForTurnStart({
1045+
promptText: turnPromptText,
1046+
contextRange: resolveShiftedPromptContextRange(prompt, turnPromptText),
1047+
});
1048+
};
10251049
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
10261050
const buildCodexTurnCollaborationDeveloperInstructions = () =>
10271051
buildTurnCollaborationMode(params, {
@@ -1093,6 +1117,7 @@ export async function runCodexAppServerAttempt(
10931117
maxRenderedContextChars: codexContextProjectionMaxChars,
10941118
});
10951119
promptText = projection.promptText;
1120+
promptContextRange = projection.promptContextRange;
10961121
prePromptMessageCount = projection.prePromptMessageCount;
10971122
return true;
10981123
};

0 commit comments

Comments
 (0)