2026-04-26 00:29:37 -07:00
|
|
|
import json
|
2025-08-08 20:36:44 -07:00
|
|
|
import logging
|
2026-03-31 17:20:41 -07:00
|
|
|
from collections.abc import Callable
|
2025-08-08 20:36:44 -07:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from pathlib import Path
|
2026-03-31 17:20:41 -07:00
|
|
|
from typing import Any, Optional
|
2025-08-08 20:36:44 -07:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
2026-01-09 12:26:56 -08:00
|
|
|
from strix.telemetry import posthog
|
2026-04-25 18:54:46 -07:00
|
|
|
from strix.telemetry.scan_artifacts import ScanArtifactWriter
|
2025-10-29 06:14:08 +03:00
|
|
|
|
|
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_global_tracer: Optional["Tracer"] = None
|
|
|
|
|
|
2026-03-31 17:20:41 -07:00
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
def get_global_tracer() -> Optional["Tracer"]:
|
|
|
|
|
return _global_tracer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_global_tracer(tracer: "Tracer") -> None:
|
|
|
|
|
global _global_tracer # noqa: PLW0603
|
|
|
|
|
_global_tracer = tracer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Tracer:
|
2026-04-25 13:47:37 -07:00
|
|
|
"""Per-scan in-memory state the TUI renders + per-scan artifact writer.
|
|
|
|
|
|
|
|
|
|
Holds live state the TUI reads (chat messages, agent tree, tool
|
|
|
|
|
executions, vulnerability reports, LLM usage). Writes vulnerability
|
|
|
|
|
markdown + CSV + final pentest report to ``strix_runs/<scan>/``.
|
|
|
|
|
|
|
|
|
|
Conversation history goes to the SDK's ``SQLiteSession`` instead;
|
|
|
|
|
SDK trace events are not persisted here.
|
|
|
|
|
"""
|
|
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
def __init__(self, run_name: str | None = None):
|
|
|
|
|
self.run_name = run_name
|
|
|
|
|
self.run_id = run_name or f"run-{uuid4().hex[:8]}"
|
|
|
|
|
self.start_time = datetime.now(UTC).isoformat()
|
|
|
|
|
self.end_time: str | None = None
|
|
|
|
|
|
|
|
|
|
self.agents: dict[str, dict[str, Any]] = {}
|
|
|
|
|
self.tool_executions: dict[int, dict[str, Any]] = {}
|
|
|
|
|
self.chat_messages: list[dict[str, Any]] = []
|
2026-04-25 16:24:45 -07:00
|
|
|
self._next_exec_id = 1
|
2025-08-08 20:36:44 -07:00
|
|
|
|
|
|
|
|
self.vulnerability_reports: list[dict[str, Any]] = []
|
|
|
|
|
self.final_scan_result: str | None = None
|
|
|
|
|
|
2026-04-25 12:44:48 -07:00
|
|
|
# LLM usage roll-up across all agents in this run.
|
|
|
|
|
self._llm_stats: dict[str, Any] = {
|
|
|
|
|
"input_tokens": 0,
|
|
|
|
|
"output_tokens": 0,
|
|
|
|
|
"cached_tokens": 0,
|
|
|
|
|
"cost": 0.0,
|
|
|
|
|
"requests": 0,
|
2026-04-25 09:30:23 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
self.scan_results: dict[str, Any] | None = None
|
|
|
|
|
self.scan_config: dict[str, Any] | None = None
|
|
|
|
|
self.run_metadata: dict[str, Any] = {
|
|
|
|
|
"run_id": self.run_id,
|
|
|
|
|
"run_name": self.run_name,
|
|
|
|
|
"start_time": self.start_time,
|
|
|
|
|
"end_time": None,
|
2025-11-01 01:25:07 +02:00
|
|
|
"targets": [],
|
2025-08-08 20:36:44 -07:00
|
|
|
"status": "running",
|
|
|
|
|
}
|
|
|
|
|
self._run_dir: Path | None = None
|
2026-04-25 16:28:07 -07:00
|
|
|
self._writer: ScanArtifactWriter | None = None
|
2025-08-08 20:36:44 -07:00
|
|
|
self._next_message_id = 1
|
2026-03-09 05:11:24 -03:00
|
|
|
|
2026-02-23 18:24:29 -08:00
|
|
|
self.caido_url: str | None = None
|
2026-01-07 19:51:41 -08:00
|
|
|
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
|
2025-10-29 06:14:08 +03:00
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
def set_run_name(self, run_name: str) -> None:
|
|
|
|
|
self.run_name = run_name
|
|
|
|
|
self.run_id = run_name
|
2026-03-09 05:11:24 -03:00
|
|
|
self.run_metadata["run_name"] = run_name
|
|
|
|
|
self.run_metadata["run_id"] = run_name
|
|
|
|
|
self._run_dir = None
|
2026-04-25 16:28:07 -07:00
|
|
|
self._writer = None
|
2025-08-08 20:36:44 -07:00
|
|
|
|
|
|
|
|
def get_run_dir(self) -> Path:
|
|
|
|
|
if self._run_dir is None:
|
2025-11-22 22:28:14 +04:00
|
|
|
runs_dir = Path.cwd() / "strix_runs"
|
2025-08-08 20:36:44 -07:00
|
|
|
runs_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
run_dir_name = self.run_name if self.run_name else self.run_id
|
|
|
|
|
self._run_dir = runs_dir / run_dir_name
|
|
|
|
|
self._run_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
return self._run_dir
|
|
|
|
|
|
2026-04-26 00:29:37 -07:00
|
|
|
def hydrate_from_run_dir(self) -> None:
|
2026-04-26 00:57:52 -07:00
|
|
|
"""Reload prior-scan state from ``{run_dir}/`` for resume.
|
|
|
|
|
|
|
|
|
|
Called by :func:`run_strix_scan` before any new agent runs.
|
|
|
|
|
Restores:
|
|
|
|
|
|
|
|
|
|
- ``vulnerability_reports`` from ``vulnerabilities.json`` so
|
|
|
|
|
:meth:`add_vulnerability_report` doesn't allocate a colliding
|
|
|
|
|
``vuln-0001`` and overwrite the prior on-disk MD.
|
|
|
|
|
- ``run_metadata`` (start_time, run_id, targets, status) from
|
|
|
|
|
``run_metadata.json`` so audit-trail timestamps + the final
|
|
|
|
|
report's duration calc reflect the original scan, not just
|
|
|
|
|
this resume segment.
|
|
|
|
|
|
|
|
|
|
Idempotent on missing files (fresh runs land here too via the
|
|
|
|
|
same code path). **Raises on corruption** — silently swallowing
|
|
|
|
|
a corrupt ``vulnerabilities.json`` would let the next vuln
|
|
|
|
|
allocate ``vuln-0001`` and overwrite the prior MD on disk
|
|
|
|
|
(data loss). Caller is expected to fail the run loud and let
|
|
|
|
|
the user inspect ``{run_dir}`` or pick a fresh ``--run-name``.
|
2026-04-26 00:29:37 -07:00
|
|
|
"""
|
2026-04-26 00:57:52 -07:00
|
|
|
run_dir = self.get_run_dir()
|
|
|
|
|
|
|
|
|
|
meta_path = run_dir / "run_metadata.json"
|
|
|
|
|
if meta_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"run_metadata.json at {meta_path} is unreadable: {exc}",
|
|
|
|
|
) from exc
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
if isinstance(data.get("start_time"), str):
|
|
|
|
|
self.start_time = data["start_time"]
|
|
|
|
|
self.run_metadata.update(
|
|
|
|
|
{
|
|
|
|
|
k: v
|
|
|
|
|
for k, v in data.items()
|
|
|
|
|
if k in {"run_id", "run_name", "start_time", "targets", "status"}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
"tracer hydrated run_metadata from %s (start_time=%s)",
|
|
|
|
|
meta_path,
|
|
|
|
|
self.start_time,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
json_path = run_dir / "vulnerabilities.json"
|
|
|
|
|
if json_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"vulnerabilities.json at {json_path} is corrupt ({exc}); "
|
|
|
|
|
f"refusing to start fresh — that would overwrite prior "
|
|
|
|
|
f"vulnerability MDs on disk. Inspect or delete the run dir.",
|
|
|
|
|
) from exc
|
2026-04-26 00:29:37 -07:00
|
|
|
if not isinstance(data, list):
|
2026-04-26 00:57:52 -07:00
|
|
|
raise RuntimeError(
|
|
|
|
|
f"vulnerabilities.json at {json_path} is not a list",
|
|
|
|
|
)
|
2026-04-26 00:29:37 -07:00
|
|
|
self.vulnerability_reports = [r for r in data if isinstance(r, dict)]
|
|
|
|
|
writer = self._get_writer()
|
|
|
|
|
for r in self.vulnerability_reports:
|
|
|
|
|
rid = r.get("id")
|
|
|
|
|
if isinstance(rid, str):
|
|
|
|
|
writer._saved_vuln_ids.add(rid)
|
|
|
|
|
logger.info(
|
|
|
|
|
"tracer hydrated %d vulnerability report(s) from %s",
|
|
|
|
|
len(self.vulnerability_reports),
|
|
|
|
|
json_path,
|
|
|
|
|
)
|
2026-04-26 00:57:52 -07:00
|
|
|
|
|
|
|
|
bus_path = run_dir / "bus.json"
|
|
|
|
|
if bus_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
bus_data = json.loads(bus_path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, json.JSONDecodeError):
|
|
|
|
|
# Caller will surface this same corruption via ``bus.restore``;
|
|
|
|
|
# no need to fail twice. Skip the agents/stats hydrate path.
|
|
|
|
|
bus_data = None
|
|
|
|
|
if isinstance(bus_data, dict):
|
|
|
|
|
self._hydrate_agents_tree(bus_data)
|
|
|
|
|
self._hydrate_llm_stats(bus_data)
|
|
|
|
|
|
|
|
|
|
def _hydrate_agents_tree(self, bus_data: dict[str, Any]) -> None:
|
|
|
|
|
"""Populate ``self.agents`` from a bus snapshot.
|
|
|
|
|
|
|
|
|
|
Without this, the TUI tree on resume would only show agents
|
|
|
|
|
currently running (mirrored by ``on_agent_start``); completed /
|
|
|
|
|
crashed / stopped children from the prior run would be invisible
|
|
|
|
|
even though the bus knows about them.
|
|
|
|
|
"""
|
|
|
|
|
statuses = bus_data.get("statuses") or {}
|
|
|
|
|
names = bus_data.get("names") or {}
|
|
|
|
|
parent_of = bus_data.get("parent_of") or {}
|
|
|
|
|
if not isinstance(statuses, dict):
|
|
|
|
|
return
|
|
|
|
|
timestamp = self.start_time
|
|
|
|
|
for agent_id, status in statuses.items():
|
|
|
|
|
if not isinstance(agent_id, str):
|
|
|
|
|
continue
|
|
|
|
|
self.agents[agent_id] = {
|
|
|
|
|
"id": 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": status,
|
|
|
|
|
"created_at": timestamp,
|
|
|
|
|
"updated_at": timestamp,
|
|
|
|
|
}
|
|
|
|
|
logger.info("tracer hydrated %d agent(s) into tree", len(self.agents))
|
|
|
|
|
|
|
|
|
|
def _hydrate_llm_stats(self, bus_data: dict[str, Any]) -> None:
|
|
|
|
|
"""Seed ``self._llm_stats`` from the bus snapshot's per-agent counters.
|
|
|
|
|
|
|
|
|
|
Aggregates ``stats_live + stats_completed`` so the resumed scan's
|
|
|
|
|
TUI footer shows cumulative tokens / requests across the prior
|
|
|
|
|
run plus whatever the resume adds, instead of resetting to zero.
|
|
|
|
|
"""
|
|
|
|
|
totals = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0, "requests": 0}
|
|
|
|
|
for bucket_key in ("stats_live", "stats_completed"):
|
|
|
|
|
bucket = bus_data.get(bucket_key) or {}
|
|
|
|
|
if not isinstance(bucket, dict):
|
|
|
|
|
continue
|
|
|
|
|
for entry in bucket.values():
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
continue
|
|
|
|
|
totals["input_tokens"] += int(entry.get("in", 0) or 0)
|
|
|
|
|
totals["output_tokens"] += int(entry.get("out", 0) or 0)
|
|
|
|
|
totals["cached_tokens"] += int(entry.get("cached", 0) or 0)
|
|
|
|
|
totals["requests"] += int(entry.get("calls", 0) or 0)
|
|
|
|
|
for k, v in totals.items():
|
|
|
|
|
self._llm_stats[k] = v
|
|
|
|
|
logger.info(
|
|
|
|
|
"tracer hydrated llm stats from bus (in=%d out=%d cached=%d requests=%d)",
|
|
|
|
|
totals["input_tokens"],
|
|
|
|
|
totals["output_tokens"],
|
|
|
|
|
totals["cached_tokens"],
|
|
|
|
|
totals["requests"],
|
|
|
|
|
)
|
2026-04-26 00:29:37 -07:00
|
|
|
|
2026-04-25 16:28:07 -07:00
|
|
|
def _get_writer(self) -> ScanArtifactWriter:
|
|
|
|
|
if self._writer is None:
|
|
|
|
|
self._writer = ScanArtifactWriter(self.get_run_dir())
|
|
|
|
|
return self._writer
|
|
|
|
|
|
2026-04-25 09:30:23 -07:00
|
|
|
def add_vulnerability_report(
|
2025-08-08 20:36:44 -07:00
|
|
|
self,
|
|
|
|
|
title: str,
|
|
|
|
|
severity: str,
|
2026-01-07 16:33:16 -08:00
|
|
|
description: str | None = None,
|
|
|
|
|
impact: str | None = None,
|
|
|
|
|
target: str | None = None,
|
|
|
|
|
technical_analysis: str | None = None,
|
|
|
|
|
poc_description: str | None = None,
|
|
|
|
|
poc_script_code: str | None = None,
|
|
|
|
|
remediation_steps: str | None = None,
|
|
|
|
|
cvss: float | None = None,
|
|
|
|
|
cvss_breakdown: dict[str, str] | None = None,
|
|
|
|
|
endpoint: str | None = None,
|
|
|
|
|
method: str | None = None,
|
|
|
|
|
cve: str | None = None,
|
2026-02-15 16:40:26 -08:00
|
|
|
cwe: str | None = None,
|
|
|
|
|
code_locations: list[dict[str, Any]] | None = None,
|
2025-08-08 20:36:44 -07:00
|
|
|
) -> str:
|
|
|
|
|
report_id = f"vuln-{len(self.vulnerability_reports) + 1:04d}"
|
|
|
|
|
|
2026-01-07 16:33:16 -08:00
|
|
|
report: dict[str, Any] = {
|
2025-08-08 20:36:44 -07:00
|
|
|
"id": report_id,
|
|
|
|
|
"title": title.strip(),
|
|
|
|
|
"severity": severity.lower().strip(),
|
|
|
|
|
"timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 16:33:16 -08:00
|
|
|
if description:
|
|
|
|
|
report["description"] = description.strip()
|
|
|
|
|
if impact:
|
|
|
|
|
report["impact"] = impact.strip()
|
|
|
|
|
if target:
|
|
|
|
|
report["target"] = target.strip()
|
|
|
|
|
if technical_analysis:
|
|
|
|
|
report["technical_analysis"] = technical_analysis.strip()
|
|
|
|
|
if poc_description:
|
|
|
|
|
report["poc_description"] = poc_description.strip()
|
|
|
|
|
if poc_script_code:
|
|
|
|
|
report["poc_script_code"] = poc_script_code.strip()
|
|
|
|
|
if remediation_steps:
|
|
|
|
|
report["remediation_steps"] = remediation_steps.strip()
|
|
|
|
|
if cvss is not None:
|
|
|
|
|
report["cvss"] = cvss
|
|
|
|
|
if cvss_breakdown:
|
|
|
|
|
report["cvss_breakdown"] = cvss_breakdown
|
|
|
|
|
if endpoint:
|
|
|
|
|
report["endpoint"] = endpoint.strip()
|
|
|
|
|
if method:
|
|
|
|
|
report["method"] = method.strip()
|
|
|
|
|
if cve:
|
|
|
|
|
report["cve"] = cve.strip()
|
2026-02-15 16:40:26 -08:00
|
|
|
if cwe:
|
|
|
|
|
report["cwe"] = cwe.strip()
|
|
|
|
|
if code_locations:
|
|
|
|
|
report["code_locations"] = code_locations
|
2026-01-07 16:33:16 -08:00
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
self.vulnerability_reports.append(report)
|
|
|
|
|
logger.info(f"Added vulnerability report: {report_id} - {title}")
|
2026-01-09 12:26:56 -08:00
|
|
|
posthog.finding(severity)
|
2025-10-29 06:14:08 +03:00
|
|
|
|
|
|
|
|
if self.vulnerability_found_callback:
|
2026-01-07 19:51:41 -08:00
|
|
|
self.vulnerability_found_callback(report)
|
2025-10-29 06:14:08 +03:00
|
|
|
|
2025-11-22 22:28:14 +04:00
|
|
|
self.save_run_data()
|
2025-08-08 20:36:44 -07:00
|
|
|
return report_id
|
|
|
|
|
|
2026-01-07 19:23:17 -08:00
|
|
|
def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
|
|
|
|
|
return list(self.vulnerability_reports)
|
|
|
|
|
|
2026-01-07 16:33:16 -08:00
|
|
|
def update_scan_final_fields(
|
2025-08-08 20:36:44 -07:00
|
|
|
self,
|
2026-01-07 16:33:16 -08:00
|
|
|
executive_summary: str,
|
|
|
|
|
methodology: str,
|
|
|
|
|
technical_analysis: str,
|
|
|
|
|
recommendations: str,
|
2025-08-08 20:36:44 -07:00
|
|
|
) -> None:
|
|
|
|
|
self.scan_results = {
|
|
|
|
|
"scan_completed": True,
|
2026-01-07 16:33:16 -08:00
|
|
|
"executive_summary": executive_summary.strip(),
|
|
|
|
|
"methodology": methodology.strip(),
|
|
|
|
|
"technical_analysis": technical_analysis.strip(),
|
|
|
|
|
"recommendations": recommendations.strip(),
|
|
|
|
|
"success": True,
|
2025-08-08 20:36:44 -07:00
|
|
|
}
|
|
|
|
|
|
2026-01-07 16:33:16 -08:00
|
|
|
self.final_scan_result = f"""# Executive Summary
|
|
|
|
|
|
|
|
|
|
{executive_summary.strip()}
|
|
|
|
|
|
|
|
|
|
# Methodology
|
|
|
|
|
|
|
|
|
|
{methodology.strip()}
|
|
|
|
|
|
|
|
|
|
# Technical Analysis
|
|
|
|
|
|
|
|
|
|
{technical_analysis.strip()}
|
|
|
|
|
|
|
|
|
|
# Recommendations
|
|
|
|
|
|
|
|
|
|
{recommendations.strip()}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
logger.info("Updated scan final fields")
|
2025-11-22 22:28:14 +04:00
|
|
|
self.save_run_data(mark_complete=True)
|
2026-01-09 12:26:56 -08:00
|
|
|
posthog.end(self, exit_reason="finished_by_tool")
|
2025-08-08 20:36:44 -07:00
|
|
|
|
|
|
|
|
def log_chat_message(
|
|
|
|
|
self,
|
|
|
|
|
content: str,
|
|
|
|
|
role: str,
|
|
|
|
|
agent_id: str | None = None,
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
|
|
|
|
) -> int:
|
|
|
|
|
message_id = self._next_message_id
|
|
|
|
|
self._next_message_id += 1
|
|
|
|
|
|
2026-04-25 13:47:37 -07:00
|
|
|
self.chat_messages.append(
|
|
|
|
|
{
|
|
|
|
|
"message_id": message_id,
|
|
|
|
|
"content": content,
|
|
|
|
|
"role": role,
|
|
|
|
|
"agent_id": agent_id,
|
|
|
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
|
|
|
"metadata": metadata or {},
|
|
|
|
|
}
|
2026-03-09 05:11:24 -03:00
|
|
|
)
|
2025-08-08 20:36:44 -07:00
|
|
|
return message_id
|
|
|
|
|
|
|
|
|
|
def set_scan_config(self, config: dict[str, Any]) -> None:
|
|
|
|
|
self.scan_config = config
|
|
|
|
|
self.run_metadata.update(
|
|
|
|
|
{
|
2025-11-01 01:25:07 +02:00
|
|
|
"targets": config.get("targets", []),
|
2025-08-08 20:36:44 -07:00
|
|
|
"user_instructions": config.get("user_instructions", ""),
|
|
|
|
|
"max_iterations": config.get("max_iterations", 200),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-09 05:11:24 -03:00
|
|
|
def save_run_data(self, mark_complete: bool = False) -> None:
|
2026-04-25 16:28:07 -07:00
|
|
|
if mark_complete:
|
|
|
|
|
if self.end_time is None:
|
|
|
|
|
self.end_time = datetime.now(UTC).isoformat()
|
|
|
|
|
self.run_metadata["end_time"] = self.end_time
|
|
|
|
|
self.run_metadata["status"] = "completed"
|
|
|
|
|
|
|
|
|
|
self._get_writer().save(
|
|
|
|
|
vulnerability_reports=self.vulnerability_reports,
|
|
|
|
|
final_scan_result=self.final_scan_result,
|
2026-04-26 00:57:52 -07:00
|
|
|
run_metadata=dict(self.run_metadata),
|
2026-04-25 16:28:07 -07:00
|
|
|
)
|
2025-08-08 20:36:44 -07:00
|
|
|
|
2026-04-25 18:08:36 -07:00
|
|
|
def log_tool_start(
|
|
|
|
|
self,
|
|
|
|
|
agent_id: str,
|
|
|
|
|
tool_name: str,
|
|
|
|
|
args: dict[str, Any] | None = None,
|
|
|
|
|
) -> int:
|
2026-04-25 16:24:45 -07:00
|
|
|
"""Record a tool invocation in flight. Returns an exec_id."""
|
|
|
|
|
exec_id = self._next_exec_id
|
|
|
|
|
self._next_exec_id += 1
|
|
|
|
|
self.tool_executions[exec_id] = {
|
|
|
|
|
"agent_id": agent_id,
|
|
|
|
|
"tool_name": tool_name,
|
2026-04-25 18:08:36 -07:00
|
|
|
"args": args or {},
|
2026-04-25 16:24:45 -07:00
|
|
|
"status": "running",
|
|
|
|
|
"result": None,
|
|
|
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
return exec_id
|
|
|
|
|
|
|
|
|
|
def log_tool_end(self, agent_id: str, tool_name: str, result: Any) -> None:
|
|
|
|
|
"""Mark the most recent matching exec as completed."""
|
|
|
|
|
for exec_id in reversed(self.tool_executions):
|
|
|
|
|
entry = self.tool_executions[exec_id]
|
|
|
|
|
if (
|
|
|
|
|
entry.get("agent_id") == agent_id
|
|
|
|
|
and entry.get("tool_name") == tool_name
|
|
|
|
|
and entry.get("status") == "running"
|
|
|
|
|
):
|
|
|
|
|
entry["status"] = "completed"
|
|
|
|
|
entry["result"] = result
|
|
|
|
|
return
|
|
|
|
|
# No matching start (e.g. hooks added later in life) — record as completed.
|
|
|
|
|
exec_id = self._next_exec_id
|
|
|
|
|
self._next_exec_id += 1
|
|
|
|
|
self.tool_executions[exec_id] = {
|
|
|
|
|
"agent_id": agent_id,
|
|
|
|
|
"tool_name": tool_name,
|
|
|
|
|
"status": "completed",
|
|
|
|
|
"result": result,
|
|
|
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
def get_real_tool_count(self) -> int:
|
|
|
|
|
return sum(
|
|
|
|
|
1
|
2025-12-09 16:07:58 +08:00
|
|
|
for exec_data in list(self.tool_executions.values())
|
2025-08-08 20:36:44 -07:00
|
|
|
if exec_data.get("tool_name") not in ["scan_start_info", "subagent_start_info"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def get_total_llm_stats(self) -> dict[str, Any]:
|
2026-04-25 12:44:48 -07:00
|
|
|
"""Snapshot the run's aggregated LLM usage."""
|
|
|
|
|
stats = self._llm_stats
|
|
|
|
|
total = {
|
|
|
|
|
"input_tokens": int(stats["input_tokens"]),
|
|
|
|
|
"output_tokens": int(stats["output_tokens"]),
|
|
|
|
|
"cached_tokens": int(stats["cached_tokens"]),
|
|
|
|
|
"cost": round(float(stats["cost"]), 4),
|
|
|
|
|
"requests": int(stats["requests"]),
|
2025-08-08 20:36:44 -07:00
|
|
|
}
|
|
|
|
|
return {
|
2026-04-25 12:44:48 -07:00
|
|
|
"total": total,
|
|
|
|
|
"total_tokens": total["input_tokens"] + total["output_tokens"],
|
2025-08-08 20:36:44 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 09:30:23 -07:00
|
|
|
def record_llm_usage(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
input_tokens: int = 0,
|
|
|
|
|
output_tokens: int = 0,
|
|
|
|
|
cached_tokens: int = 0,
|
|
|
|
|
cost: float = 0.0,
|
|
|
|
|
requests: int = 1,
|
|
|
|
|
) -> None:
|
2026-04-25 12:44:48 -07:00
|
|
|
"""Accumulate LLM usage from the orchestration hooks."""
|
|
|
|
|
self._llm_stats["input_tokens"] += input_tokens
|
|
|
|
|
self._llm_stats["output_tokens"] += output_tokens
|
|
|
|
|
self._llm_stats["cached_tokens"] += cached_tokens
|
|
|
|
|
self._llm_stats["cost"] += cost
|
|
|
|
|
self._llm_stats["requests"] += requests
|
2026-04-25 09:30:23 -07:00
|
|
|
|
2025-08-08 20:36:44 -07:00
|
|
|
def cleanup(self) -> None:
|
2025-11-22 22:28:14 +04:00
|
|
|
self.save_run_data(mark_complete=True)
|