4Nested Graph System
Composite nodes contain subgraphs; flatten.py recursively expands them before execution.
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"]
- GraphIn config:
{ "portName": "input" }โ maps to a target handle on the composite's input - GraphOut config:
{ "portName": "output" }โ maps to a source handle on the composite's 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
- Walk all nodes โ identify those with a non-empty
subgraph - Prefix inner node IDs with
{composite_id}__for uniqueness - Identify GraphIn/GraphOut boundary nodes
- 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
- Remove GraphIn/GraphOut nodes and their internal edges
- Collect inner containers and access grants โ container
idis prefixed with{composite_id}__; eachAccessGrantDefgets itsidandcontainer_idprefixed, and itsnode_idremapped through the sameid_mapas the inner nodes (flatten.py:103โ114) - 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 |