feat: add bash context compaction - #2448
Closed
mikasenghaas wants to merge 14 commits into
Closed
Conversation
Keep long Bash harness runs alive when the provider rejects an overlong\nprompt. The harness requests a nano-rlm-style checkpoint summary and\ncontinues from a fresh branch.\n\nMake compaction the default while preserving clean context-length\ntruncation when users disable it.
mikasenghaas
commented
Aug 26, 2026
| MCP_TIMEOUT = 600.0 | ||
| CONTEXT_COMPACTION_HEADER = "X-Verifiers-Context-Compaction" | ||
|
|
||
| CHECKPOINT_COMPACTION_PROMPT = ( |
| "seamlessly continue the work." | ||
| ) | ||
|
|
||
| POST_COMPACTION_FRAMING = ( |
| ) | ||
|
|
||
| COMPACTED_TOOL_RESULT = ( | ||
| "[tool output dropped because it exceeded the model context limit]" |
Member
Author
There was a problem hiding this comment.
just say "context limit" here
mikasenghaas
commented
Aug 26, 2026
| else: | ||
| prompt = ( | ||
| "Call the `overflow_context` tool exactly once, then answer `recovered`. " | ||
| "In an RLM IPython session, call it with " |
Member
Author
There was a problem hiding this comment.
dont mention rlm here
Member
Author
There was a problem hiding this comment.
bc we use this test for bash as well no?
mikasenghaas
commented
Aug 26, 2026
Comment on lines
+170
to
+172
| base_url = os.environ.get("VF_COMPACTION_E2E_BASE_URL") | ||
| model = os.environ.get("VF_COMPACTION_E2E_MODEL") | ||
| context_window = int(os.environ.get("VF_COMPACTION_E2E_CONTEXT_WINDOW", "4096")) |
Member
Author
There was a problem hiding this comment.
hmm no env vars wtf
| from verifiers.v1.types import ID | ||
|
|
||
|
|
||
| class CompactionConfig(BaseConfig): |
Member
Author
There was a problem hiding this comment.
duplicate this in bash and rlm, not in top-level config
| @@ -715,14 +715,10 @@ async def sample() -> web.Response: | |||
| status=400, | |||
| ) | |||
| except OverlongPromptError as e: | |||
Member
Author
There was a problem hiding this comment.
why even catch this at all? isn't this doing rewriting of sorts?return web.json_response( dialect.error_body("context_length"), status=400, )
Review: compaction is a per-loop policy, so each in-house harness owns its CompactionConfig and threshold discovery instead of sharing a top-level config class and a clients module. The duplicated resolver caches per (base_url, model) and queries the upstream /models card host-side, since the interception server serves no /models route for agent-side discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: interception rewrote overlong prompt failures into a synthesized "context_length" body. Let them fall through to the generic RolloutError relay instead: the harness receives the provider's original message (from which it can learn the context window) with the error's 400 status. A rollout whose final call overflowed without recovery now fails like any other provider error; "context_length" is gone as a stop condition, so drop it from Trace.is_truncated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikasenghaas
commented
Aug 26, 2026
| return value | ||
|
|
||
|
|
||
| async def resolve_compaction_threshold(ctx: ModelContext) -> int | None: |
Member
Author
There was a problem hiding this comment.
wait this should not be vf side... but harness side
| @@ -43,10 +43,10 @@ def __init__(self, message: str = "", *, status_code: int = 502) -> None: | |||
|
|
|||
|
|
|||
| class OverlongPromptError(ProviderError): | |||
Member
Author
There was a problem hiding this comment.
do we still use this? if not, remove?
Review: threshold discovery belongs to the agent loops, not the vf host. The bash program reads the provider's /models card itself (like nano-rlm's engine already does) and the vf harnesses only forward the explicitly configured threshold. The RLM policy crosses ACP flat (compaction toggle + summarize_at_tokens) to match nano-rlm's flattened ExecutionPolicy; pin bumped to f452d52. Behind the interception server (which serves no /models route) discovery yields nothing and both loops fall back to reactive compaction, learning the threshold from the relayed provider error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: nothing catches or type-checks OverlongPromptError since interception relays it like any other provider failure. Raise a plain ProviderError with a deterministic 400 at the two sites that produced it (renderer pre-flight overflow, Responses context_length_exceeded) and drop the phrase-sniffing from model_error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add GET /v1/models to the interception server, relaying the upstream listing through the session's shared client (cached per endpoint, one upstream fetch per process). Agent loops can now discover their compaction threshold in training and eval alike. The path is dialect-universal: OpenAI and Anthropic SDKs both list models at /v1/models, so one route serves every dialect and only the auth carrier differs (tried per dialect). The response schema stays the upstream's; only OpenAI-compatible engines advertise a context window (vLLM's max_model_len), others fall back to reactive compaction. Also parse the listing as a parameterized mapping in the bash program - the OpenAI SDK cannot construct a bare dict, so discovery raised ValueError instead of returning None. Pin nano-rlm da9f4a4 with the same fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
summarize_at_tokens is a plain token count now - the (lo, hi) task-seeded draw is no longer used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: the models listing is a proxy concern, not a client one. handle_models relays GET /v1/models straight from the session's endpoint config (the matched dialect contributes bearer extraction and provider auth), the upstream body and status pass through verbatim, and the Client.models method and its per-endpoint cache go away - every request hits the provider, like any other relay. Also order compaction last in BashHarnessConfig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The model can ignore tool_choice="none" on the checkpoint turn and reply with a tool call and no text (observed on ~6% of compactions with deepseek-v4-flash on Terminal-Bench 2) - the rebuilt branch then starts with no context on the work done so far. Resample the checkpoint up to three times until it yields a text summary; pin nano-rlm ac8fdb0 with the same fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The failed checkpoints were not disobedience: the model understood the request and chose to run one more state-gathering tool call before summarizing, which the loop never grants it. Say explicitly that the summary must come from the conversation as it stands; pin nano-rlm b1b4140 with the same wording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One checkpoint call per compaction - the hardened prompt is the guard against tool-call replies. Also name estimated_tokens' input for what it counts. Pin nano-rlm f5c14aa with the same changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
mikasenghaas
added a commit
that referenced
this pull request
Aug 31, 2026
## 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](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > The shared agent loop now permanently truncates large tool outputs and, when compaction is enabled, replaces long histories with model-generated summaries—both can change task outcomes; the new models relay is ancillary and failures only disable auto-thresholds. > > **Overview** > Adds an **opt-in** context compaction path for the bash agent: `BashHarnessConfig` gains `CompactionConfig` (`summarize_at_tokens` optional), which forwards `--compaction` / `--summarize-at-tokens` into the shared minimal/bash chat program. > > When compaction is on, the program uses a new **`Compactor`** to compact before context blows up: it can trigger on usage crossing a threshold (explicit token limit, or auto from the model card with a 16k reserve), on `finish_reason=length`, on post-tool estimated token growth, or reactively on provider 400/413 overflow. Compaction asks the model for a plain-text handoff summary (`tool_choice=none`), rebuilds history as system + framed summary user message, falls back to the last “good” checkpoint on overflow, and **ends the loop cleanly** on `CompactionFailed` instead of crashing. Threshold discovery calls `models.list()` against the intercepted base URL. > > **Tool results are always middle-truncated** at ~20KB (head/tail + warning) via `bound_tool_message` before they enter the transcript, including interception rewrites. > > The interception server adds **`GET /v1/models`** as an authenticated upstream relay (30s timeout) so the in-container SDK can read provider context-window fields without recording a model turn. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e1c80ac. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> > [!NOTE] > ### Add tool truncation and auto-compaction to `BashHarness` agent loop > - Adds `CompactionConfig` to `BashHarnessConfig` and passes `--compaction` and `--summarize-at-tokens` flags to the spawned program in [harness.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2454/files#diff-1544e2ee3426241d8c97940a3e8daf25b2eeeeb95f8abb27e88bed4af420e609) > - Introduces the `Compactor` class in [program.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2454/files#diff-cfa9a2dff501160af71f3c07beb4c6ca40926e64e93b43f217548811dcc78a36) that detects context exhaustion, builds a summary checkpoint, and rebuilds the conversation; auto-discovers a token threshold from the provider's `/v1/models` endpoint when none is given > - Truncates large tool outputs to `TOOL_OUTPUT_MAX_BYTES` (20,000) with head/tail preservation via `truncate_tool_output` before they enter the conversation > - Proxies `GET /v1/models` through the interception server in [server.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2454/files#diff-84e2f8d027d609e8760ef6d533535ec9d99dfc6384d5ddb6111b4001b2010f35) so the program can query the provider for context window size > - `chat()` now returns the full completion object instead of the first message, and the main loop ends cleanly on `CompactionFailed` > - Risk: `chat()` signature and return type changed in [program.py](https://github.com/PrimeIntellect-ai/verifiers/pull/2454/files#diff-cfa9a2dff501160af71f3c07beb4c6ca40926e64e93b43f217548811dcc78a36); any callers expecting the first message instead of the full completion will break. Tool outputs over 20,000 bytes are silently truncated mid-conversation. > > <!-- Macroscope's review summary starts here --> > > <sup><a href="https://app.macroscope.com">Macroscope</a> summarized 27e1c91.</sup> > <!-- Macroscope's review summary ends here --> > <!-- Macroscope's pull request summary ends here --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CompactionConfigto Bash and RLM harnessesOverlongPromptErrorto the harness without stopping the trace1e45450Supersedes #1983 with automatic context-limit detection and the current Bash harness API.
Companion: nano-rlm #147.
Breaking
OverlongPromptError. Harnesses must handle the returnedcontext_lengtherror or apply their own rollout limit.RLMHarnessConfig.summarize_at_tokensmoves toRLMHarnessConfig.compaction.summarize_at_tokens.compactionunset to disable proactive and reactive compaction.Verification
Before this change, interception stopped the trace on an overlong prompt. After this change, the harness receives the provider error and can recover.
uv run pytest -q— passed; live E2E tests skipped withoutPRIME_API_KEYuv run ruff check .uv run ruff format --check .uv run pytest -q— 136 passedThe one-off taskset and configs lived under
/tmp/vf-context-compaction. They are not part of this PR. The model server started with:Each case used this command, with the named case config:
bash-decode.toml1.0; the first call ended at 4,096 tokens and the trace opened a compacted branch.bash-tool-result.toml1.0; the trace recordedOverlongPromptErrorand the dropped-output marker.rlm-decode.toml1.0andnum_compactions=1.rlm-tool-result.toml1.0, recordedOverlongPromptError, andnum_compactions=2.Note
Add context compaction to Bash harness program
compactionfield toBashHarnessConfig(defaultTrue), which passes--compactionto the underlying programInterceptionServerchecksX-Verifiers-Context-Compaction=1on model calls. If present and the prompt is overlong, it returns a 400context_lengtherror without stopping the rollout, allowing the client to compact and retryBashHarnessConfig.compactiondefaults toTrue, changing default behavior for all Bash harnesses to attempt compaction instead of terminating on overlong promptsMacroscope summarized cf0ad84. (Automatic summaries will resume when PR exits draft mode or review begins).