mirror of
https://github.com/usestrix/strix.git
synced 2026-08-19 10:05:12 +02:00
Simplify SDK-native orchestration
This commit is contained in:
+12
-12
@@ -13,9 +13,9 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.orchestration.scan import run_strix_scan
|
||||
from strix.orchestration.runner import run_strix_scan
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.tracer import Tracer, set_global_tracer
|
||||
from strix.telemetry.scan_store import ScanStore, set_global_scan_store
|
||||
|
||||
from .utils import (
|
||||
build_live_stats_text,
|
||||
@@ -95,8 +95,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
|
||||
}
|
||||
|
||||
tracer = Tracer(args.run_name)
|
||||
tracer.set_scan_config(scan_config)
|
||||
scan_store = ScanStore(args.run_name)
|
||||
scan_store.set_scan_config(scan_config)
|
||||
scan_store.hydrate_from_run_dir()
|
||||
|
||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||
report_id = report.get("id", "unknown")
|
||||
@@ -114,13 +115,13 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
console.print(vuln_panel)
|
||||
console.print()
|
||||
|
||||
tracer.vulnerability_found_callback = display_vulnerability
|
||||
scan_store.vulnerability_found_callback = display_vulnerability
|
||||
|
||||
def cleanup_on_exit() -> None:
|
||||
tracer.cleanup()
|
||||
scan_store.cleanup()
|
||||
|
||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||
tracer.cleanup()
|
||||
scan_store.cleanup()
|
||||
sys.exit(1)
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
@@ -129,14 +130,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
signal.signal(signal.SIGHUP, signal_handler)
|
||||
|
||||
set_global_tracer(tracer)
|
||||
set_global_scan_store(scan_store)
|
||||
|
||||
def create_live_status() -> Panel:
|
||||
status_text = Text()
|
||||
status_text.append("Penetration test in progress", style="bold #22c55e")
|
||||
status_text.append("\n\n")
|
||||
|
||||
stats_text = build_live_stats_text(tracer)
|
||||
stats_text = build_live_stats_text(scan_store)
|
||||
if stats_text:
|
||||
status_text.append(stats_text)
|
||||
|
||||
@@ -179,7 +180,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
scan_id=args.run_name,
|
||||
image=_resolve_sandbox_image(),
|
||||
local_sources=getattr(args, "local_sources", None) or [],
|
||||
tracer=tracer,
|
||||
interactive=bool(getattr(args, "interactive", False)),
|
||||
)
|
||||
finally:
|
||||
@@ -196,7 +196,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
console.print(f"[bold red]Error during penetration test:[/] {e}")
|
||||
raise
|
||||
|
||||
if tracer.final_scan_result:
|
||||
if scan_store.final_scan_result:
|
||||
console.print()
|
||||
|
||||
final_report_text = Text()
|
||||
@@ -206,7 +206,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
Text.assemble(
|
||||
final_report_text,
|
||||
"\n\n",
|
||||
tracer.final_scan_result,
|
||||
scan_store.final_scan_result,
|
||||
),
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
|
||||
+11
-11
@@ -37,7 +37,7 @@ from strix.interface.utils import (
|
||||
validate_llm_response,
|
||||
)
|
||||
from strix.telemetry import posthog
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
from strix.telemetry.scan_store import get_global_scan_store
|
||||
|
||||
|
||||
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
|
||||
@@ -47,7 +47,7 @@ import logging # noqa: E402
|
||||
|
||||
|
||||
# Per-scan logging is set up by ``setup_scan_logging`` from inside
|
||||
# ``orchestration.scan.run_strix_scan`` once the scan ``run_dir`` is
|
||||
# ``orchestration.runner.run_strix_scan`` once the scan ``run_dir`` is
|
||||
# known — that's where ``strix.*`` levels and handlers are owned. Pre-scan
|
||||
# work (``main()``, env validation, image pull) emits via the module
|
||||
# logger; once setup_scan_logging runs, those records start landing in
|
||||
@@ -552,11 +552,11 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
|
||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||
console = Console()
|
||||
tracer = get_global_tracer()
|
||||
scan_store = get_global_scan_store()
|
||||
|
||||
scan_completed = False
|
||||
if tracer and tracer.scan_results:
|
||||
scan_completed = tracer.scan_results.get("scan_completed", False)
|
||||
if scan_store and scan_store.scan_results:
|
||||
scan_completed = scan_store.scan_results.get("scan_completed", False)
|
||||
|
||||
completion_text = Text()
|
||||
if scan_completed:
|
||||
@@ -575,7 +575,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
target_text.append("\n ")
|
||||
target_text.append(target_info["original"], style="white")
|
||||
|
||||
stats_text = build_final_stats_text(tracer)
|
||||
stats_text = build_final_stats_text(scan_store)
|
||||
|
||||
panel_parts: list[Text | str] = [completion_text, "\n\n", target_text]
|
||||
|
||||
@@ -753,16 +753,16 @@ def main() -> None:
|
||||
posthog.error("unhandled_exception", str(e))
|
||||
raise
|
||||
finally:
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
posthog.end(tracer, exit_reason=exit_reason)
|
||||
scan_store = get_global_scan_store()
|
||||
if scan_store:
|
||||
posthog.end(scan_store, exit_reason=exit_reason)
|
||||
|
||||
results_path = Path("strix_runs") / args.run_name
|
||||
display_completion_message(args, results_path)
|
||||
|
||||
if args.non_interactive:
|
||||
tracer = get_global_tracer()
|
||||
if tracer and tracer.vulnerability_reports:
|
||||
scan_store = get_global_scan_store()
|
||||
if scan_store and scan_store.vulnerability_reports:
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
|
||||
+217
-177
@@ -2,13 +2,16 @@ import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as pkg_version
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
|
||||
@@ -31,12 +34,11 @@ from textual.widgets.tree import TreeNode
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
|
||||
from strix.interface.tool_components.registry import get_tool_renderer
|
||||
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
|
||||
from strix.interface.utils import build_tui_stats_text
|
||||
from strix.orchestration.scan import run_strix_scan
|
||||
from strix.orchestration.runner import run_strix_scan
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.tracer import Tracer, set_global_tracer
|
||||
from strix.telemetry.scan_store import ScanStore, set_global_scan_store
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -639,6 +641,139 @@ class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc]
|
||||
self.mount(item)
|
||||
|
||||
|
||||
class TuiLiveView:
|
||||
"""UI-owned projection of coordinator state and SDK session items."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.agents: dict[str, dict[str, Any]] = {}
|
||||
self.chat_messages: list[dict[str, Any]] = []
|
||||
self._next_message_id = 1
|
||||
self._session_message_keys: dict[tuple[str, int], 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),
|
||||
)
|
||||
|
||||
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 sync_agent_messages_from_items(self, agent_id: str, items: list[Any]) -> None:
|
||||
other_agents = [m for m in self.chat_messages if m.get("agent_id") != agent_id]
|
||||
refreshed: list[dict[str, Any]] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
converted = _sdk_item_to_chat_message(item)
|
||||
if converted is None:
|
||||
continue
|
||||
|
||||
key = (agent_id, index)
|
||||
existing = self._session_message_keys.get(key)
|
||||
if existing is None:
|
||||
existing = {
|
||||
"message_id": self._next_message_id,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
self._next_message_id += 1
|
||||
self._session_message_keys[key] = existing
|
||||
|
||||
refreshed.append(
|
||||
{
|
||||
"message_id": existing["message_id"],
|
||||
"content": converted["content"],
|
||||
"role": converted["role"],
|
||||
"agent_id": agent_id,
|
||||
"timestamp": existing["timestamp"],
|
||||
"metadata": {"source": "sdk_session", "session_index": index},
|
||||
}
|
||||
)
|
||||
|
||||
self.chat_messages = other_agents + refreshed
|
||||
|
||||
|
||||
def _sdk_item_to_chat_message(item: Any) -> dict[str, str] | None:
|
||||
if not isinstance(item, dict):
|
||||
if hasattr(item, "model_dump"):
|
||||
item = item.model_dump(exclude_unset=True)
|
||||
else:
|
||||
return None
|
||||
|
||||
role = item.get("role")
|
||||
if role not in {"user", "assistant"}:
|
||||
return None
|
||||
|
||||
content = _extract_sdk_text(item.get("content"))
|
||||
if not content:
|
||||
return None
|
||||
return {"role": str(role), "content": content}
|
||||
|
||||
|
||||
def _extract_sdk_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
parts.append(part)
|
||||
elif isinstance(part, dict):
|
||||
text = part.get("text") or part.get("content")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
else:
|
||||
text = getattr(part, "text", None) or getattr(part, "content", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(p for p in parts if p)
|
||||
return ""
|
||||
|
||||
|
||||
class QuitScreen(ModalScreen): # type: ignore[misc]
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
@@ -704,9 +839,13 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self.args = args
|
||||
self.scan_config = self._build_scan_config(args)
|
||||
|
||||
self.tracer = Tracer(self.scan_config["run_name"])
|
||||
self.tracer.set_scan_config(self.scan_config)
|
||||
set_global_tracer(self.tracer)
|
||||
self.scan_store = ScanStore(self.scan_config["run_name"])
|
||||
self.scan_store.set_scan_config(self.scan_config)
|
||||
self.scan_store.hydrate_from_run_dir()
|
||||
set_global_scan_store(self.scan_store)
|
||||
self.live_view = TuiLiveView()
|
||||
self.live_view.hydrate_from_run_dir(self.scan_store.get_run_dir())
|
||||
self._live_sync_future: Any | None = None
|
||||
|
||||
# Pre-create the coordinator here so the TUI can route stop/chat
|
||||
# commands while the scan loop runs in a worker thread.
|
||||
@@ -759,10 +898,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _setup_cleanup_handlers(self) -> None:
|
||||
def cleanup_on_exit() -> None:
|
||||
self.tracer.cleanup()
|
||||
self.scan_store.cleanup()
|
||||
|
||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||
self.tracer.cleanup()
|
||||
self.scan_store.cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
@@ -881,9 +1020,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
self._start_scan_thread()
|
||||
|
||||
self.set_interval(0.35, self._update_ui_from_tracer)
|
||||
self.set_interval(0.35, self._update_ui)
|
||||
|
||||
def _update_ui_from_tracer(self) -> None:
|
||||
def _update_ui(self) -> None:
|
||||
if self.show_splash:
|
||||
return
|
||||
|
||||
@@ -902,8 +1041,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
except (ValueError, Exception):
|
||||
return
|
||||
|
||||
self._sync_live_view()
|
||||
|
||||
agent_updates = False
|
||||
for agent_id, agent_data in list(self.tracer.agents.items()):
|
||||
for agent_id, agent_data in list(self.live_view.agents.items()):
|
||||
if agent_id not in self._displayed_agents:
|
||||
self._add_agent_node(agent_data)
|
||||
self._displayed_agents.add(agent_id)
|
||||
@@ -922,6 +1063,43 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
self._update_vulnerabilities_panel()
|
||||
|
||||
def _sync_live_view(self) -> None:
|
||||
future = self._live_sync_future
|
||||
if future is not None:
|
||||
if not future.done():
|
||||
if self._scan_loop is not None and self._scan_loop.is_closed():
|
||||
future.cancel()
|
||||
self._live_sync_future = None
|
||||
else:
|
||||
return
|
||||
else:
|
||||
self._live_sync_future = None
|
||||
try:
|
||||
parent_of, statuses, names, session_items = future.result()
|
||||
except Exception:
|
||||
logger.exception("TUI live-view sync failed")
|
||||
else:
|
||||
for agent_id, status in statuses.items():
|
||||
self.live_view.upsert_agent(
|
||||
agent_id,
|
||||
name=names.get(agent_id, agent_id),
|
||||
parent_id=parent_of.get(agent_id),
|
||||
status=status,
|
||||
)
|
||||
for agent_id, items in session_items.items():
|
||||
self.live_view.sync_agent_messages_from_items(agent_id, items)
|
||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||
return
|
||||
|
||||
async def collect() -> tuple[
|
||||
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, list[Any]]
|
||||
]:
|
||||
parent_of, statuses, names = await self.coordinator.graph_snapshot()
|
||||
session_items = await self.coordinator.session_items_snapshot()
|
||||
return parent_of, statuses, names, session_items
|
||||
|
||||
self._live_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
||||
|
||||
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
||||
if agent_id not in self.agent_nodes:
|
||||
return False
|
||||
@@ -937,7 +1115,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"stopped": "■",
|
||||
"llm_failed": "🔴",
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "○")
|
||||
@@ -1074,8 +1251,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
if event["type"] == "chat":
|
||||
content = self._render_chat_content(event["data"])
|
||||
elif event["type"] == "tool":
|
||||
content = self._render_tool_content_simple(event["data"])
|
||||
|
||||
if content:
|
||||
if renderables:
|
||||
@@ -1090,7 +1265,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
return self._merge_renderables(renderables)
|
||||
|
||||
def _get_status_display_content( # noqa: PLR0911
|
||||
def _get_status_display_content(
|
||||
self, agent_id: str, agent_data: dict[str, Any]
|
||||
) -> tuple[Text | None, Text, bool]:
|
||||
status = agent_data.get("status", "running")
|
||||
@@ -1116,18 +1291,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
text.append(msg)
|
||||
return (text, Text(), False)
|
||||
|
||||
if status == "llm_failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
text = Text()
|
||||
if error_msg:
|
||||
text.append(error_msg, style="red")
|
||||
else:
|
||||
text.append("LLM request failed", style="red")
|
||||
self._stop_dot_animation()
|
||||
keymap = Text()
|
||||
keymap.append("Send message to retry", style="dim")
|
||||
return (text, keymap, False)
|
||||
|
||||
if status == "failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
text = Text()
|
||||
@@ -1173,7 +1336,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
return
|
||||
|
||||
try:
|
||||
agent_data = self.tracer.agents[self.selected_agent_id]
|
||||
agent_data = self.live_view.agents[self.selected_agent_id]
|
||||
content, keymap, should_animate = self._get_status_display_content(
|
||||
self.selected_agent_id, agent_data
|
||||
)
|
||||
@@ -1206,7 +1369,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
stats_content = Text()
|
||||
|
||||
stats_text = build_tui_stats_text(self.tracer)
|
||||
stats_text = build_tui_stats_text(self.scan_store)
|
||||
if stats_text:
|
||||
stats_content.append(stats_text)
|
||||
|
||||
@@ -1225,7 +1388,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
if not self._is_widget_safe(vuln_panel):
|
||||
return
|
||||
|
||||
vulnerabilities = self.tracer.vulnerability_reports
|
||||
vulnerabilities = self.scan_store.vulnerability_reports
|
||||
|
||||
if not vulnerabilities:
|
||||
self._safe_widget_operation(vuln_panel.add_class, "hidden")
|
||||
@@ -1234,8 +1397,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
enriched_vulns = []
|
||||
for vuln in vulnerabilities:
|
||||
enriched = dict(vuln)
|
||||
report_id = vuln.get("id", "")
|
||||
agent_name = self._get_agent_name_for_vulnerability(report_id)
|
||||
agent_name = enriched.get("agent_name")
|
||||
agent_id = enriched.get("agent_id")
|
||||
if not agent_name and isinstance(agent_id, str):
|
||||
agent_name = self._get_agent_name(agent_id)
|
||||
if agent_name:
|
||||
enriched["agent_name"] = agent_name
|
||||
enriched_vulns.append(enriched)
|
||||
@@ -1243,18 +1408,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._safe_widget_operation(vuln_panel.remove_class, "hidden")
|
||||
vuln_panel.update_vulnerabilities(enriched_vulns)
|
||||
|
||||
def _get_agent_name_for_vulnerability(self, report_id: str) -> str | None:
|
||||
"""Find the agent name that created a vulnerability report."""
|
||||
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
||||
if tool_data.get("tool_name") == "create_vulnerability_report":
|
||||
result = tool_data.get("result", {})
|
||||
if isinstance(result, dict) and result.get("report_id") == report_id:
|
||||
agent_id = tool_data.get("agent_id")
|
||||
if agent_id and agent_id in self.tracer.agents:
|
||||
name: str = self.tracer.agents[agent_id].get("name", "Unknown Agent")
|
||||
return name
|
||||
return None
|
||||
|
||||
def _get_sweep_animation(self, color_palette: list[str]) -> Text:
|
||||
text = Text()
|
||||
num_squares = self._sweep_num_squares
|
||||
@@ -1307,8 +1460,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
def _animate_dots(self) -> None:
|
||||
has_active_agents = False
|
||||
|
||||
if self.selected_agent_id and self.selected_agent_id in self.tracer.agents:
|
||||
agent_data = self.tracer.agents[self.selected_agent_id]
|
||||
if self.selected_agent_id and self.selected_agent_id in self.live_view.agents:
|
||||
agent_data = self.live_view.agents[self.selected_agent_id]
|
||||
status = agent_data.get("status", "running")
|
||||
if status in ["running", "waiting"]:
|
||||
has_active_agents = True
|
||||
@@ -1323,7 +1476,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
if not has_active_agents:
|
||||
has_active_agents = any(
|
||||
agent_data.get("status", "running") in ["running", "waiting"]
|
||||
for agent_data in self.tracer.agents.values()
|
||||
for agent_data in self.live_view.agents.values()
|
||||
)
|
||||
|
||||
if not has_active_agents:
|
||||
@@ -1331,28 +1484,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._spinner_frame_index = 0
|
||||
|
||||
def _agent_has_real_activity(self, agent_id: str) -> bool:
|
||||
initial_tools = {"scan_start_info", "subagent_start_info"}
|
||||
|
||||
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
||||
if tool_data.get("agent_id") == agent_id:
|
||||
tool_name = tool_data.get("tool_name", "")
|
||||
if tool_name not in initial_tools:
|
||||
return True
|
||||
|
||||
return False
|
||||
return any(msg.get("agent_id") == agent_id for msg in self.live_view.chat_messages)
|
||||
|
||||
def _agent_vulnerability_count(self, agent_id: str) -> int:
|
||||
count = 0
|
||||
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
||||
if tool_data.get("agent_id") == agent_id:
|
||||
tool_name = tool_data.get("tool_name", "")
|
||||
if tool_name == "create_vulnerability_report":
|
||||
status = tool_data.get("status", "")
|
||||
if status == "completed":
|
||||
result = tool_data.get("result", {})
|
||||
if isinstance(result, dict) and result.get("success"):
|
||||
count += 1
|
||||
return count
|
||||
return sum(
|
||||
1 for vuln in self.scan_store.vulnerability_reports if vuln.get("agent_id") == agent_id
|
||||
)
|
||||
|
||||
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
|
||||
chat_events = [
|
||||
@@ -1362,24 +1499,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"id": f"chat_{msg['message_id']}",
|
||||
"data": msg,
|
||||
}
|
||||
for msg in self.tracer.chat_messages
|
||||
for msg in self.live_view.chat_messages
|
||||
if msg.get("agent_id") == agent_id
|
||||
]
|
||||
|
||||
tool_events = [
|
||||
{
|
||||
"type": "tool",
|
||||
"timestamp": tool_data["timestamp"],
|
||||
"id": f"tool_{exec_id}",
|
||||
"data": tool_data,
|
||||
}
|
||||
for exec_id, tool_data in list(self.tracer.tool_executions.items())
|
||||
if tool_data.get("agent_id") == agent_id
|
||||
]
|
||||
|
||||
events = chat_events + tool_events
|
||||
events.sort(key=lambda e: (e["timestamp"], e["id"]))
|
||||
return events
|
||||
chat_events.sort(key=lambda e: (e["timestamp"], e["id"]))
|
||||
return chat_events
|
||||
|
||||
def watch_selected_agent_id(self, _agent_id: str | None) -> None:
|
||||
if len(self.screen_stack) > 1 or self.show_splash:
|
||||
@@ -1412,7 +1537,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
scan_id=self.scan_config["run_name"],
|
||||
image=str(image),
|
||||
local_sources=getattr(self.args, "local_sources", None) or [],
|
||||
tracer=self.tracer,
|
||||
coordinator=self.coordinator,
|
||||
interactive=True,
|
||||
),
|
||||
@@ -1470,7 +1594,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"stopped": "■",
|
||||
"llm_failed": "🔴",
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "○")
|
||||
@@ -1532,7 +1655,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:
|
||||
agent_id = node_to_copy.data["agent_id"]
|
||||
agent_data = self.tracer.agents.get(agent_id, {})
|
||||
agent_data = self.live_view.agents.get(agent_id, {})
|
||||
agent_name_raw = agent_data.get("name", "Agent")
|
||||
status = agent_data.get("status", "running")
|
||||
|
||||
@@ -1542,7 +1665,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"stopped": "■",
|
||||
"llm_failed": "🔴",
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "○")
|
||||
@@ -1567,7 +1689,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
|
||||
agents_to_move = []
|
||||
|
||||
for agent_id, agent_data in list(self.tracer.agents.items()):
|
||||
for agent_id, agent_data in list(self.live_view.agents.items()):
|
||||
if (
|
||||
agent_data.get("parent_id") == new_parent_id
|
||||
and agent_id in self.agent_nodes
|
||||
@@ -1608,71 +1730,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
return AgentMessageRenderer.render_simple(content)
|
||||
|
||||
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:
|
||||
tool_name = tool_data.get("tool_name", "Unknown Tool")
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
result = tool_data.get("result")
|
||||
|
||||
renderer = get_tool_renderer(tool_name)
|
||||
|
||||
if renderer:
|
||||
widget = renderer.render(tool_data)
|
||||
return widget.content
|
||||
|
||||
text = Text()
|
||||
|
||||
if tool_name in ("llm_error_details", "sandbox_error_details"):
|
||||
return self._render_error_details(text, tool_name, args)
|
||||
|
||||
text.append("→ Using tool ")
|
||||
text.append(tool_name, style="bold blue")
|
||||
|
||||
status_styles = {
|
||||
"running": ("●", "yellow"),
|
||||
"completed": ("✓", "green"),
|
||||
"failed": ("✗", "red"),
|
||||
"error": ("✗", "red"),
|
||||
}
|
||||
icon, style = status_styles.get(status, ("○", "dim"))
|
||||
text.append(" ")
|
||||
text.append(icon, style=style)
|
||||
|
||||
if args:
|
||||
for k, v in list(args.items())[:5]:
|
||||
str_v = str(v)
|
||||
if len(str_v) > 500:
|
||||
str_v = str_v[:497] + "..."
|
||||
text.append("\n ")
|
||||
text.append(k, style="dim")
|
||||
text.append(": ")
|
||||
text.append(str_v)
|
||||
|
||||
if status in ["completed", "failed", "error"] and result:
|
||||
result_str = str(result)
|
||||
if len(result_str) > 1000:
|
||||
result_str = result_str[:997] + "..."
|
||||
text.append("\n")
|
||||
text.append("Result: ", style="bold")
|
||||
text.append(result_str)
|
||||
|
||||
return text
|
||||
|
||||
def _render_error_details(self, text: Any, tool_name: str, args: dict[str, Any]) -> Any:
|
||||
if tool_name == "llm_error_details":
|
||||
text.append("✗ LLM Request Failed", style="red")
|
||||
else:
|
||||
text.append("✗ Sandbox Initialization Failed", style="red")
|
||||
if args.get("error"):
|
||||
text.append(f"\n{args['error']}", style="bold red")
|
||||
if args.get("details"):
|
||||
details = str(args["details"])
|
||||
if len(details) > 1000:
|
||||
details = details[:997] + "..."
|
||||
text.append("\nDetails: ", style="dim")
|
||||
text.append(details)
|
||||
return text
|
||||
|
||||
@on(Tree.NodeHighlighted) # type: ignore[misc]
|
||||
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
|
||||
if len(self.screen_stack) > 1 or self.show_splash:
|
||||
@@ -1718,21 +1775,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self.selected_agent_id,
|
||||
len(message),
|
||||
)
|
||||
if self.tracer:
|
||||
self.tracer.log_chat_message(
|
||||
content=message,
|
||||
role="user",
|
||||
agent_id=self.selected_agent_id,
|
||||
)
|
||||
|
||||
# Route to the agent's SDK session. The scan loop runs on a
|
||||
# worker thread; ``run_coroutine_threadsafe`` submits the
|
||||
# coroutine onto that loop and returns immediately so the TUI
|
||||
# stays responsive. After enqueuing the message, request a
|
||||
# graceful interrupt of the agent's current turn so the user
|
||||
# input is processed without waiting for the active LLM/tool
|
||||
# call to finish — the SDK saves the in-flight turn cleanly
|
||||
# before honoring ``cancel(mode="after_turn")``.
|
||||
# Route to the agent's SDK session. The coordinator also interrupts
|
||||
# any active stream so the message is picked up on the next run cycle.
|
||||
if self._scan_loop is not None and not self._scan_loop.is_closed():
|
||||
target_agent_id = self.selected_agent_id
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
@@ -1742,10 +1786,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
),
|
||||
self._scan_loop,
|
||||
)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.coordinator.request_interrupt(target_agent_id, mode="after_turn"),
|
||||
self._scan_loop,
|
||||
)
|
||||
|
||||
self._displayed_events.clear()
|
||||
self._update_chat_view()
|
||||
@@ -1754,8 +1794,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _get_agent_name(self, agent_id: str) -> str:
|
||||
try:
|
||||
if self.tracer and agent_id in self.tracer.agents:
|
||||
agent_name = self.tracer.agents[agent_id].get("name")
|
||||
if agent_id in self.live_view.agents:
|
||||
agent_name = self.live_view.agents[agent_id].get("name")
|
||||
if isinstance(agent_name, str):
|
||||
return agent_name
|
||||
except (KeyError, AttributeError) as e:
|
||||
@@ -1822,12 +1862,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
agent_name = "Unknown Agent"
|
||||
|
||||
try:
|
||||
if self.tracer and self.selected_agent_id in self.tracer.agents:
|
||||
agent_data = self.tracer.agents[self.selected_agent_id]
|
||||
if self.selected_agent_id in self.live_view.agents:
|
||||
agent_data = self.live_view.agents[self.selected_agent_id]
|
||||
agent_name = agent_data.get("name", "Unknown Agent")
|
||||
|
||||
agent_status = agent_data.get("status", "running")
|
||||
if agent_status not in ["running", "waiting", "llm_failed"]:
|
||||
if agent_status not in ["running", "waiting"]:
|
||||
return agent_name, False
|
||||
|
||||
agent_events = self._gather_agent_events(self.selected_agent_id)
|
||||
@@ -1862,7 +1902,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
self._scan_thread.join(timeout=1.0)
|
||||
|
||||
self.tracer.cleanup()
|
||||
self.scan_store.cleanup()
|
||||
|
||||
self.exit()
|
||||
|
||||
|
||||
+14
-109
@@ -23,16 +23,6 @@ from rich.text import Text
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
# Token formatting utilities
|
||||
def format_token_count(count: float) -> str:
|
||||
count = int(count)
|
||||
if count >= 1_000_000:
|
||||
return f"{count / 1_000_000:.1f}M"
|
||||
if count >= 1_000:
|
||||
return f"{count / 1_000:.1f}K"
|
||||
return str(count)
|
||||
|
||||
|
||||
# Display utilities
|
||||
def get_severity_color(severity: str) -> str:
|
||||
severity_colors = {
|
||||
@@ -206,13 +196,13 @@ def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR091
|
||||
return text
|
||||
|
||||
|
||||
def _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:
|
||||
def _build_vulnerability_stats(stats_text: Text, scan_store: Any) -> None:
|
||||
"""Build vulnerability section of stats text."""
|
||||
vuln_count = len(tracer.vulnerability_reports)
|
||||
vuln_count = len(scan_store.vulnerability_reports)
|
||||
|
||||
if vuln_count > 0:
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for report in tracer.vulnerability_reports:
|
||||
for report in scan_store.vulnerability_reports:
|
||||
severity = report.get("severity", "").lower()
|
||||
if severity in severity_counts:
|
||||
severity_counts[severity] += 1
|
||||
@@ -245,63 +235,20 @@ def _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:
|
||||
stats_text.append("\n")
|
||||
|
||||
|
||||
def _build_llm_stats(stats_text: Text, total_stats: dict[str, Any]) -> None:
|
||||
"""Build LLM usage section of stats text."""
|
||||
if total_stats["requests"] > 0:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Input Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
|
||||
|
||||
if total_stats["cached_tokens"] > 0:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cached Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
|
||||
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Output Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
|
||||
|
||||
if total_stats["cost"] > 0:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append(f"${total_stats['cost']:.4f}", style="bold #fbbf24")
|
||||
else:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("· ", style="dim white")
|
||||
stats_text.append("Tokens ", style="dim")
|
||||
stats_text.append("0", style="white")
|
||||
|
||||
|
||||
def build_final_stats_text(tracer: Any) -> Text:
|
||||
"""Build stats text for final output with detailed messages and LLM usage."""
|
||||
def build_final_stats_text(scan_store: Any) -> Text:
|
||||
"""Build final stats from Strix-owned scan artifacts."""
|
||||
stats_text = Text()
|
||||
if not tracer:
|
||||
if not scan_store:
|
||||
return stats_text
|
||||
|
||||
_build_vulnerability_stats(stats_text, tracer)
|
||||
|
||||
tool_count = tracer.get_real_tool_count()
|
||||
agent_count = len(tracer.agents)
|
||||
|
||||
stats_text.append("Agents", style="dim")
|
||||
stats_text.append(" ")
|
||||
stats_text.append(str(agent_count), style="bold white")
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Tools", style="dim")
|
||||
stats_text.append(" ")
|
||||
stats_text.append(str(tool_count), style="bold white")
|
||||
|
||||
llm_stats = tracer.get_total_llm_stats()
|
||||
_build_llm_stats(stats_text, llm_stats["total"])
|
||||
_build_vulnerability_stats(stats_text, scan_store)
|
||||
|
||||
return stats_text
|
||||
|
||||
|
||||
def build_live_stats_text(tracer: Any) -> Text:
|
||||
def build_live_stats_text(scan_store: Any) -> Text:
|
||||
stats_text = Text()
|
||||
if not tracer:
|
||||
if not scan_store:
|
||||
return stats_text
|
||||
|
||||
model = load_settings().llm.model or "unknown"
|
||||
@@ -309,16 +256,13 @@ def build_live_stats_text(tracer: Any) -> Text:
|
||||
stats_text.append(str(model), style="white")
|
||||
stats_text.append("\n")
|
||||
|
||||
vuln_count = len(tracer.vulnerability_reports)
|
||||
tool_count = tracer.get_real_tool_count()
|
||||
agent_count = len(tracer.agents)
|
||||
|
||||
vuln_count = len(scan_store.vulnerability_reports)
|
||||
stats_text.append("Vulnerabilities ", style="dim")
|
||||
stats_text.append(f"{vuln_count}", style="white")
|
||||
stats_text.append("\n")
|
||||
if vuln_count > 0:
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for report in tracer.vulnerability_reports:
|
||||
for report in scan_store.vulnerability_reports:
|
||||
severity = report.get("severity", "").lower()
|
||||
if severity in severity_counts:
|
||||
severity_counts[severity] += 1
|
||||
@@ -340,57 +284,18 @@ def build_live_stats_text(tracer: Any) -> Text:
|
||||
|
||||
stats_text.append("\n")
|
||||
|
||||
stats_text.append("Agents ", style="dim")
|
||||
stats_text.append(str(agent_count), style="white")
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Tools ", style="dim")
|
||||
stats_text.append(str(tool_count), style="white")
|
||||
|
||||
llm_stats = tracer.get_total_llm_stats()
|
||||
total_stats = llm_stats["total"]
|
||||
|
||||
stats_text.append("\n")
|
||||
|
||||
stats_text.append("Input Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
|
||||
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cached Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
|
||||
|
||||
stats_text.append("\n")
|
||||
|
||||
stats_text.append("Output Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
|
||||
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append(f"${total_stats['cost']:.4f}", style="#fbbf24")
|
||||
|
||||
return stats_text
|
||||
|
||||
|
||||
def build_tui_stats_text(tracer: Any) -> Text:
|
||||
def build_tui_stats_text(scan_store: Any) -> Text:
|
||||
stats_text = Text()
|
||||
if not tracer:
|
||||
if not scan_store:
|
||||
return stats_text
|
||||
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append(str(model), style="white")
|
||||
|
||||
llm_stats = tracer.get_total_llm_stats()
|
||||
total_stats = llm_stats["total"]
|
||||
|
||||
total_tokens = total_stats["input_tokens"] + total_stats["output_tokens"]
|
||||
if total_tokens > 0:
|
||||
stats_text.append("\n")
|
||||
stats_text.append(f"{format_token_count(total_tokens)} tokens", style="white")
|
||||
|
||||
if total_stats["cost"] > 0:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append(f"${total_stats['cost']:.2f}", style="white")
|
||||
|
||||
caido_url = getattr(tracer, "caido_url", None)
|
||||
caido_url = getattr(scan_store, "caido_url", None)
|
||||
if caido_url:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Caido: ", style="bold white")
|
||||
|
||||
Reference in New Issue
Block a user