"""Top-level Strix scan runner. The SDK owns model/tool execution and per-agent sessions. This module owns Strix-specific scan setup, child-agent startup, resume, and the small wake loop needed to keep every agent addressable after its SDK run parks. """ from __future__ import annotations import contextlib import json import logging import uuid from collections.abc import Callable from typing import TYPE_CHECKING, Any from agents import RunConfig from agents.sandbox import SandboxRunConfig from strix.agents.factory import build_strix_agent, make_child_factory from strix.config import load_settings from strix.config.models import configure_sdk_model_defaults, normalize_model_name from strix.core.agents import AgentCoordinator from strix.core.execution import ( respawn_subagents, run_agent_loop, ) from strix.core.execution import ( spawn_child_agent as start_child_agent, ) from strix.core.inputs import ( DEFAULT_MAX_TURNS, build_root_task, build_scope_context, make_model_settings, ) from strix.core.paths import run_dir_for, runtime_state_dir from strix.core.sessions import open_agent_session from strix.runtime import session_manager from strix.telemetry.logging import set_scan_id, setup_scan_logging if TYPE_CHECKING: from agents.memory import SQLiteSession from agents.result import RunResultBase logger = logging.getLogger(__name__) StreamEventSink = Callable[[str, Any], None] async def run_strix_scan( *, scan_config: dict[str, Any], scan_id: str | None = None, image: str, local_sources: list[dict[str, str]] | None = None, coordinator: AgentCoordinator | None = None, interactive: bool = False, max_turns: int = DEFAULT_MAX_TURNS, model: str | None = None, cleanup_on_exit: bool = True, event_sink: StreamEventSink | None = None, ) -> RunResultBase | None: """Run or resume one Strix scan against a sandbox.""" if scan_id is None: scan_id = f"scan-{uuid.uuid4().hex[:8]}" # Resolve run_dir before any heavy bring-up so the log file captures # everything from sandbox start onwards. run_dir = run_dir_for(scan_id) run_dir.mkdir(parents=True, exist_ok=True) state_dir = runtime_state_dir(run_dir) state_dir.mkdir(parents=True, exist_ok=True) teardown_logging = setup_scan_logging(run_dir) set_scan_id(scan_id) agents_path = state_dir / "agents.json" agents_db = state_dir / "agents.db" is_resume = agents_path.exists() logger.info( "%s Strix scan %s (image=%s, max_turns=%d, interactive=%s, run_dir=%s)", "Resuming" if is_resume else "Starting", scan_id, image, max_turns, interactive, run_dir, ) settings = load_settings() configure_sdk_model_defaults(settings) resolved_model = normalize_model_name(model or settings.llm.model or "") if not resolved_model: raise RuntimeError( "No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().", ) logger.info("LLM model resolved: %s", resolved_model) # Caller may pre-create the coordinator so it can route stop/chat # commands while the scan loop runs in another thread. if coordinator is None: coordinator = AgentCoordinator() coordinator.set_snapshot_path(agents_path) # Wire the per-agent todo store to ``{run_dir}/.state/todos.json`` (mirrored # on every CRUD) and reload any prior todos so respawned subagents # find their lists intact. Same for the shared notes store. from strix.tools.notes.tools import hydrate_notes_from_disk from strix.tools.todo.tools import hydrate_todos_from_disk hydrate_todos_from_disk(state_dir) hydrate_notes_from_disk(state_dir) root_id: str | None = None if is_resume: try: snap = json.loads(agents_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise RuntimeError( f"Cannot resume scan {scan_id}: agents.json is unreadable: {exc}", ) from exc if not agents_db.exists(): raise RuntimeError( f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}", ) await coordinator.restore(snap) for aid, parent in coordinator.parent_of.items(): if parent is None: root_id = aid break if root_id is None: raise RuntimeError( f"Cannot resume scan {scan_id}: agents.json has no root agent (parent=None)", ) logger.info( "Resume: restored coordinator with %d agent(s); root=%s", len(coordinator.statuses), root_id, ) else: root_id = uuid.uuid4().hex[:8] logger.info("Bringing up sandbox session for scan %s", scan_id) bundle = await session_manager.create_or_reuse( scan_id, image=image, local_sources=local_sources or [], ) logger.info("Sandbox ready for scan %s", scan_id) sessions_to_close: list[SQLiteSession] = [] try: targets = scan_config.get("targets") or [] scan_mode = str(scan_config.get("scan_mode") or "deep") is_whitebox = any(t.get("type") == "local_code" for t in targets) skills = list(scan_config.get("skills") or []) root_task = build_root_task(scan_config) model_settings = make_model_settings(settings.llm.reasoning_effort) run_config = RunConfig( model=resolved_model, model_settings=model_settings, sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]), trace_include_sensitive_data=False, ) scope_context = build_scope_context(scan_config) root_agent = build_strix_agent( name="strix", skills=skills, is_root=True, scan_mode=scan_mode, is_whitebox=is_whitebox, interactive=interactive, system_prompt_context=scope_context, ) if not is_resume: await coordinator.register( root_id, "strix", parent_id=None, task=root_task, skills=skills, ) child_agent_builder = make_child_factory( scan_mode=scan_mode, is_whitebox=is_whitebox, interactive=interactive, system_prompt_context=scope_context, ) async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]: return await start_child_agent( coordinator=coordinator, factory=child_agent_builder, agents_db_path=agents_db, sessions_to_close=sessions_to_close, run_config=run_config, max_turns=max_turns, interactive=interactive, event_sink=event_sink, **kwargs, ) context: dict[str, Any] = { "coordinator": coordinator, "sandbox_session": bundle["session"], "caido_client": bundle["caido_client"], "agent_id": root_id, "parent_id": None, "interactive": interactive, "spawn_child_agent": spawn_child_agent, } # All agents share one SQLite database; SDK session_id separates # each agent's conversation inside that database. root_session = open_agent_session(root_id, agents_db) sessions_to_close.append(root_session) await coordinator.attach_runtime(root_id, session=root_session) if is_resume: await respawn_subagents( coordinator=coordinator, factory=child_agent_builder, agents_db_path=agents_db, sessions_to_close=sessions_to_close, run_config=run_config, max_turns=max_turns, interactive=interactive, parent_ctx=context, root_id=root_id, event_sink=event_sink, ) initial_input: Any = [] if is_resume else root_task # Resume + new ``--instruction``: SDK replay drives root from # agents.db with ``initial_input=[]``, so a brand-new instruction # passed on the resume CLI would otherwise be silently ignored. # Inject it as a fresh user message in root's SDK session; the # next run cycle will replay it with the rest of the session. resume_instruction = str(scan_config.get("resume_instruction") or "").strip() if is_resume and resume_instruction: await coordinator.send( root_id, { "from": "user", "type": "instruction", "priority": "high", "content": resume_instruction, }, ) logger.info( "Resume: injected new instruction into root SDK session (len=%d)", len(resume_instruction), ) async with coordinator._lock: root_status = coordinator.statuses.get(root_id) return await run_agent_loop( agent=root_agent, initial_input=initial_input, run_config=run_config, context=context, max_turns=max_turns, coordinator=coordinator, agent_id=root_id, interactive=interactive, session=root_session, start_parked=bool(interactive and is_resume and root_status != "running"), event_sink=event_sink, ) except BaseException: logger.exception("Strix scan %s failed", scan_id) # Cancel any descendant tasks the root spawned before unwinding. # cancel_descendants is idempotent and handles the empty-tree case. if root_id is not None: await coordinator.cancel_descendants(root_id) # The SDK's on_agent_end hook only fires after a successful # ``Runner.run_streamed`` reaches the agent's first turn. A # failure earlier (e.g., model-provider routing, sandbox # bring-up) leaves the root stuck at status="running" — the # TUI keeps animating "Initializing" forever. Finalize it # here so the coordinator reflects reality. with contextlib.suppress(Exception): await coordinator.set_status(root_id, "failed") raise finally: for s in sessions_to_close: with contextlib.suppress(Exception): s.close() with contextlib.suppress(Exception): await coordinator._maybe_snapshot() if cleanup_on_exit: logger.info("Tearing down sandbox session for scan %s", scan_id) await session_manager.cleanup(scan_id) logger.info("Strix scan %s done", scan_id) teardown_logging()