Skip to content

fix: bound generation history and flush per turn to stop Stop-hook OOM on long sessions - #122

Merged
hassiebp merged 2 commits into
mainfrom
hassiebbot/bound-generation-history-311b
Oct 28, 2026
Merged

hassiebp merged 2 commits into
mainfrom
hassiebbot/bound-generation-history-311b

Conversation

@hassiebp

Copy link
Copy Markdown
Collaborator

Closes LFE-17701. Closes #119.

Problem

On long Codex sessions the Stop hook aborts with a V8 heap OOM, and from then on none of that session's turns reach Langfuse.

Every generation's input is the full conversation so far: earlier turns plus earlier steps of the current turn, tool outputs included. Each input is JSON.stringify'd onto its span, so memory grows roughly quadratically with session length. All pending turns are converted before a single flush at shutdown, and only then are turns marked in the sidecar.

That gives three ways to fail, each of which leaves every turn unmarked and the next hook with an even bigger backlog:

  1. Heap OOM: a hard abort() that the hook's try/catch can't catch.
  2. RangeError: Invalid string length: the batched OTLP request body passes V8's ~512 MB string limit. This happens even without an OOM.
  3. Hook timeout: after that serializer error, forceFlush waits out the 30 s export timeout, which is over the 30 s Codex hook timeout.

Changes

1. Bound the conversation attached to each generation (max_history_chars, default 200000, 0 disables).

  • The budget covers the whole conversation: earlier turns and earlier steps of the current turn. Capping only earlier turns doesn't fix one long turn; a single 60-step turn with 500 KB tool outputs still failed.
  • It keeps the most recent whole messages that fit and replaces the rest with one [N earlier message(s) omitted …] system marker.
  • No message is ever cut, so this stays in line with fix: stop truncating traced content at 20,000 characters #90's rule that traced content should never be truncated. Only the repeated copies of older history are dropped. Tool spans, generation outputs and earlier turns' traces still hold every message in full.
  • Message sizes are cached per message object, so applying the budget costs about the budget per generation.
  • Configurable via langfuse.json or LANGFUSE_CODEX_MAX_HISTORY_CHARS. Invalid values fall back to the default without discarding the rest of the config file.

2. Flush and mark each turn before converting the next.

  • convertRollout takes an onTurnExported callback. The hook uses it to flush() and then markTurnUploaded per turn.
  • A backlog now drains turn by turn instead of all-or-nothing, and only one turn's spans are held in memory at a time.
  • Delivery semantics are unchanged: a turn is marked only after its export succeeds. A failed flush stops the loop and leaves the remaining turns retryable.

Measurements

Built hook, synthetic rollouts, local sink that accepts uploads, --max-old-space-size=1536:

Workload Before After
1 turn × 60 steps × 500 KB tool output ~916 MB heap, Invalid string length, 33 s, nothing uploaded 74 MB, 0.5 s, 1/1 marked
18 turns × 20 steps × 20 KB ~1.3 GB heap, Invalid string length, 36 s, nothing uploaded 51 MB, 0.7 s, 18/18 marked
40 turns × 20 steps × 20 KB heap OOM, SIGABRT (exit 134) 80 MB, 1.4 s, 40/40 marked

Tests

  • config.test.ts: the default value, reading from the file and from env, and invalid values being ignored without dropping the file.
  • trace.test.ts: the budget covering earlier turns and current-turn steps, the exact marker text, and 0 meaning no cap.
  • hook-command.test.ts: the end-to-end hook delivers turn 1, the sink rejects turn 2, and the sidecar contains only turn-1.
  • All 5 new tests fail on main and pass here. pnpm lint and pnpm test (125 tests) pass.

Linear Issue: LFE-17701

Open in Web Open in Cursor 

cursoragent and others added 2 commits October 21, 2026 14:11
Every generation serialized the full session history (earlier turns plus
earlier steps of the current turn) onto its span, so memory grew
quadratically with session length and long sessions crashed the Stop hook
with a V8 heap OOM or an oversized export request.

Keep only the most recent messages that fit in max_history_chars
(default 200000, 0 disables) and replace the rest with a single
omitted-messages marker. Messages are never cut, and tool spans keep their
full output.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>
The Stop hook converted every pending turn, flushed once, and only then
marked turns in the sidecar. A backlog therefore sat in memory all at once
and was delivered all-or-nothing: any crash, timeout or failed export left
every turn unmarked, so the next hook retried an even larger backlog.

Flush after each turn and mark it as soon as its export succeeds, so a
backlog drains incrementally and only one turn's spans are held at a time.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>
@hassiebp
hassiebp marked this pull request as ready for review October 28, 2026 09:03
@hassiebp
hassiebp merged commit feb4fc3 into main Oct 28, 2026
4 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T09:06:13.534938Z 4381a3f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4381a3fdd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

current: readonly ChatMlMessage[],
maxChars: number,
): ChatMlMessage[] {
const all = [...earlier, ...current];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid materializing full history before enforcing the cap

For sessions with many turns or tool calls, max_history_chars does not bound the memory used here because [...earlier, ...current] first copies every historical message, and convertRollout has already retained a separate cumulative historyPrefixes array 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 👍 / 👎.


uploaded.add(turn.turnId);
exportedTurnIds.push(turn.turnId);
await options.onTurnExported?.(turn.turnId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush nested subagent turns instead of only outer turns

When one parent turn contains a long subagent rollout, this callback is reached only after emitTurn finishes recursively converting every child turn; recursive convertRollout calls use parentObservation, whose branch never invokes onTurnExported. Consequently all nested spans remain buffered together, so a single parent turn with enough subagent activity can still exhaust memory or exceed the exporter body limit. Nested completed turns need a flush path distinct from the outer-turn sidecar marking callback.

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment on lines +396 to +402
while (start > 0) {
const chars = serializedChars(all[start - 1]);
if (used + chars > maxChars) break;
used += chars;
start--;
}
if (start === 0) return all;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 boundedConversation, the loop breaks at the first message that does not fit, so one message over max_history_chars (default 200000) drops every older message too. Fix: always keep the current turn's user message and the newest message even when over budget, and skip an oversized message instead of stopping, so smaller recent messages still fit.

Why this was flagged

The trigger is a Codex turn whose user message, or whose latest tool output, serializes to more than max_history_chars (200000 by default). The PR's own 500 KB tool-output workload is this case. boundedConversation (trace.ts:396-402) walks backward and breaks as soon as used + chars > maxChars. If the newest message is the oversized one, start stays at all.length. generationInput then gets only the [N earlier message(s) omitted ...] system marker, with no user prompt and no preceding assistant or tool messages. Every later generation in that turn has the same problem, because the oversized message stays the newest-but-growing tail. On the base branch, each LLM generation span showed the full conversation, including the turn's user message. Nothing else keeps the current user message in the generation input: turnMessages (trace.ts:525) only feeds boundedConversation.

Verification: The trigger is any single message whose JSON.stringify length is over max_history_chars (default 200000). In boundedConversation (trace.ts:396-402), the walk stops at the first message that does not fit. When that is the newest, the returned array is only the omission marker. The base generationInput always pushed userMessage(turn) and the prior steps.

Comment on lines +396 to +408
while (start > 0) {
const chars = serializedChars(all[start - 1]);
if (used + chars > maxChars) break;
used += chars;
start--;
}
if (start === 0) return all;
return [
{
role: "system",
content: `[${start} earlier message(s) omitted: the conversation exceeds max_history_chars (${maxChars})]`,
},
...all.slice(start),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 boundedConversation, the newest-first loop stops at any message boundary. When the cut falls between an assistant tool_calls message and its role: "tool" replies, those replies stay but their call is replaced by the omitted marker. Fix: after the loop, advance start past leading tool messages so a call and its results are kept or dropped together, and count the skipped ones in the marker's N.

Why this was flagged

The trigger is any Codex turn whose history goes over max_history_chars (default 200000) where a step has tool calls, which is the normal shape of a long session. The loop at trace.ts:396-401 keeps the longest suffix of whole messages that fits. A single large assistantMessage carrying tool_calls, or a budget boundary that lands inside one step's toolMessages run, leaves all[start] as a role: "tool" message whose tool_call_id belongs to an omitted assistant message. The input stored on the LLM span is then [system prompt, omission marker, tool, tool, ..., assistant, ...]. Playground replay, dataset export or evals that pass this input to an OpenAI-compatible API get the rejection "messages with role 'tool' must be a response to a preceding message with 'tool_calls'". On the base branch, generation inputs always contained every assistant tool_calls message ahead of its results. No other check repairs the sequence, because generationInput and attachToolDefinitions copy the trimmed list as it is.

Verification: Severity: nit. In plugins/tracing/src/trace.ts, boundedConversation walks all from newest to oldest, stops at whatever message no longer fits and never checks roles. Before this change, generationInput always emitted whole assistant-plus-tools groups, so every stored input was a well-formed ChatML sequence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop hook V8 heap OOM on long sessions: every generation rebuilds its full input from the entire session history (O(turns × history))

2 participants