refactor: reorganize core report and tui modules

This commit is contained in:
0xallam
2026-04-26 14:28:50 -07:00
parent c163ef882b
commit 629ea60b02
37 changed files with 1185 additions and 1061 deletions
+6 -8
View File
@@ -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
+25 -6
View File
@@ -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',
+1
View File
@@ -0,0 +1 @@
"""Strix scan runtime core."""
@@ -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:
@@ -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")
@@ -1,4 +1,4 @@
"""Pure helper builders for Strix scan orchestration."""
"""Pure input builders for Strix scan runs."""
from __future__ import annotations
+303
View File
@@ -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()
+10
View File
@@ -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)
+4 -4
View File
@@ -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()
+5 -5
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
"""Textual TUI interface."""
from strix.interface.tui.app import StrixTUIApp, run_tui
__all__ = ["StrixTUIApp", "run_tui"]
@@ -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()
+58
View File
@@ -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()
+356
View File
@@ -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"
+30
View File
@@ -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
-11
View File
@@ -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.
"""
+7 -1
View File
@@ -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",
]
@@ -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)
+180
View File
@@ -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)
-8
View File
@@ -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",
]
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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__)
+3 -3
View File
@@ -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 {
+2 -2
View File
@@ -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 {