graph-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/release.yml +49 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +126 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +128 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +94 -0
- data/LICENSE +21 -0
- data/Makefile +114 -0
- data/README.md +464 -0
- data/Rakefile +15 -0
- data/docs/README.md +55 -0
- data/docs/api_reference.md +832 -0
- data/docs/concepts.md +216 -0
- data/docs/edges.md +265 -0
- data/docs/error_handling.md +241 -0
- data/docs/human_in_the_loop.md +231 -0
- data/docs/persistence.md +276 -0
- data/docs/quickstart.md +154 -0
- data/docs/send_and_command.md +218 -0
- data/docs/state.md +181 -0
- data/docs/streaming.md +172 -0
- data/graph-agent.gemspec +48 -0
- data/lib/graph_agent/channels/base_channel.rb +52 -0
- data/lib/graph_agent/channels/binary_operator_aggregate.rb +56 -0
- data/lib/graph_agent/channels/ephemeral_value.rb +59 -0
- data/lib/graph_agent/channels/last_value.rb +49 -0
- data/lib/graph_agent/channels/topic.rb +58 -0
- data/lib/graph_agent/checkpoint/base_saver.rb +38 -0
- data/lib/graph_agent/checkpoint/in_memory_saver.rb +145 -0
- data/lib/graph_agent/constants.rb +9 -0
- data/lib/graph_agent/errors.rb +41 -0
- data/lib/graph_agent/graph/compiled_state_graph.rb +362 -0
- data/lib/graph_agent/graph/conditional_edge.rb +57 -0
- data/lib/graph_agent/graph/edge.rb +23 -0
- data/lib/graph_agent/graph/mermaid_visualizer.rb +154 -0
- data/lib/graph_agent/graph/message_graph.rb +18 -0
- data/lib/graph_agent/graph/node.rb +61 -0
- data/lib/graph_agent/graph/state_graph.rb +197 -0
- data/lib/graph_agent/reducers.rb +34 -0
- data/lib/graph_agent/state/schema.rb +54 -0
- data/lib/graph_agent/types/cache_policy.rb +12 -0
- data/lib/graph_agent/types/command.rb +26 -0
- data/lib/graph_agent/types/interrupt.rb +28 -0
- data/lib/graph_agent/types/retry_policy.rb +42 -0
- data/lib/graph_agent/types/send.rb +26 -0
- data/lib/graph_agent/types/state_snapshot.rb +28 -0
- data/lib/graph_agent/version.rb +5 -0
- data/lib/graph_agent.rb +29 -0
- metadata +158 -0
data/docs/concepts.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Core Concepts
|
|
2
|
+
|
|
3
|
+
## Graphs
|
|
4
|
+
|
|
5
|
+
A **graph** is a directed workflow of nodes connected by edges. GraphAgent provides
|
|
6
|
+
`StateGraph` as the primary graph builder and `MessageGraph` as a convenience
|
|
7
|
+
subclass for chat-oriented workflows.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A graph is built in three phases:
|
|
14
|
+
|
|
15
|
+
1. **Define** — add nodes, edges, and conditional edges.
|
|
16
|
+
2. **Compile** — validate the graph and produce a `CompiledStateGraph`.
|
|
17
|
+
3. **Execute** — invoke or stream the compiled graph.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## State
|
|
22
|
+
|
|
23
|
+
State is the shared data structure that flows through the graph. Every node
|
|
24
|
+
reads state and returns updates that are applied back to it.
|
|
25
|
+
|
|
26
|
+
State is defined by a **Schema** that declares fields, their types, optional
|
|
27
|
+
reducers, and defaults.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
schema = GraphAgent::State::Schema.new do
|
|
31
|
+
field :messages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
32
|
+
field :count, type: Integer, default: 0
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Last-value semantics
|
|
37
|
+
|
|
38
|
+
Fields without a reducer use **last-value** semantics: the most recent write wins.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
field :status # no reducer → last write wins
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Aggregation semantics
|
|
45
|
+
|
|
46
|
+
Fields with a reducer **accumulate** values. The reducer is called as
|
|
47
|
+
`reducer.call(current_value, new_value)` each time the field is updated.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
field :messages, reducer: GraphAgent::Reducers::ADD, default: []
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [State Management](state.md) for the full DSL and all built-in reducers.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Nodes
|
|
58
|
+
|
|
59
|
+
A **node** is a callable (block, lambda, method) that receives the current
|
|
60
|
+
state and optionally a config hash, then returns a Hash of state updates.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
graph.add_node("greet") do |state|
|
|
64
|
+
{ greeting: "Hello, #{state[:name]}!" }
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Nodes can also return `Command` or `Send` objects for advanced routing (see
|
|
69
|
+
[Send & Command](send_and_command.md)).
|
|
70
|
+
|
|
71
|
+
### Arity
|
|
72
|
+
|
|
73
|
+
- **0 args** — `->() { { key: value } }` — no state access.
|
|
74
|
+
- **1 arg** — `->(state) { ... }` — reads state.
|
|
75
|
+
- **2 args** — `->(state, config) { ... }` — reads state and config.
|
|
76
|
+
|
|
77
|
+
### Retry
|
|
78
|
+
|
|
79
|
+
Nodes can have a `RetryPolicy` for automatic retries on failure:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
policy = GraphAgent::RetryPolicy.new(max_attempts: 3)
|
|
83
|
+
graph.add_node("flaky", method(:call_api), retry_policy: policy)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Edges
|
|
89
|
+
|
|
90
|
+
Edges define transitions between nodes.
|
|
91
|
+
|
|
92
|
+
### Normal edges
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
graph.add_edge("node_a", "node_b")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Conditional edges
|
|
99
|
+
|
|
100
|
+
Route dynamically based on state:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
graph.add_conditional_edges("router", ->(state) {
|
|
104
|
+
state[:route]
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Entry and exit
|
|
109
|
+
|
|
110
|
+
Every graph needs at least one entry point from `START` and typically ends at `END_NODE`:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
graph.add_edge(GraphAgent::START, "first_node")
|
|
114
|
+
graph.add_edge("last_node", GraphAgent::END_NODE)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Convenience helpers:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
graph.set_entry_point("first_node")
|
|
121
|
+
graph.set_finish_point("last_node")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
See [Edges](edges.md) for waiting edges, sequences, conditional entry points,
|
|
125
|
+
and more.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Supersteps (Pregel Execution Model)
|
|
130
|
+
|
|
131
|
+
GraphAgent executes graphs using the **Pregel** (Bulk Synchronous Parallel)
|
|
132
|
+
model. Each iteration is called a **superstep**:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
┌─────────┐
|
|
136
|
+
│ PLAN │ Determine which nodes to run based on edges/state
|
|
137
|
+
└────┬────┘
|
|
138
|
+
▼
|
|
139
|
+
┌─────────┐
|
|
140
|
+
│ EXECUTE │ Run all active nodes on a frozen snapshot of state
|
|
141
|
+
└────┬────┘
|
|
142
|
+
▼
|
|
143
|
+
┌─────────┐
|
|
144
|
+
│ UPDATE │ Apply all node outputs atomically via reducers
|
|
145
|
+
└────┬────┘
|
|
146
|
+
▼
|
|
147
|
+
┌──────────┐
|
|
148
|
+
│CHECKPOINT│ Save state if a checkpointer is configured
|
|
149
|
+
└────┬─────┘
|
|
150
|
+
▼
|
|
151
|
+
┌─────────┐
|
|
152
|
+
│ REPEAT │ Continue until END_NODE is reached or recursion_limit hit
|
|
153
|
+
└─────────┘
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Key properties:
|
|
157
|
+
|
|
158
|
+
- Nodes within the same superstep see the **same frozen state snapshot**.
|
|
159
|
+
- All updates are applied **atomically** after every node in the step finishes.
|
|
160
|
+
- A **recursion limit** (default 25) prevents infinite loops.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Channels
|
|
165
|
+
|
|
166
|
+
Channels are the internal mechanism that stores individual state fields. You
|
|
167
|
+
rarely interact with channels directly, but understanding them helps when
|
|
168
|
+
debugging:
|
|
169
|
+
|
|
170
|
+
| Channel | Behavior |
|
|
171
|
+
|---------|----------|
|
|
172
|
+
| `LastValue` | Stores a single value; errors if updated more than once per step |
|
|
173
|
+
| `BinaryOperatorAggregate` | Applies a reducer to aggregate multiple updates |
|
|
174
|
+
| `EphemeralValue` | Resets to empty between steps |
|
|
175
|
+
| `Topic` | Collects multiple values; optionally accumulates across steps |
|
|
176
|
+
|
|
177
|
+
See `lib/graph_agent/channels/` for implementation details.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## START and END_NODE
|
|
182
|
+
|
|
183
|
+
Two sentinel constants control graph flow:
|
|
184
|
+
|
|
185
|
+
| Constant | Value | Purpose |
|
|
186
|
+
|----------|-------|---------|
|
|
187
|
+
| `GraphAgent::START` | `:"__start__"` | Virtual entry node; edges from START define where execution begins |
|
|
188
|
+
| `GraphAgent::END_NODE` | `:"__end__"` | Virtual terminal node; reaching END_NODE stops execution |
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
graph.add_edge(GraphAgent::START, "first")
|
|
192
|
+
graph.add_edge("last", GraphAgent::END_NODE)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Compilation
|
|
198
|
+
|
|
199
|
+
Calling `compile` validates the graph and returns a `CompiledStateGraph`:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
app = graph.compile(
|
|
203
|
+
checkpointer: checkpointer, # optional persistence
|
|
204
|
+
interrupt_before: ["review"], # optional human-in-the-loop
|
|
205
|
+
interrupt_after: ["draft"], # optional human-in-the-loop
|
|
206
|
+
debug: false # enable debug events
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Validation checks:
|
|
211
|
+
|
|
212
|
+
- At least one entry point exists (edge from `START` or conditional entry).
|
|
213
|
+
- Every edge references an existing node (or `START`/`END_NODE`).
|
|
214
|
+
- Every node has at least one outgoing edge or conditional edge.
|
|
215
|
+
|
|
216
|
+
If validation fails, an `InvalidGraphError` is raised at compile time.
|
data/docs/edges.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Edges
|
|
2
|
+
|
|
3
|
+
Edges define how control flows between nodes in the graph. GraphAgent supports
|
|
4
|
+
several edge types for different routing patterns.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Normal Edges
|
|
9
|
+
|
|
10
|
+
A **normal edge** creates an unconditional transition from one node to another.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
graph.add_edge("node_a", "node_b")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
After `node_a` executes, `node_b` will always execute next.
|
|
17
|
+
|
|
18
|
+
`add_edge` returns `self`, so calls can be chained:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
graph.add_edge("a", "b")
|
|
22
|
+
.add_edge("b", "c")
|
|
23
|
+
.add_edge("c", GraphAgent::END_NODE)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Entry Points (from START)
|
|
29
|
+
|
|
30
|
+
Every graph must have at least one entry point — an edge from `START` to a node:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
graph.add_edge(GraphAgent::START, "first_node")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or use the convenience method:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
graph.set_entry_point("first_node")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Both are equivalent; `set_entry_point` calls `add_edge(START, node_name)`.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Exit Points (to END_NODE)
|
|
47
|
+
|
|
48
|
+
To terminate the graph, route to `END_NODE`:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
graph.add_edge("last_node", GraphAgent::END_NODE)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or use the convenience method:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
graph.set_finish_point("last_node")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
When all active nodes route to `END_NODE`, the graph stops and returns the
|
|
61
|
+
final state.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Conditional Edges
|
|
66
|
+
|
|
67
|
+
A **conditional edge** routes dynamically based on the current state. Provide a
|
|
68
|
+
callable (lambda, proc, or method) that receives state and returns the name of
|
|
69
|
+
the next node:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
router = ->(state) do
|
|
73
|
+
if state[:score] > 0.8
|
|
74
|
+
"approve"
|
|
75
|
+
else
|
|
76
|
+
"reject"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
graph.add_conditional_edges("evaluate", router)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### With a path_map
|
|
84
|
+
|
|
85
|
+
A `path_map` translates the return value of the routing function to actual node
|
|
86
|
+
names. This decouples your routing logic from the graph structure:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
graph.add_conditional_edges(
|
|
90
|
+
"classifier",
|
|
91
|
+
->(state) { state[:category] },
|
|
92
|
+
{
|
|
93
|
+
"billing" => "billing_handler",
|
|
94
|
+
"technical" => "tech_handler",
|
|
95
|
+
"other" => "general_handler"
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If the path function returns `"billing"`, the graph transitions to
|
|
101
|
+
`"billing_handler"`.
|
|
102
|
+
|
|
103
|
+
A `:default` key in the path_map is used as a fallback:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
graph.add_conditional_edges(
|
|
107
|
+
"router",
|
|
108
|
+
->(state) { state[:intent] },
|
|
109
|
+
{
|
|
110
|
+
"buy" => "purchase_flow",
|
|
111
|
+
"return" => "return_flow",
|
|
112
|
+
default: "help_flow"
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Routing to END_NODE
|
|
118
|
+
|
|
119
|
+
A conditional edge can terminate the graph by returning `END_NODE`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
graph.add_conditional_edges("check", ->(state) {
|
|
123
|
+
state[:done] ? GraphAgent::END_NODE.to_s : "process"
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Config access
|
|
128
|
+
|
|
129
|
+
The routing function can accept two arguments to access config:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
graph.add_conditional_edges("node", ->(state, config) {
|
|
133
|
+
config.dig(:configurable, :model) == "fast" ? "quick_path" : "thorough_path"
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Conditional Entry Points
|
|
140
|
+
|
|
141
|
+
Route from `START` conditionally:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
graph.set_conditional_entry_point(
|
|
145
|
+
->(state) { state[:type] },
|
|
146
|
+
{ "chat" => "chat_node", "search" => "search_node" }
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This is equivalent to:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
graph.add_conditional_edges(GraphAgent::START, path, path_map)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Waiting Edges (Multi-Source Synchronization)
|
|
159
|
+
|
|
160
|
+
A **waiting edge** fires only when **all** source nodes have executed in the
|
|
161
|
+
same step. Pass an Array of source nodes:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
graph.add_edge(["fetch_a", "fetch_b"], "merge")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Here `"merge"` will only execute once both `"fetch_a"` and `"fetch_b"` have
|
|
168
|
+
completed in the same superstep. This is useful for fan-in / join patterns.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Sequences
|
|
173
|
+
|
|
174
|
+
`add_sequence` is a shorthand for creating a chain of nodes with normal edges
|
|
175
|
+
between them:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
graph.add_sequence([
|
|
179
|
+
["step1", ->(state) { { data: "raw" } }],
|
|
180
|
+
["step2", ->(state) { { data: state[:data] + "_cleaned" } }],
|
|
181
|
+
["step3", ->(state) { { data: state[:data] + "_validated" } }]
|
|
182
|
+
])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
This is equivalent to:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
graph.add_node("step1", ->(state) { { data: "raw" } })
|
|
189
|
+
graph.add_node("step2", ->(state) { { data: state[:data] + "_cleaned" } })
|
|
190
|
+
graph.add_node("step3", ->(state) { { data: state[:data] + "_validated" } })
|
|
191
|
+
graph.add_edge("step1", "step2")
|
|
192
|
+
graph.add_edge("step2", "step3")
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
You still need to add entry and exit edges yourself:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
graph.add_edge(GraphAgent::START, "step1")
|
|
199
|
+
graph.add_edge("step3", GraphAgent::END_NODE)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Sequence items can be:
|
|
203
|
+
|
|
204
|
+
- `[name, action]` — a two-element array with node name and callable.
|
|
205
|
+
- A callable with a `.name` method (e.g., a method reference) — the name is
|
|
206
|
+
inferred automatically.
|
|
207
|
+
- A string — references an already-added node by name.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Validation Rules
|
|
212
|
+
|
|
213
|
+
At compile time, the graph validates:
|
|
214
|
+
|
|
215
|
+
1. **Entry point exists** — at least one edge from `START` or a conditional
|
|
216
|
+
entry point.
|
|
217
|
+
2. **Edge references are valid** — every source/target in an edge must be a
|
|
218
|
+
known node, `START`, or `END_NODE`.
|
|
219
|
+
3. **No dead-end nodes** — every node must have at least one outgoing edge or
|
|
220
|
+
conditional edge.
|
|
221
|
+
|
|
222
|
+
Violations raise `InvalidGraphError`.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Complete Example
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
require "graph_agent"
|
|
230
|
+
|
|
231
|
+
schema = GraphAgent::State::Schema.new do
|
|
232
|
+
field :query, type: String
|
|
233
|
+
field :results, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
234
|
+
field :route, type: String
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
238
|
+
|
|
239
|
+
graph.add_node("classify") do |state|
|
|
240
|
+
route = state[:query].include?("weather") ? "weather" : "general"
|
|
241
|
+
{ route: route }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
graph.add_node("weather") do |state|
|
|
245
|
+
{ results: ["Weather: sunny, 72°F"] }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
graph.add_node("general") do |state|
|
|
249
|
+
{ results: ["I can help with that!"] }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
graph.add_edge(GraphAgent::START, "classify")
|
|
253
|
+
graph.add_conditional_edges(
|
|
254
|
+
"classify",
|
|
255
|
+
->(state) { state[:route] },
|
|
256
|
+
{ "weather" => "weather", "general" => "general" }
|
|
257
|
+
)
|
|
258
|
+
graph.add_edge("weather", GraphAgent::END_NODE)
|
|
259
|
+
graph.add_edge("general", GraphAgent::END_NODE)
|
|
260
|
+
|
|
261
|
+
app = graph.compile
|
|
262
|
+
result = app.invoke({ query: "What's the weather?" })
|
|
263
|
+
puts result[:results]
|
|
264
|
+
# => ["Weather: sunny, 72°F"]
|
|
265
|
+
```
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
GraphAgent defines a hierarchy of error classes for different failure modes.
|
|
4
|
+
All errors inherit from `GraphAgent::GraphError`, which inherits from
|
|
5
|
+
`StandardError`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Error Hierarchy
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
StandardError
|
|
13
|
+
└── GraphAgent::GraphError
|
|
14
|
+
├── GraphAgent::GraphRecursionError
|
|
15
|
+
├── GraphAgent::InvalidUpdateError
|
|
16
|
+
├── GraphAgent::EmptyChannelError
|
|
17
|
+
├── GraphAgent::InvalidGraphError
|
|
18
|
+
├── GraphAgent::NodeExecutionError
|
|
19
|
+
├── GraphAgent::GraphInterrupt
|
|
20
|
+
├── GraphAgent::EmptyInputError
|
|
21
|
+
└── GraphAgent::TaskNotFound
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## GraphRecursionError
|
|
27
|
+
|
|
28
|
+
Raised when the graph exceeds its `recursion_limit` without reaching
|
|
29
|
+
`END_NODE`.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
begin
|
|
33
|
+
app.invoke(input, recursion_limit: 10)
|
|
34
|
+
rescue GraphAgent::GraphRecursionError => e
|
|
35
|
+
puts e.message
|
|
36
|
+
# => "Recursion limit of 10 reached without hitting END node"
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The default limit is **25** (`CompiledStateGraph::DEFAULT_RECURSION_LIMIT`).
|
|
41
|
+
Increase it for graphs that legitimately need many steps:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
result = app.invoke(input, recursion_limit: 100)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Common causes:
|
|
48
|
+
|
|
49
|
+
- A conditional edge never routes to `END_NODE`.
|
|
50
|
+
- A loop's exit condition is never satisfied.
|
|
51
|
+
- The limit is too low for the workload.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## InvalidUpdateError
|
|
56
|
+
|
|
57
|
+
Raised when a channel receives an invalid update. The most common case is a
|
|
58
|
+
`LastValue` channel receiving more than one value in a single step:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# Two nodes both write to the same last-value field in the same step
|
|
62
|
+
# => InvalidUpdateError: "At key 'status': Can receive only one value per step."
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Also raised by `EphemeralValue` when `guard: true` (the default) and more than
|
|
66
|
+
one value is written per step.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## NodeExecutionError
|
|
71
|
+
|
|
72
|
+
Wraps any unhandled exception raised inside a node. Provides access to the
|
|
73
|
+
node name and the original error:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
begin
|
|
77
|
+
app.invoke(input)
|
|
78
|
+
rescue GraphAgent::NodeExecutionError => e
|
|
79
|
+
puts "Failed node: #{e.node_name}"
|
|
80
|
+
puts "Original error: #{e.original_error.class}: #{e.original_error.message}"
|
|
81
|
+
puts e.message
|
|
82
|
+
# => "Error in node 'fetch_data': Connection refused"
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`GraphInterrupt` and `GraphRecursionError` raised inside nodes are **not**
|
|
87
|
+
wrapped — they propagate directly.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## GraphInterrupt
|
|
92
|
+
|
|
93
|
+
Raised when the graph hits an interrupt point (see
|
|
94
|
+
[Human-in-the-Loop](human_in_the_loop.md)). Contains an array of
|
|
95
|
+
`Interrupt` objects:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
begin
|
|
99
|
+
app.invoke(input, config: config)
|
|
100
|
+
rescue GraphAgent::GraphInterrupt => e
|
|
101
|
+
puts "#{e.interrupts.length} interrupt(s)"
|
|
102
|
+
e.interrupts.each do |interrupt|
|
|
103
|
+
puts " #{interrupt.value} (id: #{interrupt.id})"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## EmptyChannelError
|
|
111
|
+
|
|
112
|
+
Raised when reading from a channel that has no value. This is an internal
|
|
113
|
+
error — you typically encounter it only if you access a state field that was
|
|
114
|
+
never initialized and has no default.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## InvalidGraphError
|
|
119
|
+
|
|
120
|
+
Raised at **compile time** (when calling `graph.compile`) if the graph
|
|
121
|
+
structure is invalid:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
begin
|
|
125
|
+
app = graph.compile
|
|
126
|
+
rescue GraphAgent::InvalidGraphError => e
|
|
127
|
+
puts e.message
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Validation checks:
|
|
132
|
+
|
|
133
|
+
| Check | Error message |
|
|
134
|
+
|-------|---------------|
|
|
135
|
+
| No entry point | `"Graph must have an entry point..."` |
|
|
136
|
+
| Edge references unknown source | `"Edge references unknown source node '...'"` |
|
|
137
|
+
| Edge references unknown target | `"Edge references unknown target node '...'"` |
|
|
138
|
+
| Node has no outgoing edges | `"Node '...' has no outgoing edges"` |
|
|
139
|
+
| Duplicate node name | `"Node '...' already exists"` |
|
|
140
|
+
| Reserved node name | `"Node name '...' is reserved"` |
|
|
141
|
+
| END as start node | `"END cannot be a start node"` |
|
|
142
|
+
| START as end node | `"START cannot be an end node"` |
|
|
143
|
+
| Missing node action | `"Node action must be provided"` |
|
|
144
|
+
| Duplicate branch name | `"Branch '...' already exists for node '...'"` |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## EmptyInputError
|
|
149
|
+
|
|
150
|
+
Raised when the graph receives empty input where input is required.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## TaskNotFound
|
|
155
|
+
|
|
156
|
+
Raised when referencing a task that does not exist in the checkpoint.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## RetryPolicy
|
|
161
|
+
|
|
162
|
+
Configure automatic retries for individual nodes to handle transient failures:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
policy = GraphAgent::RetryPolicy.new(
|
|
166
|
+
initial_interval: 0.5, # seconds before first retry
|
|
167
|
+
backoff_factor: 2.0, # multiply interval each attempt
|
|
168
|
+
max_interval: 128.0, # cap on interval
|
|
169
|
+
max_attempts: 3, # total attempts (1 initial + 2 retries)
|
|
170
|
+
jitter: true, # add random jitter to intervals
|
|
171
|
+
retry_on: StandardError # which errors to retry
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
graph.add_node("api_call", method(:call_api), retry_policy: policy)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### retry_on options
|
|
178
|
+
|
|
179
|
+
| Value | Behavior |
|
|
180
|
+
|-------|----------|
|
|
181
|
+
| `StandardError` (default) | Retry on any standard error |
|
|
182
|
+
| `[Net::ReadTimeout, Timeout::Error]` | Retry only on specific error classes |
|
|
183
|
+
| `->(e) { e.message.include?("429") }` | Custom predicate |
|
|
184
|
+
|
|
185
|
+
### Retry timing
|
|
186
|
+
|
|
187
|
+
For attempt `n` (0-indexed), the interval is:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
interval = initial_interval * (backoff_factor ^ n)
|
|
191
|
+
interval = min(interval, max_interval)
|
|
192
|
+
interval += rand() * interval * 0.1 # if jitter enabled
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Example: Retrying API calls
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
retry_policy = GraphAgent::RetryPolicy.new(
|
|
199
|
+
max_attempts: 5,
|
|
200
|
+
initial_interval: 1.0,
|
|
201
|
+
backoff_factor: 2.0,
|
|
202
|
+
retry_on: [Net::ReadTimeout, Net::OpenTimeout]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
graph.add_node("call_llm", retry_policy: retry_policy) do |state|
|
|
206
|
+
response = call_openai(state[:messages])
|
|
207
|
+
{ messages: [response] }
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Comprehensive Error Handling
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
require "graph_agent"
|
|
217
|
+
|
|
218
|
+
config = { configurable: { thread_id: "t1" } }
|
|
219
|
+
|
|
220
|
+
begin
|
|
221
|
+
result = app.invoke(input, config: config)
|
|
222
|
+
puts "Success: #{result}"
|
|
223
|
+
|
|
224
|
+
rescue GraphAgent::GraphInterrupt => e
|
|
225
|
+
puts "Human review needed"
|
|
226
|
+
snapshot = app.get_state(config)
|
|
227
|
+
puts "State: #{snapshot.values}"
|
|
228
|
+
|
|
229
|
+
rescue GraphAgent::GraphRecursionError
|
|
230
|
+
puts "Graph ran too many steps — check for infinite loops"
|
|
231
|
+
|
|
232
|
+
rescue GraphAgent::NodeExecutionError => e
|
|
233
|
+
puts "Node '#{e.node_name}' failed: #{e.original_error.message}"
|
|
234
|
+
|
|
235
|
+
rescue GraphAgent::InvalidUpdateError => e
|
|
236
|
+
puts "State update conflict: #{e.message}"
|
|
237
|
+
|
|
238
|
+
rescue GraphAgent::GraphError => e
|
|
239
|
+
puts "Graph error: #{e.message}"
|
|
240
|
+
end
|
|
241
|
+
```
|