AgentCanvas / Pages / Developer Guide / Design Docs / Graph / Loop Control System
2026-06-13 12:45

A drawn graph is acyclic, but an agent must loop β€” observe, act, observe again. The loop control system is the one structure that expresses iteration: a paired iterIn / iterOut, the loop pivots. iterIn opens each iteration and hands the body its inputs; iterOut closes it, collects what carries forward, and decides whether to go round again. This page is the deep reference on that subsystem β€” the pivots, what flows between them, and how nested loops (scopes) are analysed. How the executor actually schedules the firing is Graph Executor; the JSON schema is Graph System.


1. What a loop pivot is

Plain dataflow stops when data stops flowing β€” fine for a pipeline, not for an agent. The engine recognises exactly one looping structure: a paired iterIn and iterOut. They are not wired to each other; they are linked by id (config.pairedWith), and the executor hands iterOut's collected values back to iterIn between iterations. Everything drawn between them β€” predict, env step, whatever β€” is the loop body, ordinary nodes with no special treatment. Each pivot is two-sided: iterIn has an init side (run-start inputs) and an output side (per-iteration values to the body); iterOut has an input side (loop-carry values + a stop signal) and a final side (values emitted once, when the loop ends).

This is the current model since ADR-dataflow-008 + ADR-dataflow-009. The earlier three-node model (a separate initialize seed node and a separate termination node) is gone: init values moved onto iterIn's init side, and the halt signal moved onto iterOut's stop input. Graphs that still carry those node types are rejected at load (Β§9).

2. The smallest loop

One iterIn, one iterOut, one carried value (obs), and a stop signal from the body. The three handle families you'll meet β€” init_*, iterout_*, final_* β€” all appear here:

entry init_obs iterIn body Β· step iterOut iterout_obs / init_obs obs done β†’ stop loop-carry β€” handed back by pairing (no wire) final_obs Β· once after-loop node solid = wire you draw Β· blue dashed = pairing linkage (not an edge)

Its two configs β€” note there is no edge between the pivots, only pairedWith:

// iterIn β€” declares the init side; output handles are synthesised at load
{ "id": "iterIn_1", "type": "iterIn", "config": {
    "version": 3, "pairedWith": "iterOut_1",
    "initPorts": [ { "name": "obs", "wire_type": "TEXT", "persist": false } ] } }

// iterOut β€” carry ports on the input side, stop halt input, final_* on the output side
{ "id": "iterOut_1", "type": "iterOut", "config": {
    "pairedWith": "iterIn_1",
    "ports": [ { "name": "obs", "wire_type": "TEXT", "persist": true } ] } }

3. The four-moment mental model

Hold the whole subsystem as four moments in the life of a value:

MomentWhereWhat happens
SeediterIn init side, run-startThe entry node's values land on init_<name>; iterIn emits them to the body on iteration 0.
CarryiterOut β†’ iterIn, each boundaryiterOut collects config.ports values and the executor transfers them into iterIn's iterout_<name> slots; iterIn re-fires, opening the next iteration.
StopiterOut stop input, each boundaryIf the BOOL wired into stop is truthy (or the step budget is hit), the scope terminates after this iteration.
FinaliseiterOut final side, onceAt termination iterOut emits each carry port on final_<name> plus a constant final_stop β€” exactly once β€” feeding the after-loop verdict stage.

Β§4 details seed and carry (the port-synthesis machinery), Β§5 stop, Β§6 finalise. The runtime timing of these β€” the order in which the boundary phases run β€” belongs to the executor (Graph Executor Β§4.3); here we describe the contract, not the schedule.

4. Port synthesis & the persist matrix

iterIn never carries a hand-written port list. Its effective config.ports is synthesised at load by _synthesize_iterin_ports from two writers, with prefixes that keep the two value sources distinct on the same iterIn:

A direct canvas edge into iterIn that targets an undeclared handle is also accepted as an init-writer surrogate (the handle name is used verbatim). The persist flag and the writer origin give four legal per-port configurations:

OriginpersistBehaviour
initfalse (default)Seed once; the slot empties after iteration 0 β€” pure run-start input.
inittrueA run-invariant the body reads every iteration (instruction, episode config) β€” set this for anything the body needs past iteration 0.
iterouttrue (default)Standard loop-carry: refreshed by iterOut each boundary.
iteroutfalseLoop-carry that must be re-supplied every iteration or the slot goes empty β€” rare.

A name may legitimately appear on both sides (init_X for iteration 0, iterout_X for 1+) β€” that's the normal pattern for an observation that the entry node seeds and the loop then refreshes. It is only safe when the init entry is one-shot; Β§10 has the freeze hazard if it isn't.

5. Loop-carry & the stop signal

At each iteration boundary the executor reads iterOut's collected outputs and routes every config.ports value X into the paired iterIn's iterout_X slot, then re-queues iterIn. A persist=false slot clears after its consumer fires; a persist=true slot holds until overwritten. That transfer is the loop β€” there is no drawn back-edge.

Termination is decided once per iteration, at the iterOut boundary, from iterOut's own stop input (a class-level optional BOOL). If stop is truthy the scope ends after the current iteration; if it's unwired, the loop runs purely to its step_budget. stop is special only in that it does not participate in loop-carry (it isn't mirrored to the final side and a bare stop arrival doesn't make iterOut ready β€” readiness still needs the carry ports). There is no separate termination node and no termination condition string; the halt is just this BOOL.

6. The final side & the after-loop band

When a scope terminates, iterOut emits once on its final side: one final_<name> per carry port (mirroring the terminal iteration's values) plus a constant final_stop=True. These are real wires β€” verdict nodes (evaluate, graphOut) connect to them with ordinary edges β€” but they deliver exactly once, at the end.

The set of nodes downstream of a root iterOut's final_* edges is the after-loop band: the verdict stage that runs once, after the loop. Its defining rule (enforced by the validator, Β§9) is purity β€” a band node may take inputs only from the final side or from other band nodes, never from the loop body. Anything the verdict needs must ride the pivot: expose it as an iterOut carry port and read it from final_<name>. This is what makes β€œthe verdict reflects the terminal iteration” a structural guarantee rather than a scheduling accident β€” the rationale is in ADR-dataflow-009, the runtime drain in Graph Executor Β§5.1.

7. Scopes: what a loop is, in code

A scope is one iterIn/iterOut pair together with the body it bounds. The executor and validator work in terms of a ScopeInfo (scope_analysis.py), keyed by the iterIn node id:

@dataclass
class ScopeInfo:                    # scope_analysis.py β€” one (iterIn, iterOut) pair
    scope_id: str                   # the iterIn node id β€” canonical key for the scope
    iter_out_id: str                # its paired iterOut
    parent_scope_id: str | None     # None = root scope
    child_scope_ids: list[str]      # scopes nested directly inside this one
    member_node_ids: set[str]       # body nodes, by innermost membership
    graphin_node_ids: list[str]     # graphIn boundary nodes serving this scope
    graphout_node_ids: list[str]    # graphOut boundary / latch nodes
    step_budget: int | None         # resolved from iter_in.config.step_budget

Nodes outside any pivot pair belong to the implicit graph scope (id GRAPH_SCOPE_ID = "") β€” entry nodes and the after-loop band live there. A graph with zero or one scopes is is_single_scope, and the executor takes a fast path that mirrors the pre-nesting behaviour exactly; multi-scope graphs go through the general machinery of Β§8.

8. Nested scopes & the forest

Loops can nest β€” an outer planning loop containing an inner action loop. analyze_scopes(graph) builds the full ScopeForest (and returns any topology errors) by a pure analysis of the flat graph:

  1. Membership β€” a node belongs to a scope if it is reachable forward from that iterIn and backward from its iterOut; the intersection is the body. A node in several nested bodies is assigned to the innermost one (member_node_ids).
  2. Nesting β€” scopes are arranged by pivot containment: the parent is the innermost scope whose body contains this scope's pivots, giving parent_scope_id / child_scope_ids and the root_scope_ids at the top.
  3. Boundaries β€” graphIn/graphOut nodes are attached to the scope they serve; an inner scope's graphOut acts as a latch so the outer body can read the inner loop's result.
  4. Validation β€” cross-scope wires that bypass the graphIn/graphOut boundary, duplicate pairedWith, or unpaired pivots are reported as errors.
outer scope (root) iterIn α΅’ iterOut α΅’ inner scope iterIn ⁱ body iterOut ⁱ latched result

The forest also writes a per-node node_to_scope map and the graph_scope_node_ids (everything outside any author scope). The executor consults all of this to run each scope's boundary independently. This nesting machinery is ADR-dataflow-007 + ADR-executor-003.

9. Validator contract (loop-specific)

validate_graph_connectivity (graph_def.py) enforces the loop rules at graph load; the full catalogue (and the non-loop rules) is in Graph System Β§4.2. The loop-specific ones:

10. Edge cases & hazards

11. References

AgentCanvas docs