6Real-Time Observability
WebSocket broadcasts per-step node firings to the frontend; canvas reflects execution live.
Every step streams observations, reasoning, actions, and metrics via WebSocket. Researchers see what the agent sees, read what it thinks, and can pause/step/resume.
Design doc: Execution Logs
1. Architecture
Two broadcast paths coexist (TODO #38 refactor, 2026-04-10):
GraphExecutor
โ
โโ (A) Viewer sinks self-emit per-node viewer_data events
โ (imageViewer, textViewer, textScroll, actionLog, metrics)
โ โ
โ โโ _SinkBase.forward() โ serialize_for_display() โ
โ broadcast({type: "viewer_data", node_id, step, fields})
โ
โโ (B) graphOut sinks buffer _last_inputs โ IterOut fires โ
โ _broadcast_step() consolidates into one nav_step event
โ
โโ Per-node firing โ ExecutionLogger โ exec_log event (ADR-observability-003)
โโ Status changes โ nav_status; loop end โ nav_complete
โโ Batch eval โ eval_progress + eval_episode_done (ADR-eval-002)
โ
โโ broadcast() โ WebSocket โ all connected clients
โ
ws.ts (WSManager)
โ
โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโ
โผ โผ โผ
store.ts per-node hook UI panels
(navSteps) useNodeOutput (render)
(execution_id
+ node_id routing)
The old single-path consolidation (every viewer type was special-cased in _broadcast_step) is gone โ viewer sinks now own their own serialization and broadcast directly, and the executor only consolidates graphOut for the global NavigatePage / EvalPage store. The _OUTPUT_TYPES registry in the executor was deleted with the refactor.
2. WebSocket Protocol
Endpoint: ws://[host]/ws
2.1 Message Envelope
{
type: string, // Event type
data: unknown, // Type-specific payload
timestamp: string, // ISO 8601
execution_id?: string // Canvas execution routing
}
2.2 Event Types
| Type | When | Payload |
|---|---|---|
viewer_data |
Viewer sink fires (per node) | { node_id, step, fields } โ serialized port payloads keyed by port name (ADR-components-006, TODO #38) |
nav_step |
iterOut fires |
Consolidated snapshot from graphOut sinks โ RGB/depth base64, action, position, state containers |
nav_status |
Execution state change | { status: "running" \| "paused" \| "done" \| "error", step } |
nav_complete |
Loop finished | { metrics: { SPL, SR, nDTW, SDTW }, step } |
exec_log |
Per-node firing (canvas mode) | { step, node_id, node_type, duration_ms, error } โ suppressed in batch eval (ADR-observability-003) |
eval_progress |
Episode queued / started | { run_id, worker_id, episode_index, done, total } (ADR-eval-002) |
eval_episode_done |
Episode finished in batch | { run_id, worker_id, episode_index, result } (ADR-eval-002) |
error_event |
Any envelope published to ErrorBus |
ErrorEnvelope โ { id, ts, severity, source, code, title, message, scope, details, hint } (ADR-observability-004) |
nav_llm_step |
โ | Not emitted today; frontend still has a handler wired |
2.3 nav_step Payload
{
step: number,
action: number, // 0-3
action_name: string, // "FORWARD", "LEFT", "RIGHT", "STOP"
position: [number, number, number],
rgb_base64: string, // base64 PNG
depth_base64: string, // base64 PNG
response: string, // LLM response text
done: boolean,
metrics?: Record<string, number>, // only when done=true
containers?: Record<string, { // state container snapshots
label: string,
states: Record<string, { type, value_type, size?, value?, preview? }>
}>
}
3. Output Viewer Nodes
Output viewer nodes are canvas nodes that display data to the researcher. They don't produce outputs โ they're sinks. All subclass _SinkBase in agent_loop/builtin_nodes.py, which walks each node's display_fields, runs serialize_for_display() on the wired inputs, and emits one viewer_data WS event per firing.
| Node Type | Displays | Notes |
|---|---|---|
imageViewer |
RGB / depth image grid | Configurable ports โ config.ports declares one port per cell (IMAGE / DEPTH), plus rows / cols for grid layout (ADR-components-007). Replaced the fixed observationViewer. |
textViewer |
Latest TEXT (single block) | text_viewer display_type; scalar value (ADR-components-008). |
textScroll |
Accumulating TEXT history (scrollable stack) | Shares the text_viewer renderer with textViewer โ accumulate=True routes each write through _SinkBase's vacc_* history path; frontend renders the array as stacked blocks with +N earlier trimming (ADR-components-008, replaced thinkingLog). |
actionLog |
Action history | log_list renderer; structured ACTION entries. |
metrics |
SPL / SR / nDTW / SDTW | metric_table renderer. |
graphOut |
Consolidated nav_step fields |
The only sink still consumed by _broadcast_step; feeds the global NavigatePage store and live nav status bar. |
Viewer sinks serialize and emit their own viewer_data events inside forward(). Only graphOut still buffers inputs in node.state["_last_inputs"] for the iteration-boundary nav_step consolidation. The legacy _OUTPUT_TYPES registry that special-cased every viewer in the executor has been deleted.
4. Broadcast Paths
4.1 Per-node viewer_data (primary path)
Each viewer sink self-emits a viewer_data event at every firing. The frontend's useNodeOutput(nodeId) hook subscribes by node_id, so panels render independently of whether the upstream graph is still mid-iteration.
# _SinkBase.forward() โ serialize wired inputs, accumulate if needed, broadcast
fields[key] = serialize_for_display(value) # IMAGE/DEPTH โ base64, etc.
if field.accumulate: # textScroll, actionLog
history = getattr(ctx, f"vacc_{key}", []) + [fields[key]]
setattr(ctx, f"vacc_{key}", history)
fields[key] = list(history)
await broadcast(ctx.session._ws("viewer_data",
{"node_id": self.node_id, "step": ctx.step, "fields": fields}))
4.2 Consolidated nav_step (graphOut path)
_broadcast_step() still fires at each iterOut boundary (two-pivot model, ADR-dataflow-008) but only scans graphOut sinks โ it merges their _last_inputs, converts numpy arrays to base64, and emits one nav_step event for the global navigation store. Graphs without an graphOut sink produce no nav_step โ they rely on viewer_data alone.
4.3 Execution logs (exec_log)
The ExecutionLogger (ADR-observability-003) wraps every node firing: exterior I/O captured automatically, interior detail added via self._self_log(key, value) inside forward(). Each firing emits a short exec_log summary; full records stream to JSONL. Canvas runs persist to outputs/runs/{execution_id}/log.jsonl; batch eval persists per episode to outputs/eval_runs/{run_id}/episodes/ep{idx:04d}/log.jsonl and suppresses the WS event (ExecutionPrinciples.suppress_nav_events). See ADR-eval-004 for the per-episode layout rationale.
4.4 Batch eval events
BatchEvalRunner (ADR-eval-002) emits eval_progress + eval_episode_done tagged with worker_id so the Eval page can render per-worker lanes. A run_id groups events into one batch.
4.5 Error reporting (error_event)
The ErrorBus (ADR-observability-004, agentcanvas/backend/app/errors.py) is the unified event channel for everything the user needs to see about a run going wrong โ distinct from the structured per-firing capture that exec_log provides. Producers covered today:
- Per-node executor catch (
graph_executor.py ยท _fire_node) โ wraps_fire_node(); envelope carriesscope = {node_id, node_type, origin, step, execution_id}. - Graph-level executor catch (
graph_executor.py ยท run) โ top-leveltry/exceptaround the run loop; envelope carriesscope = {step, execution_id}. - FastAPI global
Exceptionhandler (main.py) โ uncaught HTTP route exceptions; envelope carriesscope = {endpoint, method}and is also returned as the 500 body so the frontend HTTP wrapper can recover the sameid. LogBridgeHandlerโ installed on the root logger at INFO+; everylog.info / warning / error / exceptionbecomes an envelope withsource="log". Skipsagentcanvas.errors+ uvicorn / httpx / httpcore / watchfiles / asyncio prefixes to avoid feedback loops + noise.
Bus dispatch is async over a 500-deep queue: publish() does one ring-buffer append (lock-protected) + loop.call_soon_threadsafe(queue.put_nowait, env), so producers stay loop-agnostic and thread-safe. A single dispatcher coroutine started in the FastAPI lifespan drains the queue and fans out to subscribers; the WS bridge subscriber emits each envelope as {type: "error_event", data: envelope.to_dict()} over /ws. The 200-entry ring buffer is exposed at GET /api/errors for backfill โ the frontend calls it on every WS (re)connect to recover envelopes published while the socket was down.
5. Execution ID Routing
Multiple agent graphs can run concurrently. The execution_id mechanism ensures events reach the correct output panels:
- Frontend generates an
exec_<timestamp>_<random>id when the user clicks Play (not a UUID) - The id is sent as
execution_idinPOST /run - Backend tags every WebSocket event with the same
execution_id - Frontend filters events by
execution_idand routes to the correct canvas nodes
Graph A (exec_id: "aaa") โ Output nodes see only "aaa" events
Graph B (exec_id: "bbb") โ Output nodes see only "bbb" events
6. Frontend Wiring
6.1 WSManager (ws.ts)
class WSManager {
connect() // Auto-reconnect with exponential backoff
on(type: string, handler): unsub // Subscribe to event type
on('*', handler): unsub // Wildcard: all events
}
6.2 Zustand Store (store.ts)
subscribeWS() wires WebSocket events to dataflow store updates:
| WS Event | Store Update |
|---|---|
nav_step |
Appends to navSteps, updates navCurrentRgb/Depth (driven by graphOut) |
nav_status |
Sets navStatus |
nav_complete |
Sets navMetrics, navStatus: 'done' |
viewer_data |
Dispatched via useNodeOutput(nodeId) hook โ routed by node_id, bypasses the global store |
exec_log |
Feeds the LogPanel in the bottom OutputDrawer |
error_event |
Routed to useErrorStore.ingest() โ drives the toast layer + Report tab + toolbar bell badge (ADR-observability-004) |
eval_progress / eval_episode_done |
Drives the Eval page (EvalProgressBar, EpisodesTable, per-worker grouping) |
6.3 Playback Controls
The frontend toolbar sends control commands:
| Control | Endpoint | Effect |
|---|---|---|
| Play | POST /run |
Start execution |
| Pause | POST /run/pause |
Set pause event โ executor blocks at next iteration |
| Resume | POST /run/pause (toggle) |
Clear pause event |
| Stop | POST /run/stop |
Set stop event โ executor exits loop |
| Step | Pause + single advance | Execute one iteration |
7. Key Files
| File | Role |
|---|---|
agentcanvas/backend/app/agent_loop/graph_executor.py |
_broadcast_step() (outputPort consolidation) + exec_log emission around each node firing |
agentcanvas/backend/app/agent_loop/builtin_nodes.py |
_SinkBase + ImageViewerSink / TextViewerSink / TextScrollSink / ActionLogSink / MetricsViewerSink โ self-emit viewer_data |
agentcanvas/backend/app/agent_loop/eval_batch.py |
eval_progress / eval_episode_done emission |
agentcanvas/backend/app/logging/logger.py |
ExecutionLogger โ JSONL persistence + exec_log envelope (ADR-observability-003) |
agentcanvas/backend/app/state.py |
broadcast() โ fan out a WSMessage to every connected client |
agentcanvas/backend/app/api/execution/websocket.py |
/ws endpoint โ accepts connections, registers/unregisters clients |
agentcanvas/backend/app/standard/wire_types.py |
serialize_for_display(), image_to_base64(), depth_to_base64() |
agentcanvas/backend/app/agent_loop/loop_runner.py |
Pause / stop / resume event management |
agentcanvas/frontend/src/ws.ts |
WSManager โ auto-reconnect WebSocket client |
agentcanvas/frontend/src/store.ts |
Zustand store + subscribeWS() for nav_* and eval events |
agentcanvas/frontend/src/canvas/nodes/agentloop/inner/layouts/useNodeOutput.ts |
Per-node viewer_data hook |
agentcanvas/frontend/src/canvas/runPipeline.ts |
Canvas โ API execution bridge |
8. Node Schema API
The frontend fetches node rendering metadata at design time via a REST endpoint. This is distinct from the runtime WebSocket events above โ it's a one-shot query used when the canvas loads to know how to render each node type.
8.1 Endpoint
GET /api/components/node-schemas
Returns all registered node types (built-in + loaded nodesets) with port metadata and ui_config.
8.2 Response Shape
[
{
"type": "env_habitat__observe",
"display_name": "Observe",
"description": "Get current RGB + depth observation",
"category": "env",
"icon": "Eye",
"kind": "block",
"ports_mode": "mirror",
"config_schema": {},
"default_config": {},
"ui_config": {
"color": "emerald",
"layout": "block",
"width": "",
"min_height": "",
"rounding": "",
"min_width": "",
"max_width": "",
"config_fields": [],
"display_fields": []
},
"input_ports": [
{ "name": "trigger", "wire_type": "BOOL", "description": "...", "optional": false }
],
"output_ports": [
{ "name": "rgb", "wire_type": "IMAGE", "description": "...", "optional": false }
]
}
]
8.3 ui_config Fields
| Field | Type | Purpose |
|---|---|---|
color |
string |
Tailwind color name for node chrome (e.g. "emerald", "orange", "pink") |
layout |
string |
Rendering layout: "block" (default), "strip" (narrow gate), "viewer" (output display), "imageGrid" (image-panel grid), "note" (canvas sticky-note) |
width |
string |
CSS width override (e.g. "40px" for strip nodes) |
min_height |
string |
CSS min-height override |
min_width |
string |
CSS min-width override |
max_width |
string |
CSS max-width override |
rounding |
string |
Tailwind border-radius class (e.g. "rounded-r-lg") |
config_fields |
list |
Editable fields shown in the node body (type, label, options, default) |
display_fields |
list |
Read-only display areas (type, label) โ used by viewer-layout nodes |
config_fields and display_fields are serialized from ConfigField and DisplayField dataclasses defined on each BaseCanvasNode subclass's ui_config ClassVar (ADR-components-004).
8.4 Dynamic Option Injection
Config fields with a __DYNAMIC_PROFILES__ sentinel in their options list are replaced at response time with the live LLM profile list from ProfileStore. This enables the LLMCallNode's model selector dropdown to reflect the user's configured profiles without hardcoding.
8.5 Key Files
| File | Role |
|---|---|
agentcanvas/backend/app/api/platform/components.py |
list_node_schemas(), _serialize_ui_config(), _inject_dynamic_options() |
agentcanvas/backend/app/components/bases.py |
NodeUIConfig, ConfigField, DisplayField dataclasses |
agentcanvas/backend/app/agent_loop/builtin_nodes.py |
NODE_HANDLERS registry + built-in viewer sinks (_SinkBase + ImageViewerSink / TextViewerSink / TextScrollSink / ActionLogSink / MetricsViewerSink) + OutputPortNode โ the retired graph_executor.py module's role folded in here |
Status
| Item | Status | Notes |
|---|---|---|
imageViewer node |
Done | ImageViewerSink in builtin_nodes.py; configurable ports + rows/cols grid (ADR-components-007, replaced fixed observationViewer) |
textViewer node |
Done | TextViewerSink in builtin_nodes.py (ADR-components-008) |
textScroll node |
Done | TextScrollSink in builtin_nodes.py; shares text_viewer renderer with textViewer via accumulate=True (ADR-components-008, replaced thinkingLog) |
actionLog node |
Done | ActionLogSink in builtin_nodes.py |
metrics node |
Done | MetricsViewerSink in builtin_nodes.py |
graphOut node |
Done | GraphOutNode โ only sink still consumed by _broadcast_step |
broadcast node |
Removed | No broadcast node type / BroadcastNode class in the tree anymore |
_OUTPUT_TYPES registry |
Removed | Deleted with the TODO #38 viewer refactor; viewers now own their own broadcast |
trajectory node |
Deferred | No longer in _OUTPUT_TYPES; no handler registered. Planned top-down map viewer |
Per-node viewer_data events |
Done | _SinkBase.forward() serializes + broadcasts; frontend routes by node_id via useNodeOutput |
_last_inputs buffering |
Done (outputPort only) | Other viewer sinks do not use this field anymore |
_broadcast_step consolidation |
Done | Fires at iterOut under the two-pivot model (ADR-dataflow-008); only graphOut sinks contribute |
serialize_for_display |
Done | Backs every viewer_data emission (standard/wire_types.py) |
image_to_base64 / depth_to_base64 |
Done | standard/wire_types.py |
WebSocket broadcast() |
Done | Lives in state.py (moved from api/websocket.py during the api/ domain split) |
/ws endpoint |
Done | api/execution/websocket.py accepts + registers clients |
WSManager (auto-reconnect, on(), connected) |
Done | ws.ts with exponential backoff |
subscribeWS() store wiring |
Done | nav_step, nav_status, nav_complete, exec_log, eval_progress, eval_episode_done |
execution_id routing |
Done | End-to-end: backend tags events, frontend filters by ID |
nav_step event |
Done | Emitted at iterOut boundary |
nav_status event |
Done | Emitted on running / paused / done / error |
nav_complete event |
Done | Emitted with final metrics |
nav_llm_step event |
Not emitted | Frontend handler still wired; no backend producer today |
exec_log event (ADR-observability-003) |
Done | Per firing in canvas mode; suppressed under ExecutionPrinciples.suppress_nav_events in batch eval |
| Execution log persistence | Done | ExecutionLogger โ JSONL at outputs/runs/{execution_id}/ (canvas) / outputs/eval_runs/{run_id}/episodes/ep{NNNN}/ (eval, per episode, ADR-eval-004) |
eval_progress / eval_episode_done (ADR-eval-002) |
Done | Carries worker_id for per-worker UI grouping |
| Playback controls (pause / stop) | Done | POST /run/pause, POST /run/stop |