mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Tests for provider-agnostic conversation compaction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from strix.config import ContextSettings
|
|
from strix.llm import compaction
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from agents.memory.session_settings import SessionSettings
|
|
|
|
|
|
class FakeSession:
|
|
"""Minimal in-memory Session for exercising compaction."""
|
|
|
|
session_id = "fake"
|
|
session_settings: SessionSettings | None = None
|
|
|
|
def __init__(self, items: list[Any]) -> None:
|
|
self._items = list(items)
|
|
|
|
async def get_items(self, limit: int | None = None) -> list[Any]:
|
|
return list(self._items) if limit is None else list(self._items[-limit:])
|
|
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
self._items.extend(items)
|
|
|
|
async def clear_session(self) -> None:
|
|
self._items = []
|
|
|
|
async def pop_item(self) -> Any:
|
|
return self._items.pop() if self._items else None
|
|
|
|
|
|
def _user(text: str) -> dict[str, Any]:
|
|
return {"role": "user", "content": text}
|
|
|
|
|
|
def _assistant(text: str) -> dict[str, Any]:
|
|
return {"role": "assistant", "content": text}
|
|
|
|
|
|
def _call(call_id: str, name: str = "exec_command") -> dict[str, Any]:
|
|
return {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"}
|
|
|
|
|
|
def _output(call_id: str, text: str = "done") -> dict[str, Any]:
|
|
return {"type": "function_call_output", "call_id": call_id, "output": text}
|
|
|
|
|
|
def _turns(n: int) -> list[dict[str, Any]]:
|
|
items: list[dict[str, Any]] = []
|
|
for i in range(n):
|
|
items += [
|
|
_user(f"task {i}"),
|
|
_call(f"c{i}"),
|
|
_output(f"c{i}", f"result {i}"),
|
|
_assistant(f"ok {i}"),
|
|
]
|
|
return items
|
|
|
|
|
|
def _has_orphan_tool_output(items: list[Any]) -> bool:
|
|
call_ids = {i["call_id"] for i in items if compaction._is_tool_call(i)}
|
|
return any(i["call_id"] not in call_ids for i in items if compaction._is_tool_output(i))
|
|
|
|
|
|
def test_is_context_overflow_uses_litellm_typed_error() -> None:
|
|
overflow = ContextWindowExceededError(
|
|
message="context length exceeded", model="m", llm_provider="openai"
|
|
)
|
|
assert compaction.is_context_overflow(overflow)
|
|
assert not compaction.is_context_overflow(
|
|
RateLimitError(message="slow down", model="m", llm_provider="openai")
|
|
)
|
|
assert not compaction.is_context_overflow(RuntimeError("maximum context length is 8192"))
|
|
|
|
|
|
def test_is_context_overflow_matches_untyped_openrouter_400() -> None:
|
|
# OpenRouter overflows arrive as a plain BadRequestError, so match the message.
|
|
openrouter = BadRequestError(
|
|
message=(
|
|
"litellm.BadRequestError: This endpoint's maximum context length is 16385 "
|
|
"tokens. However, you requested about 75064 tokens. Please reduce the length "
|
|
"of the messages."
|
|
),
|
|
model="openrouter/openai/gpt-3.5-turbo",
|
|
llm_provider="openrouter",
|
|
)
|
|
assert compaction.is_context_overflow(openrouter)
|
|
|
|
|
|
def test_is_context_overflow_ignores_rate_limit_shaped_bad_request() -> None:
|
|
# A 400 that is really throttling must never be treated as an overflow.
|
|
throttled = BadRequestError(
|
|
message="Rate limit exceeded, please slow down",
|
|
model="openrouter/openai/gpt-4o",
|
|
llm_provider="openrouter",
|
|
)
|
|
assert not compaction.is_context_overflow(throttled)
|
|
unrelated = BadRequestError(
|
|
message="Invalid value for 'temperature'", model="m", llm_provider="openrouter"
|
|
)
|
|
assert not compaction.is_context_overflow(unrelated)
|
|
|
|
|
|
def test_select_split_never_orphans_tool_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
|
|
items = _turns(10)
|
|
split = compaction._select_split("m", items, keep_tokens=25)
|
|
recent = items[split:]
|
|
assert recent # something is kept
|
|
assert not _has_orphan_tool_output(recent)
|
|
|
|
|
|
def test_select_split_handles_parallel_calls(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(compaction, "count_tokens", lambda _m, _t: 1)
|
|
# Two parallel calls then their two outputs.
|
|
items = [
|
|
_user("start"),
|
|
_call("a"),
|
|
_call("b"),
|
|
_output("a"),
|
|
_output("b"),
|
|
_assistant("done"),
|
|
]
|
|
# keep_tokens picks a boundary that would land between the calls/outputs.
|
|
split = compaction._select_split("m", items, keep_tokens=2)
|
|
assert not _has_orphan_tool_output(items[split:])
|
|
|
|
|
|
def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int) -> None:
|
|
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
|
|
monkeypatch.setattr(compaction, "context_window", lambda _m: window)
|
|
monkeypatch.setattr(compaction, "output_limit", lambda _m: 0)
|
|
context = ContextSettings()
|
|
context.keep_tokens = keep_tokens
|
|
context.compact_buffer_tokens = 0
|
|
context.summary_max_tokens = 64
|
|
context.auto_compact = True
|
|
settings = SimpleNamespace(
|
|
context=context,
|
|
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
|
|
)
|
|
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
|
|
|
|
|
|
def _model_response(text: str) -> Any:
|
|
chunk = ResponseOutputText(annotations=[], text=text, type="output_text")
|
|
message = ResponseOutputMessage(
|
|
id="msg", content=[chunk], role="assistant", status="completed", type="message"
|
|
)
|
|
return SimpleNamespace(output=[message])
|
|
|
|
|
|
def _patch_summary(
|
|
monkeypatch: pytest.MonkeyPatch, text: str, captured: dict[str, Any] | None = None
|
|
) -> None:
|
|
class FakeModel:
|
|
async def get_response(self, **kwargs: Any) -> Any:
|
|
if captured is not None:
|
|
captured.update(kwargs)
|
|
return _model_response(text)
|
|
|
|
class FakeProvider:
|
|
def get_model(self, model_name: str | None) -> Any:
|
|
if captured is not None:
|
|
captured["model"] = model_name
|
|
return FakeModel()
|
|
|
|
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_noop_when_within_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch_budget(monkeypatch, keep_tokens=50, window=1_000_000)
|
|
session = FakeSession(_turns(10))
|
|
before = await session.get_items()
|
|
|
|
assert await compaction.maybe_compact(session, model="m") is False
|
|
assert await session.get_items() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_rewrites_and_keeps_pairs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
|
_patch_summary(monkeypatch, "SUMMARY BODY")
|
|
session = FakeSession(_turns(12))
|
|
|
|
assert await compaction.maybe_compact(session, model="m", force=True) is True
|
|
|
|
items = await session.get_items()
|
|
assert items[0]["role"] == "user"
|
|
assert items[0]["content"].startswith(compaction._CHECKPOINT_TAG)
|
|
assert "SUMMARY BODY" in items[0]["content"]
|
|
assert len(items) < len(_turns(12))
|
|
assert not _has_orphan_tool_output(items)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Window large enough to leave real room for the summary instructions.
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
|
captured: dict[str, Any] = {}
|
|
_patch_summary(monkeypatch, "NEW", captured)
|
|
|
|
prior = compaction._checkpoint_item("OLD SUMMARY TEXT")
|
|
session = FakeSession([prior, *_turns(12)])
|
|
|
|
assert await compaction.maybe_compact(session, model="m", force=True) is True
|
|
assert "OLD SUMMARY TEXT" in captured["input"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summarize_routes_through_provider_with_settings(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
|
monkeypatch.setattr(
|
|
compaction,
|
|
"load_settings",
|
|
lambda: SimpleNamespace(
|
|
llm=SimpleNamespace(
|
|
api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"}
|
|
)
|
|
),
|
|
)
|
|
captured: dict[str, Any] = {}
|
|
_patch_summary(monkeypatch, "S", captured)
|
|
|
|
assert await compaction._summarize("litellm/openai/some-model", "p", 64) == "S"
|
|
assert captured["model"] == "litellm/openai/some-model"
|
|
settings = captured["model_settings"]
|
|
assert settings.extra_headers == {"X-Feature-Key": "svc"}
|
|
assert settings.max_tokens == 64
|
|
|
|
|
|
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
|
|
|
|
fitted = compaction._fit_to_tokens("m", text, 500)
|
|
|
|
assert len(fitted) <= 500
|
|
assert compaction._HEAD_TRUNCATED_MARKER in fitted
|
|
# Small text is returned untouched.
|
|
assert compaction._fit_to_tokens("m", "short", 500) == "short"
|
|
|
|
|
|
def test_summary_output_tokens_capped_at_model_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
context = ContextSettings()
|
|
monkeypatch.setattr(compaction, "load_settings", lambda: SimpleNamespace(context=context))
|
|
|
|
monkeypatch.setattr(compaction, "output_limit", lambda _m: 1_000)
|
|
context.summary_max_tokens = 4_096
|
|
# Configured allowance above the model cap is clamped down to the cap.
|
|
assert compaction._summary_output_tokens("m") == 1_000
|
|
# Below the cap, the configured value is used unchanged.
|
|
context.summary_max_tokens = 500
|
|
assert compaction._summary_output_tokens("m") == 500
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# A tiny window with a huge head must not send an oversized summary request.
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
|
captured: dict[str, Any] = {}
|
|
_patch_summary(monkeypatch, "S", captured)
|
|
big_turns = [{"role": "user", "content": "y" * 2_000} for _ in range(50)]
|
|
session = FakeSession(big_turns)
|
|
|
|
assert await compaction.maybe_compact(session, model="m") is True
|
|
# count_tokens==len(chars); prompt must fit the model window.
|
|
assert len(captured["input"]) <= 4_000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_request_fits_when_room_is_below_old_floor(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# Head-input budget must shrink to the real room so the request fits.
|
|
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, Any] = {}
|
|
_patch_summary(monkeypatch, "S", captured)
|
|
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["input"]) <= window
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
|
|
|
class BoomModel:
|
|
async def get_response(self, **_kwargs: Any) -> Any:
|
|
raise RuntimeError("boom")
|
|
|
|
class BoomProvider:
|
|
def get_model(self, _model_name: str | None) -> Any:
|
|
return BoomModel()
|
|
|
|
monkeypatch.setattr(compaction, "StrixProvider", BoomProvider)
|
|
session = FakeSession(_turns(12))
|
|
before = await session.get_items()
|
|
|
|
assert await compaction.maybe_compact(session, model="m", force=True) is False
|
|
assert await session.get_items() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_compact_skips_when_no_room_to_summarise(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# No room for any head -> no (doomed) summary is attempted.
|
|
_patch_budget(monkeypatch, keep_tokens=30, window=200)
|
|
captured: dict[str, Any] = {}
|
|
_patch_summary(monkeypatch, "S", captured)
|
|
session = FakeSession(_turns(12))
|
|
before = await session.get_items()
|
|
|
|
assert await compaction.maybe_compact(session, model="m", force=True) is False
|
|
assert not captured
|
|
assert await session.get_items() == before
|