From 5c6dbe22c39d1809da9a6bed04e7be2a71d01be0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 31 Aug 2026 20:04:51 -0500 Subject: [PATCH] fix(compression): take reasoning_content when the summarizer leaves content empty Local and thinking backends (DeepSeek, Qwen, Kimi) often return a usable summary in reasoning fields. Treat that as the summary instead of burning another 100s+ empty-content retry. Leave the wire max_tokens omit intact. Co-authored-by: Chris DePuy Co-authored-by: chenhm --- agent/context_compressor.py | 24 ++---- ...t_context_compressor_reasoning_fallback.py | 81 +++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 tests/agent/test_context_compressor_reasoning_fallback.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3f4b1f3530..10fa08bec3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -33,6 +33,7 @@ from agent.auxiliary_client import ( _is_connection_error, aux_interrupt_protection, call_llm, + extract_content_or_reasoning, ) from agent.context_engine import ContextEngine, sanitize_memory_context from agent.error_classifier import FailoverReason, classify_api_error @@ -5326,22 +5327,13 @@ This compaction should PRIORITISE preserving all information related to the focu ) if self._compression_cancelled(): raise AuxiliaryExplicitCancellation() - # ``_validate_llm_response`` only guarantees ``choices[0].message`` - # exists, not that it's an object with ``.content``. Some - # OpenAI-compatible proxies / local backends return a dict- or - # str-shaped message; coerce defensively instead of crashing. - if isinstance(response, dict): - choices = response.get("choices") or [{}] - message = choices[0].get("message") if isinstance(choices[0], dict) else getattr(choices[0], "message", None) - else: - message = response.choices[0].message - if isinstance(message, dict): - content = message.get("content") - else: - content = getattr(message, "content", message) - # Handle cases where content is not a string (e.g., dict from llama.cpp) - if not isinstance(content, str): - content = str(content) if content else "" + # Dict/object/str messages + reasoning-field fallback (DeepSeek / + # Qwen / Kimi return content="" with the summary in + # reasoning_content). Cap the fallback so a CoT dump cannot + # become the compaction summary. + content = extract_content_or_reasoning( + response, max_reasoning_chars=8000 + ) # Some OpenAI-compatible proxies (e.g. cmkey.cn, one-api channels) # return a well-formed HTTP 200 with an empty or whitespace-only # ``content`` instead of an error or empty ``choices``. That payload diff --git a/tests/agent/test_context_compressor_reasoning_fallback.py b/tests/agent/test_context_compressor_reasoning_fallback.py new file mode 100644 index 0000000000..a4aefd8b41 --- /dev/null +++ b/tests/agent/test_context_compressor_reasoning_fallback.py @@ -0,0 +1,81 @@ +"""Reasoning-model summaries must not trip the empty-content failure path. + +Local / thinking backends (DeepSeek, Qwen, Kimi) often return content="" +with the usable text in reasoning / reasoning_content. _generate_summary +should accept that via extract_content_or_reasoning, bound the fallback +so a CoT dump cannot grow the transcript, and still fail closed when +both fields are empty (#11978). +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX + + +def _compressor(**overrides): + kwargs = dict(model="test/model", quiet_mode=True, tail_mode="legacy") + kwargs.update(overrides) + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + return ContextCompressor(**kwargs) + + +def _turns(): + return [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + ] + + +def test_empty_content_uses_reasoning_content(): + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace( + content="", + reasoning_content="kept reasoning summary", + ))] + ) + with patch("agent.context_compressor.call_llm", return_value=response): + out = _compressor()._generate_summary(_turns()) + assert out is not None + assert "kept reasoning summary" in out + assert out.startswith(SUMMARY_PREFIX) + + +def test_whitespace_content_falls_back_for_dict_and_object(): + class _Msg: + content = " " + reasoning_content = "object reasoning" + + for message, needle in ( + ({"content": " ", "reasoning_content": "dict reasoning"}, "dict reasoning"), + (_Msg(), "object reasoning"), + ): + response = {"choices": [{"message": message}]} + with patch("agent.context_compressor.call_llm", return_value=response): + out = _compressor()._generate_summary(_turns()) + assert out is not None + assert needle in out + + +def test_oversized_reasoning_fallback_is_truncated(): + reasoning = "t" * 20_000 + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace( + content="", + reasoning_content=reasoning, + ))] + ) + with patch("agent.context_compressor.call_llm", return_value=response): + out = _compressor()._generate_summary(_turns()) + assert out is not None + assert reasoning not in out + assert len(out) < 15_000 + + +def test_empty_content_without_reasoning_still_fails(): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = None + with patch("agent.context_compressor.call_llm", return_value=mock_response): + out = _compressor()._generate_summary(_turns()) + assert out is None