Skip to content

Commit ea76a45

Browse files
VACIncjalehman
andauthored
Prevent Codex thread rotation from losing next-step context (#94093)
Merged via squash. Prepared head SHA: 1f3ced8 Maintainer decision: `checks-node-core-tooling` is an unrelated baseline/tooling failure; PR-relevant CI and real behavior proof passed. Co-authored-by: VACInc <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 84bcdaa commit ea76a45

4 files changed

Lines changed: 216 additions & 23 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: 80 additions & 2 deletions
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,
@@ -2165,8 +2166,22 @@ describe("runCodexAppServerAttempt", () => {
21652166
const sessionFile = path.join(tempDir, "session.jsonl");
21662167
const workspaceDir = path.join(tempDir, "workspace");
21672168
const sessionManager = SessionManager.open(sessionFile);
2169+
sessionManager.appendMessage(
2170+
userMessage(
2171+
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
2172+
Date.now(),
2173+
),
2174+
);
21682175
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
21692176
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
2177+
for (let index = 0; index < 8; index += 1) {
2178+
sessionManager.appendMessage(
2179+
assistantMessage(
2180+
`continuity filler ${index}: ${"x".repeat(4_000)}`,
2181+
Date.now() + 2 + index,
2182+
),
2183+
);
2184+
}
21702185
const harness = createStartedThreadHarness();
21712186
const params = createParams(sessionFile, workspaceDir);
21722187
params.prompt = "make the default webpage openclaw";
@@ -2185,12 +2200,57 @@ describe("runCodexAppServerAttempt", () => {
21852200
"";
21862201

21872202
expect(inputText).toContain("OpenClaw assembled context for this turn:");
2203+
expect(inputText).toContain("older next-step anchor: keep the handoff checklist");
21882204
expect(inputText).toContain("we are fixing the Opik default project");
21892205
expect(inputText).toContain("Opik default project context");
21902206
expect(inputText).toContain("Current user request:");
21912207
expect(inputText).toContain("make the default webpage openclaw");
21922208
});
21932209

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+
21942254
it("keeps thread-start developer instructions stable when adding fresh-thread continuity", async () => {
21952255
let hookCalls = 0;
21962256
const beforePromptBuild = vi.fn(async () => {
@@ -4787,11 +4847,28 @@ describe("runCodexAppServerAttempt", () => {
47874847
}
47884848
const sessionManager = SessionManager.open(sessionFile);
47894849
sessionManager.appendMessage(
4790-
userMessage("post-binding user context", bindingUpdatedAt + 1_000),
4850+
userMessage(
4851+
"pre-binding native-owned context: keep the original plan",
4852+
bindingUpdatedAt - 2_000,
4853+
),
4854+
);
4855+
sessionManager.appendMessage(
4856+
userMessage(
4857+
"post-binding user context: resume the release checklist",
4858+
bindingUpdatedAt + 1_000,
4859+
),
47914860
);
47924861
sessionManager.appendMessage(
47934862
assistantMessage("post-binding assistant context", bindingUpdatedAt + 2_000),
47944863
);
4864+
for (let index = 0; index < 8; index += 1) {
4865+
sessionManager.appendMessage(
4866+
assistantMessage(
4867+
`post-binding continuity filler ${index}: ${"x".repeat(4_000)}`,
4868+
bindingUpdatedAt + 3_000 + index,
4869+
),
4870+
);
4871+
}
47954872
await fs.writeFile(
47964873
path.join(path.dirname(sessionFile), "sessions.json"),
47974874
JSON.stringify({
@@ -4835,7 +4912,8 @@ describe("runCodexAppServerAttempt", () => {
48354912
const inputText =
48364913
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
48374914
"";
4838-
expect(inputText).toContain("post-binding user context");
4915+
expect(inputText).toContain("pre-binding native-owned context: keep the original plan");
4916+
expect(inputText).toContain("post-binding user context: resume the release checklist");
48394917
expect(inputText).toContain("post-binding assistant context");
48404918
const savedBinding = await readCodexAppServerBinding(sessionFile);
48414919
expect(savedBinding?.threadId).toBe("thread-1");

0 commit comments

Comments
 (0)