AgentCanvas / Pages / Developer Guide / Capabilities / Graph-Expressible Agents
2026-07-13

Please read the blog post Rollout Loop vs. Agent Loop first β€” it argues the loop terminology this page builds on. An agent design is a graph you can draw, and every graph in this repo is classified by two independent questions. Form β€” does it loop? A one-shot pipeline is a DAG workflow; a graph that repeats is a Loop, drawn as a paired IterIn/IterOut with a body between them. Realization β€” what the structure means: a loop advancing on the environment's clock is a rollout loop, one advancing on the reasoner's is an agent loop, and a fixed reasoning pipeline that never loops at all is an agent workflow. The executor answers only the first question β€” every form is the same GraphDefinition JSON run by the same fire-on-input GraphExecutor β€” and the second question it never sees at all. Deep dives: Graph Executor Β· Loop Control.

FORM β€” all the executor sees REALIZATION β€” what the structure means caption summarize graphOut DAG workflow β€” runs once iterIn body iterOut Loop β€” repeats until stop observe policy envStep rollout loop β€” env clock Β· one env step per iteration llmCall llmCall action agent workflow β€” fixed LLM pipeline Β· runs once per step reason toolCall agent loop β€” LLM clock Β· one LLM turn per iteration one GraphDefinition JSON Β· one GraphExecutor β€” fire-on-input form-agnostic: never branches on shape Β· realization-blind: never reads meaning solid = data wire Β· dashed arc = pairing linkage (not a wire) Β· dashed accent = form β†’ the realizations it can carry

1. Two Axes, Five Terms

Term Axis In this repo
DAG workflow form An acyclic graph β€” no (IterIn, IterOut) pair anywhere. Entry nodes fire, values propagate forward, the run ends when nothing is left to fire. Runs exactly once.
Loop form The iteration construct: paired IterIn/IterOut pivots plus the body drawn between them. Purely structural β€” it says nothing about what iterates.
Rollout loop realization A loop on the environment's clock: one env step per iteration, until the episode ends. Reasoning optional β€” a frozen policy in the body is still a rollout loop.
Agent workflow realization Reasoning realized as a DAG: a fixed pipeline of LLM/VLM calls, run front-to-back once per invocation β€” typically once per rollout step. The shape of most embodied-agent methods (NavGPT, MapGPT, VoxPoser β€” all ported here; ReKep is the same pattern).
Agent loop realization A loop on the reasoner's clock: one LLM turn per iteration β€” reason, call tools, read results (ReAct). No environment required.

The two axes never meet inside the engine. Form is the executor's whole world: a node fires when its inputs have arrived, and the only types it special-cases are the two pivots. Realization exists only in what you drag into the body β€” an EnvStep and a toolCall look identical to the scheduler. That separation is the capability: any agent design that can be drawn as boxes and wires runs here, with no engine changes.

2. DAG Workflow

A DAG workflow is a graph with no cycles. Entry nodes β€” nodes with no incoming edges and no required inputs β€” fire first; outputs propagate forward; the run completes when nothing is left to fire. No pivots involved: the graph runs exactly once.

2.1 Example JSON

{
  "name": "Caption Pipeline",
  "nodes": [
    { "id": "cap", "type": "vlmCall", "label": "VLM Call", "config": {} },
    { "id": "sum", "type": "llmCall",    "label": "Summarize",   "config": {} },
    { "id": "out", "type": "graphOut", "label": "Output",      "config": {} }
  ],
  "edges": [
    { "id": "e1", "source": "cap", "target": "sum", "sourceHandle": "text", "targetHandle": "text" },
    { "id": "e2", "source": "sum", "target": "out", "sourceHandle": "text", "targetHandle": "text" }
  ],
  "step_budget": 1
}

3. Loop

A loop is a cycle drawn with two pivot nodes. IterIn opens each iteration and hands the body its inputs; IterOut closes it, collects what carries forward, and decides whether to go round again. The pivots 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 is the loop body: ordinary nodes with no special treatment.

Each pivot is two-sided. IterIn's init side receives run-start seeds β€” ordinary edges into init_<name> handles, declared in config.initPorts; its other side emits the per-iteration loop-carry bundle into the body. IterOut's input side collects the carry values plus a stop signal; its final_* side fires exactly once, when the loop ends.

entry iterIn EnvObserve LLMCall EnvStep iterOut init_* done β†’ stop loop-carry β€” handed back by pairing (no wire) final_* Β· once after-loop node solid = wire you draw Β· dashed = pairing linkage (not an edge)

Two-sided pivots are the current model β€” ADR-dataflow-008 (2026-06-10). The earlier standalone initialize node type is deleted outright: validate_graph_connectivity rejects graphs that still carry one, with a migration hint.

3.1 What the executor does with the cycle

The GraphExecutor handles the cycle explicitly:

  1. IterIn (ports_mode="source") is not seed-eligible β€” the executor explicitly excludes type == "iterIn" from the seed rule (graph_executor.py). Run-start seed edges land in its init_<name> port slots; it fires once any slot is populated (iter 1) and again on each IterOut transfer (iter 2+). Slots with persist=true re-emit every iteration; persist=false slots clear after the fire (one-shot).
  2. Downstream body nodes fire as data flows forward.
  3. IterOut fires once per iteration, at the end: - Increments the per-scope step_counter (mirrored to self.step_counter on the outermost scope for backward compat); - Broadcasts step_end signal so lifetime="step" state containers clear; - Checkpoints state containers at root-scope iter boundaries; - Flushes the execution log to JSONL; - Calls _broadcast_step() to consolidate output-viewer data into the nav_step WebSocket event; - Transfers its outputs into the paired IterIn's iterout_<name> port slots and pushes IterIn back onto the ready queue.
  4. The loop repeats until the paired IterOut's stop input is truthy (checked once per iteration at the boundary) or step_budget is reached; node code may also raise StopExecution as an escape hatch.

Current IterOut handling (abridged from agent_loop/graph_executor.py):

if node.type == "iterOut":
    _io_scope_id = node.config.get("pairedWith", "") or self._outermost_scope_id
    _io_scope = self.scope_state.get(_io_scope_id)
    _io_scope.step_counter += 1
    self.broadcast_signal("step_end", {"step": _io_scope.step_counter, "scope_id": _io_scope_id})
    # ... checkpoint, log flush, _broadcast_step, multi-scope settle ...
    paired = self.nodes[_io_scope_id]   # the iterIn
    paired.pending_inputs.update(result)
    ready_queue.append(paired.id)

3.2 Multi-Scope Loops

A single flat graph can contain N coexisting (IterIn, IterOut) pairs β€” each defines a scope with its own iteration cadence (ADR-dataflow-007, ADR-executor-003). The executor computes a scope forest at run-start via analyze_scopes(); each scope keeps its own step_counter; graphOut nodes inside inner scopes buffer their value into state["latched_value"] and flush on scope termination (_propagate_graphout_latches). The outermost scope's termination ends the run.

3.3 Termination

A loop ends when the paired iterOut's stop BOOL input is truthy β€” read once per iteration at the boundary. The executor ends that scope and fires the terminal iteration's values out on the iterOut's final_* handles: the after-loop verdict stage. In multi-scope graphs stop is per-scope β€” an inner scope's stop ends only that scope; only the root scope's stop ends the run. (The standalone Termination node type was removed 2026-06-11; StopExecution survives only as a node-code escape hatch for aborting mid-iteration.)

4. Realizations: Rollout Loop, Agent Workflow, Agent Loop

The two loop realizations use the same two pivots; the difference is whose clock the iteration follows. A rollout loop follows the environment's: each iteration observes, acts, and steps the world once, and the loop ends when the episode does. Nothing in that requires reasoning β€” a frozen CMA or VLA policy stepping an env is a rollout loop with zero reasoning inside. An agent loop follows the reasoner's: each iteration is one LLM turn β€” reason, call tools, read results β€” repeated until the task is done. This is the LLM-agent community's sense of "agent" (ReAct), and it involves no environment unless a tool happens to wrap one. An embodied graph always has the first and only sometimes the second.

Most embodied-agent methods are agent workflows, not agent loops. NavGPT, MapGPT, and VoxPoser decide each action with a fixed sequence of LLM/VLM calls that runs front-to-back and emits its decision β€” there is no open-ended iteration inside the deciding. That is an agent workflow: reasoning realized in DAG form (ReKep is the same pattern). On the canvas it is literally a DAG wired into a rollout loop's body β€” the shape of the ported methods under workspace/nodesets/method/.

The loop figure in Β§3 draws a rollout loop with an LLM inside it. Swap LLMCall for a frozen policy node and it is a pure rollout loop; replace the env nodes with tool calls and the same two pivots run an agent loop. The executor is blind to the swap β€” same pivots, same transfer, same stop.

Where ReAct stands here. A ReAct-style agent loop runs today in single-node form: the whole reason ↔ tool loop lives inside one custom node's Python β€” an LLMCallNode subclass with a tool registry passed as config β€” so the graph sees one box. The graph-native form (the loop drawn on the canvas: a router node plus predeclared tool branches) is still in development; and anything past a bounded, predeclared tool pool β€” tools created at runtime, unbounded subagent spawning β€” is v2 territory by design. Details: Major Versions.

5. Composition

Because the executor is form-agnostic, the forms mix freely:

This composability comes from two mechanisms:

  1. Nested graph system β€” composite nodes with subgraphs (see Nested Graph System)
  2. Flatten before execute β€” flatten.py recursively expands all composites, so the executor always sees a flat graph

6. Key Files

File Role
agentcanvas/backend/app/agent_loop/graph_executor.py Dataflow scheduler, IterOut→IterIn transfer + init-edge slot routing, per-scope step counting, multi-scope settle isolation
agentcanvas/backend/app/agent_loop/builtin_nodes.py Pivot node classes β€” IterInNode, IterOutNode, plus other framework-shipped nodes
agentcanvas/backend/app/agent_loop/scope_analysis.py analyze_scopes() β€” builds the scope forest from pairedWith wiring before run-start
agentcanvas/backend/app/agent_loop/flatten.py flatten_graph() β€” recursively expands composite nodes so the executor sees a flat graph
agentcanvas/backend/app/standard/node_io.py get_required_inputs() β€” class-level required-port registry + node IO schema (no node classes live here; the Termination node type was removed)
agentcanvas/backend/app/graph_def.py validate_graph_connectivity() β€” rejects required-but-unwired ports at API boundary, no silent never-fires

Status

Item Status Notes
DAG workflow execution Done GraphExecutor runs single forward pass when no IterIn/IterOut present
Loop β€” two-pivot model (two-sided IterIn + IterOut) Done Per ADR-dataflow-008. Run-start seeds as ordinary edges into iterIn init slots; IterOut transfer per iteration via pairedWith
Seed-node discovery rule Done A node is queued at run-start iff its type is not iterIn AND it has no incoming edges AND it has no required input ports
Loop termination via iterOut.stop Done Truthy stop BOOL input on iterOut, read once per iteration at the boundary; ends the scope + emits the final side. The Termination node type was removed 2026-06-11; StopExecution survives only as a node-code escape hatch. Multi-scope: stop is per-scope, only the root scope ends the run
Mixed composition (loop inside DAG / DAG inside loop) Done flatten_graph() expands composites before run-start; executor handles mixed patterns uniformly
Multi-scope iteration Done N coexisting (IterIn, IterOut) pairs per graph, per-scope step counter, scope forest from analyze_scopes(), portOut latch propagation (ADR-dataflow-007 + ADR-executor-003)
validate_graph_connectivity() guard Done Required-but-unwired ports rejected at API boundary, no silent never-fires (HTTP 400 from graph_def.py)

All items fully implemented.

AgentCanvas docs