From 1afa26a0d51f9811d29a599a94c4ff64c7d95f45 Mon Sep 17 00:00:00 2001 From: Alan Hsu <30566862+chesterXalan@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:51:35 +0800 Subject: [PATCH 1/2] fix(compression): fit summarizer input to small aux windows instead of lowering the session threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A summary model whose context window is smaller than the session threshold used to trigger an auto-lower of the live threshold to the aux window — halving a 272K main model's usable context when paired with a 128K compression model: compaction fires at ~47% of the real window, twice as often, discarding conversation detail early and breaking the prompt-cache prefix each time. The comparison itself was against the wrong quantity: the summariser request is already bounded (per-message truncation plus the _SUMMARY_INPUT_MAX_CHARS aggregate cap), so it never approaches the session threshold. What actually needs to fit the aux window is that bounded request — not the conversation. check_compression_model_feasibility now stashes the resolved aux window on the compressor and leaves the threshold alone; the 64K hard floor is unchanged. _generate_summary assembles the prompt through a single builder and, when the stashed window is smaller than the assembled request plus the summary output budget, shrinks the serialized-turns block (head+tail, explicit omitted-middle marker — the same shape as the existing aggregate cap) until it fits. estimate_tokens_rough over-counts, so a passing fit cannot overflow the real window; a still-oversized pathological prompt is sent best-effort and a failure routes through the existing main-model fallback. _bound_summary_input gains an optional max_chars override used by the fit loop. --- agent/context_compressor.py | 108 +++++++++-- agent/conversation_compression.py | 149 +++------------ tests/agent/test_summary_window_fit.py | 173 ++++++++++++++++++ .../run_agent/test_compression_feasibility.py | 73 +++----- 4 files changed, 320 insertions(+), 183 deletions(-) create mode 100644 tests/agent/test_summary_window_fit.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3eb05b6b9713..a7b8106fc177 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -512,6 +512,15 @@ def _prune_stale_reasoning_replay(messages: List[Dict[str, Any]]) -> int: # test_compression_small_ctx_threshold_floor.py). _SUMMARY_INPUT_MAX_CHARS = 160_000 +# Per-call fit for small-context summary models: safety margin reserved on +# top of the summary output budget when shrinking the summariser input to a +# small auxiliary window (prompt scaffold, template sections, markers), and +# the floor below which the input is never shrunk — the 64K minimum-context +# hard floor for compression models guarantees the floor is unreachable in +# practice; it only guards against a pathological window/budget combination. +_SUMMARY_FIT_MARGIN_TOKENS = 2_048 +_SUMMARY_FIT_MIN_CONTENT_CHARS = 8_000 + # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -2663,6 +2672,11 @@ def __init__( self.awaiting_real_usage_after_compression = False self.summary_model = summary_model_override or "" + # Context window of the auxiliary summary model, stashed by + # check_compression_model_feasibility() at session start. 0 = unknown + # or same as main — the per-call input fit in _generate_summary stays + # inactive and the default _SUMMARY_INPUT_MAX_CHARS cap applies. + self.summary_model_context_length: int = 0 self._session_db: Any = None self._session_id: str = "" @@ -3820,7 +3834,7 @@ def _bullets(items: list[str], limit: int = 8) -> str: return summary @classmethod - def _bound_summary_input(cls, content: str) -> str: + def _bound_summary_input(cls, content: str, max_chars: Optional[int] = None) -> str: """Cap total summarizer input while preserving beginning and recent tail. Per-message truncation alone is not enough for very long sessions: a @@ -3829,8 +3843,12 @@ def _bound_summary_input(cls, content: str) -> str: because the beginning often has task setup and the tail has the most recent state; explicitly mark the omitted middle so the summarizer knows context was intentionally compressed before it saw the prompt. + + ``max_chars`` overrides the default aggregate cap — the per-call fit + for small-context summary models passes a tighter, window-derived cap. """ - if len(content) <= cls._SUMMARY_INPUT_MAX_CHARS: + cap = max_chars if max_chars is not None else cls._SUMMARY_INPUT_MAX_CHARS + if len(content) <= cap: return content marker_template = ( @@ -3841,12 +3859,12 @@ def _bound_summary_input(cls, content: str) -> str: # head/tail split is known. The second marker can differ by a few chars # if the comma-formatted number changes width, so recompute once. marker = marker_template.format(omitted=len(content)) - remaining = max(cls._SUMMARY_INPUT_MAX_CHARS - len(marker), 0) + remaining = max(cap - len(marker), 0) head_chars = int(remaining * 0.45) tail_chars = remaining - head_chars omitted = max(len(content) - head_chars - tail_chars, 0) marker = marker_template.format(omitted=omitted) - remaining = max(cls._SUMMARY_INPUT_MAX_CHARS - len(marker), 0) + remaining = max(cap - len(marker), 0) head_chars = int(remaining * 0.45) tail_chars = remaining - head_chars tail = content[-tail_chars:].lstrip() if tail_chars else "" @@ -4134,6 +4152,7 @@ def _generate_summary( {_temporal_anchoring_rule} Write only the summary body. Do not include any preamble or prefix.""" + _bounded_previous_summary = "" if self._previous_summary: # Iterative update: preserve existing info, add new progress. # Bound the previous-summary block with the same aggregate cap as @@ -4145,7 +4164,10 @@ def _generate_summary( _bounded_previous_summary = self._bound_summary_input( self._previous_summary ) - prompt = f"""{_summarizer_preamble} + + def _assemble_prompt(_content: str) -> str: + if self._previous_summary: + _prompt = f"""{_summarizer_preamble} You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated. @@ -4153,31 +4175,89 @@ def _generate_summary( {_bounded_previous_summary} NEW TURNS TO INCORPORATE: -{content_to_summarize}{_memory_section} +{_content}{_memory_section} Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved. {_template_sections}""" - else: - # First compaction: summarize from scratch - prompt = f"""{_summarizer_preamble} + else: + # First compaction: summarize from scratch + _prompt = f"""{_summarizer_preamble} Create a structured checkpoint summary for the conversation after earlier turns are compacted. The summary should preserve enough detail for continuity without re-reading the original turns. TURNS TO SUMMARIZE: -{content_to_summarize}{_memory_section} +{_content}{_memory_section} Use this exact structure: {_template_sections}""" - # Inject focus topic guidance when the user provides one via /compress . - # This goes at the end of the prompt so it takes precedence. - if focus_topic: - prompt += f""" + # Inject focus topic guidance when the user provides one via + # /compress . This goes at the end of the prompt so it + # takes precedence. + if focus_topic: + _prompt += f""" FOCUS TOPIC: "{focus_topic}" This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" + return _prompt + + prompt = _assemble_prompt(content_to_summarize) + + # ── Per-call input fit for small-context summary models ── + # The default _SUMMARY_INPUT_MAX_CHARS cap targets slow/large aux + # backends, not the aux model's window. When the configured summary + # model's window (stashed by check_compression_model_feasibility) is + # smaller than the assembled request, shrink the serialized-turns + # block — the dominant, already-truncatable term — until the whole + # prompt plus the summary output budget fits. estimate_tokens_rough + # overestimates, so a passing check cannot overflow the real window. + _aux_window = int(getattr(self, "summary_model_context_length", 0) or 0) + if _aux_window and self.summary_model: + _fit_budget = _aux_window - summary_budget - _SUMMARY_FIT_MARGIN_TOKENS + _fit_trimmed = False + for _ in range(4): + _prompt_tokens = estimate_tokens_rough(prompt) + if _fit_budget <= 0 or _prompt_tokens <= _fit_budget: + break + if len(content_to_summarize) <= _SUMMARY_FIT_MIN_CONTENT_CHARS: + logger.warning( + "Summarizer prompt (~%d tokens) still exceeds %s's " + "fit budget (%d tokens) after trimming input to the " + "%d-char floor — sending best effort; a failure " + "falls back to the main model.", + _prompt_tokens, + self.summary_model, + _fit_budget, + _SUMMARY_FIT_MIN_CONTENT_CHARS, + ) + break + _target_chars = max( + _SUMMARY_FIT_MIN_CONTENT_CHARS, + int( + len(content_to_summarize) + * _fit_budget + / _prompt_tokens + * 0.9 + ), + ) + if _target_chars >= len(content_to_summarize): + _target_chars = len(content_to_summarize) - 1 + content_to_summarize = self._bound_summary_input( + content_to_summarize, max_chars=_target_chars + ) + prompt = _assemble_prompt(content_to_summarize) + _fit_trimmed = True + if _fit_trimmed: + logger.info( + "Summarizer input trimmed to fit %s's %d-token window " + "(prompt ~%d tokens, output budget %d).", + self.summary_model, + _aux_window, + estimate_tokens_rough(prompt), + summary_budget, + ) try: call_kwargs = { diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 83341b0041e2..580318d5cbe4 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1707,134 +1707,31 @@ def check_compression_model_feasibility(agent: Any) -> None: ) threshold = agent.context_compressor.threshold_tokens - if aux_context < threshold: - # Auto-correct: lower the live session threshold so - # compression actually works this session. The hard floor - # above guarantees aux_context >= MINIMUM_CONTEXT_LENGTH, - # so the new threshold is always >= 64K. - # - # The compression summariser sends a single user-role - # prompt (no system prompt, no tools) to the aux model, so - # new_threshold == aux_context is safe: the request is - # the raw messages plus a small summarisation instruction. - old_threshold = threshold - new_threshold = aux_context - agent.context_compressor.threshold_tokens = new_threshold - # ``tail_token_budget`` is derived from the trigger threshold, not - # directly from the model window. Keep it in lockstep with this - # just-in-time correction exactly as ContextCompressor.update_model() - # does. Leaving the old budget behind can make the tail's 1.5x soft - # ceiling wider than the lowered trigger, so compression preserves - # nearly the entire request and repeatedly re-fires. - summary_target_ratio = getattr( - agent.context_compressor, "summary_target_ratio", None - ) - if isinstance(summary_target_ratio, (int, float)): - agent.context_compressor.tail_token_budget = int( - new_threshold * summary_target_ratio - ) - # Keep threshold_percent in sync so future main-model - # context_length changes (update_model) re-derive from a - # sensible number rather than the original too-high value. - main_ctx = agent.context_compressor.context_length - if main_ctx: - agent.context_compressor.threshold_percent = ( - new_threshold / main_ctx - ) - safe_pct = int((aux_context / main_ctx) * 100) if main_ctx else 50 - # The "lower the threshold" suggestion must survive the built-in - # trigger recomputation (#67422): _effective_threshold_percent() - # raises sub-75% values back up for main windows under 512K, and - # _compute_threshold_tokens() further applies the output-token - # reservation, the 64K floor, and the degenerate-window guard. - # Recommending a value those would override is silently ignored - # and this warning would reappear every session — so mirror the - # compressor's own math and only offer the option when the - # recomputed trigger actually fits the auxiliary model's context. - # External engines own compaction policy (#44439); the built-in - # floor doesn't apply to them, so keep the plain suggestion. - from agent.context_compressor import ContextCompressor as _CC - - recomputed_threshold = None - if main_ctx and isinstance(agent.context_compressor, _CC): - recomputed_threshold = _CC._compute_threshold_tokens( - main_ctx, - _CC._effective_threshold_percent(main_ctx, safe_pct / 100), - getattr(agent.context_compressor, "max_tokens", None), - ) - threshold_suggestion_viable = ( - recomputed_threshold is None or recomputed_threshold <= aux_context - ) - # Build human-readable "model (provider)" labels for both - # the main model and the compression model so users can - # tell at a glance which provider each side is actually - # using. When the configured provider is empty or "auto", - # fall back to the client's base_url hostname. - _main_model = getattr(agent, "model", "") or "?" - _main_provider = getattr(agent, "provider", "") or "" - _aux_provider_label = ( - _aux_cfg_provider - if _aux_cfg_provider and _aux_cfg_provider != "auto" - else "" - ) - if not _aux_provider_label: - try: - from urllib.parse import urlparse - _aux_provider_label = ( - urlparse(aux_base_url).hostname or aux_base_url - ) - except Exception: - _aux_provider_label = aux_base_url or "auto" - _main_label = ( - f"{_main_model} ({_main_provider})" - if _main_provider - else _main_model - ) - _aux_label = f"{aux_model} ({_aux_provider_label})" - msg = ( - f"⚠ Compression model {_aux_label} context is " - f"{aux_context:,} tokens, but the main model " - f"{_main_label}'s compression threshold was " - f"{old_threshold:,} tokens. " - f"Auto-lowered this session's threshold to " - f"{new_threshold:,} tokens so compression can run.\n" - ) - if threshold_suggestion_viable: - msg += ( - f" To make this permanent, edit config.yaml — either:\n" - f" 1. Use a larger compression model:\n" - f" auxiliary:\n" - f" compression:\n" - f" model: \n" - f" 2. Lower the compression threshold:\n" - f" compression:\n" - f" threshold: 0.{safe_pct:02d}" - ) - else: - msg += ( - f" To make this permanent, use a larger compression " - f"model in config.yaml:\n" - f" auxiliary:\n" - f" compression:\n" - f" model: \n" - f" (Lowering compression.threshold cannot help here — " - f"with {_main_label}'s {main_ctx:,}-token window, " - f"Hermes's small-context floor and output reservation " - f"would recompute the trigger to " - f"{recomputed_threshold:,} tokens, still above the " - f"compression model's {aux_context:,}.)" - ) - agent._compression_warning = msg - agent._emit_status(msg) - logger.warning( - "Auxiliary compression model %s has %d token context, " - "below the main model's compression threshold of %d " - "tokens — auto-lowered session threshold to %d to " - "keep compression working.", + # Stash the resolved auxiliary window for the per-call input fit in + # ``_generate_summary``. The summariser's request is BOUNDED — + # per-message truncation plus the ``_SUMMARY_INPUT_MAX_CHARS`` + # aggregate cap — so its size is governed by those caps, not by the + # session threshold. An aux window smaller than the threshold + # therefore does not require lowering the threshold; it only means + # the per-call bound must target the aux window instead of the + # default cap. (The previous behaviour lowered the session + # threshold to ``aux_context``, which silently shrank the main + # model's usable window — a 272K session compacting at 128K doubles + # compaction frequency and discards conversation detail early — + # while the actual summariser request was already far below the + # threshold it was being compared against.) + agent.context_compressor.summary_model_context_length = int( + aux_context or 0 + ) + if aux_context and aux_context < threshold: + logger.info( + "Auxiliary compression model %s has a %d-token context " + "below the %d-token session threshold — summariser input " + "will be bounded per call to fit; session threshold " + "unchanged.", aux_model, aux_context, - old_threshold, - new_threshold, + threshold, ) except ValueError: # Hard rejections (aux below minimum context) must propagate diff --git a/tests/agent/test_summary_window_fit.py b/tests/agent/test_summary_window_fit.py new file mode 100644 index 000000000000..b4ee5d8d0d92 --- /dev/null +++ b/tests/agent/test_summary_window_fit.py @@ -0,0 +1,173 @@ +"""Per-call summarizer-input fit for small-context auxiliary models. + +A summary model whose context window is smaller than the session threshold +must NOT shrink the session threshold (the old auto-lower halved the main +model's usable window). Instead: + +* ``check_compression_model_feasibility`` stashes the aux window on the + compressor and leaves the threshold alone; and +* ``_generate_summary`` shrinks the serialized-turns block per call until + the assembled prompt plus the output budget fits that window. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from agent.context_compressor import ( + _SUMMARY_FIT_MARGIN_TOKENS, + ContextCompressor, +) +from agent.conversation_compression import check_compression_model_feasibility +from agent.model_metadata import estimate_tokens_rough + + +def _compressor(context_length: int = 200_000) -> ContextCompressor: + with patch( + "agent.context_compressor.get_model_context_length", + return_value=context_length, + ): + c = ContextCompressor( + model="test/big-model", + threshold_percent=0.85, + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + _ = c.context_length + return c + + +def _response(content: str): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = content + return mock_response + + +def _cjk_turns(n: int = 30, chars: int = 5_000) -> list: + # CJK text is the worst case for the rough estimator (1 token/char), + # so it exercises the fit loop with realistic dense content. + return [ + {"role": "user" if i % 2 == 0 else "assistant", "content": "測" * chars} + for i in range(n) + ] + + +class TestFeasibilityStashesWindowInsteadOfLoweringThreshold: + def _agent(self, compressor: ContextCompressor) -> SimpleNamespace: + return SimpleNamespace( + compression_enabled=True, + context_compressor=compressor, + _current_main_runtime=lambda: {}, + _custom_providers={}, + _aux_compression_context_length_config=None, + _emit_status=lambda _msg: None, + model="test/big-model", + provider="test", + ) + + def _run_check(self, agent, aux_context: int) -> None: + client = SimpleNamespace(base_url="https://aux.invalid/v1", api_key="k") + with ( + patch( + "agent.auxiliary_client.get_text_auxiliary_client", + return_value=(client, "small-model"), + ), + patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=("small-provider", "small-model", "", "", ""), + ), + patch( + "agent.model_metadata.get_model_context_length", + return_value=aux_context, + ), + ): + check_compression_model_feasibility(agent) + + def test_small_aux_window_keeps_threshold_and_stashes_window(self): + compressor = _compressor() + threshold_before = compressor.threshold_tokens + percent_before = compressor.threshold_percent + agent = self._agent(compressor) + assert 128_000 < threshold_before # premise of the scenario + + self._run_check(agent, aux_context=128_000) + + assert compressor.threshold_tokens == threshold_before + assert compressor.threshold_percent == percent_before + assert compressor.summary_model_context_length == 128_000 + # No user-facing warning: the per-call bound makes the model work. + assert getattr(agent, "_compression_warning", None) is None + + def test_large_aux_window_stashes_without_side_effects(self): + compressor = _compressor() + threshold_before = compressor.threshold_tokens + agent = self._agent(compressor) + + self._run_check(agent, aux_context=1_000_000) + + assert compressor.threshold_tokens == threshold_before + assert compressor.summary_model_context_length == 1_000_000 + + +class TestGenerateSummaryWindowFit: + def test_prompt_trimmed_to_fit_small_window(self): + c = _compressor() + c.summary_model = "small-model" + c.summary_model_context_length = 70_000 + turns = _cjk_turns() + + with patch( + "agent.context_compressor.call_llm", + return_value=_response("summary body"), + ) as mock_call: + summary = c._generate_summary(turns) + + assert summary # the call still succeeds end-to-end + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + budget = c._compute_summary_budget(turns) + fit_budget = 70_000 - budget - _SUMMARY_FIT_MARGIN_TOKENS + assert estimate_tokens_rough(prompt) <= fit_budget + assert "summary input truncated" in prompt + + def test_no_trim_without_stashed_window(self): + c = _compressor() + c.summary_model = "small-model" + c.summary_model_context_length = 0 + turns = _cjk_turns() + + with patch( + "agent.context_compressor.call_llm", + return_value=_response("summary body"), + ) as mock_call: + c._generate_summary(turns) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + # Content is CJK-dense: without the per-call fit the prompt far + # exceeds the small window the previous test squeezed into. + assert estimate_tokens_rough(prompt) > 70_000 + + def test_no_trim_when_summary_model_is_main(self): + # summary_model == "" means the main model summarizes; the per-call + # fit must stay inactive even if a stale window value is present. + c = _compressor() + c.summary_model = "" + c.summary_model_context_length = 70_000 + turns = _cjk_turns() + + with patch( + "agent.context_compressor.call_llm", + return_value=_response("summary body"), + ) as mock_call: + c._generate_summary(turns) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert estimate_tokens_rough(prompt) > 70_000 + + def test_bound_summary_input_honors_max_chars_override(self): + content = "x" * 50_000 + bounded = ContextCompressor._bound_summary_input(content, max_chars=10_000) + assert len(bounded) <= 10_000 + assert "summary input truncated" in bounded + # Default cap leaves short content untouched. + assert ContextCompressor._bound_summary_input(content) == content diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index 8626841cb613..6057247e5079 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -71,9 +71,13 @@ def _make_agent( @patch("agent.model_metadata.get_model_context_length", return_value=80_000) @patch("agent.auxiliary_client.get_text_auxiliary_client") -def test_auto_corrects_threshold_when_aux_context_below_threshold(mock_get_client, mock_ctx_len): - """Auto-correction: aux >= 64K floor but < threshold → lower threshold - to aux_context so compression still works this session.""" +def test_small_aux_window_keeps_threshold_and_stashes_it(mock_get_client, mock_ctx_len): + """aux >= 64K floor but < threshold → threshold UNCHANGED; the aux window + is stashed for _generate_summary's per-call input fit instead. Lowering + the session threshold (the old behaviour) silently shrank the main + model's usable window, while the summariser request — bounded by + per-message truncation and _SUMMARY_INPUT_MAX_CHARS — never approached + the threshold it was compared against.""" agent = _make_agent(main_context=200_000, threshold_percent=0.50) # threshold = 100,000 — aux has 80,000 (above 64K floor, below threshold) mock_client = MagicMock() @@ -86,30 +90,14 @@ def test_auto_corrects_threshold_when_aux_context_below_threshold(mock_get_clien agent._check_compression_model_feasibility() - assert len(messages) == 1 - assert "Compression model" in messages[0] - assert "80,000" in messages[0] # aux context - assert "100,000" in messages[0] # old threshold - assert "Auto-lowered" in messages[0] - # Actionable persistence guidance included - assert "config.yaml" in messages[0] - assert "auxiliary:" in messages[0] - assert "compression:" in messages[0] - # 200K main is under the 512K small-context limit and 80K/200K = 40% sits - # below the 75% floor — a `threshold:` suggestion would be raised back to - # 75% and ignored (#67422), so the message must not offer one and must - # explain the recomputed trigger instead (0.75 * 200K = 150K). - assert "threshold:" not in messages[0] - assert "150,000" in messages[0] - # Warning stored for gateway replay - assert agent._compression_warning is not None - # Threshold on the live compressor was actually lowered to aux_context. - assert agent.context_compressor.threshold_tokens == 80_000 - # Every threshold-derived budget must move with it. Keeping the original - # 20K tail here would protect 25% of the lowered threshold instead of the - # configured 20%, and larger real-world mismatches can make the tail's 1.5x - # soft ceiling wider than the entire compression trigger. - assert agent.context_compressor.tail_token_budget == 16_000 + # No user-facing warning: the per-call bound makes the model just work. + assert messages == [] + assert agent._compression_warning is None + # Threshold and every threshold-derived budget stay untouched. + assert agent.context_compressor.threshold_tokens == 100_000 + assert agent.context_compressor.tail_token_budget == 20_000 + # The aux window is stashed for the per-call fit. + assert agent.context_compressor.summary_model_context_length == 80_000 @patch("agent.model_metadata.get_model_context_length", return_value=32_768) @@ -325,15 +313,15 @@ def test_no_unavailable_warning_when_configured_fallback_chain_resolves(): # ── Two-phase: __init__ + run_conversation replay ─────────────────── -@patch("agent.model_metadata.get_model_context_length", return_value=80_000) @patch("agent.auxiliary_client.get_text_auxiliary_client") -def test_warning_stored_for_gateway_replay(mock_get_client, mock_ctx_len): - """__init__ stores the warning; _replay sends it through status_callback.""" +def test_warning_stored_for_gateway_replay(mock_get_client): + """__init__ stores the warning; _replay sends it through status_callback. + + The small-aux-window case no longer warns (the per-call input fit makes + it work silently), so the replay path is exercised with the remaining + startup warning: no auxiliary provider available at all.""" agent = _make_agent(main_context=200_000, threshold_percent=0.50) - mock_client = MagicMock() - mock_client.base_url = "https://openrouter.ai/api/v1" - mock_client.api_key = "sk-aux" - mock_get_client.return_value = (mock_client, "google/gemini-3-flash-preview") + mock_get_client.return_value = (None, None) # Phase 1: __init__ — _emit_status prints (CLI) but callback is None vprint_messages = [] @@ -349,7 +337,7 @@ def test_warning_stored_for_gateway_replay(mock_get_client, mock_ctx_len): agent._replay_compression_warning() assert any( - ev == "lifecycle" and "Auto-lowered" in msg + ev == "lifecycle" and "No auxiliary LLM provider" in msg for ev, msg in callback_events ) @@ -380,16 +368,14 @@ def test_no_replay_when_no_warning(mock_get_client, mock_ctx_len): -# ── #67422: threshold suggestion must survive the small-context floor ──────── - - +# ── Large-context main model: same no-lower semantics ─────────────────────── @patch("agent.model_metadata.get_model_context_length", return_value=300_000) @patch("agent.auxiliary_client.get_text_auxiliary_client") -def test_threshold_suggestion_kept_for_large_context_main(mock_get_client, mock_ctx_len): - """Main window >= 512K has no floor — any suggestion is honored, so the - `threshold:` option stays even below 75%.""" +def test_large_context_main_also_keeps_threshold(mock_get_client, mock_ctx_len): + """A 1M-window main model with a 300K aux stays at its 500K threshold — + the stash-and-fit semantics do not depend on the small-context floor.""" agent = _make_agent(main_context=1_000_000, threshold_percent=0.50) # threshold = 500,000 — aux has 300,000 mock_client = MagicMock() @@ -402,8 +388,9 @@ def test_threshold_suggestion_kept_for_large_context_main(mock_get_client, mock_ agent._check_compression_model_feasibility() - assert len(messages) == 1 - assert "threshold: 0.30" in messages[0] + assert messages == [] + assert agent.context_compressor.threshold_tokens == 500_000 + assert agent.context_compressor.summary_model_context_length == 300_000 From 6ce35a12efed6aea9a926a760087da3d33a36405 Mon Sep 17 00:00:00 2001 From: Alan Hsu <30566862+chesterXalan@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:51:46 +0800 Subject: [PATCH 2/2] fix(compression): review follow-ups for the per-call window fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses automated-review findings on the window-fit change: * Log when the trim loop exits via its iteration cap while the prompt is still over the fit budget — previously only the content-floor exit warned, and a non-converged prompt was sent silently. (Reaching the cap without converging requires a scaffold-dominated prompt, so this is defensive, but a silently oversized send deserves a trace.) * Restore a user-facing diagnostic for small aux windows, without the old auto-lower warning's noise: when the per-call fit actually trims the summarizer input, compress_context emits a one-time-per-session status pointing at auxiliary.compression.model — mirroring the existing _last_summary_fallback_used surfacing pattern. Sessions whose windows never need trimming see nothing. --- agent/context_compressor.py | 38 ++++++++++++++++++++------ agent/conversation_compression.py | 21 ++++++++++++++ tests/agent/test_summary_window_fit.py | 2 ++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a7b8106fc177..0369d7da0f6a 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1609,6 +1609,7 @@ def on_session_reset(self) -> None: self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_summary_input_trimmed = False self._last_feasibility_skip = False self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None @@ -1880,6 +1881,7 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_summary_input_trimmed = False self._last_feasibility_skip = False self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None @@ -4217,6 +4219,7 @@ def _assemble_prompt(_content: str) -> str: if _aux_window and self.summary_model: _fit_budget = _aux_window - summary_budget - _SUMMARY_FIT_MARGIN_TOKENS _fit_trimmed = False + _fit_floor_warned = False for _ in range(4): _prompt_tokens = estimate_tokens_rough(prompt) if _fit_budget <= 0 or _prompt_tokens <= _fit_budget: @@ -4232,6 +4235,7 @@ def _assemble_prompt(_content: str) -> str: _fit_budget, _SUMMARY_FIT_MIN_CONTENT_CHARS, ) + _fit_floor_warned = True break _target_chars = max( _SUMMARY_FIT_MIN_CONTENT_CHARS, @@ -4250,14 +4254,31 @@ def _assemble_prompt(_content: str) -> str: prompt = _assemble_prompt(content_to_summarize) _fit_trimmed = True if _fit_trimmed: - logger.info( - "Summarizer input trimmed to fit %s's %d-token window " - "(prompt ~%d tokens, output budget %d).", - self.summary_model, - _aux_window, - estimate_tokens_rough(prompt), - summary_budget, - ) + _final_prompt_tokens = estimate_tokens_rough(prompt) + # Signal for the one-time user-facing notice surfaced by + # compress_context (mirrors _last_summary_fallback_used). + self._last_summary_input_trimmed = True + if _final_prompt_tokens > _fit_budget and not _fit_floor_warned: + # Iteration cap exhausted without converging — distinct + # from the floor case above, which already warned. + logger.warning( + "Summarizer input trim did not converge: prompt " + "~%d tokens still exceeds %s's fit budget " + "(%d tokens) after 4 passes — sending best effort; " + "a failure falls back to the main model.", + _final_prompt_tokens, + self.summary_model, + _fit_budget, + ) + else: + logger.info( + "Summarizer input trimmed to fit %s's %d-token " + "window (prompt ~%d tokens, output budget %d).", + self.summary_model, + _aux_window, + _final_prompt_tokens, + summary_budget, + ) try: call_kwargs = { @@ -6548,6 +6569,7 @@ def compress( # after compress() returns to decide whether to surface a warning. self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_summary_input_trimmed = False self._last_feasibility_skip = False self._last_summary_error = None self._last_aux_model_failure_error = None diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 580318d5cbe4..a3a2a87087d8 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -2917,6 +2917,27 @@ def _release_lock() -> None: getattr(agent.context_compressor, "_last_feasibility_skip", False) ) + # One-time per-session notice when the per-call window fit actually + # engaged: the summary succeeded, but the aux model's window forced + # input trimming. Educates users on the config remedy without the + # per-compression noise the old auto-lower warning produced (it fired + # at session start whether or not trimming would ever happen). + if getattr(agent.context_compressor, "_last_summary_input_trimmed", False) is True: + agent.context_compressor._last_summary_input_trimmed = False + if not getattr(agent, "_summary_trim_notice_sent", False): + agent._summary_trim_notice_sent = True + _trim_model = ( + getattr(agent.context_compressor, "summary_model", "") or "?" + ) + agent._emit_status( + f"ℹ Compression model {_trim_model}'s context window is " + "smaller than this conversation's summarizer input, so " + "the input was trimmed to fit (oldest middle turns " + "shortened first). For untrimmed summaries, set " + "auxiliary.compression.model in config.yaml to a " + "larger-context model." + ) + # If compression aborted (aux LLM failed to produce a usable summary) # the compressor returns the input messages unchanged. Surface the # error to the user, skip the session-rotation work entirely (no diff --git a/tests/agent/test_summary_window_fit.py b/tests/agent/test_summary_window_fit.py index b4ee5d8d0d92..fe2aaa2751b4 100644 --- a/tests/agent/test_summary_window_fit.py +++ b/tests/agent/test_summary_window_fit.py @@ -129,6 +129,8 @@ def test_prompt_trimmed_to_fit_small_window(self): fit_budget = 70_000 - budget - _SUMMARY_FIT_MARGIN_TOKENS assert estimate_tokens_rough(prompt) <= fit_budget assert "summary input truncated" in prompt + # Signal consumed by compress_context's one-time user notice. + assert c._last_summary_input_trimmed is True def test_no_trim_without_stashed_window(self): c = _compressor()