Compare commits

..
Author SHA1 Message Date
Ian f565cece23 fix(compaction): chain any input filter already set on the run config
The image scrub for text-only models also sets call_model_input_filter; replacing it dropped the scrub.
2026-10-06 21:49:28 -04:00
Ian 94adabd67e fix(compaction): rerun without the budget check when compaction can't fit the session 2026-10-06 21:33:32 -04:00
Ian 3a69a45f86 fix(compaction): stop the run and compact before a request would overrun the context window 2026-10-06 21:33:31 -04:00
Ian e011253ebc test(compaction): drop comment restating the test name 2026-10-06 21:33:30 -04:00
Ian 5227960e99 fix(compaction): fall back to reasoning text when the summary has none
A reasoning model can spend the whole summary output budget thinking and return only a reasoning item. _extract_text read only output messages, so compaction logged 'summary returned no content' and the agent failed. Use the reasoning text, with a warning, when there is no output text.
2026-10-06 21:33:30 -04:00
6 changed files with 316 additions and 39 deletions

No files matched your search

-6
View File
@@ -905,12 +905,6 @@ def model_supports_reasoning(model_name: str) -> bool:
return bool(entry and entry.get("supports_reasoning"))
def model_supports_images(model_name: str) -> bool:
"""Return whether the model accepts image input. Assume yes until proven otherwise."""
entry = _catalog_entry(model_name)
return entry is None or bool(entry.get("supports_vision"))
def _bare_openai_name(model_name: str) -> str:
name = model_name.strip().lower()
for prefix in ("litellm/", "any-llm/", "openai/"):
+76 -2
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
import uuid
from collections.abc import Callable
from dataclasses import replace
from functools import cache
from typing import TYPE_CHECKING, Any, cast
@@ -34,7 +36,12 @@ from strix.core.sessions import (
strip_all_images_from_session,
)
from strix.llm import request_log
from strix.llm.compaction import is_context_overflow, maybe_compact
from strix.llm.compaction import (
CompactionNeededError,
is_context_overflow,
maybe_compact,
needs_compaction,
)
if TYPE_CHECKING:
@@ -44,6 +51,7 @@ if TYPE_CHECKING:
from agents.lifecycle import RunHooks
from agents.memory import Session, SQLiteSession
from agents.result import RunResultBase
from agents.run_config import CallModelData, ModelInputData
from strix.core.agents import AgentCoordinator, Status
@@ -120,6 +128,51 @@ async def _compact_session(
)
async def _session_needs_compaction(agent: Any, session: Session, run_config: RunConfig) -> bool:
model = _run_config_model(run_config)
if model is None:
return False
return await asyncio.to_thread(
needs_compaction,
model,
_agent_instructions(agent),
_agent_tools_text(agent),
list(await session.get_items()),
)
def _with_compaction_check(run_config: RunConfig) -> RunConfig:
"""Stop the run before a model request that would overrun the context window so
the cycle can compact and resume: the SDK builds each request from its in-memory
history, so compacting the session mid-run would not shrink it."""
model = _run_config_model(run_config)
if model is None:
return run_config
inner = run_config.call_model_input_filter
first_call = True
async def _check(data: CallModelData[Any]) -> ModelInputData:
nonlocal first_call
model_data = data.model_data
if inner is not None:
result = inner(data)
model_data = await result if inspect.isawaitable(result) else result
# Skip the first call: the cycle compacts right before the run, and the run's
# new input is only persisted after this filter, so stopping here would lose it.
if not first_call and await asyncio.to_thread(
needs_compaction,
model,
model_data.instructions or "",
_agent_tools_text(data.agent),
model_data.input,
):
raise CompactionNeededError(model)
first_call = False
return model_data
return replace(run_config, call_model_input_filter=_check)
_MAX_TRANSIENT_MODEL_RETRIES = 5
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 90.0
@@ -725,6 +778,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
image_strips = 0
compactions = 0
model_retries = 0
check_context = session is not None
request_log.set_retry_attempt(0)
while True:
stream: Any = None
@@ -747,7 +801,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
stream = Runner.run_streamed(
agent,
input=input_data,
run_config=run_config,
run_config=_with_compaction_check(run_config) if check_context else run_config,
context=context,
max_turns=max_turns,
session=session,
@@ -824,6 +878,26 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
)
input_data = []
continue
if session is not None and isinstance(exc, CompactionNeededError):
# Rerun without the check if compaction can't bring the session under
# the budget; a real overflow then falls to the capped recovery below.
try:
compacted = await _compact_session(agent, session, run_config, force=True)
check_context = compacted and not await _session_needs_compaction(
agent, session, run_config
)
except Exception:
logger.exception("context budget compaction failed for %s", agent_id)
check_context = False
logger.info(
"%s hit the context budget; %s",
agent_id,
"compacted and resuming"
if check_context
else "rerunning without the budget check",
)
input_data = []
continue
if (
compactions < _MAX_COMPACTIONS_PER_CYCLE
and session is not None
+51 -21
View File
@@ -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
@@ -160,22 +164,22 @@ def _content_text(content: Any) -> str:
return ""
def _truncate(text: str, limit: int) -> str:
return text if len(text) <= limit else f"{text[:limit]}\n[truncated]"
def _truncate(text: str, limit: int | None) -> str:
return text if limit is None or len(text) <= limit else f"{text[:limit]}\n[truncated]"
def _serialize_item(item: Any) -> str:
def _serialize_item(item: Any, max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
if not isinstance(item, dict):
return str(item)
item_type = item.get("type")
role = item.get("role")
if item_type == "function_call":
args = _truncate(str(item.get("arguments", "")), _TOOL_OUTPUT_MAX_CHARS)
args = _truncate(str(item.get("arguments", "")), max_chars)
return f"[tool_call {item.get('name', '?')}] {args}"
if item_type == "function_call_output":
output = item.get("output")
text = output if isinstance(output, str) else _content_text(output)
return f"[tool_result] {_truncate(text, _TOOL_OUTPUT_MAX_CHARS)}"
return f"[tool_result] {_truncate(text, max_chars)}"
if item_type == "reasoning":
return ""
if role or item_type == "message":
@@ -183,8 +187,30 @@ def _serialize_item(item: Any) -> str:
return ""
def _serialize_items(items: list[Any]) -> str:
return "\n".join(s for s in (_serialize_item(item) for item in items) if s)
def _serialize_items(items: list[Any], max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
return "\n".join(s for s in (_serialize_item(item, max_chars) for item in items) if s)
class CompactionNeededError(Exception):
"""The next model request would overrun the model's usable context window."""
def _context_budget(model: str) -> int:
context = load_settings().context
reserve = max(context.compact_buffer_tokens, output_limit(model))
return max(context.keep_tokens, context_window(model) - reserve)
def _request_tokens(model: str, instructions: str, tools_text: str, items: list[Any]) -> int:
text = "\n".join((instructions, tools_text, _serialize_items(items, max_chars=None)))
return count_tokens(model, text)
def needs_compaction(model: str, instructions: str, tools_text: str, items: list[Any]) -> bool:
"""Whether a request carrying ``items`` would overrun ``model``'s context budget."""
if not load_settings().context.auto_compact:
return False
return _request_tokens(model, instructions, tools_text, items) > _context_budget(model)
def _is_tool_call(item: Any) -> bool:
@@ -288,15 +314,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:
@@ -358,11 +391,8 @@ async def maybe_compact(
if len(items) < _MIN_ITEMS_TO_COMPACT:
return False
window = context_window(model)
reserve = max(context.compact_buffer_tokens, output_limit(model))
budget = max(context.keep_tokens, window - reserve)
used = count_tokens(model, "\n".join((instructions, tools_text, _serialize_items(items))))
if not force and used <= budget:
used = _request_tokens(model, instructions, tools_text, items)
if not force and used <= _context_budget(model):
return False
split = _select_split(model, items, context.keep_tokens)
+43 -1
View File
@@ -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
@@ -136,7 +138,9 @@ def test_select_split_handles_parallel_calls(monkeypatch: pytest.MonkeyPatch) ->
assert not _has_orphan_tool_output(items[split:])
def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int) -> None:
def _patch_budget(
monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int
) -> ContextSettings:
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)
@@ -150,6 +154,7 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window:
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
)
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
return context
def _model_response(text: str) -> Any:
@@ -178,6 +183,20 @@ def _patch_summary(
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
def test_needs_compaction_counts_full_tool_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=50, window=10_000)
items = [_call("c1"), _output("c1", "x" * 20_000)]
assert compaction.needs_compaction("m", "", "", items) is True
assert compaction.needs_compaction("m", "", "", _turns(2)) is False
def test_needs_compaction_respects_auto_compact_off(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=50, window=10_000).auto_compact = False
assert compaction.needs_compaction("m", "", "", [_output("c1", "x" * 20_000)]) is False
@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)
@@ -242,6 +261,29 @@ 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:
_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
+146
View File
@@ -9,9 +9,11 @@ from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from agents import RunConfig
from agents.exceptions import MaxTurnsExceeded
from agents.items import MessageOutputItem
from agents.memory import SQLiteSession
from agents.run_config import CallModelData, ModelInputData
from agents.tool_context import ToolContext
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal, ResponseOutputText
@@ -22,6 +24,7 @@ from strix.core.execution import (
notify_parent_on_terminal,
)
from strix.core.sessions import seed_initial_input
from strix.llm.compaction import CompactionNeededError
from strix.tools.agents_graph.tools import agent_finish, stop_agent
from strix.tools.finish.tool import finish_scan
@@ -1531,3 +1534,146 @@ async def test_autonomous_nudge_does_not_offer_the_user() -> None:
)
assert "wait_for_user" not in items[0]["content"]
@pytest.mark.asyncio
async def test_compaction_check_stops_run_after_first_call_when_over_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: True)
guard: Any = execution._with_compaction_check(RunConfig(model="m")).call_model_input_filter
data = CallModelData(
model_data=ModelInputData(input=[{"role": "user", "content": "hi"}], instructions="sys"),
agent=MagicMock(tools=[]),
context=None,
)
assert await guard(data) is data.model_data
with pytest.raises(CompactionNeededError):
await guard(data)
@pytest.mark.asyncio
async def test_compaction_check_chains_an_existing_input_filter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: list[Any] = []
monkeypatch.setattr(execution, "needs_compaction", lambda *args: seen.append(args[3]))
filtered = ModelInputData(input=[{"role": "user", "content": "scrubbed"}], instructions="sys")
run_config = RunConfig(model="m", call_model_input_filter=lambda _data: filtered)
check: Any = execution._with_compaction_check(run_config).call_model_input_filter
data = CallModelData(
model_data=ModelInputData(input=[], instructions="sys"),
agent=MagicMock(tools=[]),
context=None,
)
assert await check(data) is filtered
assert await check(data) is filtered
assert seen == [filtered.input]
class _BudgetStream:
def __init__(self, exc: BaseException | None) -> None:
self.run_loop_exception = exc
self.new_items: list[Any] = []
async def stream_events(self) -> Any:
for event in _NO_STREAM_EVENTS:
yield event
@pytest.mark.asyncio
async def test_run_cycle_compacts_and_resumes_on_every_budget_stop(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
stops = execution._MAX_COMPACTIONS_PER_CYCLE + 1
streams = [_BudgetStream(CompactionNeededError("m")) for _ in range(stops)]
streams.append(_BudgetStream(None))
runs: list[dict[str, Any]] = []
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
runs.append(kwargs)
return streams[len(runs) - 1]
forced: list[bool] = []
async def _compact(*_args: Any, force: bool) -> bool:
forced.append(force)
return True
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
monkeypatch.setattr(execution, "_compact_session", _compact)
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: False)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
session = SQLiteSession("root", tmp_path / "agents.db")
result = await execution._run_cycle(
MagicMock(),
coordinator,
"root",
input_data="task",
run_config=RunConfig(model="m"),
context={},
max_turns=5,
session=session,
interactive=True,
event_sink=None,
hooks=None,
)
assert cast("Any", result) is streams[-1]
assert len(runs) == stops + 1
assert forced.count(True) == stops
assert all(run["input"] == [] for run in runs[1:])
assert all(run["run_config"].call_model_input_filter is not None for run in runs)
session.close()
@pytest.mark.parametrize(
("compacted", "still_over"),
[(False, False), (True, True)],
ids=["compaction_failed", "still_over_budget"],
)
@pytest.mark.asyncio
async def test_run_cycle_reruns_unchecked_when_compaction_cannot_fit(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch, compacted: bool, still_over: bool
) -> None:
streams = [_BudgetStream(CompactionNeededError("m")), _BudgetStream(None)]
runs: list[dict[str, Any]] = []
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
runs.append(kwargs)
return streams[len(runs) - 1]
async def _compact(*_args: Any, **_kwargs: Any) -> bool:
return compacted
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
monkeypatch.setattr(execution, "_compact_session", _compact)
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: still_over)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
session = SQLiteSession("root", tmp_path / "agents.db")
result = await execution._run_cycle(
MagicMock(),
coordinator,
"root",
input_data="task",
run_config=RunConfig(model="m"),
context={},
max_turns=5,
session=session,
interactive=True,
event_sink=None,
hooks=None,
)
assert cast("Any", result) is streams[-1]
assert len(runs) == 2
assert runs[0]["run_config"].call_model_input_filter is not None
assert runs[1]["run_config"].call_model_input_filter is None
assert runs[1]["input"] == []
session.close()
-9
View File
@@ -16,7 +16,6 @@ from strix.config.models import (
_NonStreamingModel,
_TurnGuardModel,
configure_sdk_model_defaults,
model_supports_images,
request_timeout_extra_args,
resolve_api_type,
routes_through_litellm,
@@ -203,11 +202,3 @@ def test_configure_sdk_api_route_follows_the_given_model(
models.configure_sdk_api_route("my-private-model", settings)
assert routes == ["responses", "chat_completions"]
def test_image_support_follows_the_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(litellm.model_cost, "acme-text", {})
monkeypatch.setitem(litellm.model_cost, "acme-vision", {"supports_vision": True})
assert model_supports_images("acme-text") is False
assert model_supports_images("litellm/openai/acme-vision") is True
assert model_supports_images("acme-unknown-model") is True