Repository navigation
fix: bound generation history and flush per turn to stop Stop-hook OOM on long sessions #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -367,21 +367,55 @@ function turnHistoryMessages(turn: Turn): ChatMlMessage[] { | |
| return messages; | ||
| } | ||
|
|
||
| const messageChars = new WeakMap<ChatMlMessage, number>(); | ||
|
|
||
| function serializedChars(message: ChatMlMessage): number { | ||
| let chars = messageChars.get(message); | ||
| if (chars === undefined) { | ||
| chars = JSON.stringify(message).length; | ||
| messageChars.set(message, chars); | ||
| } | ||
| return chars; | ||
| } | ||
|
|
||
| /** | ||
| * The conversation a generation saw, keeping only the most recent messages | ||
| * that fit in `maxChars` (0 = unbounded). Every generation serializes its | ||
| * input onto its span, so an unbounded history costs memory quadratic in the | ||
| * session length and can crash the hook on long sessions. | ||
| */ | ||
| function boundedConversation( | ||
| earlier: readonly ChatMlMessage[], | ||
| current: readonly ChatMlMessage[], | ||
| maxChars: number, | ||
| ): ChatMlMessage[] { | ||
| const all = [...earlier, ...current]; | ||
| if (maxChars <= 0) return all; | ||
| let used = 0; | ||
| let start = all.length; | ||
| while (start > 0) { | ||
| const chars = serializedChars(all[start - 1]); | ||
| if (used + chars > maxChars) break; | ||
| used += chars; | ||
| start--; | ||
| } | ||
| if (start === 0) return all; | ||
|
Comment on lines
+396
to
+402
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Users with one oversized message lose the whole conversation from generation inputs, including the current turn's own user prompt. Only an omission marker is left. In Why this was flaggedThe trigger is a Codex turn whose user message, or whose latest tool output, serializes to more than Verification: The trigger is any single message whose JSON.stringify length is over |
||
| return [ | ||
| { | ||
| role: "system", | ||
| content: `[${start} earlier message(s) omitted: the conversation exceeds max_history_chars (${maxChars})]`, | ||
| }, | ||
| ...all.slice(start), | ||
|
Comment on lines
+396
to
+408
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Trimmed generation inputs can start with tool results whose assistant tool call was dropped, so the stored conversation is not valid ChatML. In Why this was flaggedThe trigger is any Codex turn whose history goes over Verification: Severity: nit. In plugins/tracing/src/trace.ts, |
||
| ]; | ||
| } | ||
|
|
||
| function generationInput( | ||
| systemMessage: string | undefined, | ||
| historyPrefix: ChatMlMessage[], | ||
| turn: Turn, | ||
| stepIndex: number, | ||
| conversation: ChatMlMessage[], | ||
| ): ChatMlMessage[] | undefined { | ||
| const messages: ChatMlMessage[] = []; | ||
| if (systemMessage) messages.push({ role: "system", content: systemMessage }); | ||
| messages.push(...historyPrefix); | ||
| const user = userMessage(turn); | ||
| if (user) messages.push(user); | ||
| for (let j = 0; j < stepIndex; j++) { | ||
| messages.push(assistantMessage(turn.steps[j])); | ||
| messages.push(...toolMessages(turn.steps[j])); | ||
| } | ||
| messages.push(...conversation); | ||
| return messages.length > 0 ? messages : undefined; | ||
| } | ||
|
|
||
|
|
@@ -487,14 +521,21 @@ async function emitTurn( | |
| const systemMessage = systemPromptText(turn.systemPrompt); | ||
| const historyPrefix = ctx.historyPrefix ?? []; | ||
| const spawnObservations = new Map<string, LangfuseObservation>(); | ||
| const user = userMessage(turn); | ||
| const turnMessages: ChatMlMessage[] = user ? [user] : []; | ||
|
|
||
| for (let i = 0; i < turn.steps.length; i++) { | ||
| const step = turn.steps[i]; | ||
| const conversation = boundedConversation( | ||
| historyPrefix, | ||
| turnMessages, | ||
| ctx.config.max_history_chars, | ||
| ); | ||
| const generation = startObservation( | ||
| isSubagent ? "LLM Subagent" : "LLM", | ||
| { | ||
| input: attachToolDefinitions( | ||
| generationInput(systemMessage, historyPrefix, turn, i), | ||
| generationInput(systemMessage, conversation), | ||
| turn.toolDefinitions, | ||
| ), | ||
| output: buildGenerationOutput(step), | ||
|
|
@@ -521,6 +562,7 @@ async function emitTurn( | |
| } | ||
|
|
||
| generation.end(new Date(generationEnd(step))); | ||
| turnMessages.push(assistantMessage(step), ...toolMessages(step)); | ||
| } | ||
|
|
||
| const announced: SubagentRollout[] = []; | ||
|
|
@@ -639,6 +681,8 @@ export async function convertRollout( | |
| seenThreadIds?: Set<string>; | ||
| ancestorTurnIds?: ReadonlySet<string>; | ||
| stoppedTurnId?: string; | ||
| /** Awaited after each exported turn, before the next one is converted. */ | ||
| onTurnExported?: (turnId: string) => Promise<void>; | ||
| }, | ||
| ): Promise<string[]> { | ||
| const { sessionMeta, turns } = parseSession(await loadSession(rolloutFile)); | ||
|
|
@@ -774,6 +818,7 @@ export async function convertRollout( | |
|
|
||
| uploaded.add(turn.turnId); | ||
| exportedTurnIds.push(turn.turnId); | ||
| await options.onTurnExported?.(turn.turnId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When one parent turn contains a long subagent rollout, this callback is reached only after Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| return exportedTurnIds; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For sessions with many turns or tool calls,
max_history_charsdoes not bound the memory used here because[...earlier, ...current]first copies every historical message, andconvertRollouthas already retained a separate cumulativehistoryPrefixesarray for every turn. The number of retained references remains quadratic in session length, and each generation temporarily allocates another full-history array before discarding old entries, so sufficiently long sessions can still hit the OOM this change is intended to prevent. Walk the two inputs backward or maintain a bounded rolling history without materializing all prior messages.Useful? React with 👍 / 👎.