mirror of
https://github.com/usestrix/strix.git
synced 2026-08-18 17:52:32 +02:00
169 lines
5.4 KiB
Python
169 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from agents import ModelSettings, RunConfig, Runner
|
|
|
|
from strix.config import codex
|
|
from strix.core import execution
|
|
from strix.core.agents import AgentCoordinator
|
|
|
|
|
|
class _FakeStream:
|
|
def __init__(self, exc: BaseException | None = None) -> None:
|
|
self._exc = exc
|
|
self.run_loop_exception: BaseException | None = None
|
|
|
|
async def stream_events(self) -> Any:
|
|
if self._exc is not None:
|
|
raise self._exc
|
|
events: list[Any] = []
|
|
for event in events:
|
|
yield event
|
|
|
|
|
|
def _patch_fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(execution, "_TRANSIENT_MODEL_RETRY_BASE_DELAY_S", 0.0)
|
|
monkeypatch.setattr(execution, "_TRANSIENT_MODEL_RETRY_MAX_DELAY_S", 0.0)
|
|
|
|
|
|
def _guardrail_stream() -> _FakeStream:
|
|
return _FakeStream(codex.CodexContentGuardrailError("gpt-5.6-sol"))
|
|
|
|
|
|
async def _run_once(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streams: list[_FakeStream],
|
|
*,
|
|
fallback_model: str | None = None,
|
|
denied_retries: int = 3,
|
|
primary_model: str = "openai/gpt-5.6-sol",
|
|
fallback_model_settings: ModelSettings | None = None,
|
|
) -> tuple[Any, list[tuple[str | None, ModelSettings]], AgentCoordinator]:
|
|
_patch_fast_backoff(monkeypatch)
|
|
calls: list[tuple[str | None, ModelSettings]] = []
|
|
|
|
def _fake_run_streamed(*_args: Any, **kwargs: Any) -> _FakeStream:
|
|
run_config = kwargs["run_config"]
|
|
calls.append((run_config.model, run_config.model_settings))
|
|
return streams[len(calls) - 1]
|
|
|
|
monkeypatch.setattr(Runner, "run_streamed", _fake_run_streamed)
|
|
|
|
coordinator = AgentCoordinator()
|
|
await coordinator.register("root", "strix", parent_id=None)
|
|
if fallback_model is not None:
|
|
coordinator.configure_denial_fallback(
|
|
fallback_model, denied_retries, model_settings=fallback_model_settings
|
|
)
|
|
|
|
result = await execution._run_cycle(
|
|
object(),
|
|
coordinator,
|
|
"root",
|
|
input_data="task",
|
|
run_config=RunConfig(model=primary_model, model_settings=ModelSettings()),
|
|
context={},
|
|
max_turns=5,
|
|
session=None,
|
|
interactive=False,
|
|
event_sink=None,
|
|
hooks=None,
|
|
)
|
|
return result, calls, coordinator
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_denied_turn_is_retried_on_the_fallback_model(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
streams = [_guardrail_stream(), _FakeStream()]
|
|
result, models, coordinator = await _run_once(
|
|
monkeypatch,
|
|
streams,
|
|
fallback_model="openai/gpt-5.4",
|
|
)
|
|
|
|
assert result is streams[1]
|
|
assert [model for model, _ in models] == ["openai/gpt-5.6-sol", "openai/gpt-5.4"]
|
|
# One denial is below the threshold, so the agent is not pinned to the
|
|
# fallback and its next turn starts on the main model again.
|
|
assert await coordinator.is_on_denial_fallback("root") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_is_pinned_to_the_fallback_after_repeated_denials(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
streams = [_guardrail_stream() for _ in range(3)] + [_FakeStream()]
|
|
result, models, coordinator = await _run_once(
|
|
monkeypatch,
|
|
streams,
|
|
fallback_model="openai/gpt-5.4",
|
|
)
|
|
|
|
assert result is streams[3]
|
|
assert [model for model, _ in models] == ["openai/gpt-5.6-sol"] + ["openai/gpt-5.4"] * 3
|
|
assert await coordinator.is_on_denial_fallback("root") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_cycle_does_not_retry_guardrail_without_fallback(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
guardrail = codex.CodexContentGuardrailError("gpt-5.6-sol")
|
|
with pytest.raises(codex.CodexContentGuardrailError):
|
|
await _run_once(monkeypatch, [_FakeStream(guardrail), _FakeStream()])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_cycle_pins_on_first_denial_at_boundary(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
streams = [_guardrail_stream(), _FakeStream()]
|
|
result, models, coordinator = await _run_once(
|
|
monkeypatch,
|
|
streams,
|
|
fallback_model="openai/gpt-5.4",
|
|
denied_retries=1,
|
|
)
|
|
|
|
assert result is streams[1]
|
|
assert [model for model, _ in models] == ["openai/gpt-5.6-sol", "openai/gpt-5.4"]
|
|
assert await coordinator.is_on_denial_fallback("root") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_uses_its_own_model_settings(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fallback_settings = ModelSettings(parallel_tool_calls=True)
|
|
streams = [_guardrail_stream(), _FakeStream()]
|
|
result, calls, _coordinator = await _run_once(
|
|
monkeypatch,
|
|
streams,
|
|
fallback_model="openai/gpt-5.4",
|
|
denied_retries=1,
|
|
fallback_model_settings=fallback_settings,
|
|
)
|
|
|
|
assert result is streams[1]
|
|
assert calls[0][1] is not fallback_settings
|
|
assert calls[1][1] is fallback_settings
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_denial_fallback_state_round_trips_through_snapshot() -> None:
|
|
coordinator = AgentCoordinator()
|
|
await coordinator.register("root", "strix", parent_id=None)
|
|
coordinator.configure_denial_fallback("openai/gpt-5.4", 3)
|
|
await coordinator.record_denial("root")
|
|
await coordinator.mark_denial_fallback("root")
|
|
|
|
restored = AgentCoordinator()
|
|
await restored.restore(await coordinator.snapshot())
|
|
|
|
assert restored.denial_counts == {"root": 1}
|
|
assert await restored.is_on_denial_fallback("root") is True
|