AgentCanvas / Pages / Developer Guide / Capabilities / 1Graph-Expressible Agents
2026-06-17

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:

  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 β€” 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:

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.

AgentCanvas docs