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 <chris@650group.com> Co-authored-by: chenhm <chenhm@yuancheng.local>
This commit is contained in:
committed by
brooklyn!
parent
02458e67ad
commit
5c6dbe22c3
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user