diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7e878119a1..e8688d253b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1393,6 +1393,7 @@ class ContextCompressor(ContextEngine): "protected_head_tokens": None, "protected_tail_tokens": None, "middle_window_tokens": None, + "prellm_skip_count": 0, "aux_prompt_tokens": None, "aux_output_reservation": None, "aux_provider": "", @@ -5892,7 +5893,11 @@ This compaction should PRIORITISE preserving all information related to the focu 1. Prune old tool results (cheap pre-pass, no LLM call) 2. Protect head messages (system prompt + first exchange) 3. Find tail boundary by token budget (~20K tokens of recent context) - 4. Summarize middle turns with structured LLM prompt + 4. Summarize middle turns with structured LLM prompt (skipped + pre-LLM when the middle is below + ``_FEASIBILITY_SKIP_MIDDLE_FRACTION`` of the threshold after a + prior real-usage ineffectiveness strike — the deterministic + fallback drop recovers the negligible savings instead) 5. On re-compression, iteratively update the previous summary Blank platform-echo user rows trailing the latest actionable user @@ -5912,7 +5917,9 @@ This compaction should PRIORITISE preserving all information related to the focu everything else. Inspired by Claude Code's ``/compact``. force: If True, clear any active summary-failure cooldown before running so a manual ``/compress`` can retry immediately after - an auto-compression abort. Auto-compress callers pass False. + an auto-compression abort, and bypass the pre-LLM feasibility + skip so an explicit user request always exercises the full + summary path. Auto-compress callers pass False. memory_context: Optional provider-supplied context to preserve in the summary prompt. Whitespace-only values are ignored. """ @@ -6203,7 +6210,6 @@ This compaction should PRIORITISE preserving all information related to the focu ) # Phase 3: Generate structured summary - summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) # Pre-LLM feasibility check: if the middle section is too small to # yield meaningful token savings, skip the expensive LLM summarization @@ -6226,7 +6232,14 @@ This compaction should PRIORITISE preserving all information related to the focu # handling paths are always exercised on explicit user request. feasibility_skip = False if not force and self._ineffective_compression_count >= 1: - middle_tokens = estimate_messages_tokens_rough(turns_to_summarize) + # _record_compression_regions already estimated this exact window + # into the telemetry dict above; reuse it so the log line and + # telemetry can never disagree. The regions helper no-ops when the + # telemetry attr isn't a dict, so fall back to a fresh estimate + # when the key is absent/None (0 is a legitimate value). + middle_tokens = telemetry.get("middle_window_tokens") + if middle_tokens is None: + middle_tokens = estimate_messages_tokens_rough(turns_to_summarize) if middle_tokens < int( self.threshold_tokens * _FEASIBILITY_SKIP_MIDDLE_FRACTION ): @@ -6248,6 +6261,9 @@ This compaction should PRIORITISE preserving all information related to the focu if feasibility_skip: summary = None # No LLM call; Phase 4 inserts the deterministic fallback else: + # Deriving the auto focus topic scans recent user turns — only pay + # for it when a summary will actually be generated. + summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) summary = self._generate_summary( turns_to_summarize, focus_topic=summary_focus_topic, diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index ba54b99776..cccf654abb 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -2727,9 +2727,7 @@ class TestPreLlmFeasibilityCheck: compressor._ineffective_compression_count = 1 # one prior real strike msgs = self._make_messages() - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary") as mock_gen: # Middle section is tiny → feasibility skip fires compressor.compress(msgs, force=False) @@ -2747,9 +2745,7 @@ class TestPreLlmFeasibilityCheck: compressor._last_summary_auth_failure = True # stale from prior failure msgs = self._make_messages() - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary") as mock_gen: result = compressor.compress(msgs, force=False) # Should NOT abort — should produce compressed output with fallback @@ -2763,9 +2759,7 @@ class TestPreLlmFeasibilityCheck: compressor._last_summary_network_failure = True # stale msgs = self._make_messages() - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary") as mock_gen: result = compressor.compress(msgs, force=False) assert result is not None @@ -2777,9 +2771,7 @@ class TestPreLlmFeasibilityCheck: compressor._ineffective_compression_count = 1 msgs = self._make_messages() - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary", return_value="LLM summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary", return_value="LLM summary") as mock_gen: compressor.compress(msgs, force=True) # _generate_summary MUST have been called despite tiny middle section @@ -2791,9 +2783,7 @@ class TestPreLlmFeasibilityCheck: compressor._ineffective_compression_count = 0 msgs = self._make_messages() - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary", return_value="LLM summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary", return_value="LLM summary") as mock_gen: compressor.compress(msgs, force=False) mock_gen.assert_called_once() @@ -2836,9 +2826,7 @@ class TestPreLlmFeasibilityCheck: msgs.append({"role": "user", "content": f"recent request {i}"}) msgs.append({"role": "assistant", "content": "big result " + "x" * 20000}) - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary") as mock_gen: result = compressor.compress(msgs, force=False) mock_gen.assert_not_called() @@ -2859,9 +2847,7 @@ class TestPreLlmFeasibilityCheck: msgs = self._make_messages() for _ in range(2): - with patch("agent.context_compressor.call_llm") as mock_llm, \ - patch.object(compressor, "_generate_summary") as mock_gen: - mock_llm.return_value = (None, None) + with patch.object(compressor, "_generate_summary") as mock_gen: compressor.compress(list(msgs), force=False) mock_gen.assert_not_called() # Mirror the boundary wrapper's bookkeeping