treat guardrail rejections like any other LLM error

This commit is contained in:
Ahmed Allam
2026-07-27 23:49:35 +00:00
parent 1814a63de0
commit 8209e92081
5 changed files with 3 additions and 194 deletions
-2
View File
@@ -15,7 +15,6 @@ from strix.config.loader import (
apply_config_override,
load_settings,
persist_current,
reload_settings,
)
from strix.config.settings import (
ContextSettings,
@@ -39,5 +38,4 @@ __all__ = [
"apply_config_override",
"load_settings",
"persist_current",
"reload_settings",
]
-7
View File
@@ -45,13 +45,6 @@ def load_settings() -> Settings:
return _cached
def reload_settings() -> Settings:
"""Invalidate the cache and re-resolve settings from env + JSON file."""
global _cached # noqa: PLW0603
_cached = None
return load_settings()
def apply_config_override(path: Path) -> None:
"""Switch the JSON source to ``path`` and invalidate the cache."""
global _override, _cached # noqa: PLW0603
+2 -59
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import logging
import uuid
from collections.abc import Callable
@@ -22,14 +21,12 @@ from openai import (
RateLimitError,
)
from strix.config import codex, reload_settings
from strix.config.models import configure_sdk_model_defaults
from strix.core.hooks import (
BudgetExceededError,
BudgetPausedError,
SubagentBudgetReservedError,
)
from strix.core.inputs import child_initial_input, make_model_settings
from strix.core.inputs import child_initial_input
from strix.core.sessions import (
enforce_image_budget,
open_agent_session,
@@ -61,31 +58,6 @@ def _run_config_model(run_config: RunConfig) -> str | None:
return run_config.model if isinstance(run_config.model, str) else None
def refresh_run_config_model(run_config: RunConfig) -> RunConfig:
"""Adopt a changed configured model before re-running a woken agent."""
current_model = _run_config_model(run_config)
if current_model is None:
return run_config
try:
settings = reload_settings()
except Exception:
logger.exception("settings reload failed; keeping model %s", current_model)
return run_config
new_model = (settings.llm.model or "").strip()
if not new_model or new_model == current_model:
return run_config
configure_sdk_model_defaults(settings)
model_settings = make_model_settings(
settings.llm.reasoning_effort,
model_name=new_model,
force_required_tool_choice=settings.llm.force_required_tool_choice,
request_timeout=settings.llm.timeout,
prompt_cache=settings.llm.prompt_cache,
)
logger.info("model switched on wake: %s -> %s", current_model, new_model)
return dataclasses.replace(run_config, model=new_model, model_settings=model_settings)
def _agent_instructions(agent: Any) -> str:
instructions = getattr(agent, "instructions", None)
return instructions if isinstance(instructions, str) else ""
@@ -116,12 +88,6 @@ async def _compact_session(
)
_GUARDRAIL_PARK_ERROR = (
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
"Switch STRIX_LLM to a model that isn't blocked (update it in ~/.strix/cli-config.json "
"and message this agent, or resume the scan with a new STRIX_LLM)."
)
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
_MAX_TRANSIENT_MODEL_RETRIES = 4
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
@@ -232,7 +198,6 @@ async def run_agent_loop(
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
await coordinator.consume_pending(agent_id)
run_config = refresh_run_config_model(run_config)
with contextlib.suppress(BudgetPausedError):
result = await _run_cycle(
agent,
@@ -339,7 +304,6 @@ async def respawn_subagents(
if coordinator.parent_of.get(aid) is None or aid == root_id:
continue
md["_restored_status"] = status
md["_restored_error"] = coordinator.errors.get(aid)
candidates.append(
(
aid,
@@ -352,8 +316,7 @@ async def respawn_subagents(
for child_id, name, parent_id, md in candidates:
try:
restored_status = str(md.get("_restored_status") or "running")
recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
start_parked = interactive and restored_status != "running" and not recoverable_park
start_parked = interactive and restored_status != "running"
if start_parked:
logger.warning(
@@ -609,10 +572,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
if session is not None:
input_data = []
continue
if codex.is_content_guardrail_error(exc):
return await _handle_content_guardrail(
coordinator, agent_id, exc, interactive=interactive
)
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
@@ -630,22 +589,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
return stream
async def _handle_content_guardrail(
coordinator: AgentCoordinator,
agent_id: str,
exc: BaseException,
*,
interactive: bool,
) -> RunResultBase | None:
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
if interactive:
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
return None
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
async def _settle_run_result(
coordinator: AgentCoordinator,
agent_id: str,
+1 -7
View File
@@ -376,12 +376,6 @@ async def run_strix_scan(
async with coordinator._lock:
root_status = coordinator.statuses.get(root_id)
root_error = coordinator.errors.get(root_id)
root_recoverable_park = root_status == "waiting" and bool(root_error)
root_start_parked = bool(
interactive and is_resume and root_status != "running" and not root_recoverable_park
)
result = await run_agent_loop(
agent=root_agent,
@@ -393,7 +387,7 @@ async def run_strix_scan(
agent_id=root_id,
interactive=interactive,
session=root_session,
start_parked=root_start_parked,
start_parked=bool(interactive and is_resume and root_status != "running"),
event_sink=event_sink,
hooks=hooks,
)
-119
View File
@@ -6,21 +6,15 @@ import asyncio
import contextlib
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from agents import RunConfig
from agents.memory import SQLiteSession
from agents.tool_context import ToolContext
from strix.config import codex
from strix.core import execution
from strix.core.agents import AgentCoordinator
from strix.core.execution import (
_handle_content_guardrail,
_notify_parent_on_terminal,
_notify_root_on_budget_reserve,
respawn_subagents,
)
from strix.tools.finish.tool import finish_scan
@@ -501,116 +495,3 @@ async def test_terminal_notice_does_not_cancel_parent_stream(tmp_path: Any) -> N
assert stream.cancelled is False
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_guardrail_interactive_parks_agent_wakeable(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=True)
assert result is None
assert coordinator.statuses["child"] == "waiting"
assert "STRIX_LLM" in coordinator.errors["child"]
waiter = asyncio.create_task(coordinator.wait_for_message("child"))
await asyncio.sleep(0)
assert not waiter.done()
session = SQLiteSession("child", tmp_path / "agents.db")
await coordinator.attach_runtime("child", session=session)
await coordinator.send("child", {"from": "user", "content": "switched model, resume"})
await asyncio.wait_for(waiter, timeout=1.0)
session.close()
@pytest.mark.asyncio
async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
await coordinator.attach_runtime("root", session=session)
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=False)
assert result is None
assert coordinator.statuses["child"] == "failed"
assert "STRIX_LLM" in coordinator.errors["child"]
assert coordinator.statuses["root"] == "running"
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("blocked", "recon", parent_id="root")
await coordinator.register("peer_waiter", "recon", parent_id="root")
await coordinator.set_status("blocked", "waiting", error="STRIX_LLM guardrail")
await coordinator.set_status("peer_waiter", "waiting")
parked: dict[str, bool] = {}
async def _fake_start_child_runner(**kwargs: Any) -> None:
parked[kwargs["child_id"]] = bool(kwargs["start_parked"])
monkeypatch.setattr(execution, "_start_child_runner", _fake_start_child_runner)
await respawn_subagents(
coordinator=coordinator,
factory=lambda **_kwargs: object(),
agents_db_path=tmp_path / "agents.db",
sessions_to_close=[],
run_config=MagicMock(),
max_turns=10,
interactive=True,
parent_ctx={"agent_id": "root", "parent_id": None},
root_id="root",
)
assert parked["blocked"] is False
assert parked["peer_waiter"] is True
class _StubLlmSettings:
def __init__(self, model: str) -> None:
self.model = model
self.reasoning_effort = None
self.force_required_tool_choice = False
self.timeout = None
self.prompt_cache = False
class _StubSettings:
def __init__(self, model: str) -> None:
self.llm = _StubLlmSettings(model)
def test_refresh_run_config_model_adopts_changed_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(execution, "reload_settings", lambda: _StubSettings("openai/gpt-5"))
monkeypatch.setattr(execution, "configure_sdk_model_defaults", lambda _settings: None)
run_config = RunConfig(model="chatgpt/gpt-5.6-sol")
refreshed = execution.refresh_run_config_model(run_config)
assert refreshed is not run_config
assert refreshed.model == "openai/gpt-5"
assert run_config.model == "chatgpt/gpt-5.6-sol"
def test_refresh_run_config_model_keeps_unchanged_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(execution, "reload_settings", lambda: _StubSettings("chatgpt/gpt-5.6-sol"))
run_config = RunConfig(model="chatgpt/gpt-5.6-sol")
assert execution.refresh_run_config_model(run_config) is run_config