Legacy shell tool discards output beyond 1 MiB without counting it; the model-facing truncation notice is a constant

Open 💬 2 comments Opened Jul 26, 2026 by tylorsaling-source

What version of the Codex App are you using?

The affected rollout records report cli_version: 0.144.2. The matching public source is tag rust-v0.144.2, commit a6645b6b8a656360fa16fb7e1c6721d0697d3d6a. The desktop build string was not captured for these sessions.

What subscription do you have?

ChatGPT Pro.

What platform is your computer?

Windows 11, x64.

What issue are you seeing?

Legacy shell tool output loses data in two stages. The first stage is silent and uncounted, and the notice the model receives for the second stage is a constant that carries no information about the first.

Stage 1 — silent, unbounded, never counted.

The shell capture path retains only the first 1 MiB and discards the rest without recording how much was lost:

So everything past 1 MiB is gone before any model-facing formatting runs, and the quantity is not carried forward anywhere.

Stage 2 — marked, but the number is a constant.

The legacy shell model formatter calls bare truncate_text, which performs a 50/50 head-and-tail elision and inserts …N tokens truncated… in the middle. A nearby alternate formatter calls formatted_truncate_text, which prepends Warning: truncated output (original token count: N) at the head. The shell path does not use it.

For ASCII-heavy output the mid-payload number is fully determined:

captured prefix              1,048,576 bytes
model-facing budget         10,000 × 4 = 40,000 bytes
removed by presentation      1,048,576 − 40,000 = 1,008,576 bytes
reported token notice        1,008,576 / 4 = 252,144

252144 therefore appears on every shell output that exceeds 1 MiB, regardless of whether the command produced 1.1 MB or 900 MB. It measures what the second formatter removed from the already-capped prefix.

Observed behaviour

Across three days of sessions on a large private repository:

  • 7,070 tool outputs, 84 truncated (1.19%)
  • of 4,841 shell_command / function_call_output records, 9 carried …252144 tokens truncated… — the identical figure every time
  • 0 shell-path records carried the head Warning: truncated output form
  • a representative capped record delivered 40,069 characters, with the notice at offset 20,044 — roughly 20k of head, the notice, then roughly 20k of tail

The practical effect is that the agent receives a response that begins and ends normally, with the only indication of loss placed mid-payload where content competes with it, and the magnitude it reports is a constant. Work then proceeds on partial data. In our case the largest losses landed on diagnostic reads during an incident, and the resulting repairs were made against evidence that was missing its middle.

Expected behaviour

  1. Bytes discarded by the 1 MiB capture cap are counted and carried forward.
  2. The model-facing notice appears at the head of the payload, in the same form the exec path already uses.
  3. The reported original size reflects all observed bytes, not the retained prefix.

Suggested fix, with existing precedent

PR #32150 (merged 2026-07-10, commit 6138909d) already does exactly this for unified exec: it preserves omitted-byte metadata and computes the original estimate from all observed bytes. It did not touch the legacy shell formatter, which still calls bare truncate_text on current main (source).

Applying the same pattern to the shell path:

  1. Count total or omitted bytes in read_output while it already drains to EOF.
  2. Carry that metadata through RawExecToolCallOutput and ExecToolCallOutput.
  3. Emit a head warning in format_exec_output_for_model, using the total-byte count for the original-token estimate.
  4. Keep the head-and-tail body if desired, but do not rely on the inline marker as the only warning.
  5. Add a regression using two outputs above 1 MiB of differing sizes, asserting the reported original count differs rather than remaining 252144.

Note that the deployed suite currently locks the existing shape — this test expects shell output to begin normally and contain only the middle marker, so it would need updating alongside.

Related

  • #35226 — context auto-compaction loop; this is a different mechanism but the same underlying path
  • #35050 — Code Mode batching; that thread has been treating truncation as a batching side-effect, and this shows the large losses come from the non-batched shell path
  • #9758 — earlier, closed, async-path variant of the byte-cap accounting

View original on GitHub ↗

2 Comments

tylorsaling-source · 1 month ago

Update: the regression this issue proposed, plus where the discarded bytes actually go.

Two measured results since filing.

1. The constant marker, demonstrated deterministically (the regression suggested here)

I built the exact regression this issue proposes — two shell outputs whose first 1,048,576 bytes are byte-identical but whose totals and true endings differ:

  • A — 1,200,000 bytes, ends ACTUAL_RESULT=PASS
  • B — 2,200,000 bytes, ends ACTUAL_RESULT=FAIL

Both produce a byte-identical model-visible capture: same …252144 tokens truncated… notice, same visible head/tail offsets, and neither final result is visible. The reported original count stays 252144 regardless of a 1 MB difference in true size — confirming the notice is a constant, exactly as this issue describes. 252144 = (1,048,576 − ~40,000) / 4, i.e. the reduction of the retained prefix to the model budget, carrying no information about stage-1 loss.

2. The discarded bytes are not recoverable — they are gone from the session record too

On a real event — a 4,095,789-byte single-line match that hit the cap — I traced a three-stage contraction:

| stage | bytes | % of source |
|---|---:|---:|
| raw matching line (lower bound) | 4,095,789 | 100% |
| legacy shell capture cap | 1,048,576 | 25.6% |
| persisted JSONL payload.output | 40,073 | 0.98% |

The persisted tail fingerprints the 1 MiB capture boundary (source chars 1,048,214–1,048,341), not command EOF. The ~3,047,447 source chars after it appear nowhere in the rollout, and a source-EOF fingerprint has zero matches across the file. The reader draining to EOF (to avoid pipe back-pressure) does not persist those bytes — append_capped stops at 1 MiB and the rest is consumed and discarded.

So the loss isn't merely hidden from the model — it's irrecoverable from the session record. That strengthens the fix direction here: since the bytes can't be recovered after the fact, counting them (this issue's ask) is the only available mitigation, via the already-merged unified-exec pattern (#32150) applied to the shell path.

Scope (what this is not)

This does not explain session-store disk growth. Post-cap shell bytes are discarded, not persisted; the only shell record is the ~40 KB model-facing artifact, and the custom_tool_call_output records in the same rollout topped out at 609 chars. Disk growth (#34061) is a separate amplification mechanism (already-persisted records duplicated across compaction/resume/subagents), not this path. Related: #14206 argues for spilling large outputs to files instead of truncating — a different remedy with its own disk trade-off; #32888 and #34719 show the compaction path doing its own silent/accounting loss.

tylorsaling-source · 1 month ago

The cleansed evidence and source map supporting this legacy-shell issue are now published at https://github.com/tylorsaling-source/codex-residual-fidelity. The smallest implementable step is to count all observed bytes while draining, carry total/omitted metadata into model-visible output, and test identical first-1-MiB prefixes with different true totals. The broader cross-plane contract remains tracked in #35528.