From 629ea60b0225f874b3cc7165b24c87fb53a7d069 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sun, 26 Apr 2026 14:28:50 -0700 Subject: [PATCH] refactor: reorganize core report and tui modules --- pyproject.toml | 14 +- strix.spec | 31 +- strix/core/__init__.py | 1 + .../coordinator.py => core/agents.py} | 2 +- .../runner.py => core/execution.py} | 592 +++++------------- .../utils.py => core/inputs.py} | 2 +- strix/core/runner.py | 303 +++++++++ strix/core/sessions.py | 10 + strix/interface/cli.py | 8 +- strix/interface/main.py | 10 +- strix/interface/tui/__init__.py | 6 + strix/interface/{tui.py => tui/app.py} | 421 +------------ strix/interface/tui/history.py | 58 ++ strix/interface/tui/live_view.py | 356 +++++++++++ strix/interface/tui/messages.py | 30 + .../renderers}/__init__.py | 0 .../renderers}/agent_message_renderer.py | 0 .../renderers}/agents_graph_renderer.py | 0 .../renderers}/base_renderer.py | 0 .../renderers}/finish_renderer.py | 0 .../renderers}/notes_renderer.py | 0 .../renderers}/proxy_renderer.py | 0 .../renderers}/registry.py | 0 .../renderers}/reporting_renderer.py | 0 .../renderers}/thinking_renderer.py | 0 .../renderers}/todo_renderer.py | 0 .../renderers}/user_message_renderer.py | 0 .../renderers}/web_search_renderer.py | 0 strix/orchestration/__init__.py | 11 - strix/report/__init__.py | 8 +- .../scan_store.py => report/state.py} | 179 +----- strix/report/writer.py | 180 ++++++ strix/telemetry/__init__.py | 8 - strix/telemetry/posthog.py | 4 +- strix/tools/agents_graph/tools.py | 2 +- strix/tools/finish/tool.py | 6 +- strix/tools/reporting/tool.py | 4 +- 37 files changed, 1185 insertions(+), 1061 deletions(-) create mode 100644 strix/core/__init__.py rename strix/{orchestration/coordinator.py => core/agents.py} (99%) rename strix/{orchestration/runner.py => core/execution.py} (56%) rename strix/{orchestration/utils.py => core/inputs.py} (98%) create mode 100644 strix/core/runner.py create mode 100644 strix/core/sessions.py create mode 100644 strix/interface/tui/__init__.py rename strix/interface/{tui.py => tui/app.py} (81%) create mode 100644 strix/interface/tui/history.py create mode 100644 strix/interface/tui/live_view.py create mode 100644 strix/interface/tui/messages.py rename strix/interface/{tool_components => tui/renderers}/__init__.py (100%) rename strix/interface/{tool_components => tui/renderers}/agent_message_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/agents_graph_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/base_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/finish_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/notes_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/proxy_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/registry.py (100%) rename strix/interface/{tool_components => tui/renderers}/reporting_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/thinking_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/todo_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/user_message_renderer.py (100%) rename strix/interface/{tool_components => tui/renderers}/web_search_renderer.py (100%) delete mode 100644 strix/orchestration/__init__.py rename strix/{telemetry/scan_store.py => report/state.py} (60%) create mode 100644 strix/report/writer.py diff --git a/pyproject.toml b/pyproject.toml index 63266ae..8afa285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -217,22 +217,20 @@ ignore = [ "strix/agents/factory.py" = ["TC002"] # Entry point: ``Path`` is used at runtime by the typing of the # session_manager call; importing under TYPE_CHECKING would defer -# resolution past where mypy needs it. ``build_root_task`` legitimately -# walks every supported target type — splitting it into per-type -# helpers would add indirection without simplifying anything. -"strix/orchestration/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"] -# ScanStore carries scan artifact/report fields, artifact rendering, and +# resolution past where mypy needs it. +"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"] +# ReportState carries scan artifact/report fields and # a runtime ``Callable`` annotation on ``vulnerability_found_callback``. -"strix/telemetry/scan_store.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"] +"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"] # Interface utility branches per scope-mode / target-type combination; # splitting would obscure the decision tree without simplifying it. "strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"] # CLI / TUI / main keep extensive lazy imports + broad exception # swallows for resilience around terminal-rendering errors. "strix/interface/cli.py" = ["BLE001", "PLC0415"] -"strix/interface/tui.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"] +"strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"] "strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"] -"strix/interface/tool_components/agent_message_renderer.py" = ["PLC0415"] +"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"] [tool.ruff.lint.isort] force-single-line = false diff --git a/strix.spec b/strix.spec index 7f5d2ed..60dd847 100644 --- a/strix.spec +++ b/strix.spec @@ -116,18 +116,38 @@ hiddenimports = [ 'strix.interface.main', 'strix.interface.cli', 'strix.interface.tui', + 'strix.interface.tui.app', + 'strix.interface.tui.history', + 'strix.interface.tui.live_view', + 'strix.interface.tui.messages', + 'strix.interface.tui.renderers', + 'strix.interface.tui.renderers.agent_message_renderer', + 'strix.interface.tui.renderers.agents_graph_renderer', + 'strix.interface.tui.renderers.base_renderer', + 'strix.interface.tui.renderers.finish_renderer', + 'strix.interface.tui.renderers.notes_renderer', + 'strix.interface.tui.renderers.proxy_renderer', + 'strix.interface.tui.renderers.registry', + 'strix.interface.tui.renderers.reporting_renderer', + 'strix.interface.tui.renderers.thinking_renderer', + 'strix.interface.tui.renderers.todo_renderer', + 'strix.interface.tui.renderers.user_message_renderer', + 'strix.interface.tui.renderers.web_search_renderer', 'strix.interface.utils', - 'strix.interface.tool_components', 'strix.agents', 'strix.agents.factory', 'strix.agents.prompt', 'strix.config.models', - 'strix.orchestration', - 'strix.orchestration.coordinator', - 'strix.orchestration.runner', - 'strix.orchestration.utils', + 'strix.core', + 'strix.core.agents', + 'strix.core.execution', + 'strix.core.inputs', + 'strix.core.runner', + 'strix.core.sessions', 'strix.report', 'strix.report.dedupe', + 'strix.report.state', + 'strix.report.writer', 'strix.runtime', 'strix.runtime.backends', 'strix.runtime.caido_bootstrap', @@ -136,7 +156,6 @@ hiddenimports = [ 'strix.telemetry', 'strix.telemetry.logging', 'strix.telemetry.posthog', - 'strix.telemetry.scan_store', 'strix.tools', 'strix.tools.agents_graph.tools', 'strix.tools.finish.tool', diff --git a/strix/core/__init__.py b/strix/core/__init__.py new file mode 100644 index 0000000..8e07f9d --- /dev/null +++ b/strix/core/__init__.py @@ -0,0 +1 @@ +"""Strix scan runtime core.""" diff --git a/strix/orchestration/coordinator.py b/strix/core/agents.py similarity index 99% rename from strix/orchestration/coordinator.py rename to strix/core/agents.py index 21fba07..5351af2 100644 --- a/strix/orchestration/coordinator.py +++ b/strix/core/agents.py @@ -1,4 +1,4 @@ -"""SDK-native coordinator for Strix's addressable agent graph. +"""SDK-native state for Strix's addressable agent graph. The Agents SDK owns model/tool execution and per-agent conversation history. Strix owns only product semantics the SDK does not provide: diff --git a/strix/orchestration/runner.py b/strix/core/execution.py similarity index 56% rename from strix/orchestration/runner.py rename to strix/core/execution.py index cae70e2..b5222c2 100644 --- a/strix/orchestration/runner.py +++ b/strix/core/execution.py @@ -1,59 +1,38 @@ -"""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. -""" +"""Execution loop for addressable SDK-backed Strix agents.""" from __future__ import annotations import asyncio import contextlib -import json import logging import uuid from collections.abc import Callable -from pathlib import Path from typing import TYPE_CHECKING, Any, cast from agents import RunConfig, Runner from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError -from agents.memory import SQLiteSession -from agents.sandbox import SandboxRunConfig from openai import APIError -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.orchestration.coordinator import AgentCoordinator, Status -from strix.orchestration.utils import ( - DEFAULT_MAX_TURNS, - build_root_task, - build_scope_context, - child_initial_input, - make_model_settings, -) -from strix.runtime import session_manager -from strix.telemetry.logging import set_scan_id, setup_scan_logging +from strix.core.inputs import child_initial_input +from strix.core.sessions import open_agent_session if TYPE_CHECKING: + from pathlib import Path + from agents.items import TResponseInputItem - from agents.memory import Session + from agents.memory import Session, SQLiteSession from agents.result import RunResultBase + from strix.core.agents import AgentCoordinator, Status + logger = logging.getLogger(__name__) StreamEventSink = Callable[[str, Any], None] -def _open_agent_session(agent_id: str, path: Path) -> SQLiteSession: - path.parent.mkdir(parents=True, exist_ok=True) - return SQLiteSession(session_id=agent_id, db_path=path) - - -async def _run_agent_loop( +async def run_agent_loop( *, agent: Any, initial_input: Any, @@ -125,6 +104,150 @@ async def _run_agent_loop( ) +async def spawn_child_agent( + *, + coordinator: AgentCoordinator, + factory: Any, + agents_db_path: Path, + sessions_to_close: list[SQLiteSession], + run_config: RunConfig, + max_turns: int, + interactive: bool, + parent_ctx: dict[str, Any], + name: str, + task: str, + skills: list[str], + parent_history: list[Any], + event_sink: StreamEventSink | None = None, +) -> dict[str, Any]: + parent_id = parent_ctx.get("agent_id") + if not isinstance(parent_id, str): + raise TypeError("Parent agent_id missing from context") + + child_id = uuid.uuid4().hex[:8] + child_agent = factory(name=name, skills=skills) + await coordinator.register( + child_id, + name, + parent_id, + task=task, + skills=skills, + ) + + await _start_child_runner( + parent_ctx=parent_ctx, + coordinator=coordinator, + agents_db_path=agents_db_path, + sessions_to_close=sessions_to_close, + run_config=run_config, + max_turns=max_turns, + interactive=interactive, + child_agent=child_agent, + child_id=child_id, + name=name, + parent_id=parent_id, + task=task, + initial_input=child_initial_input( + name=name, + child_id=child_id, + parent_id=parent_id, + task=task, + parent_history=parent_history, + ), + event_sink=event_sink, + ) + + return { + "success": True, + "agent_id": child_id, + "name": name, + "parent_id": parent_id, + "message": f"Spawned '{name}' ({child_id}) running in parallel.", + } + + +async def respawn_subagents( + *, + coordinator: AgentCoordinator, + factory: Any, + agents_db_path: Path, + sessions_to_close: list[SQLiteSession], + run_config: RunConfig, + max_turns: int, + interactive: bool, + parent_ctx: dict[str, Any], + root_id: str, + event_sink: StreamEventSink | None = None, +) -> None: + """Re-spawn subagent runners from a restored coordinator snapshot.""" + async with coordinator._lock: + # Snapshot the iteration view first so we can mutate via coordinator + # below without "dict changed during iteration" trouble. + agents_snapshot = [ + (aid, status, dict(coordinator.metadata.get(aid, {}))) + for aid, status in coordinator.statuses.items() + ] + candidates: list[tuple[str, str, str | None, dict[str, Any]]] = [] + for aid, status, md in agents_snapshot: + if not interactive and status not in {"running", "waiting"}: + continue + if coordinator.parent_of.get(aid) is None or aid == root_id: + continue + md["_restored_status"] = status + candidates.append( + ( + aid, + coordinator.names.get(aid, aid), + coordinator.parent_of.get(aid), + md, + ) + ) + + for child_id, name, parent_id, md in candidates: + try: + restored_status = str(md.get("_restored_status") or "running") + start_parked = interactive and restored_status != "running" + + if start_parked: + logger.warning( + "respawn %s (%s): starting parked from status=%s", + child_id, + name, + restored_status, + ) + + child_skills = list(md.get("skills") or []) + child_agent = factory(name=name, skills=child_skills) + await _start_child_runner( + parent_ctx=parent_ctx, + coordinator=coordinator, + agents_db_path=agents_db_path, + sessions_to_close=sessions_to_close, + run_config=run_config, + max_turns=max_turns, + interactive=interactive, + child_agent=child_agent, + child_id=child_id, + name=name, + parent_id=parent_id, + task=str(md.get("task", "")), + initial_input=[], + start_parked=start_parked, + event_sink=event_sink, + ) + logger.info( + "respawned %s (%s) parent=%s task_len=%d", + child_id, + name, + parent_id or "-", + len(md.get("task", "")), + ) + except Exception: + logger.exception("respawn %s failed; marking crashed", child_id) + with contextlib.suppress(Exception): + await coordinator.set_status(child_id, "crashed") + + async def _run_noninteractive_until_lifecycle( agent: Any, coordinator: AgentCoordinator, @@ -323,68 +446,6 @@ async def _notify_parent_on_crash( ) -async def _spawn_child_agent( - *, - coordinator: AgentCoordinator, - factory: Any, - agents_db_path: Path, - sessions_to_close: list[SQLiteSession], - run_config: RunConfig, - max_turns: int, - interactive: bool, - parent_ctx: dict[str, Any], - name: str, - task: str, - skills: list[str], - parent_history: list[Any], - event_sink: StreamEventSink | None = None, -) -> dict[str, Any]: - parent_id = parent_ctx.get("agent_id") - if not isinstance(parent_id, str): - raise TypeError("Parent agent_id missing from context") - - child_id = uuid.uuid4().hex[:8] - child_agent = factory(name=name, skills=skills) - await coordinator.register( - child_id, - name, - parent_id, - task=task, - skills=skills, - ) - - await _start_child_runner( - parent_ctx=parent_ctx, - coordinator=coordinator, - agents_db_path=agents_db_path, - sessions_to_close=sessions_to_close, - run_config=run_config, - max_turns=max_turns, - interactive=interactive, - child_agent=child_agent, - child_id=child_id, - name=name, - parent_id=parent_id, - task=task, - initial_input=child_initial_input( - name=name, - child_id=child_id, - parent_id=parent_id, - task=task, - parent_history=parent_history, - ), - event_sink=event_sink, - ) - - return { - "success": True, - "agent_id": child_id, - "name": name, - "parent_id": parent_id, - "message": f"Spawned '{name}' ({child_id}) running in parallel.", - } - - async def _start_child_runner( *, parent_ctx: dict[str, Any], @@ -403,7 +464,7 @@ async def _start_child_runner( start_parked: bool = False, event_sink: StreamEventSink | None = None, ) -> None: - session = _open_agent_session(child_id, agents_db_path) + session = open_agent_session(child_id, agents_db_path) sessions_to_close.append(session) await coordinator.attach_runtime(child_id, session=session) @@ -413,7 +474,7 @@ async def _start_child_runner( child_ctx["task"] = task task_handle = asyncio.create_task( - _run_agent_loop( + run_agent_loop( agent=child_agent, initial_input=initial_input, run_config=run_config, @@ -429,346 +490,3 @@ async def _start_child_runner( name=f"agent-{name}-{child_id}", ) await coordinator.attach_runtime(child_id, task=task_handle) - - -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 = Path.cwd() / "strix_runs" / scan_id - run_dir.mkdir(parents=True, exist_ok=True) - teardown_logging = setup_scan_logging(run_dir) - set_scan_id(scan_id) - - agents_path = run_dir / "agents.json" - agents_db = run_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}/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(run_dir) - hydrate_notes_from_disk(run_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 _spawn_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() - - -async def _respawn_subagents( - *, - coordinator: AgentCoordinator, - factory: Any, - agents_db_path: Path, - sessions_to_close: list[SQLiteSession], - run_config: RunConfig, - max_turns: int, - interactive: bool, - parent_ctx: dict[str, Any], - root_id: str, - event_sink: StreamEventSink | None = None, -) -> None: - """Re-spawn subagent runners from a restored coordinator snapshot. - - Each child gets its own SDK ``session_id`` inside the shared - ``agents.db`` so the SDK replays its prior conversation. Interactive - mode respawns every registered child as a parked runner unless it was - actively running before the crash. That keeps completed/stopped/ - crashed/failed agents addressable: a later message wakes the SDK - session instead of being dropped into a dead inbox. - """ - async with coordinator._lock: - # Snapshot the iteration view first so we can mutate via coordinator - # below without "dict changed during iteration" trouble. - agents_snapshot = [ - (aid, status, dict(coordinator.metadata.get(aid, {}))) - for aid, status in coordinator.statuses.items() - ] - candidates: list[tuple[str, str, str | None, dict[str, Any]]] = [] - for aid, status, md in agents_snapshot: - if not interactive and status not in {"running", "waiting"}: - continue - if coordinator.parent_of.get(aid) is None or aid == root_id: - continue - md["_restored_status"] = status - candidates.append( - ( - aid, - coordinator.names.get(aid, aid), - coordinator.parent_of.get(aid), - md, - ) - ) - - for child_id, name, parent_id, md in candidates: - try: - restored_status = str(md.get("_restored_status") or "running") - start_parked = interactive and restored_status != "running" - - if start_parked: - logger.warning( - "respawn %s (%s): starting parked from status=%s", - child_id, - name, - restored_status, - ) - - child_skills = list(md.get("skills") or []) - child_agent = factory(name=name, skills=child_skills) - await _start_child_runner( - parent_ctx=parent_ctx, - coordinator=coordinator, - agents_db_path=agents_db_path, - sessions_to_close=sessions_to_close, - run_config=run_config, - max_turns=max_turns, - interactive=interactive, - child_agent=child_agent, - child_id=child_id, - name=name, - parent_id=parent_id, - task=str(md.get("task", "")), - initial_input=[], - start_parked=start_parked, - event_sink=event_sink, - ) - logger.info( - "respawned %s (%s) parent=%s task_len=%d", - child_id, - name, - parent_id or "-", - len(md.get("task", "")), - ) - except Exception: - logger.exception("respawn %s failed; marking crashed", child_id) - with contextlib.suppress(Exception): - await coordinator.set_status(child_id, "crashed") diff --git a/strix/orchestration/utils.py b/strix/core/inputs.py similarity index 98% rename from strix/orchestration/utils.py rename to strix/core/inputs.py index 8686785..87bf007 100644 --- a/strix/orchestration/utils.py +++ b/strix/core/inputs.py @@ -1,4 +1,4 @@ -"""Pure helper builders for Strix scan orchestration.""" +"""Pure input builders for Strix scan runs.""" from __future__ import annotations diff --git a/strix/core/runner.py b/strix/core/runner.py new file mode 100644 index 0000000..80be6bc --- /dev/null +++ b/strix/core/runner.py @@ -0,0 +1,303 @@ +"""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 pathlib import Path +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.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 = Path.cwd() / "strix_runs" / scan_id + run_dir.mkdir(parents=True, exist_ok=True) + teardown_logging = setup_scan_logging(run_dir) + set_scan_id(scan_id) + + agents_path = run_dir / "agents.json" + agents_db = run_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}/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(run_dir) + hydrate_notes_from_disk(run_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() diff --git a/strix/core/sessions.py b/strix/core/sessions.py new file mode 100644 index 0000000..1dd3b73 --- /dev/null +++ b/strix/core/sessions.py @@ -0,0 +1,10 @@ +"""SDK session helpers for Strix agents.""" + +from pathlib import Path + +from agents.memory import SQLiteSession + + +def open_agent_session(agent_id: str, path: Path) -> SQLiteSession: + path.parent.mkdir(parents=True, exist_ok=True) + return SQLiteSession(session_id=agent_id, db_path=path) diff --git a/strix/interface/cli.py b/strix/interface/cli.py index acdcbbb..00de20f 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -13,9 +13,9 @@ from rich.panel import Panel from rich.text import Text from strix.config import load_settings -from strix.orchestration.runner import run_strix_scan +from strix.core.runner import run_strix_scan +from strix.report.state import ReportState, set_global_report_state from strix.runtime import session_manager -from strix.telemetry.scan_store import ScanStore, set_global_scan_store from .utils import ( build_live_stats_text, @@ -95,7 +95,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 "resume_instruction": getattr(args, "user_explicit_instruction", None) or "", } - scan_store = ScanStore(args.run_name) + scan_store = ReportState(args.run_name) scan_store.set_scan_config(scan_config) scan_store.hydrate_from_run_dir() @@ -130,7 +130,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 if hasattr(signal, "SIGHUP"): signal.signal(signal.SIGHUP, signal_handler) - set_global_scan_store(scan_store) + set_global_report_state(scan_store) def create_live_status() -> Panel: status_text = Text() diff --git a/strix/interface/main.py b/strix/interface/main.py index 4d8ebfc..d526961 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -41,9 +41,9 @@ from strix.interface.utils import ( rewrite_localhost_targets, validate_config_file, ) +from strix.report.state import get_global_report_state from strix.telemetry import posthog from strix.telemetry.logging import configure_dependency_logging -from strix.telemetry.scan_store import get_global_scan_store HOST_GATEWAY_HOSTNAME = "host.docker.internal" @@ -53,7 +53,7 @@ import logging # noqa: E402 # Per-scan logging is set up by ``setup_scan_logging`` from inside -# ``orchestration.runner.run_strix_scan`` once the scan ``run_dir`` is +# ``core.runner.run_strix_scan`` once the scan ``run_dir`` is # known — that's where ``strix.*`` levels and handlers are owned. Pre-scan # work (``main()``, env validation, image pull) emits via the module # logger; once setup_scan_logging runs, those records start landing in @@ -558,7 +558,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser def display_completion_message(args: argparse.Namespace, results_path: Path) -> None: console = Console() - scan_store = get_global_scan_store() + scan_store = get_global_report_state() scan_completed = False if scan_store and scan_store.scan_results: @@ -765,7 +765,7 @@ def main() -> None: posthog.error("unhandled_exception", str(e)) raise finally: - scan_store = get_global_scan_store() + scan_store = get_global_report_state() if scan_store: posthog.end(scan_store, exit_reason=exit_reason) @@ -773,7 +773,7 @@ def main() -> None: display_completion_message(args, results_path) if args.non_interactive: - scan_store = get_global_scan_store() + scan_store = get_global_report_state() if scan_store and scan_store.vulnerability_reports: sys.exit(2) diff --git a/strix/interface/tui/__init__.py b/strix/interface/tui/__init__.py new file mode 100644 index 0000000..371ef81 --- /dev/null +++ b/strix/interface/tui/__init__.py @@ -0,0 +1,6 @@ +"""Textual TUI interface.""" + +from strix.interface.tui.app import StrixTUIApp, run_tui + + +__all__ = ["StrixTUIApp", "run_tui"] diff --git a/strix/interface/tui.py b/strix/interface/tui/app.py similarity index 81% rename from strix/interface/tui.py rename to strix/interface/tui/app.py index 58ab2e0..cecf8f1 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui/app.py @@ -2,14 +2,11 @@ import argparse import asyncio import atexit import contextlib -import json import logging import signal -import sqlite3 import sys import threading from collections.abc import Callable -from datetime import UTC, datetime from importlib.metadata import PackageNotFoundError from importlib.metadata import version as pkg_version from pathlib import Path @@ -34,13 +31,15 @@ from textual.widgets import Button, Label, Static, TextArea, Tree from textual.widgets.tree import TreeNode from strix.config import load_settings -from strix.interface.tool_components import render_tool_widget -from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer -from strix.interface.tool_components.user_message_renderer import UserMessageRenderer +from strix.core.runner import run_strix_scan +from strix.interface.tui.live_view import TuiLiveView +from strix.interface.tui.messages import send_user_message_to_agent +from strix.interface.tui.renderers import render_tool_widget +from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRenderer +from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer from strix.interface.utils import build_tui_stats_text -from strix.orchestration.runner import run_strix_scan +from strix.report.state import ReportState, set_global_report_state from strix.runtime import session_manager -from strix.telemetry.scan_store import ScanStore, set_global_scan_store logger = logging.getLogger(__name__) @@ -643,386 +642,6 @@ class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc] self.mount(item) -class TuiLiveView: - """UI-owned projection of agent state plus SDK stream events.""" - - def __init__(self) -> None: - self.agents: dict[str, dict[str, Any]] = {} - self.events: list[dict[str, Any]] = [] - self._next_event_id = 1 - self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {} - self._tool_event_by_call_id: dict[str, dict[str, Any]] = {} - - def hydrate_from_run_dir(self, run_dir: Path) -> None: - agents_path = run_dir / "agents.json" - if not agents_path.exists(): - return - try: - agents_data = json.loads(agents_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return - statuses = agents_data.get("statuses") or {} - names = agents_data.get("names") or {} - parent_of = agents_data.get("parent_of") or {} - if not isinstance(statuses, dict): - return - for agent_id, status in statuses.items(): - if not isinstance(agent_id, str): - continue - self.upsert_agent( - agent_id, - name=names.get(agent_id, agent_id) if isinstance(names, dict) else agent_id, - parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None, - status=str(status), - ) - self._hydrate_sdk_session_history(run_dir, statuses.keys()) - - def _hydrate_sdk_session_history(self, run_dir: Path, agent_ids: Any) -> None: - agents_db = run_dir / "agents.db" - session_ids = [aid for aid in agent_ids if isinstance(aid, str)] - if not agents_db.exists() or not session_ids: - return - session_id_set = set(session_ids) - try: - with sqlite3.connect(agents_db) as conn: - rows = conn.execute( - "select id, session_id, message_data, created_at " - "from agent_messages order by id" - ).fetchall() - except sqlite3.Error: - logger.exception("Failed to hydrate TUI history from %s", agents_db) - return - - for row_id, agent_id, message_data, created_at in rows: - if agent_id not in session_id_set: - continue - try: - item = json.loads(message_data) - except (TypeError, json.JSONDecodeError): - logger.debug("Skipping unreadable SDK session item %s for %s", row_id, agent_id) - continue - if not isinstance(item, dict): - continue - self._ingest_session_history_item( - str(agent_id), - item, - timestamp=_sqlite_timestamp_to_iso(created_at), - ) - - def upsert_agent( - self, - agent_id: str, - *, - name: str | None = None, - parent_id: str | None = None, - status: str | None = None, - error_message: str | None = None, - ) -> None: - now = datetime.now(UTC).isoformat() - current = self.agents.setdefault( - agent_id, - { - "id": agent_id, - "name": name or agent_id, - "parent_id": parent_id, - "status": status or "running", - "created_at": now, - "updated_at": now, - }, - ) - if name is not None: - current["name"] = name - if parent_id is not None or "parent_id" not in current: - current["parent_id"] = parent_id - if status is not None: - current["status"] = status - if error_message: - current["error_message"] = error_message - current["updated_at"] = now - - def record_user_message(self, agent_id: str, content: str) -> None: - self._append_event( - agent_id, - "chat", - { - "role": "user", - "content": content, - "metadata": {"source": "tui_user"}, - }, - ) - - def ingest_sdk_event(self, agent_id: str, event: Any) -> None: - event_type = getattr(event, "type", "") - if event_type == "raw_response_event": - self._ingest_raw_response_event(agent_id, getattr(event, "data", None)) - return - if event_type != "run_item_stream_event": - return - - item = getattr(event, "item", None) - item_type = getattr(item, "type", "") - if item_type == "message_output_item": - self._record_assistant_message(agent_id, _sdk_message_text(item), final=True) - elif item_type == "tool_call_item": - self._record_tool_call(agent_id, item) - elif item_type == "tool_call_output_item": - self._record_tool_output(agent_id, item) - - def events_for_agent(self, agent_id: str) -> list[dict[str, Any]]: - return [event for event in self.events if event.get("agent_id") == agent_id] - - def has_events_for_agent(self, agent_id: str) -> bool: - return any(event.get("agent_id") == agent_id for event in self.events) - - def _ingest_raw_response_event(self, agent_id: str, data: Any) -> None: - data_type = getattr(data, "type", "") - if data_type == "response.output_text.delta": - delta = getattr(data, "delta", "") - if delta: - self._record_assistant_message(agent_id, str(delta), final=False) - - def _ingest_session_history_item( - self, - agent_id: str, - item: dict[str, Any], - *, - timestamp: str, - ) -> None: - item_type = item.get("type") - role = item.get("role") - if role in {"user", "assistant"} and (item_type in {None, "message"}): - content = _session_message_text(item) - if content: - self._append_event( - agent_id, - "chat", - { - "role": role, - "content": content, - "metadata": {"source": "sdk_session"}, - }, - timestamp=timestamp, - ) - return - - if item_type == "function_call": - self._record_tool_call_data( - agent_id, - { - "call_id": str(item.get("call_id") or item.get("id") or ""), - "tool_name": str(item.get("name") or "tool"), - "args": _parse_json_object(item.get("arguments")), - }, - timestamp=timestamp, - ) - return - - if item_type == "function_call_output": - self._record_tool_output_data( - agent_id, - { - "call_id": str(item.get("call_id") or item.get("id") or ""), - "tool_name": "tool", - "output": item.get("output"), - }, - timestamp=timestamp, - ) - - def _record_assistant_message(self, agent_id: str, content: str, *, final: bool) -> None: - if not content: - return - existing = self._open_assistant_event_by_agent.get(agent_id) - if existing is None: - event = self._append_event( - agent_id, - "chat", - { - "role": "assistant", - "content": content, - "metadata": {"source": "sdk_stream", "streaming": not final}, - }, - ) - if not final: - self._open_assistant_event_by_agent[agent_id] = event - return - - data = existing["data"] - if final: - data["content"] = content - data["metadata"]["streaming"] = False - self._open_assistant_event_by_agent.pop(agent_id, None) - else: - data["content"] = f"{data.get('content', '')}{content}" - self._bump_event(existing) - - def _record_tool_call(self, agent_id: str, item: Any) -> None: - self._record_tool_call_data(agent_id, _sdk_tool_call_data(item)) - - def _record_tool_call_data( - self, - agent_id: str, - call: dict[str, Any], - *, - timestamp: str | None = None, - ) -> None: - call_id = call["call_id"] - existing = self._tool_event_by_call_id.get(call_id) - tool_data = { - "tool_name": call["tool_name"], - "args": call["args"], - "status": "running", - "agent_id": agent_id, - "call_id": call_id, - } - if existing is None: - event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp) - self._tool_event_by_call_id[call_id] = event - else: - existing["data"].update(tool_data) - self._bump_event(existing, timestamp=timestamp) - - def _record_tool_output(self, agent_id: str, item: Any) -> None: - self._record_tool_output_data(agent_id, _sdk_tool_output_data(item)) - - def _record_tool_output_data( - self, - agent_id: str, - output: dict[str, Any], - *, - timestamp: str | None = None, - ) -> None: - call_id = output["call_id"] - event = self._tool_event_by_call_id.get(call_id) - if event is None: - event = self._append_event( - agent_id, - "tool", - { - "tool_name": output["tool_name"], - "args": {}, - "status": "completed", - "agent_id": agent_id, - "call_id": call_id, - }, - timestamp=timestamp, - ) - self._tool_event_by_call_id[call_id] = event - - result = _parse_json_value(output["output"]) - event["data"]["result"] = result - event["data"]["status"] = _tool_status_from_result(result) - self._bump_event(event, timestamp=timestamp) - - def _append_event( - self, - agent_id: str, - event_type: str, - data: dict[str, Any], - *, - timestamp: str | None = None, - ) -> dict[str, Any]: - event = { - "id": f"{event_type}_{self._next_event_id}", - "type": event_type, - "agent_id": agent_id, - "timestamp": timestamp or datetime.now(UTC).isoformat(), - "version": 0, - "data": data, - } - self._next_event_id += 1 - self.events.append(event) - return event - - @staticmethod - def _bump_event(event: dict[str, Any], *, timestamp: str | None = None) -> None: - event["version"] = int(event.get("version", 0)) + 1 - event["timestamp"] = timestamp or datetime.now(UTC).isoformat() - - -def _sdk_tool_call_data(item: Any) -> dict[str, Any]: - raw = getattr(item, "raw_item", None) - call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item)) - tool_name = str( - _raw_field(raw, "name") or _raw_field(raw, "type") or getattr(item, "title", None) or "tool" - ) - return { - "call_id": call_id, - "tool_name": tool_name, - "args": _parse_json_object(_raw_field(raw, "arguments")), - } - - -def _sdk_tool_output_data(item: Any) -> dict[str, Any]: - raw = getattr(item, "raw_item", None) - call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item)) - return { - "call_id": call_id, - "tool_name": str(_raw_field(raw, "name") or _raw_field(raw, "type") or "tool"), - "output": getattr(item, "output", _raw_field(raw, "output")), - } - - -def _sdk_message_text(item: Any) -> str: - raw = getattr(item, "raw_item", None) - return _message_content_text(_raw_field(raw, "content", [])) - - -def _session_message_text(item: dict[str, Any]) -> str: - return _message_content_text(item.get("content", "")) - - -def _message_content_text(content: Any) -> str: - parts: list[str] = [] - content_items = content if isinstance(content, list) else [content] - for part in content_items: - if isinstance(part, str): - parts.append(part) - continue - text = _raw_field(part, "text") - if isinstance(text, str): - parts.append(text) - return "".join(parts) - - -def _raw_field(raw: Any, key: str, default: Any = None) -> Any: - if isinstance(raw, dict): - return raw.get(key, default) - return getattr(raw, key, default) - - -def _parse_json_object(value: Any) -> dict[str, Any]: - parsed = _parse_json_value(value) - return parsed if isinstance(parsed, dict) else {} - - -def _parse_json_value(value: Any) -> Any: - if not isinstance(value, str): - return value - try: - return json.loads(value) - except json.JSONDecodeError: - return value - - -def _tool_status_from_result(result: Any) -> str: - if isinstance(result, dict) and result.get("success") is False: - return "failed" - return "completed" - - -def _sqlite_timestamp_to_iso(value: Any) -> str: - if not isinstance(value, str) or not value.strip(): - return datetime.now(UTC).isoformat() - text = value.strip() - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return text - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC).isoformat() - - class QuitScreen(ModalScreen): # type: ignore[misc] def compose(self) -> ComposeResult: yield Grid( @@ -1068,7 +687,7 @@ class QuitScreen(ModalScreen): # type: ignore[misc] class StrixTUIApp(App): # type: ignore[misc] - CSS_PATH = "assets/tui_styles.tcss" + CSS_PATH = str(Path(__file__).resolve().parent.parent / "assets" / "tui_styles.tcss") ALLOW_SELECT = True SIDEBAR_MIN_WIDTH = 120 @@ -1088,17 +707,17 @@ class StrixTUIApp(App): # type: ignore[misc] self.args = args self.scan_config = self._build_scan_config(args) - self.scan_store = ScanStore(self.scan_config["run_name"]) + self.scan_store = ReportState(self.scan_config["run_name"]) self.scan_store.set_scan_config(self.scan_config) self.scan_store.hydrate_from_run_dir() - set_global_scan_store(self.scan_store) + set_global_report_state(self.scan_store) self.live_view = TuiLiveView() self.live_view.hydrate_from_run_dir(self.scan_store.get_run_dir()) self._agent_graph_sync_future: Any | None = None # Pre-create the coordinator here so the TUI can route stop/chat # commands while the scan loop runs in a worker thread. - from strix.orchestration.coordinator import AgentCoordinator + from strix.core.agents import AgentCoordinator self.coordinator = AgentCoordinator() @@ -1991,18 +1610,14 @@ class StrixTUIApp(App): # type: ignore[misc] len(message), ) target_agent_id = self.selected_agent_id - self.live_view.record_user_message(target_agent_id, message) - # Route to the agent's SDK session. The coordinator also interrupts - # any active stream so the message is picked up on the next run cycle. - if self._scan_loop is not None and not self._scan_loop.is_closed(): - asyncio.run_coroutine_threadsafe( - self.coordinator.send( - target_agent_id, - {"from": "user", "content": message, "type": "instruction"}, - ), - self._scan_loop, - ) + send_user_message_to_agent( + coordinator=self.coordinator, + loop=self._scan_loop, + live_view=self.live_view, + target_agent_id=target_agent_id, + message=message, + ) self._displayed_events.clear() self._update_chat_view() diff --git a/strix/interface/tui/history.py b/strix/interface/tui/history.py new file mode 100644 index 0000000..9410283 --- /dev/null +++ b/strix/interface/tui/history.py @@ -0,0 +1,58 @@ +"""Historical SDK session loading for the TUI.""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from pathlib import Path + + +logger = logging.getLogger(__name__) + + +def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[str, Any], str]]: + agents_db = run_dir / "agents.db" + session_ids = [aid for aid in agent_ids if isinstance(aid, str)] + if not agents_db.exists() or not session_ids: + return [] + session_id_set = set(session_ids) + try: + with sqlite3.connect(agents_db) as conn: + rows = conn.execute( + "select id, session_id, message_data, created_at from agent_messages order by id" + ).fetchall() + except sqlite3.Error: + logger.exception("Failed to hydrate TUI history from %s", agents_db) + return [] + + items: list[tuple[str, dict[str, Any], str]] = [] + for row_id, agent_id, message_data, created_at in rows: + if agent_id not in session_id_set: + continue + try: + item = json.loads(message_data) + except (TypeError, json.JSONDecodeError): + logger.debug("Skipping unreadable SDK session item %s for %s", row_id, agent_id) + continue + if isinstance(item, dict): + items.append((str(agent_id), item, _sqlite_timestamp_to_iso(created_at))) + return items + + +def _sqlite_timestamp_to_iso(value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + return datetime.now(UTC).isoformat() + text = value.strip() + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return text + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC).isoformat() diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py new file mode 100644 index 0000000..9d497ca --- /dev/null +++ b/strix/interface/tui/live_view.py @@ -0,0 +1,356 @@ +"""TUI-owned projection of SDK session history and stream events.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from pathlib import Path + +from strix.interface.tui.history import load_session_history + + +class TuiLiveView: + """UI projection of agent state plus SDK stream/session events.""" + + def __init__(self) -> None: + self.agents: dict[str, dict[str, Any]] = {} + self.events: list[dict[str, Any]] = [] + self._next_event_id = 1 + self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {} + self._tool_event_by_call_id: dict[str, dict[str, Any]] = {} + + def hydrate_from_run_dir(self, run_dir: Path) -> None: + agents_path = run_dir / "agents.json" + if not agents_path.exists(): + return + try: + agents_data = json.loads(agents_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + statuses = agents_data.get("statuses") or {} + names = agents_data.get("names") or {} + parent_of = agents_data.get("parent_of") or {} + if not isinstance(statuses, dict): + return + for agent_id, status in statuses.items(): + if not isinstance(agent_id, str): + continue + self.upsert_agent( + agent_id, + name=names.get(agent_id, agent_id) if isinstance(names, dict) else agent_id, + parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None, + status=str(status), + ) + self._hydrate_sdk_session_history(run_dir, statuses.keys()) + + def _hydrate_sdk_session_history(self, run_dir: Path, agent_ids: Any) -> None: + for agent_id, item, timestamp in load_session_history(run_dir, agent_ids): + self._ingest_session_history_item( + agent_id, + item, + timestamp=timestamp, + ) + + def upsert_agent( + self, + agent_id: str, + *, + name: str | None = None, + parent_id: str | None = None, + status: str | None = None, + error_message: str | None = None, + ) -> None: + now = datetime.now(UTC).isoformat() + current = self.agents.setdefault( + agent_id, + { + "id": agent_id, + "name": name or agent_id, + "parent_id": parent_id, + "status": status or "running", + "created_at": now, + "updated_at": now, + }, + ) + if name is not None: + current["name"] = name + if parent_id is not None or "parent_id" not in current: + current["parent_id"] = parent_id + if status is not None: + current["status"] = status + if error_message: + current["error_message"] = error_message + current["updated_at"] = now + + def record_user_message(self, agent_id: str, content: str) -> None: + self._append_event( + agent_id, + "chat", + { + "role": "user", + "content": content, + "metadata": {"source": "tui_user"}, + }, + ) + + def ingest_sdk_event(self, agent_id: str, event: Any) -> None: + event_type = getattr(event, "type", "") + if event_type == "raw_response_event": + self._ingest_raw_response_event(agent_id, getattr(event, "data", None)) + return + if event_type != "run_item_stream_event": + return + + item = getattr(event, "item", None) + item_type = getattr(item, "type", "") + if item_type == "message_output_item": + self._record_assistant_message(agent_id, _sdk_message_text(item), final=True) + elif item_type == "tool_call_item": + self._record_tool_call(agent_id, item) + elif item_type == "tool_call_output_item": + self._record_tool_output(agent_id, item) + + def events_for_agent(self, agent_id: str) -> list[dict[str, Any]]: + return [event for event in self.events if event.get("agent_id") == agent_id] + + def has_events_for_agent(self, agent_id: str) -> bool: + return any(event.get("agent_id") == agent_id for event in self.events) + + def _ingest_raw_response_event(self, agent_id: str, data: Any) -> None: + data_type = getattr(data, "type", "") + if data_type == "response.output_text.delta": + delta = getattr(data, "delta", "") + if delta: + self._record_assistant_message(agent_id, str(delta), final=False) + + def _ingest_session_history_item( + self, + agent_id: str, + item: dict[str, Any], + *, + timestamp: str, + ) -> None: + item_type = item.get("type") + role = item.get("role") + if role in {"user", "assistant"} and (item_type in {None, "message"}): + content = _session_message_text(item) + if content: + self._append_event( + agent_id, + "chat", + { + "role": role, + "content": content, + "metadata": {"source": "sdk_session"}, + }, + timestamp=timestamp, + ) + return + + if item_type == "function_call": + self._record_tool_call_data( + agent_id, + { + "call_id": str(item.get("call_id") or item.get("id") or ""), + "tool_name": str(item.get("name") or "tool"), + "args": _parse_json_object(item.get("arguments")), + }, + timestamp=timestamp, + ) + return + + if item_type == "function_call_output": + self._record_tool_output_data( + agent_id, + { + "call_id": str(item.get("call_id") or item.get("id") or ""), + "tool_name": "tool", + "output": item.get("output"), + }, + timestamp=timestamp, + ) + + def _record_assistant_message(self, agent_id: str, content: str, *, final: bool) -> None: + if not content: + return + existing = self._open_assistant_event_by_agent.get(agent_id) + if existing is None: + event = self._append_event( + agent_id, + "chat", + { + "role": "assistant", + "content": content, + "metadata": {"source": "sdk_stream", "streaming": not final}, + }, + ) + if not final: + self._open_assistant_event_by_agent[agent_id] = event + return + + data = existing["data"] + if final: + data["content"] = content + data["metadata"]["streaming"] = False + self._open_assistant_event_by_agent.pop(agent_id, None) + else: + data["content"] = f"{data.get('content', '')}{content}" + self._bump_event(existing) + + def _record_tool_call(self, agent_id: str, item: Any) -> None: + self._record_tool_call_data(agent_id, _sdk_tool_call_data(item)) + + def _record_tool_call_data( + self, + agent_id: str, + call: dict[str, Any], + *, + timestamp: str | None = None, + ) -> None: + call_id = call["call_id"] + existing = self._tool_event_by_call_id.get(call_id) + tool_data = { + "tool_name": call["tool_name"], + "args": call["args"], + "status": "running", + "agent_id": agent_id, + "call_id": call_id, + } + if existing is None: + event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp) + self._tool_event_by_call_id[call_id] = event + else: + existing["data"].update(tool_data) + self._bump_event(existing, timestamp=timestamp) + + def _record_tool_output(self, agent_id: str, item: Any) -> None: + self._record_tool_output_data(agent_id, _sdk_tool_output_data(item)) + + def _record_tool_output_data( + self, + agent_id: str, + output: dict[str, Any], + *, + timestamp: str | None = None, + ) -> None: + call_id = output["call_id"] + event = self._tool_event_by_call_id.get(call_id) + if event is None: + event = self._append_event( + agent_id, + "tool", + { + "tool_name": output["tool_name"], + "args": {}, + "status": "completed", + "agent_id": agent_id, + "call_id": call_id, + }, + timestamp=timestamp, + ) + self._tool_event_by_call_id[call_id] = event + + result = _parse_json_value(output["output"]) + event["data"]["result"] = result + event["data"]["status"] = _tool_status_from_result(result) + self._bump_event(event, timestamp=timestamp) + + def _append_event( + self, + agent_id: str, + event_type: str, + data: dict[str, Any], + *, + timestamp: str | None = None, + ) -> dict[str, Any]: + event = { + "id": f"{event_type}_{self._next_event_id}", + "type": event_type, + "agent_id": agent_id, + "timestamp": timestamp or datetime.now(UTC).isoformat(), + "version": 0, + "data": data, + } + self._next_event_id += 1 + self.events.append(event) + return event + + @staticmethod + def _bump_event(event: dict[str, Any], *, timestamp: str | None = None) -> None: + event["version"] = int(event.get("version", 0)) + 1 + event["timestamp"] = timestamp or datetime.now(UTC).isoformat() + + +def _sdk_tool_call_data(item: Any) -> dict[str, Any]: + raw = getattr(item, "raw_item", None) + call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item)) + tool_name = str( + _raw_field(raw, "name") or _raw_field(raw, "type") or getattr(item, "title", None) or "tool" + ) + return { + "call_id": call_id, + "tool_name": tool_name, + "args": _parse_json_object(_raw_field(raw, "arguments")), + } + + +def _sdk_tool_output_data(item: Any) -> dict[str, Any]: + raw = getattr(item, "raw_item", None) + call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item)) + return { + "call_id": call_id, + "tool_name": str(_raw_field(raw, "name") or _raw_field(raw, "type") or "tool"), + "output": getattr(item, "output", _raw_field(raw, "output")), + } + + +def _sdk_message_text(item: Any) -> str: + raw = getattr(item, "raw_item", None) + return _message_content_text(_raw_field(raw, "content", [])) + + +def _session_message_text(item: dict[str, Any]) -> str: + return _message_content_text(item.get("content", "")) + + +def _message_content_text(content: Any) -> str: + parts: list[str] = [] + content_items = content if isinstance(content, list) else [content] + for part in content_items: + if isinstance(part, str): + parts.append(part) + continue + text = _raw_field(part, "text") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + + +def _raw_field(raw: Any, key: str, default: Any = None) -> Any: + if isinstance(raw, dict): + return raw.get(key, default) + return getattr(raw, key, default) + + +def _parse_json_object(value: Any) -> dict[str, Any]: + parsed = _parse_json_value(value) + return parsed if isinstance(parsed, dict) else {} + + +def _parse_json_value(value: Any) -> Any: + if not isinstance(value, str): + return value + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _tool_status_from_result(result: Any) -> str: + if isinstance(result, dict) and result.get("success") is False: + return "failed" + return "completed" diff --git a/strix/interface/tui/messages.py b/strix/interface/tui/messages.py new file mode 100644 index 0000000..5e362a6 --- /dev/null +++ b/strix/interface/tui/messages.py @@ -0,0 +1,30 @@ +"""Message delivery bridge from TUI input to SDK-backed agents.""" + +from __future__ import annotations + +import asyncio +from typing import Any + + +def send_user_message_to_agent( + *, + coordinator: Any, + loop: asyncio.AbstractEventLoop | None, + live_view: Any, + target_agent_id: str, + message: str, +) -> bool: + """Record a local user message and enqueue it into the target SDK session.""" + live_view.record_user_message(target_agent_id, message) + + if loop is None or loop.is_closed(): + return False + + asyncio.run_coroutine_threadsafe( + coordinator.send( + target_agent_id, + {"from": "user", "content": message, "type": "instruction"}, + ), + loop, + ) + return True diff --git a/strix/interface/tool_components/__init__.py b/strix/interface/tui/renderers/__init__.py similarity index 100% rename from strix/interface/tool_components/__init__.py rename to strix/interface/tui/renderers/__init__.py diff --git a/strix/interface/tool_components/agent_message_renderer.py b/strix/interface/tui/renderers/agent_message_renderer.py similarity index 100% rename from strix/interface/tool_components/agent_message_renderer.py rename to strix/interface/tui/renderers/agent_message_renderer.py diff --git a/strix/interface/tool_components/agents_graph_renderer.py b/strix/interface/tui/renderers/agents_graph_renderer.py similarity index 100% rename from strix/interface/tool_components/agents_graph_renderer.py rename to strix/interface/tui/renderers/agents_graph_renderer.py diff --git a/strix/interface/tool_components/base_renderer.py b/strix/interface/tui/renderers/base_renderer.py similarity index 100% rename from strix/interface/tool_components/base_renderer.py rename to strix/interface/tui/renderers/base_renderer.py diff --git a/strix/interface/tool_components/finish_renderer.py b/strix/interface/tui/renderers/finish_renderer.py similarity index 100% rename from strix/interface/tool_components/finish_renderer.py rename to strix/interface/tui/renderers/finish_renderer.py diff --git a/strix/interface/tool_components/notes_renderer.py b/strix/interface/tui/renderers/notes_renderer.py similarity index 100% rename from strix/interface/tool_components/notes_renderer.py rename to strix/interface/tui/renderers/notes_renderer.py diff --git a/strix/interface/tool_components/proxy_renderer.py b/strix/interface/tui/renderers/proxy_renderer.py similarity index 100% rename from strix/interface/tool_components/proxy_renderer.py rename to strix/interface/tui/renderers/proxy_renderer.py diff --git a/strix/interface/tool_components/registry.py b/strix/interface/tui/renderers/registry.py similarity index 100% rename from strix/interface/tool_components/registry.py rename to strix/interface/tui/renderers/registry.py diff --git a/strix/interface/tool_components/reporting_renderer.py b/strix/interface/tui/renderers/reporting_renderer.py similarity index 100% rename from strix/interface/tool_components/reporting_renderer.py rename to strix/interface/tui/renderers/reporting_renderer.py diff --git a/strix/interface/tool_components/thinking_renderer.py b/strix/interface/tui/renderers/thinking_renderer.py similarity index 100% rename from strix/interface/tool_components/thinking_renderer.py rename to strix/interface/tui/renderers/thinking_renderer.py diff --git a/strix/interface/tool_components/todo_renderer.py b/strix/interface/tui/renderers/todo_renderer.py similarity index 100% rename from strix/interface/tool_components/todo_renderer.py rename to strix/interface/tui/renderers/todo_renderer.py diff --git a/strix/interface/tool_components/user_message_renderer.py b/strix/interface/tui/renderers/user_message_renderer.py similarity index 100% rename from strix/interface/tool_components/user_message_renderer.py rename to strix/interface/tui/renderers/user_message_renderer.py diff --git a/strix/interface/tool_components/web_search_renderer.py b/strix/interface/tui/renderers/web_search_renderer.py similarity index 100% rename from strix/interface/tool_components/web_search_renderer.py rename to strix/interface/tui/renderers/web_search_renderer.py diff --git a/strix/orchestration/__init__.py b/strix/orchestration/__init__.py deleted file mode 100644 index 7dab3be..0000000 --- a/strix/orchestration/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Strix multi-agent orchestration on top of OpenAI Agents SDK. - -- :class:`AgentCoordinator` owns Strix-specific graph/status/wake state. -- SDK ``SQLiteSession`` owns per-agent conversation history and message - transport. -- ``runner.py`` owns SDK ``Runner.run_streamed`` and child-agent spawning. - -Import deeply (for example, ``from strix.orchestration.coordinator -import AgentCoordinator``) so ``import strix.orchestration`` doesn't -drag every submodule's deps in eagerly. -""" diff --git a/strix/report/__init__.py b/strix/report/__init__.py index e9a05e1..c8c80ee 100644 --- a/strix/report/__init__.py +++ b/strix/report/__init__.py @@ -1,6 +1,12 @@ """Report/finding helpers.""" from strix.report.dedupe import check_duplicate +from strix.report.state import ReportState, get_global_report_state, set_global_report_state -__all__ = ["check_duplicate"] +__all__ = [ + "ReportState", + "check_duplicate", + "get_global_report_state", + "set_global_report_state", +] diff --git a/strix/telemetry/scan_store.py b/strix/report/state.py similarity index 60% rename from strix/telemetry/scan_store.py rename to strix/report/state.py index 6f27c2a..af0be95 100644 --- a/strix/telemetry/scan_store.py +++ b/strix/report/state.py @@ -1,32 +1,30 @@ -import csv import json import logging -import tempfile from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Any, Optional from uuid import uuid4 +from strix.report.writer import write_executive_report, write_run_metadata, write_vulnerabilities from strix.telemetry import posthog logger = logging.getLogger(__name__) -_global_scan_store: Optional["ScanStore"] = None -_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} +_global_report_state: Optional["ReportState"] = None -def get_global_scan_store() -> Optional["ScanStore"]: - return _global_scan_store +def get_global_report_state() -> Optional["ReportState"]: + return _global_report_state -def set_global_scan_store(scan_store: "ScanStore") -> None: - global _global_scan_store # noqa: PLW0603 - _global_scan_store = scan_store +def set_global_report_state(report_state: "ReportState") -> None: + global _global_report_state # noqa: PLW0603 + _global_report_state = report_state -class ScanStore: +class ReportState: """Per-scan product artifact state plus artifact writer. The Agents SDK owns model/tool execution, tracing, and conversation @@ -280,168 +278,13 @@ class ScanStore: run_dir.mkdir(parents=True, exist_ok=True) if self.final_scan_result: - self._write_executive_report(run_dir) + write_executive_report(run_dir, self.final_scan_result) if self.vulnerability_reports: - self._write_vulnerabilities(run_dir) + write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids) - _atomic_write_text( - run_dir / "run_metadata.json", - json.dumps(self.run_metadata, ensure_ascii=False, indent=2, default=str), - ) + write_run_metadata(run_dir, self.run_metadata) logger.info("Essential scan data saved to: %s", run_dir) except (OSError, RuntimeError): logger.exception("Failed to save scan data") - - def _write_executive_report(self, run_dir: Path) -> None: - path = run_dir / "penetration_test_report.md" - with path.open("w", encoding="utf-8") as f: - f.write("# Security Penetration Test Report\n\n") - f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n") - f.write(f"{self.final_scan_result}\n") - logger.info("Saved final penetration test report to: %s", path) - - def _write_vulnerabilities(self, run_dir: Path) -> None: - vuln_dir = run_dir / "vulnerabilities" - vuln_dir.mkdir(exist_ok=True) - - new_reports = [r for r in self.vulnerability_reports if r["id"] not in self._saved_vuln_ids] - - for report in new_reports: - (vuln_dir / f"{report['id']}.md").write_text( - _render_vulnerability_md(report), - encoding="utf-8", - ) - self._saved_vuln_ids.add(report["id"]) - - sorted_reports = sorted( - self.vulnerability_reports, - key=lambda r: (_SEVERITY_ORDER.get(r["severity"], 5), r["timestamp"]), - ) - csv_path = run_dir / "vulnerabilities.csv" - with csv_path.open("w", encoding="utf-8", newline="") as f: - fieldnames = ["id", "title", "severity", "timestamp", "file"] - writer = csv.DictWriter(f, fieldnames=fieldnames) - writer.writeheader() - for report in sorted_reports: - writer.writerow( - { - "id": report["id"], - "title": report["title"], - "severity": report["severity"].upper(), - "timestamp": report["timestamp"], - "file": f"vulnerabilities/{report['id']}.md", - }, - ) - - _atomic_write_text( - run_dir / "vulnerabilities.json", - json.dumps(self.vulnerability_reports, ensure_ascii=False, indent=2, default=str), - ) - - if new_reports: - logger.info( - "Saved %d new vulnerability report(s) to: %s", - len(new_reports), - vuln_dir, - ) - logger.info("Updated vulnerability index: %s", csv_path) - - -def _atomic_write_text(path: Path, payload: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=str(path.parent), - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as tmp: - tmp.write(payload) - tmp_path = Path(tmp.name) - tmp_path.replace(path) - - -def _render_vulnerability_md(report: dict[str, Any]) -> str: - lines: list[str] = [ - f"# {report.get('title', 'Untitled Vulnerability')}\n", - f"**ID:** {report.get('id', 'unknown')}", - f"**Severity:** {report.get('severity', 'unknown').upper()}", - f"**Found:** {report.get('timestamp', 'unknown')}", - ] - - metadata: list[tuple[str, Any]] = [ - ("Target", report.get("target")), - ("Endpoint", report.get("endpoint")), - ("Method", report.get("method")), - ("CVE", report.get("cve")), - ("CWE", report.get("cwe")), - ] - cvss = report.get("cvss") - if cvss is not None: - metadata.append(("CVSS", cvss)) - for label, value in metadata: - if value: - lines.append(f"**{label}:** {value}") - - lines.append("") - lines.append("## Description\n") - lines.append(report.get("description") or "No description provided.") - lines.append("") - - if report.get("impact"): - lines.append("## Impact\n") - lines.append(str(report["impact"])) - lines.append("") - - if report.get("technical_analysis"): - lines.append("## Technical Analysis\n") - lines.append(str(report["technical_analysis"])) - lines.append("") - - if report.get("poc_description") or report.get("poc_script_code"): - lines.append("## Proof of Concept\n") - if report.get("poc_description"): - lines.append(str(report["poc_description"])) - lines.append("") - if report.get("poc_script_code"): - lines.append("```") - lines.append(str(report["poc_script_code"])) - lines.append("```") - lines.append("") - - if report.get("code_locations"): - lines.append("## Code Analysis\n") - for i, loc in enumerate(report["code_locations"]): - file_ref = loc.get("file", "unknown") - line_ref = "" - if loc.get("start_line") is not None: - if loc.get("end_line") and loc["end_line"] != loc["start_line"]: - line_ref = f" (lines {loc['start_line']}-{loc['end_line']})" - else: - line_ref = f" (line {loc['start_line']})" - lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}") - if loc.get("label"): - lines.append(f" {loc['label']}") - if loc.get("snippet"): - lines.append(f" ```\n {loc['snippet']}\n ```") - if loc.get("fix_before") or loc.get("fix_after"): - lines.append("\n **Suggested Fix:**") - lines.append("```diff") - if loc.get("fix_before"): - for ln in str(loc["fix_before"]).splitlines(): - lines.append(f"- {ln}") - if loc.get("fix_after"): - for ln in str(loc["fix_after"]).splitlines(): - lines.append(f"+ {ln}") - lines.append("```") - lines.append("") - - if report.get("remediation_steps"): - lines.append("## Remediation\n") - lines.append(str(report["remediation_steps"])) - lines.append("") - - return "\n".join(lines) diff --git a/strix/report/writer.py b/strix/report/writer.py new file mode 100644 index 0000000..8a08b3a --- /dev/null +++ b/strix/report/writer.py @@ -0,0 +1,180 @@ +"""Artifact writers for Strix scan reports.""" + +from __future__ import annotations + +import csv +import json +import logging +import tempfile +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +logger = logging.getLogger(__name__) + +_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + + +def write_run_metadata(run_dir: Path, run_metadata: dict[str, Any]) -> None: + _atomic_write_text( + run_dir / "run_metadata.json", + json.dumps(run_metadata, ensure_ascii=False, indent=2, default=str), + ) + + +def write_executive_report(run_dir: Path, final_scan_result: str) -> None: + path = run_dir / "penetration_test_report.md" + with path.open("w", encoding="utf-8") as f: + f.write("# Security Penetration Test Report\n\n") + f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n") + f.write(f"{final_scan_result}\n") + logger.info("Saved final penetration test report to: %s", path) + + +def write_vulnerabilities( + run_dir: Path, + vulnerability_reports: list[dict[str, Any]], + saved_vuln_ids: set[str], +) -> int: + vuln_dir = run_dir / "vulnerabilities" + vuln_dir.mkdir(exist_ok=True) + + new_reports = [r for r in vulnerability_reports if r["id"] not in saved_vuln_ids] + + for report in new_reports: + (vuln_dir / f"{report['id']}.md").write_text( + render_vulnerability_md(report), + encoding="utf-8", + ) + saved_vuln_ids.add(report["id"]) + + sorted_reports = sorted( + vulnerability_reports, + key=lambda r: (_SEVERITY_ORDER.get(r["severity"], 5), r["timestamp"]), + ) + csv_path = run_dir / "vulnerabilities.csv" + with csv_path.open("w", encoding="utf-8", newline="") as f: + fieldnames = ["id", "title", "severity", "timestamp", "file"] + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for report in sorted_reports: + writer.writerow( + { + "id": report["id"], + "title": report["title"], + "severity": report["severity"].upper(), + "timestamp": report["timestamp"], + "file": f"vulnerabilities/{report['id']}.md", + }, + ) + + _atomic_write_text( + run_dir / "vulnerabilities.json", + json.dumps(vulnerability_reports, ensure_ascii=False, indent=2, default=str), + ) + + if new_reports: + logger.info( + "Saved %d new vulnerability report(s) to: %s", + len(new_reports), + vuln_dir, + ) + logger.info("Updated vulnerability index: %s", csv_path) + return len(new_reports) + + +def _atomic_write_text(path: Path, payload: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=str(path.parent), + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp.write(payload) + tmp_path = Path(tmp.name) + tmp_path.replace(path) + + +def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PLR0915 + lines: list[str] = [ + f"# {report.get('title', 'Untitled Vulnerability')}\n", + f"**ID:** {report.get('id', 'unknown')}", + f"**Severity:** {report.get('severity', 'unknown').upper()}", + f"**Found:** {report.get('timestamp', 'unknown')}", + ] + + metadata: list[tuple[str, Any]] = [ + ("Target", report.get("target")), + ("Endpoint", report.get("endpoint")), + ("Method", report.get("method")), + ("CVE", report.get("cve")), + ("CWE", report.get("cwe")), + ] + cvss = report.get("cvss") + if cvss is not None: + metadata.append(("CVSS", cvss)) + for label, value in metadata: + if value: + lines.append(f"**{label}:** {value}") + + lines.append("") + lines.append("## Description\n") + lines.append(report.get("description") or "No description provided.") + lines.append("") + + if report.get("impact"): + lines.append("## Impact\n") + lines.append(str(report["impact"])) + lines.append("") + + if report.get("technical_analysis"): + lines.append("## Technical Analysis\n") + lines.append(str(report["technical_analysis"])) + lines.append("") + + if report.get("poc_description") or report.get("poc_script_code"): + lines.append("## Proof of Concept\n") + if report.get("poc_description"): + lines.append(str(report["poc_description"])) + lines.append("") + if report.get("poc_script_code"): + lines.append("```") + lines.append(str(report["poc_script_code"])) + lines.append("```") + lines.append("") + + if report.get("code_locations"): + lines.append("## Code Analysis\n") + for i, loc in enumerate(report["code_locations"]): + file_ref = loc.get("file", "unknown") + line_ref = "" + if loc.get("start_line") is not None: + if loc.get("end_line") and loc["end_line"] != loc["start_line"]: + line_ref = f" (lines {loc['start_line']}-{loc['end_line']})" + else: + line_ref = f" (line {loc['start_line']})" + lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}") + if loc.get("label"): + lines.append(f" {loc['label']}") + if loc.get("snippet"): + lines.append(f" ```\n {loc['snippet']}\n ```") + if loc.get("fix_before") or loc.get("fix_after"): + lines.append("\n **Suggested Fix:**") + lines.append("```diff") + if loc.get("fix_before"): + lines.extend(f"- {ln}" for ln in str(loc["fix_before"]).splitlines()) + if loc.get("fix_after"): + lines.extend(f"+ {ln}" for ln in str(loc["fix_after"]).splitlines()) + lines.append("```") + lines.append("") + + if report.get("remediation_steps"): + lines.append("## Remediation\n") + lines.append(str(report["remediation_steps"])) + lines.append("") + + return "\n".join(lines) diff --git a/strix/telemetry/__init__.py b/strix/telemetry/__init__.py index a82122b..8421eb7 100644 --- a/strix/telemetry/__init__.py +++ b/strix/telemetry/__init__.py @@ -1,14 +1,6 @@ from . import posthog -from .scan_store import ( - ScanStore, - get_global_scan_store, - set_global_scan_store, -) __all__ = [ - "ScanStore", - "get_global_scan_store", "posthog", - "set_global_scan_store", ] diff --git a/strix/telemetry/posthog.py b/strix/telemetry/posthog.py index 4561d47..5135502 100644 --- a/strix/telemetry/posthog.py +++ b/strix/telemetry/posthog.py @@ -11,7 +11,7 @@ from strix.config import load_settings if TYPE_CHECKING: - from strix.telemetry.scan_store import ScanStore + from strix.report.state import ReportState logger = logging.getLogger(__name__) @@ -114,7 +114,7 @@ def finding(severity: str) -> None: ) -def end(scan_store: "ScanStore", exit_reason: str = "completed") -> None: +def end(scan_store: "ReportState", exit_reason: str = "completed") -> None: vulnerabilities_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} for v in scan_store.vulnerability_reports: sev = v.get("severity", "info").lower() diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 50684b1..3b8c5e5 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -20,7 +20,7 @@ from typing import Any, Literal from agents import RunContextWrapper, function_tool -from strix.orchestration.coordinator import coordinator_from_context +from strix.core.agents import coordinator_from_context logger = logging.getLogger(__name__) diff --git a/strix/tools/finish/tool.py b/strix/tools/finish/tool.py index dc6d456..c70c4f1 100644 --- a/strix/tools/finish/tool.py +++ b/strix/tools/finish/tool.py @@ -9,7 +9,7 @@ from typing import Any from agents import RunContextWrapper, function_tool -from strix.orchestration.coordinator import coordinator_from_context +from strix.core.agents import coordinator_from_context logger = logging.getLogger(__name__) @@ -44,9 +44,9 @@ def _do_finish( return {"success": False, "message": "Validation failed", "errors": errors} try: - from strix.telemetry.scan_store import get_global_scan_store + from strix.report.state import get_global_report_state - scan_store = get_global_scan_store() + scan_store = get_global_report_state() if scan_store is None: logger.warning("No global scan store; scan results not persisted") return { diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index e75ad4a..01deddf 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -214,9 +214,9 @@ async def _do_create( # noqa: PLR0912 cvss_score, severity, _vector = _calculate_cvss(cvss_breakdown) try: - from strix.telemetry.scan_store import get_global_scan_store + from strix.report.state import get_global_report_state - scan_store = get_global_scan_store() + scan_store = get_global_report_state() if scan_store is None: logger.warning("No global scan store; vulnerability report not persisted") return {