From 0fdffdb1cb95e355d0868181153f02936f36ea37 Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Tue, 14 Jul 2026 20:01:53 -0700 Subject: [PATCH 01/46] feat(agentos): inspector status strip, transcript composer, and layered action errors --- .../agentos/src/inspector-tabs/common.tsx | 47 +++ .../src/inspector-tabs/lib/actor-client.ts | 89 ++++- .../agentos/src/inspector-tabs/lib/health.ts | 57 +++ .../agentos/src/inspector-tabs/lib/source.ts | 11 + .../agentos/src/inspector-tabs/lib/types.ts | 37 +- packages/agentos/src/inspector-tabs/main.tsx | 31 +- .../src/inspector-tabs/tab-boundary.tsx | 49 ++- .../src/inspector-tabs/tabs/filesystem.tsx | 19 +- .../src/inspector-tabs/tabs/transcript.tsx | 349 +++++++++++++++--- .../src/inspector-tabs/vm-status-strip.tsx | 143 +++++++ packages/agentos/tests/actor.test.ts | 2 + 11 files changed, 757 insertions(+), 77 deletions(-) create mode 100644 packages/agentos/src/inspector-tabs/lib/health.ts create mode 100644 packages/agentos/src/inspector-tabs/vm-status-strip.tsx diff --git a/packages/agentos/src/inspector-tabs/common.tsx b/packages/agentos/src/inspector-tabs/common.tsx index 0cd43c4013..cd8e77505e 100644 --- a/packages/agentos/src/inspector-tabs/common.tsx +++ b/packages/agentos/src/inspector-tabs/common.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { type ActionErrorLayer, isInspectorActionError } from "./lib/actor-client"; import { cn } from "./lib/cn"; import React, { useState } from "react"; @@ -11,6 +12,52 @@ export function AgentOsEmpty({ children }: { children: ReactNode }) { ); } +const LAYER_LABEL: Record = { + gateway: "Gateway unreachable", + auth: "Not authorized", + contract: "Not supported by this runtime", + runtime: "Runtime error", + timeout: "Timed out", +}; + +/** "This runtime doesn't expose X" empty state for contract-layer failures — + * the graceful-degradation path for actors built against other runtimes. */ +export function UnsupportedAction({ action, className }: { action?: string; className?: string }) { + return ( +
+ Not supported by this runtime + {action ? ( + + the actor does not expose {action} + + ) : null} +
+ ); +} + +/** Compact inline error note: layer label + message + hint. Used by inline + * (non-suspense) error paths; the suspense path renders via TabBoundary. */ +export function ActionErrorNote({ error, className }: { error: unknown; className?: string }) { + if (isInspectorActionError(error) && error.layer === "contract") { + return ; + } + const layer = isInspectorActionError(error) ? error.layer : undefined; + const message = error instanceof Error ? error.message : String(error); + const hint = isInspectorActionError(error) ? error.hint : undefined; + return ( +
+
+ + + {layer ? LAYER_LABEL[layer] : "Error"} + +
+
{message}
+ {hint ?
{hint}
: null} +
+ ); +} + export type DotColor = "green" | "amber" | "red" | "muted"; const DOT_CLASS: Record = { green: "text-green-500", diff --git a/packages/agentos/src/inspector-tabs/lib/actor-client.ts b/packages/agentos/src/inspector-tabs/lib/actor-client.ts index 99962cc799..04291cf54d 100644 --- a/packages/agentos/src/inspector-tabs/lib/actor-client.ts +++ b/packages/agentos/src/inspector-tabs/lib/actor-client.ts @@ -32,10 +32,89 @@ export function tabIdFromUrl(): string | undefined { return idx >= 0 ? parts[idx + 1] : undefined; } -export interface ActionError { - group?: string; - code?: string; - message?: string; +/** Which hop of the dashboard→gateway→actor→VM chain an action failure came + * from. Drives per-layer rendering (tab-boundary.tsx / common.tsx) and the + * retry policy (main.tsx): contract/auth failures never retry. */ +export type ActionErrorLayer = "gateway" | "auth" | "contract" | "runtime" | "timeout"; + +export class InspectorActionError extends Error { + readonly layer: ActionErrorLayer; + readonly action: string; + readonly hint: string; + constructor(layer: ActionErrorLayer, action: string, message: string, hint: string) { + super(message); + this.name = "InspectorActionError"; + this.layer = layer; + this.action = action; + this.hint = hint; + } +} + +export function isInspectorActionError(error: unknown): error is InspectorActionError { + return ( + error instanceof InspectorActionError || + (typeof error === "object" && + error !== null && + (error as { name?: string }).name === "InspectorActionError") + ); +} + +/** Map a raw transport/actor error onto the failing layer. Order matters: + * abort before contract before runtime (messages overlap on "error"). */ +function classifyActionError(action: string, error: unknown): InspectorActionError { + const message = error instanceof Error ? error.message : String(error); + const rivet = (error ?? {}) as { + group?: string; + code?: string; + statusCode?: number; + name?: string; + }; + if (rivet.name === "AbortError" || /\baborted\b/i.test(message)) { + return new InspectorActionError( + "timeout", + action, + `${action} timed out`, + "The VM may still be booting or the call is hung — retry in a few seconds.", + ); + } + if (/was not found/i.test(message) && /action/i.test(message)) { + return new InspectorActionError( + "contract", + action, + `This runtime does not expose the \`${action}\` action.`, + "The actor was built against a different agentOS runtime version; this panel is unavailable.", + ); + } + if ( + rivet.statusCode === 401 || + rivet.statusCode === 403 || + rivet.code === "unauthorized" || + /bearer token|x-rivet-token|unauthorized|forbidden/i.test(message) + ) { + return new InspectorActionError( + "auth", + action, + message, + "The inspector's token was rejected — reload the dashboard to refresh credentials.", + ); + } + if (rivet.code === "internal_error" || /internal error/i.test(message)) { + return new InspectorActionError( + "runtime", + action, + `${action} failed inside the actor runtime.`, + "The VM runtime likely crashed (e.g. sidecar exit) — restart the actor or server and check server logs.", + ); + } + if (error instanceof TypeError || /fetch failed|failed to fetch|networkerror/i.test(message)) { + return new InspectorActionError( + "gateway", + action, + `Could not reach the actor gateway (${message}).`, + "The engine/gateway is unreachable — check that the server is running.", + ); + } + return new InspectorActionError("runtime", action, message || `${action} failed`, "See server logs for the underlying error."); } function getHandle(): Any { @@ -63,6 +142,8 @@ export async function callAction( const timer = opts.timeoutMs ? setTimeout(() => ctrl.abort(), opts.timeoutMs) : undefined; try { return (await h.action({ name, args, signal: ctrl.signal })) as T; + } catch (error) { + throw classifyActionError(name, error); } finally { if (timer) clearTimeout(timer); } diff --git a/packages/agentos/src/inspector-tabs/lib/health.ts b/packages/agentos/src/inspector-tabs/lib/health.ts new file mode 100644 index 0000000000..11d1f37699 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/health.ts @@ -0,0 +1,57 @@ +// OPTIONAL actor actions, deliberately outside lib/source.ts: the actor.test.ts +// audit requires every source.ts action to exist in the generated contract, but +// these two are runtime extensions that some hosts provide and some don't — +// `getRuntimeHealth` is a host-side shim (e.g. rivet-opencode-example) and +// `cancelPrompt` ships in the rivetkit 2.3.3 agent-os wrapper but not the +// generated contract here. Callers must feature-detect: both throw a +// contract-layer InspectorActionError when the actor lacks them (the status +// strip hides itself; the composer disables its Stop button). +import { queryOptions } from "@tanstack/react-query"; +import { callAction, isInspectorActionError } from "./actor-client"; +import type { RuntimeHealth } from "./types"; + +export const healthQueryOptions = (actorId: string) => + queryOptions({ + queryKey: ["agent-os", actorId, "runtime-health"], + queryFn: () => callAction("getRuntimeHealth", [], { timeoutMs: 8_000 }), + refetchInterval: 5_000, + // Contract-missing is permanent for this actor: never retry, and stop + // polling (react-query keeps refetching errored queries otherwise). + retry: false, + refetchOnMount: false, + }); + +export function isMissingHealthAction(error: unknown): boolean { + return isInspectorActionError(error) && error.layer === "contract"; +} + +/** Live (loaded-in-VM) sessions — used to mark sidebar rows as running. The + * current rivetkit wrapper's listPersistedSessions rows carry no status field, + * so liveness comes from cross-referencing listSessions. Returns null when the + * runtime doesn't expose the action (callers fall back to record.status). */ +export const liveSessionsQueryOptions = (actorId: string) => + queryOptions({ + queryKey: ["agent-os", actorId, "live-sessions"], + queryFn: async (): Promise | null> => { + try { + const live = await callAction<{ sessionId: string }[]>("listSessions", []); + return new Set(live.map((s) => s.sessionId)); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return null; + throw error; + } + }, + refetchInterval: 10_000, + retry: false, + }); + +/** Best-effort prompt cancellation; resolves false when unsupported. */ +export async function cancelPrompt(sessionId: string): Promise { + try { + await callAction("cancelPrompt", [sessionId]); + return true; + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return false; + throw error; + } +} diff --git a/packages/agentos/src/inspector-tabs/lib/source.ts b/packages/agentos/src/inspector-tabs/lib/source.ts index fd2ff908fe..376e08b714 100644 --- a/packages/agentos/src/inspector-tabs/lib/source.ts +++ b/packages/agentos/src/inspector-tabs/lib/source.ts @@ -212,4 +212,15 @@ export const agentOsSource = { mapTranscriptEvent, ), }), + + // ── Composer actions (transcript tab) ───────────────────────────────── + // Imperative (not queries): the composer drives the agent. Streamed output + // arrives on the existing `sessionEvent` subscription; `sendPrompt` resolves + // when the turn completes. + sendPrompt: (sessionId: string, text: string) => + callAction<{ text?: string }>("sendPrompt", [sessionId, text]), + // Older runtimes return the sessionId string; the rivetkit wrapper returns + // `{ sessionId }` — callers normalize. + createSession: (agentType: string, options: { env?: Record }) => + callAction("createSession", [agentType, options]), }; diff --git a/packages/agentos/src/inspector-tabs/lib/types.ts b/packages/agentos/src/inspector-tabs/lib/types.ts index ead8979b0c..fc89e677a9 100644 --- a/packages/agentos/src/inspector-tabs/lib/types.ts +++ b/packages/agentos/src/inspector-tabs/lib/types.ts @@ -86,8 +86,9 @@ export interface PersistedSessionRecord { agentType: string; createdAt: number; /** VM-liveness activity status: "running" = loaded in the VM, "idle" = - * persisted but hibernated (resumable). */ - status: "running" | "idle"; + * persisted but hibernated (resumable). Absent on runtimes whose records + * carry no status — liveness then comes from `listSessions` (lib/health.ts). */ + status?: "running" | "idle"; } export interface JsonRpcNotification { jsonrpc: "2.0"; @@ -114,4 +115,36 @@ export type TranscriptEvent = { seq: number } & ( | { kind: "user" | "assistant" | "thinking"; text: string } | { kind: "tool"; tool: string; status?: string } | { kind: "raw"; label: string; json: unknown } + | { kind: "error"; text: string } ); + +// ── Runtime health (optional `getRuntimeHealth` action; see lib/health.ts) ─ +export interface RuntimeLimitWarning { + ts: number; + limit: string; + category: string; + observed: number; + capacity: number; + fillPercent: number; +} +export interface RuntimeAgentExit { + ts: number; + sessionId: string; + agentType: string; + exitCode: number | null; + restart: string; + restartCount: number; +} +export interface RuntimeHealth { + booted: boolean; + sessions: number | null; + sidecar: { state: string; activeVmCount: number } | null; + warnings: RuntimeLimitWarning[]; + agentExits: RuntimeAgentExit[]; + stderrTail: { ts: number; line: string }[]; +} + +/** Live `vmShutdown` broadcast payload mirror (Rust owns broadcasts). */ +export interface VmShutdownPayload { + reason?: "sleep" | "destroy" | "error" | string; +} diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index 4a18026e8f..586b73656b 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { lazy, type ComponentType, StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import { tabIdFromUrl } from "./lib/actor-client"; +import { isInspectorActionError, tabIdFromUrl } from "./lib/actor-client"; import { RivetProvider } from "./lib/rivet"; import { TabBoundary } from "./tab-boundary"; +import { VmStatusStrip } from "./vm-status-strip"; import React from "react"; import "./styles.css"; @@ -28,8 +29,18 @@ const queryClient = new QueryClient({ queries: { // VM-backed actions (listProcesses/readdir/readFile/stat) time out on // the FIRST call after the actor's VM hibernates and cold-wakes; - // retry so the query recovers once the VM is warm. - retry: 3, + // retry so the query recovers once the VM is warm. Contract/auth + // failures are permanent for this actor — retrying only delays the + // error UI by the full backoff, so fail those immediately. + retry: (failureCount, error) => { + if ( + isInspectorActionError(error) && + (error.layer === "contract" || error.layer === "auth") + ) { + return false; + } + return failureCount < 3; + }, retryDelay: (attempt) => Math.min(1500 * 2 ** attempt, 8000), refetchOnWindowFocus: false, staleTime: 5_000, @@ -66,12 +77,18 @@ function App() { const Tab = lazy(loader); // RivetProvider owns the shared rivetkit client (typed `useActor` + the // `callAction` transport). One boundary catches both the code-split chunk - // load and the tab's useSuspenseQuery data load. + // load and the tab's useSuspenseQuery data load. The status strip sits + // outside the boundary so a broken tab still shows VM health. return ( - - - +
+ +
+ + + +
+
); } diff --git a/packages/agentos/src/inspector-tabs/tab-boundary.tsx b/packages/agentos/src/inspector-tabs/tab-boundary.tsx index c4cf411a55..148c44f066 100644 --- a/packages/agentos/src/inspector-tabs/tab-boundary.tsx +++ b/packages/agentos/src/inspector-tabs/tab-boundary.tsx @@ -1,5 +1,8 @@ +import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { Component, type ReactNode, Suspense } from "react"; +import { ActionErrorNote, UnsupportedAction } from "./common"; +import { isInspectorActionError } from "./lib/actor-client"; import React from "react"; function TabFallback() { @@ -16,27 +19,51 @@ function TabFallback() { ); } -class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> { +class ErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + { error?: Error } +> { state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { return { error }; } + #retry = () => { + this.props.onReset?.(); + this.setState({ error: undefined }); + }; render() { - if (this.state.error) { - return ( -
- {this.state.error.message || "Failed to load."} -
- ); + const { error } = this.state; + if (!error) return this.props.children; + // Contract-layer failures are a capability gap, not a fault: render the + // quiet unsupported state with no retry (retrying cannot succeed). + if (isInspectorActionError(error) && error.layer === "contract") { + return ; } - return this.props.children; + return ( +
+ + +
+ ); } } export function TabBoundary({ children }: { children: ReactNode }) { + // QueryErrorResetBoundary clears React Query's cached suspense errors so the + // Retry button actually refetches instead of re-throwing the stale error. return ( - - }>{children} - + + {({ reset }) => ( + + }>{children} + + )} + ); } diff --git a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx index 9385240db3..d0349b8e3e 100644 --- a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { AgentOsEmpty, ChevronRight, FileGlyph, formatBytes, relativeTime } from "../common"; +import { ActionErrorNote, AgentOsEmpty, ChevronRight, FileGlyph, formatBytes, relativeTime } from "../common"; import { cn } from "../lib/cn"; import { agentOsSource } from "../lib/source"; import type { FsEntry } from "../lib/types"; @@ -85,7 +85,7 @@ function FileViewer({ actorId, path }: { actorId: string; path: string | null }) agentOsSource.fileContentQueryOptions(actorId, path), ); if (!path) return Select a file to view its contents.; - if (error) return Failed to read {path}: {(error as Error).message}; + if (error) return ; if (!data || isFetching) return Loading {path}…; return (
@@ -168,13 +168,20 @@ export function FilesystemTabConnected({ actorId }: { actorId: string }) { {rootsQuery.isLoading ? ( Loading {root}… ) : rootsQuery.error ? ( - - Failed to list {root}: {(rootsQuery.error as Error).message} - + ) : notADir ? ( Not a directory, or does not exist: {root} ) : roots.length === 0 ? ( - Empty directory. + + + Empty directory. +
+ + The VM root filesystem is in-memory — files from previous VM boots do not + survive restarts. + +
+
) : ( roots.map((entry) => ( -
- {label} - {meta ? {meta} : null} -
- {children} -
- ); -} - +// Chat-style transcript rendering. Conversation content (user/assistant) gets +// bubbles; plumbing (usage updates, unknown ACP events) collapses to one-line +// chips with the raw JSON behind an expander so it never dominates the pane. function TranscriptEventView({ event }: { event: TranscriptEvent }) { switch (event.kind) { case "user": + return ( +
+
+ {event.text || "—"} +
+
+ ); case "assistant": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
); case "thinking": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
); case "tool": return ( - - {event.status ?? ""} - +
+ + {event.tool} + {event.status ? ( + + {event.status} + + ) : null} +
+ ); + case "error": + return ( +
{event.text}
); default: return ( - -
+				
+ + + {event.label} + +
 						{JSON.stringify(event.json, null, 2)}
 					
- +
); } } +// Pins the transcript to the newest event whenever one arrives. +function ScrollAnchor({ count }: { count: number }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollIntoView({ block: "end" }); + }, [count]); + return
; +} + +// Composer defaults persist in localStorage: they contain the API key the +// user pastes, which never leaves the browser except inside createSession's +// env (sent only to this actor). +const LS_AGENT_TYPE = "agentos-inspector:composer-agent-type"; +const LS_ENV_JSON = "agentos-inspector:composer-env"; +const DEFAULT_ENV_JSON = JSON.stringify( + { + ANTHROPIC_API_KEY: "", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "anthropic/claude-haiku-4-5-20251001" }), + }, + null, + 2, +); + +function Composer({ + sessionId, + sessionStatus, + onSessionCreated, + onErrorEvent, +}: { + sessionId: string | null; + sessionStatus?: string; + onSessionCreated: (sessionId: string) => void; + onErrorEvent: (text: string) => void; +}) { + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [stopUnsupported, setStopUnsupported] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [agentType, setAgentType] = useState( + () => localStorage.getItem(LS_AGENT_TYPE) ?? "opencode", + ); + const [envJson, setEnvJson] = useState(() => localStorage.getItem(LS_ENV_JSON) ?? DEFAULT_ENV_JSON); + // The in-flight session survives re-renders; also used by Stop. + const activeSessionRef = useRef(null); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + setDraft(""); + try { + let sid = sessionId; + if (!sid) { + let env: Record; + try { + env = JSON.parse(envJson); + } catch (e) { + throw new Error(`Session env is not valid JSON: ${(e as Error).message}`); + } + const raw = await agentOsSource.createSession(agentType.trim() || "opencode", { env }); + sid = typeof raw === "string" ? raw : raw.sessionId; + onSessionCreated(sid); + } + activeSessionRef.current = sid; + await agentOsSource.sendPrompt(sid, text); + } catch (error) { + let hint = isInspectorActionError(error) ? ` — ${error.hint}` : ""; + // Prompting a persisted-but-idle session (from an earlier VM boot) + // fails inside the runtime; the generic crash hint is misleading there. + if (sessionId && sessionStatus !== "running") { + hint = + " — this session is idle (not live in the current VM boot). Send without a selection to start a fresh session, or resume it from a client."; + } + onErrorEvent(`${error instanceof Error ? error.message : String(error)}${hint}`); + } finally { + activeSessionRef.current = null; + setBusy(false); + } + }; + + const stop = async () => { + const sid = activeSessionRef.current ?? sessionId; + if (!sid) return; + try { + const supported = await cancelPrompt(sid); + if (!supported) { + setStopUnsupported(true); + onErrorEvent("Stop is not supported by this runtime (no cancelPrompt action)."); + } + } catch (error) { + onErrorEvent(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+ {optionsOpen ? ( +
+ +