diff --git a/strix/llm/compaction.py b/strix/llm/compaction.py index ecdb9a83..47fc04df 100644 --- a/strix/llm/compaction.py +++ b/strix/llm/compaction.py @@ -15,7 +15,11 @@ from typing import TYPE_CHECKING, Any from agents.model_settings import ModelSettings from agents.models.interface import ModelTracing -from openai.types.responses import ResponseOutputMessage, ResponseOutputText +from openai.types.responses import ( + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, +) from strix.config import load_settings from strix.config.models import StrixProvider @@ -288,15 +292,22 @@ def _checkpoint_item(summary: str) -> dict[str, Any]: def _extract_text(response: ModelResponse) -> str: parts: list[str] = [] + reasoning: list[str] = [] for item in response.output: - if not isinstance(item, ResponseOutputMessage): - continue - parts.extend( - chunk.text - for chunk in item.content - if isinstance(chunk, ResponseOutputText) and chunk.text - ) - return "".join(parts) + if isinstance(item, ResponseOutputMessage): + parts.extend( + chunk.text + for chunk in item.content + if isinstance(chunk, ResponseOutputText) and chunk.text + ) + elif isinstance(item, ResponseReasoningItem): + reasoning.extend(summary.text for summary in item.summary if summary.text) + if parts or not reasoning: + return "".join(parts) + # Reasoning models can spend the whole output budget thinking and return no + # message; their reasoning is a degraded but usable summary. + logger.warning("compaction summary had no output text; using reasoning text") + return "\n".join(reasoning) async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None: diff --git a/tests/test_compaction.py b/tests/test_compaction.py index 2d73d9ac..6d290e69 100644 --- a/tests/test_compaction.py +++ b/tests/test_compaction.py @@ -6,6 +6,8 @@ from types import SimpleNamespace from typing import TYPE_CHECKING, Any import pytest +from agents.extensions.models.litellm_model import InternalChatCompletionMessage +from agents.models.chatcmpl_converter import Converter from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError from openai.types.responses import ResponseOutputMessage, ResponseOutputText @@ -242,6 +244,30 @@ async def test_summarize_routes_through_provider_with_settings( assert settings.max_tokens == 64 +@pytest.mark.asyncio +async def test_summarize_falls_back_to_reasoning_when_no_output_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A reasoning model that spends its budget thinking returns only a reasoning item. + _patch_budget(monkeypatch, keep_tokens=30, window=4_000) + message = InternalChatCompletionMessage( + role="assistant", content=None, reasoning_content="REASONED SUMMARY" + ) + response = SimpleNamespace(output=Converter.message_to_output_items(message)) + + class FakeModel: + async def get_response(self, **_kwargs: Any) -> Any: + return response + + class FakeProvider: + def get_model(self, _model_name: str | None) -> Any: + return FakeModel() + + monkeypatch.setattr(compaction, "StrixProvider", FakeProvider) + + assert await compaction._summarize("m", "p", 64) == "REASONED SUMMARY" + + def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t)) text = "x" * 10_000