AgentCanvas / Pages / Developer Guide / Capabilities / 6Real-Time Observability
2026-06-17

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:

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:

  1. Frontend generates an exec_<timestamp>_<random> id when the user clicks Play (not a UUID)
  2. The id is sent as execution_id in POST /run
  3. Backend tags every WebSocket event with the same execution_id
  4. Frontend filters events by execution_id and 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
AgentCanvas docs