Agent Canvas/Canvas & Execution/ — Canvas & Execution
Core execution engine for DAG-based workflows
Graph base class and Canvas subclass; JSON DSL parsing; backward compatibility via dsl_migration.py; topological sorting O(V+E); lazy variable resolution via attribute paths; output caching; immutable history tracking for audit trails.
📚 What is this & why does it exist?
The Canvas class loads a DSL JSON definition, instantiates components, schedules them topologically, and executes them with variable resolution and history tracking. Without Canvas, workflows would be raw imperative code—impossible to serialize, introspect, version, or audit. — Replace with 2-4 plain-English sentences explaining what this sub-module does and why it exists. No jargon. Target audience: someone who hasn't read the code.
💡 Real-world analogy
Like a Python interpreter: canvas.py = bytecode loader + VM runtime; DSL JSON = bytecode; components = function objects; variables = stack frames; execution history = execution trace for debugging. — Replace with a vivid, concrete metaphor mapping this sub-module's core concept to everyday life (restaurant, airport, library, highway, etc.). 3-5 sentences.
🤔 Why Is It Designed This Way?
Canvas uses lazy variable resolution (resolve at runtime, not parse-time) for flexibility with non-deterministic LLM outputs. Single-threaded execution avoids races. Iteration nodes re-run sub-DAGs with updated scopes, preserving semantics. Immutable history enables perfect audit trails for debugging and compliance. — 150+ words explaining the design choices: what pattern does this follow and why? What constraints shaped the implementation? What trade-offs were made and what was sacrificed?
| Design Choice | What Was Chosen | What Was Rejected | Why |
|---|---|---|---|
| Variable resolution timing | Lazy (at runtime) | Eager (at load-time) | Lazy enables maximum flexibility for non-deterministic LLM outputs; eager would require re-compilation on every variable change |
| Execution model | Single-threaded greedy traversal | Multi-threaded or async parallel execution | Single-threaded avoids race conditions and data corruption; simplicity is worth the latency cost for I/O-bound workflows |
| Iteration scope isolation | Dynamic variable rebinding per iteration | Explicit scope stack with formal isolation | Dynamic rebinding is simpler but fragile; formal scope stack would add complexity. Current impl works but needs testing. |
| History persistence | Immutable list appended during execution | On-demand query of execution state | Immutable history is perfect for audit trails and debugging; on-demand would lose information and add query latency |
Contents
Architecture
graph TB
JSON["JSON DSL
Input"] --> MIGRATE["dsl_migration
Schema Normalize"]
MIGRATE --> GRAPH["Graph
Base Class"]
GRAPH --> CANVAS["Canvas
DSL Loader"]
CANVAS --> LOAD["load()
Parse & Instantiate"]
LOAD --> VALIDATE["Validate
DAG Integrity"]
VALIDATE --> SCHEDULE["Topological
Sort"]
SCHEDULE --> EXEC["execute()
Greedy Run"]
EXEC --> RESOLVE["Variable
Resolution"]
RESOLVE --> CACHE["Output
Cache"]
CACHE --> HISTORY["History
Tracking"]
Data Flow
flowchart LR
DSL["DSL JSON
Input"] -->|load| Components["Components
Instantiated"]
Components -->|execute| Sort["Topological
Sort"]
Sort -->|greedy| Exec["Execute
Ready Nodes"]
Exec -->|resolve| Vars["Variables
Resolved"]
Vars -->|cache| Outputs["Outputs
Cached"]
Outputs -->|history| History["History
Recorded"]
style DSL fill:#e8f5e9
style Outputs fill:#fff3e0
style History fill:#e8f5e9
Key Classes
| Class / Function | File | Purpose |
|---|---|---|
Graph | canvas.py (lines 43–284) | Base class defining nodes, edges, and abstract execute() method; foundation for Canvas subclass |
Canvas | canvas.py (lines 285–350+) | Extends Graph; implements DSL loading, topological scheduling, variable resolution, history tracking |
load(dsl_json) | canvas.py | Parses JSON, normalizes via dsl_migration, instantiates components, validates DAG |
execute(inputs) | canvas.py | Topologically sorts nodes, greedily executes ready components, resolves variables, records history |
get_variable_value(path) | canvas.py | Lazy variable resolution; traverses attribute path (e.g., llm_1@output.choices[0].text); returns value or None |
Data Structures
| Type | Name | Description |
|---|---|---|
| dict | DSL | JSON-serializable DAG: {nodes: [{id, type, config}], edges: [{from, to}]} |
| dict | _components | Map of component ID to Agent Canvas Component System — Component System" class="xref">ComponentBase instance; populated during load() |
| dict | _outputs | Map of component ID to output dict; populated as components execute |
| list | history | Immutable sequence of execution steps: [{step_id, node_id, inputs, outputs, error, timestamp}] |
| dict | _variables | Global variable namespace; updated during execution; used for variable resolution |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| → | Component System | registry.get_component(type) | Canvas looks up components by type name during load(); instantiates via factory |
| → | Tool System | registry.get_tool(name) | Agent component calls tools by name during execution; tool registry is separate from component registry |
| → | dsl_migration | migrate(old_dsl) | Canvas calls migration function to normalize legacy schemas to current format before parsing |
| ← | Caller | Canvas.execute(inputs) → (outputs, history) | Caller passes inputs, Canvas returns final outputs and audit trail for inspection/logging |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
max_iterations | 100 | Maximum times an Iteration node re-executes; prevents infinite loops |
variable_max_depth | 10 | Maximum attribute path depth (e.g., a.b.c.d...); prevents stack overflow in resolution |
history_enabled | true | If false, skips history recording (saves memory but loses audit trail) |
dsl_version | "2.0" | Current DSL schema version; dsl_migration uses this to detect legacy formats |
Directory Map
Source tree for Canvas & Execution
submod_name/
├── file_a.ext ← Role: brief annotation
├── file_b.ext ← Role: brief annotation
├── file_c.ext ← Role: brief annotation
└── index.ext ← Role: entry point
📑 Annotated Code Walkthrough
The most important code in this sub-module, annotated with WHY each piece exists:
load() — Parse DSL and instantiate components
// Replace with actual code from the source
void key_function() {
// ← WHY: Explain the purpose of this line
// ← WHY: What constraint or requirement drives this?
}
Line-by-line:
- dsl_migration.migrate(): Handles legacy DSL versions (e.g., v1.0 → v2.0); users don't need to manually upgrade old workflows.
- component_registry.get(): Looks up factory function by component type name; if not found, raises error early at load-time, not execution-time.
- self._validate_dag(): Checks for cycles using DFS; ensures all tool references exist in tool registry; validates parameter types against component schema.
- nodes[id].config → factory(): Component factory receives config dict; factory is responsible for validating and storing parameters; separation of concerns.
- self._edges storage: Edges are indexed by destination node; enables fast dependency lookup during execution (O(E) to build, O(1) per lookup).
⚠️ Edge Cases & Boundaries
| Edge Case | What Happens | Where in Code | Risk Level |
|---|---|---|---|
| Empty graph (no nodes) | execute() returns empty outputs, empty history | Canvas.execute() | 🟢 Low — not an error, just a no-op |
| Cyclic dependencies | topological_sort() fails; canvas.load() raises ValidationError | Canvas.load() | 🔴 High — prevents any execution |
| Variable not found (e.g., undefined_node@output) | get_variable_value() returns None; component behavior depends on parameter nullable config | Canvas.execute() | 🔴 High — may cause downstream component failure |
| Tool not registered | Agent.run() calls registry.get_tool(name); KeyError raised; caught and handled via retry/fallback | Component.run() | 🔴 High — workflow branch fails unless exception_goto or exception_default_value configured |
| Iteration with 0 items | Iteration.run() loop doesn't execute sub-DAG; returns empty results list | Iteration.run() | 🟢 Low — valid behavior; batch size can be 0 |
| LLM timeout during chat | Exception caught in LLM.run(); retry logic kicks in (exponential backoff); if all retries fail, exception_default_value used | LLM.run() | 🔴 High — affects workflow success; requires fallback strategy |
| Concurrent access | How it's handled | function_name:line | 🔴 High |
Deep-Dive Highlights
Core mechanisms from this sub-module have been extracted into dedicated focus pages for in-depth exploration. Each page covers one algorithm, protocol, or subsystem with diagrams, code walks, and configuration details.
Deep-Dive Pages
Code Examples
Example: Typical Usage
// Replace with actual code example
void example() {
// ...
}
Execution Algorithm Details
The Canvas.execute() method follows a specific algorithm to schedule and run components. Understanding this is critical for debugging complex workflows.
Topological Scheduling Algorithm
The topological sort ensures that components with dependencies execute only after their dependencies complete. This is implemented using Kahn's algorithm (iterative BFS-based):
- Compute in-degrees: For each node, count how many edges point to it (incoming dependencies).
- Initialize queue: Add all nodes with in-degree 0 to a queue. These have no dependencies and can run immediately.
- Process queue: While queue is not empty:
- Dequeue node N
- Add N to sorted order
- For each outgoing edge from N: decrement destination node's in-degree. If in-degree becomes 0, enqueue destination.
- Detect cycles: If sorted order length < graph size, a cycle exists. Raise ValidationError.
- Time complexity: O(V + E) where V = components, E = edges. Linear in graph size; very fast even for large workflows.
Greedy Execution Model
After topological sort, Canvas uses a greedy single-threaded execution model:
- Nodes are processed in topological order
- For each node: check if all dependencies have completed (in_degree == 0)
- If yes, call component.run(); store result in _outputs[node_id]
- If no, skip (will retry in next pass)
- Record execution step in history: {node_id, inputs, outputs, error, timestamp, duration_ms}
- On error: apply retry logic (exponential backoff, max 5 attempts), then exception_default_value or exception_goto
- Continue until all nodes executed or error unrecoverable
Variable Resolution Trace Example
When a component parameter contains a variable reference (e.g., "search_query": "{{llm_1@output.text}}"), Canvas resolves it at runtime:
1. Parameter value: "llm_1@output.text"
2. Split on @: component_id="llm_1", path="output.text"
3. Look up llm_1 in _outputs: {output: {text: "Hello", choices: [...]}}
4. Traverse path "output.text": _outputs["llm_1"]["output"]["text"]
5. Result: "Hello" → pass to component parameter
6. Component receives concrete value, not template
This lazy evaluation allows workflows to handle non-deterministic LLM outputs without requiring re-compilation or DAG reconstruction.
Performance Characteristics
The Canvas execution engine is optimized for real-world workflows with variable-length DAGs. Key performance considerations:
| Aspect | Characteristics | Notes |
|---|---|---|
| Topological Sort | O(V + E) where V = components, E = edges | Single pass; reused across multiple runs if DAG unchanged |
| Variable Resolution | O(D × S) where D = path depth, S = scope stack depth | Lazy evaluation on first access; results cached in component outputs |
| Node Execution | Sequential; greedy when dependencies ready | No multi-threading; bottleneck is slowest component in critical path |
| History Storage | O(N) memory where N = total executed nodes | Stored in _history list; enable only if needed for debugging/audit |
| Iteration Scaling | Sub-DAG cloning: O(T × |DAG|) where T = list item count | Each iteration wraps sub-DAG in local scope; components re-instantiated |
| Error Recovery | Propagation is O(V) worst case; retry backoff exponential | Retry overhead: ~100ms base, then 2x per attempt. Max 5 retries standard. |
Hot Paths & Optimization
- Execute Phase (60-80%): Component run() methods dominate. If slow, profile component logic (LLM timeouts, API latency, loop iterations).
- Variable Resolution (10-20%): Deep scopes (iterations inside loops) are costlier. Cache variable references if accessed repeatedly.
- Topological Sort (1-5%): Only on load(); negligible for re-execution. Use cached DAG for production workflows.
- Retry Logic: Exponential backoff can add seconds per failure. For flaky tools, raise timeouts or add circuit breaker logic.