Add live agent steering from the web viewer

Let the in-TUI viewer send a steering message to a running agent during a
live scan, reusing the same delivery path the TUI uses. Standalone strix
view reports steering unavailable, so the web composer only shows when a
live scan is in process.
This commit is contained in:
Jonathan Singer
2026-07-20 16:54:29 -04:00
parent 01c02d6d97
commit 47ded9b93c
7 changed files with 329 additions and 143 deletions
+13 -1
View File
@@ -1844,7 +1844,19 @@ class StrixTUIApp(App): # type: ignore[misc]
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
return
run_dir = self.report_state.get_run_dir()
httpd, url = serve(run_dir, open_browser=True)
def _viewer_steer(agent_id: str, message: str) -> bool:
# Reuse the exact TUI delivery path, but target the agent the
# web graph selected (not the TUI's current selection).
return send_user_message_to_agent(
coordinator=self.coordinator,
loop=self._scan_loop,
live_view=self.live_view,
target_agent_id=agent_id,
message=message,
)
httpd, url = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
except Exception:
logger.debug("failed to start local viewer", exc_info=True)
self._set_viewer_cta("[red]Viewer failed to start[/]")
+59 -3
View File
@@ -22,7 +22,7 @@ import webbrowser
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, unquote, urlsplit
from strix.core.paths import run_record_path
@@ -37,6 +37,10 @@ from strix.viewer.transcript import (
)
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
@@ -103,12 +107,21 @@ def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path
class _ViewerState:
def __init__(self, run_dir: Path, assets_dir: Path) -> None:
def __init__(
self,
run_dir: Path,
assets_dir: Path,
steer_handler: Callable[[str, str], bool] | None = None,
) -> None:
self.run_dir = run_dir
self.assets_dir = assets_dir
# The strix_runs directory that holds the launched run; used to
# enumerate and resolve other runs for the history list.
self.base_dir = run_dir.parent
# Set only when the viewer runs inside a live scan process (the TUI
# launcher), which can deliver a message to a running agent. Absent for
# standalone ``strix view`` / finished runs, so steering is unavailable.
self.steer_handler = steer_handler
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
@@ -148,6 +161,8 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._handle_forget()
elif path == "/api/report/send":
self._handle_report_send()
elif path == "/api/agents/steer":
self._handle_steer()
else:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
except BrokenPipeError:
@@ -204,6 +219,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
self._send_json(HTTPStatus.OK, payload)
return
if path == "/api/capabilities":
# Steering is only possible when the viewer shares a live scan's
# coordinator + event loop (the TUI launcher wires a handler).
self._send_json(
HTTPStatus.OK, {"can_steer": state.steer_handler is not None}
)
return
if path == "/api/auth/status":
record = auth.read_auth()
self._send_json(
@@ -299,6 +321,35 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
{"ok": True, "password": password, "filename": filename},
)
# Cap on a steering message so a runaway client cannot flood the agent.
_STEER_MESSAGE_MAX = 4000
def _handle_steer(self) -> None:
body = self._read_body()
agent_id = body.get("agent_id")
message = body.get("message")
if not isinstance(agent_id, str) or not agent_id.strip():
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_agent_id"})
return
if (
not isinstance(message, str)
or not message.strip()
or len(message) > self._STEER_MESSAGE_MAX
):
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
return
if state.steer_handler is None:
# Standalone / finished-run viewing has no live scan to steer.
self._send_json(
HTTPStatus.FORBIDDEN, {"error": "steering_unavailable"}
)
return
delivered = state.steer_handler(agent_id, message)
if delivered:
self._send_json(HTTPStatus.OK, {"ok": True})
else:
self._send_json(HTTPStatus.OK, {"ok": False, "error": "not_delivered"})
def _send_relay_error(self, exc: auth.RelayError) -> None:
status_by_code = {
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
@@ -359,15 +410,20 @@ def serve(
host: str = "127.0.0.1",
port: int = 0,
open_browser: bool = True,
steer_handler: Callable[[str, str], bool] | None = None,
) -> tuple[ThreadingHTTPServer, str]:
"""Start the viewer server on a background thread and return (server, url).
Binds an ephemeral port by default. If a fixed ``port`` is requested but in
use, falls back to an ephemeral port. Reused by both the ``strix view``
command and the in-TUI launcher; callers own the server's lifetime.
``steer_handler`` is supplied only by the in-TUI launcher, which runs inside
the live scan process and can forward a message to a running agent. Left
``None`` (standalone ``strix view``), steering is reported unavailable.
"""
assets_dir = bundle_dir()
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir)
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir, steer_handler=steer_handler)
handler = _make_handler(state)
try:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-D9rN78-T.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CiotzpD9.css">
<script type="module" crossorigin src="./assets/index-Cm9zWfr2.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CHD1Rail.css">
</head>
<body>
<div id="root"></div>
+104 -13
View File
@@ -15,6 +15,7 @@ import {
Radio,
ArrowUpRight,
History,
Send,
} from "lucide-react";
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
import { SEVERITY_COLORS } from "@/types/issues";
@@ -29,16 +30,19 @@ import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
import {
fetchAll,
fetchAuthStatus,
fetchCapabilities,
fetchRunSummary,
fetchRuns,
fetchTranscript,
fetchVulnerabilities,
forgetAuth,
steerAgent,
type AuthStatus,
type LoadedRun,
type RunsPayload,
type TranscriptAgent,
} from "@/data/serverSource";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { SIGNUP_URL, ctaUrl, track, trackCta } from "@/lib/cta";
import { runTitle } from "@/lib/target-utils";
import Sidebar from "@/components/Sidebar";
import PastRunsView from "@/components/PastRunsView";
@@ -78,6 +82,9 @@ export default function App() {
const [runs, setRuns] = useState<RunsPayload | null>(null);
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
// Whether this viewer can steer a live scan (true only inside the in-TUI
// launcher that shares the running scan's coordinator + event loop).
const [canSteer, setCanSteer] = useState(false);
const refreshAuth = useCallback(async () => {
try {
@@ -98,6 +105,12 @@ export default function App() {
useEffect(() => {
void refreshAuth();
void refreshRuns();
// Capabilities never change over a session, so fetch once on mount.
fetchCapabilities()
.then((caps) => setCanSteer(caps.can_steer))
.catch(() => {
/* absence of steering is the safe default */
});
}, [refreshAuth, refreshRuns]);
// Live polling, scoped to the active run. Re-runs when the active run changes
@@ -386,7 +399,7 @@ export default function App() {
onOpenEmail={openEmailFromOverview}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} />
<AgentsTab run={run} canSteer={canSteer} />
) : selected ? (
<div className="space-y-4">
<button
@@ -729,7 +742,7 @@ function TabButton({
);
}
function AgentsTab({ run }: { run: LoadedRun }) {
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
const { agents, events } = run.transcript;
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
// Clicking a graph node opens the agent's transcript in a modal (matching the
@@ -737,6 +750,9 @@ function AgentsTab({ run }: { run: LoadedRun }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
// Live steering is only possible in-process (canSteer) while the scan runs.
const steerable = canSteer && !run.finished;
return (
<div className="space-y-5">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
@@ -762,18 +778,16 @@ function AgentsTab({ run }: { run: LoadedRun }) {
</div>
</div>
{/* Steering footer: live control lives in Strix Cloud. */}
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
{steerable && (
<SteerComposer agents={agents} selectedId={selectedId} />
)}
{/* Re-run always routes to Strix Cloud. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Drive the agents live</p>
<p className="mt-0.5 text-xs text-[#666]">Steer and re-run this scan from the web.</p>
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Steer agents from the web"
desc="Guide the agents live while they scan."
slug="live_scan_prompt"
surface="agents"
icon={Radio}
/>
<ProInlineCta
label="Re-run in Strix Cloud with more depth"
desc="Run this scan on managed infra with more depth."
@@ -794,3 +808,80 @@ function AgentsTab({ run }: { run: LoadedRun }) {
</div>
);
}
// Live steering composer. Targets the agent selected in the graph; with none
// selected it falls back to the root agent (no parent). Only rendered while the
// scan is live and the viewer runs in-process (see AgentsTab.steerable).
function SteerComposer({
agents,
selectedId,
}: {
agents: TranscriptAgent[];
selectedId: string | null;
}) {
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const rootAgent = agents.find((a) => !a.parent_id) ?? agents[0] ?? null;
const target =
(selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null) ?? rootAgent;
const send = useCallback(async () => {
const text = message.trim();
if (!text || sending || !target) return;
setSending(true);
setFeedback(null);
const res = await steerAgent(target.id, text);
setSending(false);
if (res.ok) {
setMessage("");
setFeedback(`Sent to ${target.name}`);
track("agent_steered");
} else if (res.error === "not_delivered") {
setFeedback("Could not reach that agent (it may have finished).");
} else {
setFeedback("Could not send that message. Try again.");
}
}, [message, sending, target]);
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="flex items-center gap-2">
<Radio className="w-4 h-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Steer the agents</h2>
</div>
<p className="mt-1 mb-3 text-xs text-[#666]">
Send a live instruction to{" "}
<span className="text-[#aaa]">{target ? target.name : "the agent"}</span>
{selectedId ? " (selected)" : " (root)"}. Click an agent in the graph to steer it directly.
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
placeholder="e.g. Focus on the authentication flow next"
maxLength={4000}
disabled={sending || !target}
className="flex-1 min-w-0 rounded-lg border border-[#2a2a2a] bg-[rgba(255,255,255,0.03)] px-3 py-2 text-sm text-white placeholder:text-[#555] focus:outline-none focus:border-[#3a3a3a] disabled:opacity-50"
/>
<button
onClick={() => void send()}
disabled={sending || !message.trim() || !target}
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<Send className="w-3.5 h-3.5" aria-hidden="true" />
{sending ? "Sending" : "Send"}
</button>
</div>
{feedback && <p className="mt-2 text-xs text-[#888]">{feedback}</p>}
</div>
);
}
+22
View File
@@ -179,6 +179,28 @@ export async function fetchRuns(): Promise<RunsPayload> {
};
}
export interface Capabilities {
can_steer: boolean;
}
export type SteerResult = { ok: true } | { ok: false; error: string };
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
export async function fetchCapabilities(): Promise<Capabilities> {
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
return { can_steer: obj?.can_steer === true };
}
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
const { ok, data } = await postJson("/api/agents/steer", {
agent_id: agentId,
message,
});
if (ok && data.ok === true) return { ok: true };
return { ok: false, error: String(data.error ?? "unavailable") };
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
return { verified: obj?.verified === true, email: obj?.email ?? null };