-
Notifications
You must be signed in to change notification settings - Fork 52.3k
[Bob] feat(context): lossless tool-result tray before first send #62389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
trac3r00
wants to merge
15
commits into
NousResearch:main
from
trac3r00:fix/prune-tool-outputs-large-window
Closed
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
be3e3a7
feat(compression): prune-first phase — elide old tool outputs on an a…
trac3r00 e87d5bb
[Bob] fix: result-only pruning + measured savings gate per review
trac3r00 02ae658
[Bob] merge: resolve conflicts with upstream/main (model_thresholds/t…
trac3r00 309e869
feat(tools): externalize results before first model send
trac3r00 ac4b358
feat(compression): prune-first phase — elide old tool outputs on an a…
trac3r00 6cd515c
[Bob] fix: result-only pruning + measured savings gate per review
trac3r00 708dcc2
fix(tools): bound recoverable context outputs
jkobject 2c6cdfb
fix(tools): close aggregate output bound bypasses
jkobject 71ed614
fix(kanban): enforce aggregate show output bound
jkobject fdff372
test(context): add lossless replay benchmark and config docs
trac3r00 5044959
[Bob] merge existing prune-first PR history
trac3r00 dcede59
Merge remote-tracking branch 'origin/main' into bob/context-tray
trac3r00 2332732
test(tools): align budget contracts with context tray defaults
trac3r00 55dd7de
fix(context): keep recovery pages loop-safe and redact previews
trac3r00 2d3e4c6
test(context): avoid secret-like fixture assignment
trac3r00 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1422,6 +1422,25 @@ def update_model( | |
| # rationale as the gpt-5.5/Codex 85% autoraise. | ||
| _MIN_CTX_TRIGGER_RATIO = 0.85 | ||
|
|
||
| # -- Tool-output prune-first phase (issue #513) ------------------------ | ||
| # Large-context models resolve a high summarization threshold | ||
| # (context_length * threshold_percent). On a 1M-window model that is | ||
| # ~800K tokens, so a real coding session that plateaus at ~260K of | ||
| # context NEVER crosses it — and because the cheap tool-output prune | ||
| # (_prune_old_tool_results) only runs INSIDE compress(), it stays | ||
| # dormant too. The bulky, re-sent-every-turn tool results (measured at | ||
| # 58-86% of a long session's re-read weight) are never reclaimed. | ||
| # | ||
| # These constants gate an independent prune-only phase that fires on an | ||
| # ABSOLUTE token budget, decoupled from the summarization trigger. The | ||
| # LLM-based conversation summary still waits for threshold_tokens; only | ||
| # the free (no-LLM) tool-result elision runs early. Both default to a | ||
| # generous protect window so small/normal sessions are untouched. | ||
| # | ||
| # Disabled by default (prune_protect_tokens=None) to preserve historical | ||
| # behavior; opt in via compression.prune_protect_tokens in config.yaml. | ||
| _DEFAULT_PRUNE_MINIMUM_TOKENS = 20_000 # only prune if >=20K reclaimable | ||
|
|
||
| @staticmethod | ||
| def _coerce_max_tokens(value: Any) -> int | None: | ||
| """Normalize a max_tokens value to a positive int or None. | ||
|
|
@@ -1545,6 +1564,8 @@ def __init__( | |
| max_tokens: int | None = None, | ||
| model_thresholds: dict[str, float] | None = None, | ||
| threshold_tokens_cap: Any = None, | ||
| prune_protect_tokens: int | None = None, | ||
| prune_minimum_tokens: int | None = None, | ||
| ): | ||
| self.model = model | ||
| self.base_url = base_url | ||
|
|
@@ -1589,6 +1610,32 @@ def __init__( | |
| # deterministic "summary unavailable" handoff and drop the middle window. | ||
| self.abort_on_summary_failure = abort_on_summary_failure | ||
|
|
||
| # -- Prune-first phase (issue #513) -- | ||
| # prune_protect_tokens: keep the most recent N tokens of tool output | ||
| # untouched; elide older tool results. None disables the early phase | ||
| # entirely (historical behavior). A positive int arms it. | ||
| self.prune_protect_tokens = ( | ||
| int(prune_protect_tokens) | ||
| if isinstance(prune_protect_tokens, (int, float)) | ||
| and prune_protect_tokens | ||
| and int(prune_protect_tokens) > 0 | ||
| else None | ||
| ) | ||
| # Only run the early prune when it would reclaim at least this many | ||
| # tokens (avoids churning the prompt-cache prefix for a trivial gain). | ||
| _pmin = ( | ||
| prune_minimum_tokens | ||
| if isinstance(prune_minimum_tokens, (int, float)) | ||
| and prune_minimum_tokens | ||
| and int(prune_minimum_tokens) > 0 | ||
| else self._DEFAULT_PRUNE_MINIMUM_TOKENS | ||
| ) | ||
| self.prune_minimum_tokens = int(_pmin) | ||
| # Rough running estimate of the tokens the last early-prune reclaimed, | ||
| # surfaced in logs/status. Not authoritative (uses the same rough | ||
| # estimator as preflight), just observability. | ||
| self.last_prune_saved_tokens = 0 | ||
|
|
||
| self.context_length = get_model_context_length( | ||
| model, base_url=base_url, api_key=api_key, | ||
| config_context_length=config_context_length, | ||
|
|
@@ -1900,13 +1947,84 @@ def _automatic_compression_blocked_locally(self) -> bool: | |
| return True | ||
| return False | ||
|
|
||
| def should_prune_tools(self, prompt_tokens: Optional[int] = None) -> bool: | ||
| """Return True if the early, LLM-free tool-output prune should fire. | ||
|
|
||
| This is INDEPENDENT of :meth:`should_compress`. The summarization | ||
| trigger scales with the model window (``threshold_tokens`` = | ||
| ``context_length * threshold_percent``), which on a large-context | ||
| model can sit at hundreds of thousands of tokens — high enough that a | ||
| real session's re-sent tool output never triggers it. The prune-first | ||
| phase instead fires on an ABSOLUTE budget (``prune_protect_tokens``) | ||
| so bulky, already-seen tool results get elided long before the | ||
| window-relative summary threshold is reached (issue #513). | ||
|
|
||
| Returns False when the phase is disarmed (``prune_protect_tokens is | ||
| None``) so historical behavior is byte-for-byte preserved unless the | ||
| user opts in via ``compression.prune_protect_tokens``. | ||
| """ | ||
| if self.prune_protect_tokens is None: | ||
| return False | ||
| tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens | ||
| # Fire once the transcript exceeds the protected window PLUS the | ||
| # 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 | ||
|
|
||
| def prune_tools_only( | ||
| self, messages: List[Dict[str, Any]], | ||
| ) -> tuple[List[Dict[str, Any]], int]: | ||
| """Run the cheap tool-output prune WITHOUT any LLM summarization. | ||
|
|
||
| Reuses :meth:`_prune_old_tool_results` in ``result_only`` mode — the | ||
| same routine :meth:`compress` runs in its Phase 1 — but protects a | ||
| token-budget tail sized by ``prune_protect_tokens`` rather than the | ||
| summary tail budget. No conversation turns are summarized or dropped; | ||
| only old tool *results* (never tool *calls*, user, or assistant text) | ||
| are replaced with informative one-line stubs. Tool-call arguments are | ||
| left byte-for-byte intact (``result_only=True``). | ||
|
|
||
| After pruning, the actual token savings are measured and compared | ||
| against ``prune_minimum_tokens``. If the measured savings fall below | ||
| the threshold, the original messages are returned unchanged — this | ||
| prevents churning the prompt-cache prefix for a trivial gain. | ||
|
|
||
| Returns ``(messages, pruned_count)``. When the phase is disarmed, | ||
| nothing qualifies, or measured savings are insufficient, returns the | ||
| input unchanged with ``0``. | ||
| """ | ||
| if self.prune_protect_tokens is None or not messages: | ||
| return messages, 0 | ||
| pruned_messages, pruned_count = self._prune_old_tool_results( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| messages, | ||
| protect_tail_count=self.protect_last_n, | ||
| protect_tail_tokens=self.prune_protect_tokens, | ||
| result_only=True, | ||
| ) | ||
| if not pruned_count: | ||
| return messages, 0 | ||
| # Measured savings gate: estimate token delta and only commit | ||
| # when it meets the prune_minimum_tokens threshold. | ||
| pre_tokens = estimate_messages_tokens_rough(messages) | ||
| post_tokens = estimate_messages_tokens_rough(pruned_messages) | ||
| saved = max(0, pre_tokens - post_tokens) | ||
| if saved < self.prune_minimum_tokens: | ||
| logger.debug( | ||
| "Prune-first: measured savings %d < minimum %d — rollback", | ||
| saved, self.prune_minimum_tokens, | ||
| ) | ||
| return messages, 0 | ||
| return pruned_messages, pruned_count | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Tool output pruning (cheap pre-pass, no LLM call) | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _prune_old_tool_results( | ||
| self, messages: List[Dict[str, Any]], protect_tail_count: int, | ||
| protect_tail_tokens: int | None = None, | ||
| result_only: bool = False, | ||
| ) -> tuple[List[Dict[str, Any]], int]: | ||
| """Replace old tool result contents with informative 1-line summaries. | ||
|
|
||
|
|
@@ -1916,8 +2034,11 @@ def _prune_old_tool_results( | |
| [read_file] read config.py from line 1 (3,400 chars) | ||
|
|
||
| Also deduplicates identical tool results (e.g. reading the same file | ||
| 5x keeps only the newest full copy) and truncates large tool_call | ||
| arguments in assistant messages outside the protected tail. | ||
| 5x keeps only the newest full copy) and, unless ``result_only`` is | ||
| True, truncates large tool_call arguments in assistant messages | ||
| outside the protected tail. When ``result_only=True``, only tool | ||
| *result* content blocks are pruned — tool-call arguments, user, and | ||
| assistant text are left byte-for-byte intact. | ||
|
|
||
| Walks backward from the end, protecting the most recent messages that | ||
| fall within ``protect_tail_tokens`` (when provided) OR the last | ||
|
|
@@ -2086,14 +2207,20 @@ def _truncate_tool_call_args_at(idx: int) -> bool: | |
| # outside the protected tail. write_file with 50KB content, for | ||
| # example, survives pruning entirely without this. | ||
| # | ||
| # Skipped in result_only mode (prune-first phase) — the result-only | ||
| # contract guarantees tool-call arguments are never mutated. | ||
| # | ||
| # The shrinking is done inside the parsed JSON structure so the | ||
| # result remains valid JSON — otherwise downstream providers 400 | ||
| # on every subsequent turn until the broken call falls out of | ||
| # the window. See ``_truncate_tool_call_args_json`` docstring. | ||
| for i in range(max(0, prune_boundary)): | ||
| _truncate_tool_call_args_at(i) | ||
| if not result_only: | ||
| for i in range(max(0, prune_boundary)): | ||
| _truncate_tool_call_args_at(i) | ||
|
|
||
| # Pass 4 (issue #61932): protected-tail pressure demotion. | ||
| # Pass 4 (issue #61932): protected-tail pressure demotion. This is part | ||
| # of full compression only; result-only early pruning must not mutate | ||
| # protected recent results or tool-call arguments. | ||
| # After multiple in-place compactions the transcript can be short | ||
| # enough that nearly every remaining message sits inside the | ||
| # protected floor, yet those messages are huge completed tool / | ||
|
|
@@ -2102,7 +2229,12 @@ def _truncate_tool_call_args_at(idx: int) -> bool: | |
| # bodies *inside* the protected region until the protected tail | ||
| # fits the soft budget, always keeping a short recent floor | ||
| # verbatim so the active ask stays readable. | ||
| if protect_tail_tokens is not None and protect_tail_tokens > 0 and result: | ||
| if ( | ||
| not result_only | ||
| and protect_tail_tokens is not None | ||
| and protect_tail_tokens > 0 | ||
| and result | ||
| ): | ||
| soft_ceiling = int(protect_tail_tokens * 1.5) | ||
| keep_recent = min(_PRESSURE_KEEP_RECENT_MESSAGES, len(result)) | ||
| demote_end = len(result) - keep_recent | ||
|
|
||
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This only proves total request size crossed
protect + minimum; it does not prove that at leastprune_minimum_tokensof 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.