refactor: reorganize core report and tui modules
This commit is contained in:
+6
-8
@@ -217,22 +217,20 @@ ignore = [
|
|||||||
"strix/agents/factory.py" = ["TC002"]
|
"strix/agents/factory.py" = ["TC002"]
|
||||||
# Entry point: ``Path`` is used at runtime by the typing of the
|
# Entry point: ``Path`` is used at runtime by the typing of the
|
||||||
# session_manager call; importing under TYPE_CHECKING would defer
|
# session_manager call; importing under TYPE_CHECKING would defer
|
||||||
# resolution past where mypy needs it. ``build_root_task`` legitimately
|
# resolution past where mypy needs it.
|
||||||
# walks every supported target type — splitting it into per-type
|
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
||||||
# helpers would add indirection without simplifying anything.
|
# ReportState carries scan artifact/report fields and
|
||||||
"strix/orchestration/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
|
||||||
# ScanStore carries scan artifact/report fields, artifact rendering, and
|
|
||||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
# 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;
|
# Interface utility branches per scope-mode / target-type combination;
|
||||||
# splitting would obscure the decision tree without simplifying it.
|
# splitting would obscure the decision tree without simplifying it.
|
||||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||||
# CLI / TUI / main keep extensive lazy imports + broad exception
|
# CLI / TUI / main keep extensive lazy imports + broad exception
|
||||||
# swallows for resilience around terminal-rendering errors.
|
# swallows for resilience around terminal-rendering errors.
|
||||||
"strix/interface/cli.py" = ["BLE001", "PLC0415"]
|
"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/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]
|
[tool.ruff.lint.isort]
|
||||||
force-single-line = false
|
force-single-line = false
|
||||||
|
|||||||
+25
-6
@@ -116,18 +116,38 @@ hiddenimports = [
|
|||||||
'strix.interface.main',
|
'strix.interface.main',
|
||||||
'strix.interface.cli',
|
'strix.interface.cli',
|
||||||
'strix.interface.tui',
|
'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.utils',
|
||||||
'strix.interface.tool_components',
|
|
||||||
'strix.agents',
|
'strix.agents',
|
||||||
'strix.agents.factory',
|
'strix.agents.factory',
|
||||||
'strix.agents.prompt',
|
'strix.agents.prompt',
|
||||||
'strix.config.models',
|
'strix.config.models',
|
||||||
'strix.orchestration',
|
'strix.core',
|
||||||
'strix.orchestration.coordinator',
|
'strix.core.agents',
|
||||||
'strix.orchestration.runner',
|
'strix.core.execution',
|
||||||
'strix.orchestration.utils',
|
'strix.core.inputs',
|
||||||
|
'strix.core.runner',
|
||||||
|
'strix.core.sessions',
|
||||||
'strix.report',
|
'strix.report',
|
||||||
'strix.report.dedupe',
|
'strix.report.dedupe',
|
||||||
|
'strix.report.state',
|
||||||
|
'strix.report.writer',
|
||||||
'strix.runtime',
|
'strix.runtime',
|
||||||
'strix.runtime.backends',
|
'strix.runtime.backends',
|
||||||
'strix.runtime.caido_bootstrap',
|
'strix.runtime.caido_bootstrap',
|
||||||
@@ -136,7 +156,6 @@ hiddenimports = [
|
|||||||
'strix.telemetry',
|
'strix.telemetry',
|
||||||
'strix.telemetry.logging',
|
'strix.telemetry.logging',
|
||||||
'strix.telemetry.posthog',
|
'strix.telemetry.posthog',
|
||||||
'strix.telemetry.scan_store',
|
|
||||||
'strix.tools',
|
'strix.tools',
|
||||||
'strix.tools.agents_graph.tools',
|
'strix.tools.agents_graph.tools',
|
||||||
'strix.tools.finish.tool',
|
'strix.tools.finish.tool',
|
||||||
|
|||||||
@@ -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
|
The Agents SDK owns model/tool execution and per-agent conversation
|
||||||
history. Strix owns only product semantics the SDK does not provide:
|
history. Strix owns only product semantics the SDK does not provide:
|
||||||
@@ -1,59 +1,38 @@
|
|||||||
"""Top-level Strix scan runner.
|
"""Execution loop for addressable SDK-backed Strix agents."""
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from agents import RunConfig, Runner
|
from agents import RunConfig, Runner
|
||||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
||||||
from agents.memory import SQLiteSession
|
|
||||||
from agents.sandbox import SandboxRunConfig
|
|
||||||
from openai import APIError
|
from openai import APIError
|
||||||
|
|
||||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
from strix.core.inputs import child_initial_input
|
||||||
from strix.config import load_settings
|
from strix.core.sessions import open_agent_session
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from agents.items import TResponseInputItem
|
from agents.items import TResponseInputItem
|
||||||
from agents.memory import Session
|
from agents.memory import Session, SQLiteSession
|
||||||
from agents.result import RunResultBase
|
from agents.result import RunResultBase
|
||||||
|
|
||||||
|
from strix.core.agents import AgentCoordinator, Status
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
StreamEventSink = Callable[[str, Any], None]
|
StreamEventSink = Callable[[str, Any], None]
|
||||||
|
|
||||||
|
|
||||||
def _open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
|
async def run_agent_loop(
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return SQLiteSession(session_id=agent_id, db_path=path)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_agent_loop(
|
|
||||||
*,
|
*,
|
||||||
agent: Any,
|
agent: Any,
|
||||||
initial_input: 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(
|
async def _run_noninteractive_until_lifecycle(
|
||||||
agent: Any,
|
agent: Any,
|
||||||
coordinator: AgentCoordinator,
|
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(
|
async def _start_child_runner(
|
||||||
*,
|
*,
|
||||||
parent_ctx: dict[str, Any],
|
parent_ctx: dict[str, Any],
|
||||||
@@ -403,7 +464,7 @@ async def _start_child_runner(
|
|||||||
start_parked: bool = False,
|
start_parked: bool = False,
|
||||||
event_sink: StreamEventSink | None = None,
|
event_sink: StreamEventSink | None = 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)
|
sessions_to_close.append(session)
|
||||||
await coordinator.attach_runtime(child_id, session=session)
|
await coordinator.attach_runtime(child_id, session=session)
|
||||||
|
|
||||||
@@ -413,7 +474,7 @@ async def _start_child_runner(
|
|||||||
child_ctx["task"] = task
|
child_ctx["task"] = task
|
||||||
|
|
||||||
task_handle = asyncio.create_task(
|
task_handle = asyncio.create_task(
|
||||||
_run_agent_loop(
|
run_agent_loop(
|
||||||
agent=child_agent,
|
agent=child_agent,
|
||||||
initial_input=initial_input,
|
initial_input=initial_input,
|
||||||
run_config=run_config,
|
run_config=run_config,
|
||||||
@@ -429,346 +490,3 @@ async def _start_child_runner(
|
|||||||
name=f"agent-{name}-{child_id}",
|
name=f"agent-{name}-{child_id}",
|
||||||
)
|
)
|
||||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -13,9 +13,9 @@ from rich.panel import Panel
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from strix.config import load_settings
|
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.runtime import session_manager
|
||||||
from strix.telemetry.scan_store import ScanStore, set_global_scan_store
|
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
build_live_stats_text,
|
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 "",
|
"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.set_scan_config(scan_config)
|
||||||
scan_store.hydrate_from_run_dir()
|
scan_store.hydrate_from_run_dir()
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
if hasattr(signal, "SIGHUP"):
|
if hasattr(signal, "SIGHUP"):
|
||||||
signal.signal(signal.SIGHUP, signal_handler)
|
signal.signal(signal.SIGHUP, signal_handler)
|
||||||
|
|
||||||
set_global_scan_store(scan_store)
|
set_global_report_state(scan_store)
|
||||||
|
|
||||||
def create_live_status() -> Panel:
|
def create_live_status() -> Panel:
|
||||||
status_text = Text()
|
status_text = Text()
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ from strix.interface.utils import (
|
|||||||
rewrite_localhost_targets,
|
rewrite_localhost_targets,
|
||||||
validate_config_file,
|
validate_config_file,
|
||||||
)
|
)
|
||||||
|
from strix.report.state import get_global_report_state
|
||||||
from strix.telemetry import posthog
|
from strix.telemetry import posthog
|
||||||
from strix.telemetry.logging import configure_dependency_logging
|
from strix.telemetry.logging import configure_dependency_logging
|
||||||
from strix.telemetry.scan_store import get_global_scan_store
|
|
||||||
|
|
||||||
|
|
||||||
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
|
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
|
# 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
|
# known — that's where ``strix.*`` levels and handlers are owned. Pre-scan
|
||||||
# work (``main()``, env validation, image pull) emits via the module
|
# work (``main()``, env validation, image pull) emits via the module
|
||||||
# logger; once setup_scan_logging runs, those records start landing in
|
# 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:
|
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||||
console = Console()
|
console = Console()
|
||||||
scan_store = get_global_scan_store()
|
scan_store = get_global_report_state()
|
||||||
|
|
||||||
scan_completed = False
|
scan_completed = False
|
||||||
if scan_store and scan_store.scan_results:
|
if scan_store and scan_store.scan_results:
|
||||||
@@ -765,7 +765,7 @@ def main() -> None:
|
|||||||
posthog.error("unhandled_exception", str(e))
|
posthog.error("unhandled_exception", str(e))
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
scan_store = get_global_scan_store()
|
scan_store = get_global_report_state()
|
||||||
if scan_store:
|
if scan_store:
|
||||||
posthog.end(scan_store, exit_reason=exit_reason)
|
posthog.end(scan_store, exit_reason=exit_reason)
|
||||||
|
|
||||||
@@ -773,7 +773,7 @@ def main() -> None:
|
|||||||
display_completion_message(args, results_path)
|
display_completion_message(args, results_path)
|
||||||
|
|
||||||
if args.non_interactive:
|
if args.non_interactive:
|
||||||
scan_store = get_global_scan_store()
|
scan_store = get_global_report_state()
|
||||||
if scan_store and scan_store.vulnerability_reports:
|
if scan_store and scan_store.vulnerability_reports:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
|
|||||||
@@ -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 asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
|
||||||
from importlib.metadata import PackageNotFoundError
|
from importlib.metadata import PackageNotFoundError
|
||||||
from importlib.metadata import version as pkg_version
|
from importlib.metadata import version as pkg_version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -34,13 +31,15 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
|
|||||||
from textual.widgets.tree import TreeNode
|
from textual.widgets.tree import TreeNode
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.interface.tool_components import render_tool_widget
|
from strix.core.runner import run_strix_scan
|
||||||
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
|
from strix.interface.tui.live_view import TuiLiveView
|
||||||
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
|
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.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.runtime import session_manager
|
||||||
from strix.telemetry.scan_store import ScanStore, set_global_scan_store
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -643,386 +642,6 @@ class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc]
|
|||||||
self.mount(item)
|
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]
|
class QuitScreen(ModalScreen): # type: ignore[misc]
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Grid(
|
yield Grid(
|
||||||
@@ -1068,7 +687,7 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
|
|||||||
|
|
||||||
|
|
||||||
class StrixTUIApp(App): # 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
|
ALLOW_SELECT = True
|
||||||
|
|
||||||
SIDEBAR_MIN_WIDTH = 120
|
SIDEBAR_MIN_WIDTH = 120
|
||||||
@@ -1088,17 +707,17 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self.args = args
|
self.args = args
|
||||||
self.scan_config = self._build_scan_config(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.set_scan_config(self.scan_config)
|
||||||
self.scan_store.hydrate_from_run_dir()
|
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 = TuiLiveView()
|
||||||
self.live_view.hydrate_from_run_dir(self.scan_store.get_run_dir())
|
self.live_view.hydrate_from_run_dir(self.scan_store.get_run_dir())
|
||||||
self._agent_graph_sync_future: Any | None = None
|
self._agent_graph_sync_future: Any | None = None
|
||||||
|
|
||||||
# Pre-create the coordinator here so the TUI can route stop/chat
|
# Pre-create the coordinator here so the TUI can route stop/chat
|
||||||
# commands while the scan loop runs in a worker thread.
|
# 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()
|
self.coordinator = AgentCoordinator()
|
||||||
|
|
||||||
@@ -1991,18 +1610,14 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
len(message),
|
len(message),
|
||||||
)
|
)
|
||||||
target_agent_id = self.selected_agent_id
|
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
|
send_user_message_to_agent(
|
||||||
# any active stream so the message is picked up on the next run cycle.
|
coordinator=self.coordinator,
|
||||||
if self._scan_loop is not None and not self._scan_loop.is_closed():
|
loop=self._scan_loop,
|
||||||
asyncio.run_coroutine_threadsafe(
|
live_view=self.live_view,
|
||||||
self.coordinator.send(
|
target_agent_id=target_agent_id,
|
||||||
target_agent_id,
|
message=message,
|
||||||
{"from": "user", "content": message, "type": "instruction"},
|
)
|
||||||
),
|
|
||||||
self._scan_loop,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._displayed_events.clear()
|
self._displayed_events.clear()
|
||||||
self._update_chat_view()
|
self._update_chat_view()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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.
|
|
||||||
"""
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
"""Report/finding helpers."""
|
"""Report/finding helpers."""
|
||||||
|
|
||||||
from strix.report.dedupe import check_duplicate
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from strix.report.writer import write_executive_report, write_run_metadata, write_vulnerabilities
|
||||||
from strix.telemetry import posthog
|
from strix.telemetry import posthog
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_global_scan_store: Optional["ScanStore"] = None
|
_global_report_state: Optional["ReportState"] = None
|
||||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
||||||
|
|
||||||
|
|
||||||
def get_global_scan_store() -> Optional["ScanStore"]:
|
def get_global_report_state() -> Optional["ReportState"]:
|
||||||
return _global_scan_store
|
return _global_report_state
|
||||||
|
|
||||||
|
|
||||||
def set_global_scan_store(scan_store: "ScanStore") -> None:
|
def set_global_report_state(report_state: "ReportState") -> None:
|
||||||
global _global_scan_store # noqa: PLW0603
|
global _global_report_state # noqa: PLW0603
|
||||||
_global_scan_store = scan_store
|
_global_report_state = report_state
|
||||||
|
|
||||||
|
|
||||||
class ScanStore:
|
class ReportState:
|
||||||
"""Per-scan product artifact state plus artifact writer.
|
"""Per-scan product artifact state plus artifact writer.
|
||||||
|
|
||||||
The Agents SDK owns model/tool execution, tracing, and conversation
|
The Agents SDK owns model/tool execution, tracing, and conversation
|
||||||
@@ -280,168 +278,13 @@ class ScanStore:
|
|||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if self.final_scan_result:
|
if self.final_scan_result:
|
||||||
self._write_executive_report(run_dir)
|
write_executive_report(run_dir, self.final_scan_result)
|
||||||
|
|
||||||
if self.vulnerability_reports:
|
if self.vulnerability_reports:
|
||||||
self._write_vulnerabilities(run_dir)
|
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
|
||||||
|
|
||||||
_atomic_write_text(
|
write_run_metadata(run_dir, self.run_metadata)
|
||||||
run_dir / "run_metadata.json",
|
|
||||||
json.dumps(self.run_metadata, ensure_ascii=False, indent=2, default=str),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Essential scan data saved to: %s", run_dir)
|
logger.info("Essential scan data saved to: %s", run_dir)
|
||||||
except (OSError, RuntimeError):
|
except (OSError, RuntimeError):
|
||||||
logger.exception("Failed to save scan data")
|
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)
|
|
||||||
@@ -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)
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
from . import posthog
|
from . import posthog
|
||||||
from .scan_store import (
|
|
||||||
ScanStore,
|
|
||||||
get_global_scan_store,
|
|
||||||
set_global_scan_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ScanStore",
|
|
||||||
"get_global_scan_store",
|
|
||||||
"posthog",
|
"posthog",
|
||||||
"set_global_scan_store",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from strix.config import load_settings
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from strix.telemetry.scan_store import ScanStore
|
from strix.report.state import ReportState
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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}
|
vulnerabilities_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||||
for v in scan_store.vulnerability_reports:
|
for v in scan_store.vulnerability_reports:
|
||||||
sev = v.get("severity", "info").lower()
|
sev = v.get("severity", "info").lower()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from agents import RunContextWrapper, function_tool
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any
|
|||||||
|
|
||||||
from agents import RunContextWrapper, function_tool
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -44,9 +44,9 @@ def _do_finish(
|
|||||||
return {"success": False, "message": "Validation failed", "errors": errors}
|
return {"success": False, "message": "Validation failed", "errors": errors}
|
||||||
|
|
||||||
try:
|
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:
|
if scan_store is None:
|
||||||
logger.warning("No global scan store; scan results not persisted")
|
logger.warning("No global scan store; scan results not persisted")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ async def _do_create( # noqa: PLR0912
|
|||||||
cvss_score, severity, _vector = _calculate_cvss(cvss_breakdown)
|
cvss_score, severity, _vector = _calculate_cvss(cvss_breakdown)
|
||||||
|
|
||||||
try:
|
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:
|
if scan_store is None:
|
||||||
logger.warning("No global scan store; vulnerability report not persisted")
|
logger.warning("No global scan store; vulnerability report not persisted")
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user