1Graph-Expressible Agents
Form (DAG workflow or loop) and realization (rollout loop, agent workflow, or agent loop) are independent axes β one GraphDefinition JSON, one executor, agnostic to both.
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.
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.
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:
- IterIn (
ports_mode="source") is not seed-eligible β the executor explicitly excludestype == "iterIn"from the seed rule (graph_executor.py). Run-start seed edges land in itsinit_<name>port slots; it fires once any slot is populated (iter 1) and again on each IterOut transfer (iter 2+). Slots withpersist=truere-emit every iteration;persist=falseslots clear after the fire (one-shot). - Downstream body nodes fire as data flows forward.
- IterOut fires once per iteration, at the end:
- Increments the per-scope
step_counter(mirrored toself.step_counteron the outermost scope for backward compat); - Broadcastsstep_endsignal solifetime="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 thenav_stepWebSocket event; - Transfers its outputs into the paired IterIn'siterout_<name>port slots and pushes IterIn back onto the ready queue. - The loop repeats until the paired IterOut's
stopinput is truthy (checked once per iteration at the boundary) orstep_budgetis reached; node code may also raiseStopExecutionas 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:
- Loop inside DAG: A graph node containing a loop can be placed in a larger workflow DAG. The flattener expands it, and the executor handles the inner IterIn/IterOut cycle while the outer DAG nodes fire normally.
- DAG inside loop: A composite node containing a linear pipeline (caption β summarize) can be placed inside a loop. Each iteration expands and runs the inner DAG.
- Loop inside loop: An inner (IterIn, IterOut) pair inside an outer one β a separate scope with its own iteration cadence (Β§3.2). This is where the two realizations couple: an agent loop as the rollout's policy (the DiscussNav shape β several LLM exchanges inside every env step) or a rollout as one tool call of an outer agent loop (the Reflexion shape β run an episode, reflect, retry).
This composability comes from two mechanisms:
- Nested graph system β composite nodes with subgraphs (see Nested Graph System)
- Flatten before execute β
flatten.pyrecursively 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.