Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,24 @@ async def _compact_session(


_MAX_TRANSIENT_MODEL_RETRIES = 5
_MAX_OPENROUTER_PROMPT_POLICY_RETRIES = 3
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 90.0
_OPENROUTER_PROMPT_POLICY_REJECTION = (
"invalid prompt: your prompt was flagged as potentially violating our usage policy"
)


def _model_error_status_code(exc: BaseException) -> int | None:
code = getattr(exc, "status_code", None)
return code if isinstance(code, int) else None


def _is_openrouter_prompt_policy_rejection(exc: BaseException) -> bool:
error_text = str(exc).lower()
return "openrouter" in error_text and _OPENROUTER_PROMPT_POLICY_REJECTION in error_text


def _is_transient_model_error(exc: BaseException) -> bool:
if codex.is_content_guardrail_error(exc):
return False
Expand Down Expand Up @@ -643,6 +652,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
image_strips = 0
compactions = 0
model_retries = 0
prompt_policy_retries = 0
while True:
stream: Any = None
pre_run_items: list[Any] = []
Expand Down Expand Up @@ -719,8 +729,29 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.trigger_budget_stop()
raise
except Exception as exc:
prompt_policy_rejection = _is_openrouter_prompt_policy_rejection(exc)
if (
prompt_policy_rejection
and prompt_policy_retries < _MAX_OPENROUTER_PROMPT_POLICY_RETRIES
):
prompt_policy_retries += 1
delay = _transient_model_retry_delay(prompt_policy_retries)
logger.warning(
"intermittent OpenRouter prompt-policy rejection for %s; replaying "
"unchanged turn (attempt %d/%d, backoff %.1fs): %r",
agent_id,
prompt_policy_retries,
_MAX_OPENROUTER_PROMPT_POLICY_RETRIES,
delay,
exc,
)
await asyncio.sleep(delay)
if session is not None:
input_data = []
continue
if (
image_strips < 3
not prompt_policy_rejection
and image_strips < 3
and session is not None
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
):
Expand Down
82 changes: 81 additions & 1 deletion tests/test_execution_transient_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ def _status_error(status: int) -> APIStatusError:
)


def _openrouter_prompt_policy_rejection() -> BadRequestError:
return BadRequestError(
"OpenrouterException - Message: Invalid prompt: your prompt was flagged as "
"potentially violating our usage policy. Please try again with a different prompt",
response=httpx.Response(400, request=_request()),
body=None,
)


def test_midstream_api_error_is_transient() -> None:
assert execution._is_transient_model_error(_midstream_api_error()) is True

Expand Down Expand Up @@ -79,6 +88,12 @@ def test_content_guardrail_is_not_retried() -> None:
assert execution._is_transient_model_error(guardrail) is False


def test_openrouter_prompt_policy_rejection_has_dedicated_classification() -> None:
rejection = _openrouter_prompt_policy_rejection()
assert execution._is_openrouter_prompt_policy_rejection(rejection) is True
assert execution._is_transient_model_error(rejection) is False


def test_client_errors_are_not_transient() -> None:
bad_request = BadRequestError(
"bad", response=httpx.Response(400, request=_request()), body=None
Expand Down Expand Up @@ -109,6 +124,8 @@ def _patch_fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
async def _run_once(
monkeypatch: pytest.MonkeyPatch,
streams: list[_FakeStream],
*,
session: Any = None,
) -> Any:
_patch_fast_backoff(monkeypatch)
calls = {"n": 0}
Expand All @@ -131,7 +148,7 @@ def _fake_run_streamed(*_args: Any, **_kwargs: Any) -> _FakeStream:
run_config=cast("RunConfig", object()),
context={},
max_turns=5,
session=None,
session=session,
interactive=False,
event_sink=None,
hooks=None,
Expand All @@ -150,6 +167,69 @@ async def test_run_cycle_retries_transient_midstream_error(
assert attempts == 2


@pytest.mark.asyncio
async def test_run_cycle_retries_openrouter_prompt_policy_rejection_three_times(
monkeypatch: pytest.MonkeyPatch,
) -> None:
streams = [
_FakeStream(exc=_openrouter_prompt_policy_rejection())
for _ in range(execution._MAX_OPENROUTER_PROMPT_POLICY_RETRIES)
]
streams.append(_FakeStream())

result, attempts, _coordinator = await _run_once(monkeypatch, streams)

assert result is streams[-1]
assert attempts == 4


@pytest.mark.asyncio
async def test_run_cycle_gives_up_after_openrouter_prompt_policy_retry_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
streams = [
_FakeStream(exc=_openrouter_prompt_policy_rejection())
for _ in range(execution._MAX_OPENROUTER_PROMPT_POLICY_RETRIES + 1)
]
with pytest.raises(BadRequestError):
await _run_once(monkeypatch, streams)


@pytest.mark.asyncio
async def test_openrouter_prompt_policy_retry_preserves_session_images(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Session:
async def get_items(self) -> list[Any]:
return []

image_strips = 0

async def _strip_images(_session: Any) -> bool:
nonlocal image_strips
image_strips += 1
return True

async def _no_compaction(*_args: Any, **_kwargs: Any) -> bool:
return False

async def _no_salvage(*_args: Any, **_kwargs: Any) -> None:
return None

monkeypatch.setattr(execution, "strip_all_images_from_session", _strip_images)
monkeypatch.setattr(execution, "_compact_session", _no_compaction)
monkeypatch.setattr(execution, "_salvage_stream_to_session", _no_salvage)
streams = [
_FakeStream(exc=_openrouter_prompt_policy_rejection())
for _ in range(execution._MAX_OPENROUTER_PROMPT_POLICY_RETRIES + 1)
]

with pytest.raises(BadRequestError):
await _run_once(monkeypatch, streams, session=_Session())

assert image_strips == 0


@pytest.mark.asyncio
async def test_run_cycle_gives_up_after_max_retries(
monkeypatch: pytest.MonkeyPatch,
Expand Down