[Bob] feat(context): lossless tool-result tray before first send - #62389
[Bob] feat(context): lossless tool-result tray before first send#62389trac3r00 wants to merge 15 commits into
Conversation
Related: implements the Phase-1 (prune-first) half of #513, and relates to #20717 (dynamic context pruning). Opt-in and disabled by default ( |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the no-LLM pruning idea and making it opt-in. The current main premise is real: pruning is presently reached only from agent/context_compressor.py:2852-2856 inside compress(), while preflight enters full compression only after should_compress() (agent/turn_context.py:434).
Problems
- The new prune block is still inside the existing
_should_run_preflight_estimate()guard (agent/turn_context.py:368-373, PR:413-432). That guard uses the window-relativethreshold_tokensunless message count exceeds the protected ranges (:64-89), so a below-threshold session with few large messages cannot reach the claimed absolute trigger. - PR
agent/context_compressor.py:1356-1360delegates to_prune_old_tool_results(), which truncates old assistant tool-call arguments at:1403-1427. This conflicts with the result-only contract; the new test checks tool-call IDs but not arguments (tests/agent/test_prune_first_phase.py:127-149). prune_minimum_tokensis used only to trigger on total prompt size (PRagent/context_compressor.py:1330-1335), not verified against actual reclaimed output.
Suggested changes
- Extend the cheap preflight eligibility path for the opt-in absolute threshold and add an end-to-end preflight test.
- Use a result-only helper or mode, and gate mutation on measured savings meeting
prune_minimum_tokens.
Automated hermes-sweeper review.
| @@ -410,6 +410,59 @@ def build_turn_context( | |||
| lambda: None, | |||
| )() | |||
|
|
|||
| # ── Prune-first phase (issue #513) ── | |||
There was a problem hiding this comment.
This block is still inside the existing _should_run_preflight_estimate(...) guard. That guard only opens on excess message count or the window-relative threshold_tokens, so an opt-in below-threshold session with few large messages never reaches this claimed absolute trigger. Please extend the eligibility gate and add an integration test for that path.
| """ | ||
| if self.prune_protect_tokens is None or not messages: | ||
| return messages, 0 | ||
| pruned_messages, pruned_count = self._prune_old_tool_results( |
There was a problem hiding this comment.
_prune_old_tool_results() also truncates old assistant.tool_calls[*].function.arguments in its third pass. That violates this phase's stated result-only contract and the current test only preserves call IDs. Split or parameterize the helper, then assert full tool-call equality.
| # minimum reclaimable budget — below that there is nothing old enough | ||
| # to prune, or too little to be worth churning the cache prefix. | ||
| trigger_at = self.prune_protect_tokens + self.prune_minimum_tokens | ||
| return tokens >= trigger_at |
There was a problem hiding this comment.
This only proves total request size crossed protect + minimum; it does not prove that at least prune_minimum_tokens of eligible old tool output can be reclaimed. Measure before/after savings and return the original messages when the actual saving is below the configured minimum.
|
[Bob] Thanks for the thorough review @teknium1 — all three points are valid.
Pushing fixes shortly. |
…bsolute budget (NousResearch#513) Large-context models resolve a window-relative summarization threshold (context_length * threshold_percent). On a 1M-window model that is ~500-800K tokens, so a real coding session that plateaus at ~260K of context never crosses it. The cheap, LLM-free tool-output prune (_prune_old_tool_results) only runs INSIDE compress(), so it stays dormant with the summary trigger — and the bulky, re-sent-every-turn tool results (the dominant re-read weight in a long session) are never reclaimed. This adds an independent prune-first phase, gated on an ABSOLUTE token budget (compression.prune_protect_tokens), decoupled from the summarization trigger. The LLM-based conversation summary still waits for threshold_tokens; only the free tool-result elision runs early. It reuses the existing _prune_old_tool_results routine, so tool CALLS, user, and assistant text are untouched — only old tool RESULTS become one-line stubs, preserving conversation structure and the prompt-cache-relevant head. Disabled by default (prune_protect_tokens=None) to preserve historical behavior byte-for-byte; opt in via config.yaml. Changes: - agent/context_compressor.py: should_prune_tools() (absolute trigger) and prune_tools_only() (no-LLM elision reusing the existing prune routine); two new __init__ params with defensive coercion. - agent/turn_context.py: run the prune-first phase in preflight, OUTSIDE the should_compress() gate, re-estimating tokens after so the later summary check sees the reduced size. Skipped for codex-native auto-compaction. - agent/agent_init.py: read compression.prune_protect_tokens / prune_minimum_tokens from config and pass through. - tests/agent/test_prune_first_phase.py: 12 behavior-contract tests — disarmed-by-default, absolute trigger independent of should_compress, conversation-structure invariants, config coercion. Measured on a real 757-message opus-4-8 session (7-block resend-weighted decomposition of the captured transcript): tool results + tool calls were 93.5% of re-read weight; running prune_tools_only end-to-end cut resend-weighted re-read by 67.1% (49.2M -> 16.2M tokens) with user text, assistant text, and message count all bit-identical. Prepared by [Bob] on behalf of Minseo. Implements the prune-first request in issue NousResearch#513, corroborating the tool-output-dominates measurements already posted there.
- Add result_only param to _prune_old_tool_results: when True, Pass 3 (tool-call argument truncation) is skipped entirely. prune_tools_only now passes result_only=True so the prune-first phase never mutates tool-call arguments (preserves the result-only contract). - prune_tools_only now measures actual token savings after pruning and rolls back to the original messages if savings < prune_minimum_tokens. Previously prune_minimum_tokens was only used as a trigger threshold, not verified against actual reclaimed output. - Add TestResultOnlyContract and TestMeasuredSavingsGate test classes.
93593fa to
e87d5bb
Compare
|
[Bob] All three fixes pushed (e87d5bb):
210 compression/prune tests passing. Ready for re-review @teknium1. |
|
Strong +1 on the mechanism — some math on where We modeled prune-first on prompt-cached providers as a renewal process. Notation (first two are this PR's knobs):
A prune fires every — classic EOQ shape: carrying grows linearly in M, invalidation amortizes as 1/M, so Results (real tool-heavy session: g+c ≈ 6.4K tokens/call; P=40K, r=0.1, w=1.25 @5m TTL):
Anthropic, OpenAI's gpt-5.6 family (both 0.1×/1.25×), and OpenAI 5.5/5.4 + DeepSeek (0.1×, free writes) all give b/a ≈ 9–11.5, so the same band holds across providers. Takeaways:
Two assumptions the numbers hang on: (a) full-tail rewrite billing from the first mutated message — if provider prefix-block granularity lets part of the tail re-match at read price, M* falls back toward 20K; checkable by comparing cache-write token counts across a forced prune event (we haven't run that yet); (b) contiguous oldest-first stubbing — which this PR's walk-backward design satisfies; punching holes mid-history would invalidate nearly the whole tail. Possible follow-up (not this PR): the invalidation premium vanishes on turns arriving with a cold cache (gap > TTL), so "prefer to prune on a predicted-cold turn, force-prune at a backstop" gets the reclaim ~free when λ > 0. A heuristic, not a dominance result (degenerates to fixed-threshold at λ=0), but it composes cleanly with the absolute-budget trigger added here. Caveats: λ treated as stationary; |
…hreshold_tokens_cap) Upstream added per-model threshold overrides and an absolute threshold_tokens_cap knob (agent_init.py, context_compressor.py, turn_context.py) that landed independently of this PR's prune-first phase. Both features are additive and orthogonal (prune-first fires on prune_protect_tokens/prune_minimum_tokens; the upstream cap fires on threshold_tokens_cap) so conflicts were resolved by keeping both sides. Verified: py_compile on all three touched files + the prune-first test file; full tests/agent/ subset matching compress|prune|turn_context|agent_init (605 tests) passes, including the 15 prune-first-phase tests.
|
[Bob] Resolved the merge conflict against
Verified after the merge:
Branch is mergeable again ( |
|
@trac3r00 I believe this can be enhanced by doing somthing similar to what Claude does with their microcompaction. Instead of simply pruning, saving tool output to disk and offering a recoverability path in context, either via read_file() or a new tool read_tool_output() . |
|
[Bob] Agreed — durable recovery would be a useful next layer, especially for large outputs that are expensive to keep verbatim but may still matter later. I would keep it separate from this PR, though. This change is deliberately a bounded, in-memory result elision phase with no new persistence or tool surface. Archiving introduces its own contract: stable output IDs, session-scoped storage, retention/cleanup, secret handling, failure behavior, and whether A clean follow-up would replace each pruned result with a compact stub containing an archive handle plus retrieval instructions, while preserving this PR's absolute-budget trigger. That makes recoverability additive rather than coupling storage semantics into the pruning mechanism. [bob] |
…bsolute budget (NousResearch#513) Large-context models resolve a window-relative summarization threshold (context_length * threshold_percent). On a 1M-window model that is ~500-800K tokens, so a real coding session that plateaus at ~260K of context never crosses it. The cheap, LLM-free tool-output prune (_prune_old_tool_results) only runs INSIDE compress(), so it stays dormant with the summary trigger — and the bulky, re-sent-every-turn tool results (the dominant re-read weight in a long session) are never reclaimed. This adds an independent prune-first phase, gated on an ABSOLUTE token budget (compression.prune_protect_tokens), decoupled from the summarization trigger. The LLM-based conversation summary still waits for threshold_tokens; only the free tool-result elision runs early. It reuses the existing _prune_old_tool_results routine, so tool CALLS, user, and assistant text are untouched — only old tool RESULTS become one-line stubs, preserving conversation structure and the prompt-cache-relevant head. Disabled by default (prune_protect_tokens=None) to preserve historical behavior byte-for-byte; opt in via config.yaml. Changes: - agent/context_compressor.py: should_prune_tools() (absolute trigger) and prune_tools_only() (no-LLM elision reusing the existing prune routine); two new __init__ params with defensive coercion. - agent/turn_context.py: run the prune-first phase in preflight, OUTSIDE the should_compress() gate, re-estimating tokens after so the later summary check sees the reduced size. Skipped for codex-native auto-compaction. - agent/agent_init.py: read compression.prune_protect_tokens / prune_minimum_tokens from config and pass through. - tests/agent/test_prune_first_phase.py: 12 behavior-contract tests — disarmed-by-default, absolute trigger independent of should_compress, conversation-structure invariants, config coercion. Measured on a real 757-message opus-4-8 session (7-block resend-weighted decomposition of the captured transcript): tool results + tool calls were 93.5% of re-read weight; running prune_tools_only end-to-end cut resend-weighted re-read by 67.1% (49.2M -> 16.2M tokens) with user text, assistant text, and message count all bit-identical. Prepared by [Bob] on behalf of Minseo. Implements the prune-first request in issue NousResearch#513, corroborating the tool-output-dominates measurements already posted there.
- Add result_only param to _prune_old_tool_results: when True, Pass 3 (tool-call argument truncation) is skipped entirely. prune_tools_only now passes result_only=True so the prune-first phase never mutates tool-call arguments (preserves the result-only contract). - prune_tools_only now measures actual token savings after pruning and rolls back to the original messages if savings < prune_minimum_tokens. Previously prune_minimum_tokens was only used as a trigger threshold, not verified against actual reclaimed output. - Add TestResultOnlyContract and TestMeasuredSavingsGate test classes.
|
[Bob] Updated this existing PR into the retrieval-backed context tray requested in the thread. Large tool results are now externalized before first send/SessionDB flush with bounded previews, SHA-256 recovery fidelity, config wiring, read_file loop protection, and independent prune-first cleanup. Synthetic replay: 89.29% tool-result reduction, 56,734 -> 11,896 request tokens, 6/6 raw artifacts identical; 326 targeted tests pass. The branch is current with main and mergeable. |
…oactive prune Follow-ups on top of the cherry-picked #62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the #62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising).
…oactive prune Follow-ups on top of the cherry-picked #62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the #62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising).
…oactive prune Follow-ups on top of the cherry-picked #62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the #62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising).
|
Thanks @the3asic — the below-threshold bounding problem this targeted is now fixed on main via salvage PR #70254 (built on #62644 in the bake-off; your review's measured-savings gate idea was adopted there and credited). This branch's broader tray/recovery system grew well beyond the below-threshold scope (disk tray, SHA-256 recovery, kanban/session_search bounding) — if those halves are still wanted they'd be welcome as focused follow-ups against current main. Closing with credit for the gate design. |
|
Thanks for the clear closeout and for carrying the measured-reclaim gate into #70254 with attribution. I verified that #70254 is merged on main and that its opt-in proactive prune covers the original below-threshold problem, so closing this branch is the right call. I agree the tray/recovery work should not be carried forward as one bundle. If revisited, it should be split against current main into narrowly scoped pieces with the persistence, retention, secret-handling, and retrieval contracts reviewed independently. No further changes are needed on this closed branch. [bob] |
…oactive prune Follow-ups on top of the cherry-picked NousResearch#62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the NousResearch#62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (NousResearch#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising).
[Bob]
Summary
read_fileon its loop-safe pagination pathcompression.prune_protect_tokens/prune_minimum_tokenskanban_showandsession_searchorientation views while preserving canonical data and explicit recovery pathsPrior work integrated
This updates the existing PR rather than opening a duplicate. It selectively integrates and preserves contributor authorship from:
It intentionally does not adopt #40322's retrieval-unavailable lossy mode as the default. Iris may route or prefetch retrieval, but it is not an authority for deleting content.
Synthetic replay benchmark
python scripts/benchmark_context_tray.pySynthetic-only fixture (no private session content): 24,000 user chars, 800 assistant chars, 196,884 tool-result chars.
Verification
RED (before implementation):
The failures covered config loading, lossless write fallback, status/hash/anomaly preservation, untrusted wrappers, and sequential/concurrent pre-flush budgeting. Final review added two more RED regressions before their fixes:
GREEN (current revision):
Also verified:
python -m compileall -q agent tools scripts/benchmark_context_tray.pygit diff --check origin/main...HEADprivate_content: false