State Containers
Visible shared memory β reducers, lifetimes, access grants (not wires), the graph_state blackboard
A wire carries one value from one port to one port, then it's gone. A state container is the opposite: a named, visible box of memory that several nodes can read and write, that persists across iterations, and that resets on a schedule you declare. It's how a graph remembers β action history, a running map, a scratch blackboard. This page is the deep reference on the mechanism; the schema that holds it (ContainerDef, StateDef, AccessGrantDef) is summarised in Graph System Β§2.2.
1. What a state container is
A container is a dict of named states drawn as a box on the canvas. Each named state has a reducer (how a write merges in β append, overwrite, add), a value type (what it holds), and a lifetime (when it auto-clears). Contrast it with per-node scratch state: a node can stash anything on its ctx between fires, but that is private and dies with the node. A container is shared, inspectable in the UI, checkpointed, and survives across the whole run.
You reach for a container when memory must outlive a single firing and be seen by more than one node β a history buffer that the prompt node appends to and the reasoner reads, a SLAM map several nodes update. If only one node needs the value and only within a turn, keep it on ctx; don't make a container.
2. Why access is a grant, not a wire
The most important thing to internalise: a node connects to a container through an access grant, and a grant is not a wire. A wire delivers a value and can make its target fire; a grant only says βthis node may call read()/write() on this container.β It carries no data and never triggers firing. On the canvas it renders as a dashed violet line with no arrowhead, deliberately distinct from a solid directional data edge.
This separation (ADR-dataflow-004) is why there is no βmagic global stateβ: a node sees exactly the containers it was granted, nothing else.
3. The smallest example
One container with two states, one grant, and a handler that uses it. The JSON (schema detail in Graph System Β§2.2):
{ "containers": [ { "id": "nav_state", "label": "Nav State", "states": { "history": { "type": "accumulator", "value_type": "TEXT", "lifetime": "episode" }, "step_no": { "type": "counter", "value_type": "ANY", "lifetime": "episode" } } } ], "access_grants": [ { "id": "g1", "node_id": "reason", "container_id": "nav_state" } ] }
And the handler β the container arrives on ctx.containers, keyed by id:
# Inside a node handler β read and write a granted container by id async def forward(self, inputs, ctx): hist = ctx.containers["nav_state"] # only containers this node was granted hist.write(inputs["action"]) # reducer decides how the write merges recent = hist.read() # accumulator β the list so far # the well-known blackboard, bound only when granted: if ctx.graph_state is not None: ctx.graph_state.write("last_seen", inputs["raw_obs"])
Everything below is the detail behind those three method calls: what the reducer does (Β§4), what types a slot may hold (Β§5), when history clears (Β§6), how the grant put it on ctx.containers (Β§7), and what graph_state is (Β§8).
4. The three reducer types
A state's type is its reducer β the merge rule applied on every write(). There are exactly three, in STATE_TYPE_REGISTRY (state_containers.py); an unknown reducer name falls back to lastWrite with a warning.
| Reducer | write(x) does | Initial value | Config |
|---|---|---|---|
accumulator | appends x to a list | [] | max_size trims oldest entries |
lastWrite | overwrites β the slot becomes x | None | β |
counter | adds β value += x | 0 | β |
read() on a declared-but-never-written state returns its initial value; clear() resets it to the initial value (a deep copy, so list/dict initials don't alias). The container-level read(name) raises KeyError for a name that was never declared β there is no implicit slot creation. (A fourth reducer, a barrier for parallel execution, is deferred and not in the registry.)
5. Value types and what's rejected
A state's value_type labels what it holds β for the UI and for intent, not runtime enforcement. The accepted set, STATE_VALUE_TYPES, is memory-shaped: it keeps the small structured types and adds domain-memory types, but rejects raw per-frame wire payloads. A container holds memory, not pixels.
STATE_VALUE_TYPES = { # state_containers.py β memory, not raw frames "TEXT", "BOOL", "METRICS", "POSE", "POINTCLOUD", "OCCUPANCY_MAP", "EMBEDDING", "EPISODE_CONTEXT", "ANY", } _REMOVED_VALUE_TYPES = { # build_container() raises ValueError naming the slot "STATE", "IMAGE", "DEPTH", "ACTION", "OBSERVATION", "STEP_RESULT", }
A removed type (including the old STATE, renamed to POSE) raises a ValueError at build_container() naming the offending container and slot. An unknown-but-not-removed type only warns and is allowed. Use ANY as the escape hatch while prototyping. The relationship to wire types is laid out from the wire side in Wire Types Β§10.
6. Lifetimes and reset timing
A state's lifetime declares when it auto-clears, expressed as a set of signals that trigger clear(). The mapping is fixed; custom uses the slot's explicit reset_on list instead.
LIFETIME_TO_SIGNALS = { # state_containers.py β which signals clear a slot "forever": [], # never reset "step": ["step_end"], # cleared at every iterOut boundary "episode": ["episode_reset"], # cleared when the env starts a new episode "run": ["run_end"], # cleared once, at run teardown "custom": [], # cleared by the explicit reset_on signal list }
The signals come from the executor's bus: broadcast_signal(name) fans out to every container, and each container fans to every state, which clears itself if the signal is in its reset_on. The executor emits run_start at the top, step_start at the iterIn boundary, step_end at the iterOut boundary, run_end on both clean and error teardown; episode_reset comes from the env panel (e.g. the MP3D panel emits it on reset and on episode-index change).
Order matters at the iterOut boundary. A lifetime="step" slot is cleared by step_end, and step_end fans out before the checkpoint is taken. So a step-lifetime slot is already empty in the checkpoint β restore lands on the cleared state, which is the intended per-step semantics. Boundary phase ordering is the executor's; see Graph Executor Β§4.3.
One sharp edge: reset_on is matched by signal name only β it can't filter by which loop scope fired. In a nested-scope graph, an inner scope's step_end clears a step-lifetime slot a node in the outer scope shares. Workarounds: scope-specific custom signals, or hold the value on a carry port instead. See Loop Control for scopes.
7. How a node gets access at runtime
At build time the executor constructs the live containers from graph.containers and indexes the grants: _access_grant_index[node_id] β the set of container ids that node may touch. On every fire, the executor injects only that node's slice:
ctx.containersis set to{cid: container}for exactly the granted ids β a node literally cannot reach a container it wasn't granted.ctx.graph_stateis bound to thegraph_statecontainer only if this node holds a grant to it; otherwise it isNone(Β§8).- Dynamic-firelist children inherit their parent's grants via
_dynamic_parent_id, so spawned sub-nodes can use the same containers.
8. The graph_state blackboard
graph_state is just a container with the well-known id "graph_state" β a convenient shared scratchpad, exposed as ctx.graph_state instead of ctx.containers["graph_state"]. The one rule worth remembering: since ADR-dataflow-004 there is no auto-inject β a node that wants it must still carry an explicit grant, exactly like any other container. Without the grant, ctx.graph_state is None. On flatten, an inner composite's graph_state id is prefixed like any other container, so nested graphs get isolated blackboards unless deliberately shared.
9. Nodeset-owned containers (the second category)
Everything above describes graph-level (or home) containers β declared per-graph in graph.containers, built in the executor process, reached by grant. There is a second category: a nodeset can own a container by overriding get_containers() (components/bases.py), returning the same ContainerDefs. What differs is where the container lives:
| Graph-level (home) | Nodeset-owned | |
|---|---|---|
| Declared in | graph.containers (the JSON) | BaseNodeSet.get_containers() |
| Lives in | the executor process | the nodeset's own process (its subprocess in server mode) |
| Shared by | any granted node, via per-fire injection (Β§7) | the nodeset's own nodes, by reference on ctx.containers[id] |
| Value types | strict β raw wire payloads rejected (Β§5) | allow_opaque=True β may hold opaque values (e.g. a numba planner object) |
| Across the boundary | checkpointed in-process (Β§10) | never serialized; only a read-only preview is surfaced for display |
A nodeset-owned container is built by build_containers(β¦, allow_opaque=True) (server/auto_server_app.py); the flag relaxes the Β§5 value-type guard precisely because the value never crosses a process boundary β ADR-026's strictness only protects values that get serialized. Its live preview piggybacks on the nodeset's /call response (record_subprocess_containers) and is merged into the next nav_step tagged owner=<nodeset> (home containers are tagged owner="home") β so both categories appear in the State panel under an Owner column.
9.1 The keyed (per-key) dimension
A nodeset-owned container often serves N concurrent eval workers from one process. To keep workers from clobbering each other, a container can partition by an explicit key the caller passes at access time β read(name, key) / write(name, key, β¦), where key is typically the episode_id. Each (name, key) sub-state is lazily cloned from the declared template at its initial_value, isolated from sibling keys (StateContainer._keyed, state_containers.py). evict(key) drops every sub-state for that key β worker-safe cleanup at episode or lease end, touching only that key. This replaces the old module-global “never clear” race workaround: explore_eqa migrated its module globals to a keyed explore_eqa_mem container. Checkpointing skips opaque, non-checkpointable keyed slots by design (they are not raised on).
Cross-nodeset access is a prototype. A node granted a container homed in another process is handed a RemoteContainerProxy (server/remote_container.py) with the same read / write / evict surface β “position transparency,” so node code is unchanged β which forwards each call synchronously to the executor's broker endpoint /api/internal/containers/{exec}/{cid}/{op} (api/execution/internal_containers.py; the executor holds the single source of truth). Transport is msgpack (ADR-server-004): ndarray / torch / PIL ride raw-byte blobs, no __ndarray__ marker. This path is explicitly a prototype, not a public API β no locking, and it cannot route to a replicated nodeset's containers under worker_count>1.
10. Runtime storage, checkpoint & restore
Live values sit in the StateContainer objects the executor holds for the duration of the run; containers are per-run (a fresh GraphExecutor is built per LoopRunner.run(), and BatchEvalRunner makes a fresh runner per episode), so isolation between episodes is implicit. At each root-scope iterOut boundary the executor snapshots every container into an in-memory checkpoint ring; restore_step(n) rewinds them. The endpoints are GET /api/navigate/run/checkpoints and POST /api/navigate/run/restore/{step}.
Checkpoints are taken on the root scope only, so in a nested-scope graph an inner-scope counter can drift on restore (the snapshot cadence is the outer loop's). This is the same multi-scope caveat as the reset_on one in Β§6 β treat per-scope state in nested loops carefully.
11. Frontend & broadcasting
On the canvas a container is a node of frontend-only type stateContainer (nodes/state/StateContainerNode.tsx), extracted back into a ContainerDef on save. A grant is an edge of type accessGrant (edges/AccessGrantEdge.tsx) β rendered as a dashed violet line (stroke #8b5cf6, strokeDasharray 6 4, no arrowhead), matching the grant-not-a-wire model of Β§2. Each state shows a lifetime badge.
During a run, container snapshots ride the per-node viewer_data / step broadcasts so the UI can show live state; the contents are the same StateContainer values described above. The broadcast plumbing is the executor's (Graph Executor); this page owns only the container values it carries.
12. File reference
| File | Role |
|---|---|
agentcanvas/backend/app/agent_loop/state_containers.py | BaseState + 3 reducers, STATE_TYPE_REGISTRY, STATE_VALUE_TYPES, LIFETIME_TO_SIGNALS, build_container(s) (allow_opaque for nodeset-owned), StateContainer (read/write/evict/checkpoint/on_signal; _keyed per-key partitions) |
agentcanvas/backend/app/graph_def.py | StateDef, ContainerDef, AccessGrantDef dataclasses (schema in Graph System Β§2.2) |
agentcanvas/backend/app/agent_loop/graph_executor.py | build_containers, _access_grant_index, per-fire ctx.containers/ctx.graph_state injection (incl. RemoteContainerProxy for cross-process grants), signal bus, checkpoint/restore |
agentcanvas/backend/app/agent_loop/flatten.py | Container + access-grant id prefixing during composite expansion |
agentcanvas/backend/app/components/bases.py | BaseNodeSet.get_containers() β declares nodeset-owned containers (Β§9) |
agentcanvas/backend/app/server/auto_server_app.py | build_containers(β¦, allow_opaque=True) β builds nodeset-owned containers in the nodeset process |
agentcanvas/backend/app/server/remote_container.py | RemoteContainerProxy β cross-process read/write/evict over msgpack (prototype, Β§9.1) |
agentcanvas/backend/app/api/execution/internal_containers.py | Broker + reverse-channel for cross-nodeset container access (prototype, Β§9.1) |
agentcanvas/frontend/src/canvas/nodes/state/StateContainerNode.tsx | The stateContainer canvas node (frontend-only type) |
agentcanvas/frontend/src/canvas/edges/AccessGrantEdge.tsx | The dashed-violet accessGrant edge component |
agentcanvas/frontend/src/canvas/useFlowStore.ts | Container + grant management in the canvas store |