Skip to content

feat: tool truncation + auto-compaction to bash harness - #2454

Open
mikasenghaas wants to merge 14 commits into
mainfrom
feat/context-compaction
Open

feat: tool truncation + auto-compaction to bash harness#2454
mikasenghaas wants to merge 14 commits into
mainfrom
feat/context-compaction

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

  • add an optional CompactionConfig to the bash harness: compact into a handoff summary at summarize_at_tokens, or when 16k tokens remain below the model context window
  • the program discovers the window itself from the provider's /models card (via models.list()), so the interception server gains a stateless GET /v1/models relay (one route serves every dialect)
  • truncate a tool result over 20KB to its head and tail with a warning naming the original size
  • compact reactively on an attributed 400/413 overflow error, from the current state; a rejected checkpoint falls back to the last state that passed a threshold check (which holds a full reserve of room by definition), and an empty or tool-calling reply is resampled
  • after three failed checkpoint attempts the program ends the run cleanly — still a trainable sample
  • an overflow with no history beyond the task propagates: nothing to reclaim

The RLM harness gets the same policy in #2459, stacked on this PR so this one can merge before nano-rlm does. Together with #2453 (merged) and #2459 this supersedes #2448.

Breaking

  • None on its own: compaction is a new optional field, unset by default.

Verification

  • uv run pytest -q tests/v1 — passed; live E2E tests skipped without PRIME_API_KEY

  • Tool truncation checked by exact match: a synthetic 55KB output produces the byte-identical expected head/tail + warning header; short outputs pass through untouched.
    Terminal-Bench 2 e2e: 8 tasks, local vLLM poolside/Laguna-XS-2.1 at 32k (glm45 reasoning + glm47 tool parsers), compaction = {} so the threshold auto-discovers to 32768 − 16384 = 16384. Trace analysis:

  • 8/8 rollouts end ok=true with scoring run (1 solved); no harness errors, no truncation stops.

  • The threshold triggers exactly where designed: 7/8 episodes compacted with peak contexts of 17.0k-23.4k tokens; the one episode that stayed at 15.4k never compacted.

  • 20KB tool truncation visible in 3 episodes' traces (Warning: truncated output with original size).

  • Laguna puts its entire checkpoint reply in the reasoning channel, so under the summaries-are-content-only rule every compaction on this model exercises the failure path end to end: three resampled checkpoint attempts, then the program ends the run cleanly - the rollout stays a trainable sample. Summary carry-over itself was demonstrated on models that answer in the content channel (Qwen3-0.6B at 4k/8k, deepseek-v4-flash on tb2): every non-final branch ends with the checkpoint prompt followed by the summary, and the next branch opens with [system, framed summary].

  • A final combined verification run on a content-channel model is pending.

🤖 Generated with Claude Code


Note

Medium Risk
Opt-in compaction rewrites conversation history and adds a new upstream relay route; mistakes could affect long agent rollouts or model discovery, but default behavior is unchanged when compaction is unset.

Overview
Adds optional context compaction to the bash harness agent loop so long rollouts can keep going without hitting the model window. BashHarnessConfig gains CompactionConfig (optional summarize_at_tokens); enabling compaction forwards --compaction / --summarize-at-tokens into the uv program.

When compaction is on, the program uses a Compactor to trigger on token usage (default threshold: model context window minus 16k, discovered via models.list()), on finish_reason == length, or on provider 400/413 overflow errors. It replaces history with a handoff summary (checkpoint prompt, tool_choice=none, up to three retries with fallback to the last “good” snapshot), then continues; unrecoverable compaction ends the loop cleanly instead of crashing.

Tool results over 20KB are middle-truncated (head/tail + warning) before they enter the transcript, including interception rewrites.

The interception server adds GET /v1/models as a dialect-aware relay to upstream so the bash program can read context-window fields from model cards when no explicit threshold is set.

Reviewed by Cursor Bugbot for commit cad252f. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add tool truncation and auto-compaction to bash harness

  • Adds CompactionConfig to BashHarnessConfig so callers can enable compaction and set a summarize_at_tokens threshold; the harness passes --compaction and --summarize-at-tokens CLI flags to the program
  • Introduces a Compactor class in the bash program that summarizes conversation history when token thresholds are hit or context overflow is detected, rebuilding to a compacted system+user state; raises CompactionFailed after 3 failed checkpoint attempts to terminate cleanly
  • Truncates tool output middle-out to TOOL_OUTPUT_MAX_BYTES (20000) before inserting into the conversation, preserving head and tail with a warning
  • When compaction is enabled without an explicit threshold, auto-discovers one from the upstream model's context window via a new proxied /v1/models endpoint in InterceptionServer
  • Behavioral Change: chat() now returns the raw completion object instead of a pre-extracted message, and accepts tool_choice; tool outputs exceeding 20000 bytes are truncated before entering the conversation

Macroscope summarized cad252f.

@mikasenghaas
mikasenghaas marked this pull request as ready for review August 27, 2026 18:26
Comment thread verifiers/v1/interception/server.py
Comment thread verifiers/v1/harnesses/bash/program.py Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a substantial context-management workflow to the bash harness, including truncation of existing tool outputs, model discovery, summarization retries, and a new authenticated relay route. Its runtime behavior and unresolved overflow-recovery concern warrant human review.

You can add or adjust custom eligibility rules. Learn more.

Comment thread verifiers/v1/harnesses/bash/program.py Outdated
Comment thread verifiers/v1/harnesses/bash/program.py Outdated
Base automatically changed from feat/relay-overlong-errors to main August 27, 2026 19:43
@mikasenghaas mikasenghaas changed the title feat: add context compaction to the bash and rlm harnesses feat: feat: tool truncation + auto-compaction to bash harness Aug 27, 2026
@mikasenghaas
mikasenghaas force-pushed the feat/context-compaction branch from ca40cbf to fd382c5 Compare August 27, 2026 19:45
Comment thread verifiers/v1/harnesses/bash/program.py
Comment thread verifiers/v1/harnesses/bash/program.py Outdated
@mikasenghaas mikasenghaas changed the title feat: feat: tool truncation + auto-compaction to bash harness feat: tool truncation + auto-compaction to bash harness Aug 27, 2026
Comment thread verifiers/v1/harnesses/bash/program.py
samsja added a commit to PrimeIntellect-ai/nano-rlm that referenced this pull request Aug 28, 2026
## Summary

Hard-caps any single tool result entering the conversation at **10KB**,
middle-out (keep head+tail), with a warning header naming the original
size and line count:

```
Warning: truncated output (original token count: 12500)
Total output lines: 10001

<first 5KB>
[... 40000 bytes truncated ...]
<last 5KB>
```

Applies at the engine tool-result boundary — so it covers **all tools**
(`bash`, `edit`, and `ipython` cell output) uniformly.

## Scope: context only — tools yes, skills no

- The **session log keeps the full output** (audit trail unchanged).
- **Skill return values inside the kernel stay uncapped**: `out = await
bash(...)` holds the complete string for in-cell filtering; only what
the cell *prints* (the tool result) is subject to the cap.
Capture-then-filter workflows keep full fidelity — the cap only protects
the context window.

## Compatibility

The function is byte-compatible with the truncation in
[verifiers#2454](PrimeIntellect-ai/verifiers#2454)
(bash harness) and the copy bundled inside
[#147](#147)
(auto-compaction) — extracted standalone here so the cap can ship
independently; #147 can relocate/dedupe it when it lands.

Motivation from eval traces: unclipped tool results produced single-turn
context blowups (observed up to ~690k tokens from one `cat` on a large
file), which no compaction threshold can save you from after the fact.

Tests: truncation unit test added; 139 passed (6 pre-existing
`test_acp.py` env failures, same as clean main); ruff clean.
Comment thread verifiers/v1/harnesses/bash/program.py
Comment thread verifiers/v1/harnesses/bash/program.py
mikasenghaas and others added 7 commits August 28, 2026 22:02
Optional CompactionConfig on both in-house harnesses: compact into
a handoff summary at summarize_at_tokens, or at 90% of the model
context window when the provider advertises one. The threshold is
discovered by the agent loops themselves (the bash program reads
the provider's /models card; nano-rlm's engine already does), so
the interception server gains a stateless GET /v1/models relay
serving every dialect. On a provider overflow error the loops
compact and retry once, learning the threshold from the error
message; an oversized checkpoint request drops the newest tool
results one at a time until it fits.

RLM's summarize_at_tokens moves into the compaction config and
crosses ACP flat; nano-rlm pinned at f5c14aa.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compact when 16k tokens remain below the context window instead of
at 90% of it - a fixed reserve keeps constant headroom on any
window size (small windows keep at least half). Truncate a tool
result over 10KB middle-out before it enters the conversation,
with a warning naming the original token count and line count, so
one giant output can never leap past the reserve and the model
knows what was cut. Matches Codex's output policy; the threshold
matches pi's reserve design. Pin nano-rlm 4fd3fa2 with the same
changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each marker now names the API whose error wording it matches, and
the unattributable generics are gone - "too many tokens" also
matches Bedrock throttling, and bare "context length"/"context
window" substrings matched more than they targeted. Pin nano-rlm
3b97900 with the same map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 413 markers could never fire: a 413 arrives as a plain
APIStatusError, not BadRequestError. Catch APIStatusError at the
compaction sites and gate overflow detection on a deterministic
status (400 or 413) so marker-shaped text in a transient failure
never triggers a compaction. Pin nano-rlm 4bb5f48 with the same
fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final compaction design: proactive at a fixed reserve below a known
context window, reactive on attributed 400/413 overflow errors. A
rejected checkpoint no longer sheds tool results - it falls back to
the last state that passed a threshold check, which by definition
holds a full reserve of room; an empty or tool-calling reply is
resampled, and after three failed attempts the program ends the run
cleanly as a trainable sample instead of crashing. An overflow with
no history beyond the task propagates. Tool truncation grows to
20KB and the threshold-learning regexes go away - compaction now
requires a known window. Pin nano-rlm b928097 with the same design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RLM harness wiring moves to a stacked PR so this one can merge
before the nano-rlm companion lands. Also discover the context
window via models.list - the raw cast_to parse breaks on one Python
version or another (a bare dict cannot be constructed on 3.13, and
a parameterized dict trips inspect.isclass on 3.10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A reasoning-parsed model (observed: Laguna via the glm45 parser) can
put the entire checkpoint reply in reasoning_content, leaving
content empty - every attempt then fails and the run ends as
compaction-failed despite a perfectly good summary. The checkpoint
asked for a summary, so when content is empty accept the reasoning
text as the summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/harnesses/bash/program.py
vLLM 0.26 names the field "reasoning" and the SDK only keeps it in
model_extra, so the attribute lookup never saw it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f07f60a. Configure here.

or not is_context_overflow(error)
or not compactable(messages)
):
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overflow recovery requires a threshold

Medium Severity

Reactive compaction on a 400/413 overflow is skipped whenever threshold is None. An empty compaction config that cannot discover a window therefore never recovers from overflow, even though that recovery is described as independent of the proactive threshold.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f07f60a. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

By design: compaction requires a known context window (discovered or explicit); without one the overflow propagates. Documented scoping - training always knows the window.

A checkpoint reply that lives entirely in the reasoning channel is
resampled like an empty one - reasoning never enters the summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: last_good started at zero, so a first-turn checkpoint
rejection retried over an empty base - a summary of nothing with
the task gone; the initial conversation is now the floor. And a
multimodal MCP result is a content-part list, which the byte
truncation crashed on - only plain text is truncated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/harnesses/bash/program.py
Comment thread verifiers/v1/harnesses/bash/program.py Outdated
Review: interception hook rewrites entered the conversation
unbounded, sidestepping the 20KB tool-output limit. Every message
entering as a tool result now passes the same bound, rewrites
included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/harnesses/bash/program.py
Review: an overflow on the post-compaction work call propagated out
of the loop and crashed the rollout. The rebuilt conversation is
sized to fit by construction, so if it still overflows there are no
moves left - convert it to the compaction-failed clean ending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/harnesses/bash/program.py
Review: the post-tool snapshot was taken on a chars/4 estimate,
which can undercount dense content severalfold - the "good"
snapshot could itself be oversized, making the fallback identical
to the overflowing request. A state now becomes the fallback only
when the provider accepted that exact prompt with real usage below
the threshold, which lands the fallback before the tool results,
as designed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant