Skip to content

fix(context-compressor): replayed tool-call args stop carrying a marker models copy (#83714, salvage #83843) - #116169

Merged
kshitijk4poor merged 5 commits into
NousResearch:mainfrom
kshitijk4poor:fix/83714-truncation-marker
Sep 19, 2026
Merged

kshitijk4poor merged 5 commits into
NousResearch:mainfrom
kshitijk4poor:fix/83714-truncation-marker

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Contributor

Compaction no longer rewrites a replayed tool call's arguments with a marker the model copies.

The marker appended to a shrunk argument leaf is now
⟪HERMES-CONTEXT-COMPRESSION: N of M chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫
instead of "...[truncated]". The old text sat in the model's own history as its prior output, and a model imitated it into new tool calls whose new_string was then written to disk (#83714). The replacement also only happens when it is a net reduction, never twice on the same leaf, and the helper returns the caller's exact string when nothing was replaced.

Validation — this branch vs origin/main, through the real compress() path and the helper directly:

before (main) after
replayed dispatch args end ...[truncated] (226 chars) end with the new marker (435 chars)
628-char blob with a 201-char leaf head-truncated to 455 chars returned byte-identical
compact wire JSON, no shrinkable leaf rewritten with inserted spaces returned byte-identical
second compaction pass f(f(x)) == f(x) (counts stable)

Tests: 186 passed, 0 failed across tests/agent/test_context_compressor.py, tests/agent/test_protected_tail_pressure.py, tests/agent/test_compression_boundary.py, tests/test_trajectory_compressor.py. With _shrink mutated back to the earlier "always append" behaviour, 2 of the 3 new tests fail (the ones that pin this change) and the class-regression test fails on unmodified main. ruff check, scripts/check-windows-footguns.py --all, scripts/check_compat_pointers.py and git diff --check are clean.

Deliberate trade-offs, documented in the function docstring: the marker costs ~60 tokens per shrunk leaf against ~4 for the old one, and leaves shorter than the break-even (head_chars + marker, ~420 chars) are now left intact instead of being head-truncated.

Based on #83843 by @djbclark — cherry-picked so authorship survives; the follow-up commits (only replace when it reclaims, never re-shrink, position-based marker guard, byte-identical no-op, tests) are ours.

kshitijk4poor and others added 5 commits September 19, 2026 19:36
… into replayed tool_calls

Root cause for NousResearch#83714 (write_file/patch_tool writing literal
"...[truncated]" into files, PR NousResearch#83752's guard is the safety net, not
the fix): _truncate_tool_call_args_json() in the compression pass
shrinks long string values inside a PAST assistant message's
tool_calls[].function.arguments — the exact field that represents the
model's own prior generated output, replayed back to it verbatim on
every subsequent turn. The old marker, a bare "...[truncated]" suffix,
is indistinguishable from something the model itself could have
written (it's exactly the kind of terse ellipsis abbreviation models
already produce). A model conditioned on seeing itself "get away with"
that pattern in its own history imitates it in a new tool call,
writing the literal marker instead of real content.

This is the second bug from the same root text. The first (NousResearch#11762,
MiniMax 400s from unterminated JSON) was fixed by shrinking inside the
parsed structure so the JSON stays valid, but kept the same visible
marker text — fixing the syntax problem while leaving the imitation
problem untouched.

Fix: replace the marker with one deliberately NOT shaped like prose a
model would write — distinctive non-ASCII delimiters, an explicit "not
part of the original tool call" disclaimer, and a per-instance
char-count that won't match the next omission point even if copied
verbatim. The shrunk value stays a plain string (not a nested object)
so the NousResearch#11762 valid-JSON/matching-shape contract is unchanged — only
the marker text changed.

Checked context_compressor.py's other "...[truncated]" call sites
(_serialize_for_summary, _compact_fallback_turn, the user-message-only
one near _ACTIVE_TASK_MAX_CHARS) — none of them write into a value
that gets replayed as the main model's own assistant/tool_calls
history, so they don't share this priming risk and were left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…shrink

The anti-imitation marker is ~220 chars, longer than the 200-char head it
follows, which made the replayed-arg rewrite unsound in two ways:

- A leaf just over `head_chars` came back LONGER: a 628-char args blob
  shrank to 455 chars before this change and grew to 863 after it.
- The result was not a fixed point. `_shrink` re-ran on every later
  compaction, so a leaf's marker was rewritten from the true count
  ("2,800 of 3,000 chars omitted") to a self-referential one
  ("223 of 423"), destroying the per-instance count property the marker
  relies on and churning those bytes on each pass.

A leaf now keeps its head+marker replacement only when that is strictly
shorter, and an already-marked leaf is left alone. The two tests pin the
behaviour contract: never grow, and `shrink(shrink(x)) == shrink(x)`.
… byte-identical

Review findings on the previous commit, all reproduced:

- The "already marked" guard was a substring test, so a leaf that merely
  contains the marker — including one a model imitated into a new call, the
  NousResearch#83714 failure mode itself — was exempt from shrinking forever. The marker
  is always written at `head_chars`, so the guard now tests that position: a
  1,550-char imitated leaf shrank to 423 again.
- The helper re-serialised even when nothing was replaced, so compact wire
  JSON came back with inserted spaces and callers read it as a change
  (rewriting replayed history and counting a pressure hit for zero reclaim).
  Nothing replaced now returns the original string: a 547-char compact blob
  is byte-identical.
Second review round on the previous commit, both findings reproduced:

- `startswith(prefix, head_chars)` still exempted the imitation shape NousResearch#83714 is
  about: a replayed leaf of head + marker followed by new content was never
  shrunk again (a 5,278-char leaf stayed 5,278). The guard now also requires
  the marker to close the leaf, so that shape shrinks to head + marker with
  true counts.
- Per-leaf savings were compared in characters, but the final re-serialise
  adds separator whitespace, so compact args with many keys could come back
  LONGER (measured 3,511 -> 4,110 chars) and be counted as reclaimed pressure.
  The helper now returns the caller's string unless the whole rewrite is a net
  reduction.

Tests cover both shapes plus a many-key compact payload.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants