AgentCanvas / Pages / Developer Guide / Capabilities / 4Nested Graph System
2026-06-17

Any canvas graph can be saved as a graph node and dragged onto another canvas as a reusable block. This page explains the data model, boundary nodes, flattening algorithm, and storage.

Design doc: Graph System


1. Concepts

Term Definition
Composite node A canvas node whose subgraph field holds a GraphDefinition
GraphIn / GraphOut Boundary nodes inside a composite โ€” define its external interface
Flatten Recursive expansion of composites into a flat graph before execution
FlattenMap Mapping from flattened node IDs to original composite paths (for error tracing)
Graph An editable template stored in workspace/graphs/ โ€” opens in tabs
Graph Node A frozen composite stored in workspace/graph_nodes/ โ€” dragged onto canvas as a block

2. Data Model

A GraphDefinition is recursive โ€” nodes can contain sub-GraphDefinitions:

GraphDefinition
  โ”œโ”€โ”€ nodes: NodeDef[]
  โ”‚     โ””โ”€โ”€ subgraph: GraphDefinition | None   โ† nesting
  โ”œโ”€โ”€ edges: EdgeDef[]
  โ”œโ”€โ”€ containers: ContainerDef[]
  โ”œโ”€โ”€ access_grants: AccessGrantDef[]          โ† node โ†” container grants (ADR-dataflow-004)
  โ””โ”€โ”€ step_budget, terminationCondition, ...

access_grants replaced the earlier state_edges in ADR-dataflow-004. The dedicated top-level graph_state field is also gone โ€” a graph-level blackboard is now an ordinary ContainerDef in containers with the well-known id "graph_state", plus one AccessGrantDef per node that needs it.

A composite node on the canvas has kind = "composite" (ClassVar on the BaseCanvasNode subclass) and carries its inner graph in the JSON-side NodeDef.subgraph field (the docstring on BaseCanvasNode sometimes calls this the node's children, but the wire-level / persisted field name is subgraph โ€” see graph_def.py:NodeDef). The inner graph uses the same node/edge types as the root canvas โ€” full recursion to arbitrary depth.

3. GraphIn / GraphOut Boundaries

GraphIn and GraphOut are special control nodes that define a composite's external ports:

Parent canvas:
  [NodeA] โ”€โ”€โ†’ [Composite] โ”€โ”€โ†’ [NodeB]
                   โ”‚
Inside composite:  โ”‚
  [GraphIn: "input"] โ†’ [ProcessNode] โ†’ [GraphOut: "output"]

These are not IterIn/IterOut โ€” GraphIn/GraphOut define composite boundaries, while IterIn/IterOut handle iteration cycles.

4. Flatten Algorithm

The GraphExecutor expects a flat graph. Before execution, flatten.py recursively expands all composites:

Input:  [A] โ†’ [Composite{P_in โ†’ X โ†’ Y โ†’ P_out}] โ†’ [B]

Output: [A] โ†’ [X] โ†’ [Y] โ†’ [B]
        (GraphIn/GraphOut removed, edges rewired)

4.1 Steps

  1. Walk all nodes โ€” identify those with a non-empty subgraph
  2. Prefix inner node IDs with {composite_id}__ for uniqueness
  3. Identify GraphIn/GraphOut boundary nodes
  4. Rewire parent edges: - Incoming edges targeting the composite โ†’ target the GraphIn's downstream nodes - Outgoing edges from the composite โ†’ source from GraphOut's upstream nodes
  5. Remove GraphIn/GraphOut nodes and their internal edges
  6. Collect inner containers and access grants โ€” container id is prefixed with {composite_id}__; each AccessGrantDef gets its id and container_id prefixed, and its node_id remapped through the same id_map as the inner nodes (flatten.py:103โ€“114)
  7. Recurse if any flattened nodes also have subgraphs

4.2 FlattenMap

The FlattenMap records flat_id โ†’ (composite_path, original_id) for error tracing:

fmap.trace("agentLoop__step3__llmCall")
# โ†’ "agentLoop > step3 > llmCall"

When a node fails during execution, the error log shows the original composite path, not the flattened ID.

5. Storage: Graphs vs Graph Nodes

Two storage kinds serve different purposes (ADR-canvas-003):

Kind Location Behavior Sidebar section Extra fields
Graphs workspace/graphs/*.json Editable templates, open in tabs "Graphs" โ€”
Graph Nodes workspace/graph_nodes/*.json Frozen composites, drag onto canvas "Graph Nodes" group: str โ€” user-defined label used to cluster entries in the Explorer sidebar

5.1 Graph (Editable Template)

# Save
POST /api/graphs { name, description, nodes, edges }

# Load (opens in editor tab)
GET /api/graphs/{graph_id}

5.2 Graph Node (Frozen Composite)

"Save as Graph Node" takes a snapshot of a graph or selection. The snapshot is frozen โ€” editing the source graph doesn't affect existing graph node instances on other canvases. This is snapshot semantics.

// workspace/graph_nodes/llm_step.json
{
  "name": "LLM Step",
  "description": "Wraps an LLM call as a reusable composite block",
  "kind": "node",
  "group": "planning",
  "nodes": [
    { "id": "gin", "type": "graphIn", "config": {"portName": "observation"} },
    { "id": "llm", "type": "llmCall" },
    { "id": "gout", "type": "graphOut", "config": {"portName": "response"} }
  ],
  "edges": [...]
}

6. Key Files

File Role
agentcanvas/backend/app/agent_loop/flatten.py flatten_graph(), FlattenMap โ€” recursive composite expansion
agentcanvas/backend/app/graph_def.py GraphDefinition, NodeDef, EdgeDef, AccessGrantDef โ€” recursive data model
agentcanvas/backend/app/api/canvas/graphs.py CRUD for workspace/graphs/ and workspace/graph_nodes/ โ€” routes by kind
agentcanvas/backend/app/agent_loop/builtin_nodes.py GraphInNode / GraphOutNode โ€” the boundary node classes (node_io.py holds only the IO-schema registry, no node handlers)

Status

Item Status Notes
Recursive GraphDefinition (NodeDef.subgraph) Done Full recursion to arbitrary depth
GraphIn / GraphOut boundary nodes Done Both node types defined, handled in flatten
Flatten algorithm (prefix, rewire, remove, recurse) Done Tested with nested composites
FlattenMap + trace() Done Maps flattened IDs to original composite paths
Dual storage (graphs/ vs graph_nodes/) Done Both directories exist, routed by kind field
CRUD API (/api/graphs) Done GET, POST, PUT, DELETE all implemented
Snapshot semantics (frozen graph nodes) Done Functional guarantee holds: dragged instances get a frontend deep copy of the subgraph, and flatten.py deep-copies again at execution time, so editing the source graph never mutates existing instances. Files in graph_nodes/ have no FS-level write protection โ€” that's a policy gap, not a semantic one
GraphDefinition.from_dict() Done Works as a deserializer; no strict schema validation (missing keys default silently)
Composite node kind="composite" Partial ClassVar exists in BaseCanvasNode, but no concrete CompositeNode class โ€” composites are pure data (NodeDef with subgraph), expanded by flatten before execution
AgentCanvas docs