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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/release.yml +49 -0
  4. data/.gitignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +126 -0
  7. data/CHANGELOG.md +26 -0
  8. data/CLAUDE.md +128 -0
  9. data/Gemfile +11 -0
  10. data/Gemfile.lock +94 -0
  11. data/LICENSE +21 -0
  12. data/Makefile +114 -0
  13. data/README.md +464 -0
  14. data/Rakefile +15 -0
  15. data/docs/README.md +55 -0
  16. data/docs/api_reference.md +832 -0
  17. data/docs/concepts.md +216 -0
  18. data/docs/edges.md +265 -0
  19. data/docs/error_handling.md +241 -0
  20. data/docs/human_in_the_loop.md +231 -0
  21. data/docs/persistence.md +276 -0
  22. data/docs/quickstart.md +154 -0
  23. data/docs/send_and_command.md +218 -0
  24. data/docs/state.md +181 -0
  25. data/docs/streaming.md +172 -0
  26. data/graph-agent.gemspec +48 -0
  27. data/lib/graph_agent/channels/base_channel.rb +52 -0
  28. data/lib/graph_agent/channels/binary_operator_aggregate.rb +56 -0
  29. data/lib/graph_agent/channels/ephemeral_value.rb +59 -0
  30. data/lib/graph_agent/channels/last_value.rb +49 -0
  31. data/lib/graph_agent/channels/topic.rb +58 -0
  32. data/lib/graph_agent/checkpoint/base_saver.rb +38 -0
  33. data/lib/graph_agent/checkpoint/in_memory_saver.rb +145 -0
  34. data/lib/graph_agent/constants.rb +9 -0
  35. data/lib/graph_agent/errors.rb +41 -0
  36. data/lib/graph_agent/graph/compiled_state_graph.rb +362 -0
  37. data/lib/graph_agent/graph/conditional_edge.rb +57 -0
  38. data/lib/graph_agent/graph/edge.rb +23 -0
  39. data/lib/graph_agent/graph/mermaid_visualizer.rb +154 -0
  40. data/lib/graph_agent/graph/message_graph.rb +18 -0
  41. data/lib/graph_agent/graph/node.rb +61 -0
  42. data/lib/graph_agent/graph/state_graph.rb +197 -0
  43. data/lib/graph_agent/reducers.rb +34 -0
  44. data/lib/graph_agent/state/schema.rb +54 -0
  45. data/lib/graph_agent/types/cache_policy.rb +12 -0
  46. data/lib/graph_agent/types/command.rb +26 -0
  47. data/lib/graph_agent/types/interrupt.rb +28 -0
  48. data/lib/graph_agent/types/retry_policy.rb +42 -0
  49. data/lib/graph_agent/types/send.rb +26 -0
  50. data/lib/graph_agent/types/state_snapshot.rb +28 -0
  51. data/lib/graph_agent/version.rb +5 -0
  52. data/lib/graph_agent.rb +29 -0
  53. 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
+ ```