Architecture
How the platform is wired β frontend / backend boundary, execution engine, component system, state containers, tech stack.
Β§1System Overview
Three-tier system: frontend (React canvas) β backend (FastAPI + GraphExecutor) β workspace (user-authored Python components + simulators).
Β§2Data Flow
A single execution cycle (ADR-canvas-002: unified canvas):
- User clicks Play in the ExecutionToolbar
- Frontend generates
execution_id(UUID) getGraphForExecution()serializes the root canvas asGraphDefinition(filters out the frontend-onlystateContainernode type β containers travel asgraph.containers; output viewers are backend nodes and are sent along)POST /api/navigate/run { loop_definition, execution_id }- Backend creates
GraphExecutorfromloop_definition - Nodes fire by dataflow as inputs arrive: env observe β LLM call β parse β env step (domain nodes are nodeset-namespaced
<nodeset>__<verb>types; built-ins likellmCall/textParsefill the reasoning slots) IterOutcollects end-of-step data βIterInprovides it next iteration- Each node emits WebSocket events tagged with
execution_id - Frontend discovers output nodes by type, routes WS events to them
- Loop ends:
IterOut'sstopport goes true, the step budget is exhausted, or user Stop β the verdict stage then runs once on the after-loop band nav_completeevent carries final metrics (SPL, SR, nDTW, SDTW)
Β§3Flat Workspace (ADR-canvas-001)
The canvas is a ComfyUI-style flat workspace. All node types coexist on one surface β environment, model, reasoning, control, and output nodes are wired freely without layer boundaries.
| Category | Nodes | Purpose |
|---|---|---|
| Environment | <env>__reset / __step / __observe / __metric, β¦ | Interact with simulator via gym-like verbs (from activated env NodeSets) |
| Model | llmCall (built-in) + NodeSet model/policy nodes (e.g. policy_adapter_vlnce__predict, SAM) | Run neural policies or language models |
| Reasoning | textParse, historyLog (built-ins) + method NodeSets' reason/parse nodes | Build prompts, make decisions |
| Control | iterIn, iterOut | Manage iteration cycles; iterOut's stop port and after-loop band handle loop exit + verdict |
| Output | imageViewer, textViewer, textScroll, actionLog, metrics | Display streaming results |
| Tool | (dynamic from NodeSets) | SAM, navigation tools, etc. |
The workspace graph is a GraphDefinition β save/load the entire canvas as JSON via /api/graphs.
3.1 Unified Canvas Paradigm (ADR-canvas-002)
One editor, one node registry, one catalog at every graph level:
unifiedNodeTypes.ts: 3 explicit entries (compositeNode,stateContainer,_generic) + a Proxy routing every other registered type toGenericBlockRenderer; the full catalog enumeration lives inunifiedCatalog.tsunifiedCatalog.ts: single sidebar catalog with unified categoriesUnifiedGraphEditor.tsx: dual-mode editor (root: Zustand store, subgraph: local state with save-back)ExecutionToolbar.tsx: standalone Play/Pause/Stop controls (root canvas graph sent directly to/run)- Graph save/load: sidebar shows saved graphs from
/api/graphs, draggable as composites, loadable as root - Composites (ADR-dataflow-001) preserved for optional nesting β same editor inside
3.2 NodeSet Manager
NodeSets are managed on a separate page (not the canvas). When a NodeSet is activated, its tools appear in the canvas sidebar. When deactivated, they disappear.
3.3 Recursive Composite Nodes (ADR-dataflow-001)
Composites remain available for optional grouping β any group of nodes can become a reusable composite with defined I/O, nestable to arbitrary depth.
Key concepts:
- CompositeNode: a node that contains a
GraphDefinitionsubgraph - graphIn / graphOut: boundary nodes defining composite inputs/outputs (separate from IterIn/IterOut)
- Canvas Stack: navigation breadcrumbs appear when inside a composite
- Flatten-before-execute: backend recursively expands composites into a flat graph before execution
- Author β select nodes β Group β a composite with auto-generated
graphIn/graphOutboundaries. - Save β written to
workspace/graphs/*.jsonβ appears in the sidebar catalog, draggable onto any canvas. - Execute β
flatten_graph()expands every composite β theGraphExecutorruns one flat graph.
Β§4Execution Engine
One engine β the Graph Executor β handles every graph regardless of topology. DAG-shaped workflows are just cyclic graphs that fire each node exactly once; iterative agent loops are the same engine with iterIn/iterOut gates added. There is no separate "DAG executor" path (see Glossary Β§6).
4.1 Graph Executor
- Nodes fire when all required inputs arrive β data-driven, not topo-sort-driven
- Two-sided iteration model (ADR-dataflow-008, folding ADR-dataflow-006's three pivots to two):
iterInis two-sided β its left input ports (declared ininitPorts) capture run-start seeds once intoinit_slots, its right output ports broadcast step boundaries + expose the loop-carry bundle;iterOutcollects end-of-iter state and transfers it back toiterInviapairedWithfor the next iteration. The formerInitializepivot is folded into iterIn's left side; theinitializenode type was removed entirely on 2026-06-10 β graphs still carrying one are rejected at validation with a migration hint. - Per-node state via
_NodeStateProxy(tracks inputs, outputs, fire count) - Supports pause/resume/step via LoopRunner
- Two-sided iterOut (ADR-dataflow-009): the former
Terminationnode folded into iterOut'sstopinput port (checked once per iteration by the Decide phase); on termination iterOut emits a final side (final_<name>+final_stop) exactly once, feeding the after-loop band (evaluate β graphOut) that holds the terminal iteration's values - Multi-scope (ADR-dataflow-007): N coexisting
(iterIn, iterOut)pairs in one flat graph, each with its own iteration cadence; scope forest computed byanalyze_scopes() - Flatten step: at the top of
run(),flatten_graph()recursively expands any composite nodes before processing - Startup seed-discovery rule: a node is queued at run-start iff its type is not
iterInAND it has no incoming edges AND it has no required input ports. Required-but-unwired ports are rejected at the API boundary byvalidate_graph_connectivity()ingraph_def.pyβ no silent never-fires.
4.2 Headless Eval Path (JobScheduler)
The same engine, driven without the canvas β the platform's primary path for producing numbers. Β§2's interactive Play runs one episode; this runs hundreds.
POST /api/eval/v2/startenqueues a job on the backend-ownedJobScheduler(services/job_scheduler.py)- A per-second
tick()admits queued jobs when their calibrated per-resource estimates (VRAM + RAM) fit measured free minus reservations (declaredmarginal_vram_mbas fallback) and canvas Play isn't holding theExecutionGuardlock (state.pyβ canvas/eval mutual exclusion) - Each admitted run is its own subprocess β
python -m app.eval_subprocess_main --run-dir β¦, kernel-tied to the backend byPR_SET_PDEATHSIG. It re-scansworkspace/from disk (fresh code per launch) and attaches to parent-owned shared singletons viaregister_remote_nodesetβ no duplicate VRAM - Inside the subprocess,
BatchEvalRunner(agent_loop/eval_batch.py) iterates the episode list through freshLoopRunnerinstances; atworker_count>1anEnvWorkerPoolleases tagged env subprocesses per episode - Results land self-contained under
outputs/eval_runs/{run_id}/episodes/ep{NNNN}/(log.jsonl, assets,episode.json);spec.json/shared_urls.json/_DONEform the parentβsubprocess file protocol (services/run_state_io.py)
Governed by ADR-eval-001β¦004 + ADR-server-003 β see the Batch Eval design-doc.
Β§5Component System
Scan order: nodesets/ β nodes/; graphs/ + graph_nodes/ are pure data. Hot reload re-runs the scan via POST /api/components/reload.
5.1 Auto-Hosted Deployment (ADR-server-001)
Any BaseNodeSet can be loaded in server mode without writing a ServerApp. The registry launches auto_host in a subprocess, which uses AutoServerApp to introspect the nodeset's tools and auto-generate server functions from their port definitions. The result is identical to YAML-based servers β proxy nodes are registered via generate_proxy_nodes().
POST /api/components/nodesets/sam/load?mode=server- spawns
python -m app.server.auto_host --file β¦/sam.py --class SamNodeSet --port 9200 - fetches
/manifest, generates proxy nodes viagenerate_proxy_nodes() - canvas sees identical nodes to YAML-declared servers
| Endpoint / path | Role |
|---|---|
workspace/graphs/ | Saved graph templates (kind="graph") |
workspace/graph_nodes/ | Archived composite nodes (kind="node") |
GET /api/graphs | List all (both dirs) |
POST /api/graphs | Save β routes by kind field |
DELETE /api/graphs/{id} | Delete (searches both dirs) |
5.2 Python-Driven Node UI (ADR-components-004)
Every BaseCanvasNode carries a ui_config ClassVar (NodeUIConfig) that declares how the node renders on the canvas β color, layout, dimensions, and inline config controls. The frontend's GenericBlockRenderer reads ui_config at render time, eliminating the need for per-node .tsx components.
Two layouts: Block (default β standard rectangle with ports, label, optional config fields) and Strip (narrow vertical gate, used by iterIn, iterOut, graphIn, graphOut).
ConfigField declares inline widgets on the node face: label, slider, text, select, toggle, textarea. Example: LLMCall shows a temperature slider; Decision shows an action-space dropdown.
This reduced unifiedNodeTypes.ts to 3 explicit entries β compositeNode, stateContainer, and a _generic Proxy fallback that routes every other node type (built-ins + NodeSet tools) to GenericBlockRenderer. The former per-viewer custom .tsx components are gone: viewers now render as display-field layouts inside GenericBlockRenderer (ImageViewerField, LogListField, MetricTableField, TextViewerField under nodes/agentloop/inner/layouts/fields/). The remaining custom components handle interactions that exceed port+config rendering:
Custom .tsx | Why |
|---|---|
CompositeCanvasNode / CompositeNodeView | Subgraph preview, port derivation, drill-in |
StateContainerNode | Canvas-visible state container element (dual-wire system Β§6) β frontend-only type, filtered from execution |
The /node-schemas API endpoint includes ui_config in its response, so the frontend catalog can enrich sidebar entries with backend-defined styling at drag time.
2 base classes define the extension points:
| Base Class | What It Provides |
|---|---|
BaseCanvasNode | Custom graph node handler (forward method, NodeUIConfig for Python-driven UI, typed port declarations) β see ADR-components-004 |
BaseNodeSet | Atomic group of tools (load/unload together, optional server mode) |
5.3 Runtime Services Around the Engine
- LLM provider layer (
app/llm/) βllmCallresolves its profile throughProfileStoreβ provider registry (24 providers via litellm) with a layered env-var fallback (ADR-platform-003);llm_complete()/vlm_complete()are the call seam - Env panels (
components/env_panel.py+/api/env-panels+ frontendEnvPanel.tsx) β per-nodeset control plane for env-side runtime knobs (suite / split / episode, play / pause / reset); renamed from "controllers" 2026-06-11 - File-watch hot reload (
services/graph_watcher.py,services/nodeset_watcher.py) β graph JSON and nodeset.pyedits made on disk (e.g. by a coding agent) propagate without a restart, beyond the manualPOST /api/components/reload - Episode replay (
app/replay/+/api/replay+ frontend Replay page) β env nodesets opt in via areplay_parserthat turns persisted episode logs into an env-agnostic timeline - Monitoring (
services/resource_sampler.py+services/system_runs.py+ frontend Monitor page) β always-on ~1 Hz resource time-series plus per-run performance summaries - Structured per-run logs (
app/logging/logger.py) β automatic exterior port-I/O capture + voluntary_self_log()interior detail, persisted as JSONL (ADR-observability-003)
Β§6State Container System (ADR-dataflow-002)
A dual-wire architecture adds visible shared state alongside the existing dataflow edges:
6.1 Container Model
A StateContainer is a dict of named BaseState entries. Each state has two axes:
- Reducer type: Accumulator (list.append), LastWrite (overwrite), Counter (sum) β the
ephemeralreducer was removed 2026-04-18 - Value type: narrow set for memory (TEXT, BOOL, METRICS, POSE, POINTCLOUD, OCCUPANCY_MAP, EMBEDDING, EPISODE_CONTEXT, ANY β raw wire payloads rejected, ADR-dataflow-004)
- Lifetime:
forever/step/episode/run/customβ expands to areset_onsignal list; per-iteration clearing islifetime="step"(replaces the old Ephemeral reducer)
6.2 Two Wire Systems
| Data Edges | State Edges | |
|---|---|---|
| Carry | Typed data per-firing | Access grants (no data) |
| Visual | Solid animated lines | Dashed violet, no arrowhead |
| Created via | Port handles (left/right) | __state__ handle (bottom) |
| Visibility | Always | Toggle on/off |
6.3 graph_state
Optional graph-level container auto-accessible by all nodes β no state edges needed. Displayed in the State tab of the bottom OutputDrawer panel. Each composite can have its own scoped graph_state.
6.4 Executor Integration
- Containers built after
flatten_graph(), before seed-node discovery - Connected containers injected into
ctx._containersbefore each node fires graph_stateinjected intoctx._graph_statefor all nodesbroadcast_signal("step_end", {"step": N})fires at IterOut β states withlifetime="step"clear via theirreset_onsubscription- Container snapshots broadcast in
nav_stepWebSocket event
See State Containers (design-doc) for the full design.
Β§7Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Backend runtime | Python 3.10+ (agentcanvas env), FastAPI | Modern ML stack + async web framework (ADR-platform-004) |
| ML framework | PyTorch 2.5 + CUDA 12.1 (agentcanvas) | Modern torch for SAM, HF, LLM nodesets |
| Simulator | Habitat-Sim 0.1.7 (ac-vlnce env, Python 3.8) | Binary locked to 3.8; accessed via server mode (ADR-server-001) |
| Frontend framework | React 18 + TypeScript | Component model, strict typing |
| Canvas library | @xyflow/react (React Flow) | Mature node-graph editor with customization |
| State management | Zustand (dual store) | Lightweight, no boilerplate, works with React Flow |
| Styling | Tailwind CSS | Utility-first, fast iteration |
| Build tool | Vite | Fast HMR for frontend dev |
| Config (eval) | Hydra + OmegaConf | Composable config groups for eval harness |
| Docs | Hand-authored HTML + stdlib dev server (docs/run_dev.sh) | Full HTML expressivity; live reload via SSE on mtime change. MkDocs retired 2026-05-18. |