diff --git a/strix/llm/compaction.py b/strix/llm/compaction.py index 56f84db5..5ad3a074 100644 --- a/strix/llm/compaction.py +++ b/strix/llm/compaction.py @@ -28,8 +28,6 @@ logger = logging.getLogger(__name__) _CHECKPOINT_TAG = "" _TOOL_OUTPUT_MAX_CHARS = 2_000 _MIN_ITEMS_TO_COMPACT = 6 -# Floor for how much of the head we still try to summarise even on a tiny model. -_MIN_SUMMARY_INPUT_TOKENS = 1_000 _HEAD_TRUNCATED_MARKER = "\n\n[... older conversation omitted to fit the summary request ...]\n\n" # Substrings that identify a context-window-overflow error across providers. @@ -214,9 +212,11 @@ def _summary_input_budget(model: str, previous: str | None) -> int: if previous: overhead += count_tokens(model, previous) # Leave slack for the prompt's wrapper text ("Conversation to summarise:", - # the update instructions, etc.) that is not part of ``overhead``. + # the update instructions, etc.) that is not part of ``overhead``. Never + # floor above the actual room: doing so would let the summary request + # itself overflow a small window (and then compaction silently fails). room = context_window(model) - context.summary_max_tokens - overhead - 256 - return max(_MIN_SUMMARY_INPUT_TOKENS, room) + return max(0, room) def _build_summary_prompt(serialized_head: str, previous: str | None) -> str: diff --git a/tests/test_compaction.py b/tests/test_compaction.py index ae4a0733..f25f0abf 100644 --- a/tests/test_compaction.py +++ b/tests/test_compaction.py @@ -202,6 +202,29 @@ async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPat assert len(captured["prompt"]) <= 4_000 +@pytest.mark.asyncio +async def test_summary_request_fits_when_room_is_below_old_floor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # When the window leaves less head-input room than the old fixed floor, the + # budget must shrink to the real room so the request still fits the window + # (a fixed floor above the room would overflow and silently fail). + instructions = len(compaction._SUMMARY_INSTRUCTIONS) + window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300) + _patch_budget(monkeypatch, keep_tokens=30, window=window) + captured: dict[str, str] = {} + + async def fake_acompletion(**kwargs: Any) -> Any: + captured["prompt"] = kwargs["messages"][0]["content"] + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))]) + + monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + session = FakeSession([{"role": "user", "content": "y" * 5_000} for _ in range(20)]) + + assert await compaction.maybe_compact(session, model="m") is True + assert len(captured["prompt"]) <= window + + @pytest.mark.asyncio async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None: _patch_budget(monkeypatch, keep_tokens=30, window=50)