1Graph-Expressible Agents
DAG workflows, cyclic agent loops, and compositions of both run on the same canvas and executor.
AgentCanvas supports workflow DAGs, cyclic agent loops, and compositions of both on the same canvas. This page explains how each form works and how the executor handles them uniformly.
Design docs: Graph Executor Β· Loop Control
1. Three Forms
| Form | Pattern | Use Case |
|---|---|---|
| DAG Workflow | Linear or branching, no cycles | One-shot pipelines (caption β summarize β output) |
| Agent Loop | Cyclic via IterIn/IterOut | Iterative agents (observe β reason β act β repeat) |
| Mixed | DAG containing loops, or loops containing DAGs | Hierarchical agents with sub-loops or post-processing |
All three are expressed as the same GraphDefinition JSON and executed by the same GraphExecutor. The executor doesn't distinguish between forms β it simply fires nodes when their inputs arrive.
2. DAG Workflow
A DAG workflow is a graph with no cycles. Nodes fire in topological order (inputs before outputs), and execution ends when all reachable nodes have fired.
[VLMCaption] β [Summarize] β [GraphOut]
Executor behavior: Entry nodes (no required inputs, no incoming edges) fire first. Outputs propagate forward. No IterIn/IterOut needed β the graph runs once and completes.
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. Agent Loop
ADR-dataflow-008 + full removal (2026-06-10): the Initialize pivot is folded into a two-sided iterIn (left = run-start init inputs via initPorts, right = loop-carry) and the initialize node type has been deleted outright β validate_graph_connectivity rejects graphs that still carry one, with a migration hint.
An agent loop is a cyclic graph using two pivot nodes: a two-sided IterIn whose left/input side receives run-start seeds (declared in config.initPorts, wired as ordinary canvas edges into init_<name> handles) and whose right/output side broadcasts the per-iteration loop-carry bundle, and IterOut, which collects end-of-step data and feeds it back. The loop transfer uses a single pairedWith config field linking IterOut β IterIn; no canvas wire joins the pivots.
[entry nodes] ββinit_*βββ [IterIn] β [EnvObserve] β [LLMCall] β [Decision] β [EnvStep] β [IterOut]
β² β
βββββββββββββββββββ pairedWith (loop transfer) βββββββββββββββββ
3.1 Two Pivots
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 β the executor reads it once per iteration at the boundary, ends that scope, and fires the terminal iteration's values out on the iterOut's final_* handles (the after-loop verdict stage). The standalone Termination node type was removed 2026-06-11; node code may still raise StopExecution as an escape hatch to abort mid-iteration. In multi-scope graphs the stop is per-scope β an inner scope's stop ends only that scope; only the root scope's stop ends the run.
4. Mixed Composition
Because the executor is form-agnostic, DAGs and loops can be freely mixed:
- Loop inside DAG: A graph node containing an agent 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 an agent loop. Each iteration expands and runs the inner DAG.
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
5. 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 |
| Agent 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.