From 1814a63de0061f96be69dbbff45987478b892cbf Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Mon, 27 Jul 2026 23:44:29 +0000 Subject: [PATCH] fix: adopt a changed STRIX_LLM when a parked agent wakes and surface guardrail errors in the TUI --- strix/config/__init__.py | 2 ++ strix/config/loader.py | 7 ++++++ strix/core/execution.py | 35 ++++++++++++++++++++++++++--- strix/interface/tui/app.py | 8 +++++-- strix/interface/tui/live_view.py | 2 +- tests/test_execution.py | 38 ++++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 6 deletions(-) diff --git a/strix/config/__init__.py b/strix/config/__init__.py index f21fdab6..eaef5f07 100644 --- a/strix/config/__init__.py +++ b/strix/config/__init__.py @@ -15,6 +15,7 @@ from strix.config.loader import ( apply_config_override, load_settings, persist_current, + reload_settings, ) from strix.config.settings import ( ContextSettings, @@ -38,4 +39,5 @@ __all__ = [ "apply_config_override", "load_settings", "persist_current", + "reload_settings", ] diff --git a/strix/config/loader.py b/strix/config/loader.py index 5b940760..4bc5de8f 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -45,6 +45,13 @@ 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 diff --git a/strix/core/execution.py b/strix/core/execution.py index ae726ed3..a87118cd 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import contextlib +import dataclasses import logging import uuid from collections.abc import Callable @@ -21,13 +22,14 @@ from openai import ( RateLimitError, ) -from strix.config import codex +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 +from strix.core.inputs import child_initial_input, make_model_settings from strix.core.sessions import ( enforce_image_budget, open_agent_session, @@ -59,6 +61,31 @@ 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 "" @@ -91,7 +118,8 @@ async def _compact_session( _GUARDRAIL_PARK_ERROR = ( "Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). " - "Set STRIX_LLM to a model that isn't blocked and resume the scan to continue." + "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}) @@ -204,6 +232,7 @@ 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, diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index cc5a36c4..01fc5488 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -1040,9 +1040,9 @@ class StrixTUIApp(App): # type: ignore[misc] name=names.get(agent_id, agent_id), parent_id=parent_of.get(agent_id), status=status, - error_message=error, + error_message=error or "", ) - if status in {"failed", "crashed"} and error: + if status in {"failed", "crashed", "waiting"} and error: if agent_id not in self._error_noted_agents: self._error_noted_agents.add(agent_id) self.live_view.record_agent_error(agent_id, error) @@ -1292,6 +1292,10 @@ class StrixTUIApp(App): # type: ignore[misc] text.append("Send a message to continue", style="dim") keymap = keymap_styled([("ctrl-q", "quit")]) else: + error_msg = agent_data.get("error_message") or "" + if error_msg: + text.append(error_msg, style="red") + text.append(" \u00b7 ", style="dim") text.append("Send message to resume", style="dim") return (text, keymap, False) diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 4401fb30..11a4b033 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -82,7 +82,7 @@ class TuiLiveView: current["parent_id"] = parent_id if status is not None: current["status"] = status - if error_message: + if error_message is not None: current["error_message"] = error_message current["updated_at"] = now diff --git a/tests/test_execution.py b/tests/test_execution.py index 2ecd9a84..70a0e73f 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -9,6 +9,7 @@ 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 @@ -576,3 +577,40 @@ async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting( 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