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
@@ -0,0 +1,231 @@
1
+ # Human-in-the-Loop
2
+
3
+ GraphAgent supports pausing graph execution so a human can inspect the state,
4
+ approve actions, provide input, or modify state before resuming.
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ The human-in-the-loop pattern works in three steps:
11
+
12
+ 1. **Interrupt** — the graph pauses before or after a specific node.
13
+ 2. **Inspect / Modify** — the human reads the state, optionally modifies it.
14
+ 3. **Resume** — the graph continues from where it left off.
15
+
16
+ This requires a **checkpointer** to persist state across the pause.
17
+
18
+ ---
19
+
20
+ ## interrupt_before and interrupt_after
21
+
22
+ Specify which nodes should trigger interrupts at compile time:
23
+
24
+ ```ruby
25
+ checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
26
+
27
+ app = graph.compile(
28
+ checkpointer: checkpointer,
29
+ interrupt_before: ["human_review"], # pause BEFORE this node runs
30
+ interrupt_after: ["draft"] # pause AFTER this node runs
31
+ )
32
+ ```
33
+
34
+ - `interrupt_before` — pauses **before** the node executes. The node has not
35
+ yet seen or modified state.
36
+ - `interrupt_after` — pauses **after** the node executes and its updates are
37
+ applied.
38
+
39
+ Both accept an array of node name strings or symbols.
40
+
41
+ ---
42
+
43
+ ## Catching GraphInterrupt
44
+
45
+ When an interrupt fires, the graph raises `GraphAgent::GraphInterrupt`:
46
+
47
+ ```ruby
48
+ config = { configurable: { thread_id: "review-1" } }
49
+
50
+ begin
51
+ result = app.invoke(input, config: config)
52
+ puts "Completed: #{result}"
53
+ rescue GraphAgent::GraphInterrupt => e
54
+ puts "Paused with #{e.interrupts.length} interrupt(s)"
55
+ e.interrupts.each do |interrupt|
56
+ puts " #{interrupt.value}"
57
+ end
58
+ end
59
+ ```
60
+
61
+ Each interrupt in the `interrupts` array is a `GraphAgent::Interrupt` object:
62
+
63
+ | Attribute | Type | Description |
64
+ |-----------|------|-------------|
65
+ | `value` | Object | Description of the interrupt (typically a String) |
66
+ | `id` | String | Unique identifier for this interrupt |
67
+
68
+ ---
69
+
70
+ ## Inspecting State with get_state
71
+
72
+ After an interrupt, inspect the saved state:
73
+
74
+ ```ruby
75
+ snapshot = app.get_state(config)
76
+
77
+ puts "Current state: #{snapshot.values}"
78
+ puts "Next nodes: #{snapshot.next_nodes}"
79
+ puts "Step: #{snapshot.metadata[:step]}"
80
+ ```
81
+
82
+ This lets the human review what the graph has computed so far and decide
83
+ whether to approve, modify, or reject.
84
+
85
+ ---
86
+
87
+ ## Modifying State with update_state
88
+
89
+ The human can modify state before resuming:
90
+
91
+ ```ruby
92
+ app.update_state(config, {
93
+ approved: true,
94
+ reviewer_notes: "Looks good, proceed"
95
+ })
96
+ ```
97
+
98
+ Updates respect reducers — if a field has a reducer, the update is merged
99
+ through it.
100
+
101
+ ---
102
+
103
+ ## Resuming Execution
104
+
105
+ Resume by calling `invoke` with `nil` input and the same config:
106
+
107
+ ```ruby
108
+ result = app.invoke(nil, config: config)
109
+ ```
110
+
111
+ The graph picks up from the last checkpoint and continues execution.
112
+
113
+ ---
114
+
115
+ ## Wildcard Interrupts
116
+
117
+ Use `"*"` to interrupt before or after **every** node:
118
+
119
+ ```ruby
120
+ app = graph.compile(
121
+ checkpointer: checkpointer,
122
+ interrupt_before: ["*"]
123
+ )
124
+ ```
125
+
126
+ This is useful for step-by-step debugging or approval workflows where every
127
+ action needs human review.
128
+
129
+ ---
130
+
131
+ ## Full Example
132
+
133
+ ```ruby
134
+ require "graph_agent"
135
+
136
+ schema = GraphAgent::State::Schema.new do
137
+ field :messages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
138
+ field :draft, type: String
139
+ field :approved, type: :boolean, default: false
140
+ end
141
+
142
+ graph = GraphAgent::Graph::StateGraph.new(schema)
143
+
144
+ graph.add_node("generate_draft") do |state|
145
+ topic = state[:messages].last[:content]
146
+ { draft: "Draft response about: #{topic}" }
147
+ end
148
+
149
+ graph.add_node("human_review") do |state|
150
+ if state[:approved]
151
+ { messages: [{ role: "ai", content: state[:draft] }] }
152
+ else
153
+ { messages: [{ role: "ai", content: "Draft was rejected." }] }
154
+ end
155
+ end
156
+
157
+ graph.add_edge(GraphAgent::START, "generate_draft")
158
+ graph.add_edge("generate_draft", "human_review")
159
+ graph.add_edge("human_review", GraphAgent::END_NODE)
160
+
161
+ checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
162
+ app = graph.compile(
163
+ checkpointer: checkpointer,
164
+ interrupt_before: ["human_review"]
165
+ )
166
+
167
+ config = { configurable: { thread_id: "review-session-1" } }
168
+
169
+ # Step 1: Start execution — will pause before human_review
170
+ begin
171
+ app.invoke(
172
+ { messages: [{ role: "user", content: "Write about Ruby" }] },
173
+ config: config
174
+ )
175
+ rescue GraphAgent::GraphInterrupt => e
176
+ puts "Interrupted: #{e.interrupts.first.value}"
177
+ end
178
+
179
+ # Step 2: Inspect the draft
180
+ snapshot = app.get_state(config)
181
+ puts "Draft: #{snapshot.values[:draft]}"
182
+
183
+ # Step 3: Approve and resume
184
+ app.update_state(config, { approved: true })
185
+ result = app.invoke(nil, config: config)
186
+ puts "Final: #{result[:messages].last[:content]}"
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Patterns
192
+
193
+ ### Approval Gate
194
+
195
+ ```ruby
196
+ graph.add_node("propose_action") do |state|
197
+ { proposed_action: "Delete 50 records" }
198
+ end
199
+
200
+ graph.add_node("execute_action") do |state|
201
+ if state[:approved]
202
+ { result: "Deleted 50 records" }
203
+ else
204
+ { result: "Action cancelled" }
205
+ end
206
+ end
207
+
208
+ graph.add_edge(GraphAgent::START, "propose_action")
209
+ graph.add_edge("propose_action", "execute_action")
210
+ graph.add_edge("execute_action", GraphAgent::END_NODE)
211
+
212
+ app = graph.compile(
213
+ checkpointer: checkpointer,
214
+ interrupt_before: ["execute_action"]
215
+ )
216
+ ```
217
+
218
+ ### Human Input Collection
219
+
220
+ ```ruby
221
+ graph.add_node("ask_question") do |state|
222
+ { question: "What is your preferred language?" }
223
+ end
224
+
225
+ graph.add_node("process_answer") do |state|
226
+ { result: "You chose: #{state[:human_input]}" }
227
+ end
228
+
229
+ # After interrupt, the human provides input via update_state:
230
+ # app.update_state(config, { human_input: "Ruby" })
231
+ ```
@@ -0,0 +1,276 @@
1
+ # Persistence & Checkpointing
2
+
3
+ Persistence enables multi-turn conversations, fault recovery, and
4
+ human-in-the-loop workflows by saving graph state between invocations.
5
+
6
+ ---
7
+
8
+ ## Why Persistence Matters
9
+
10
+ Without persistence, every `invoke` starts from scratch. With a checkpointer:
11
+
12
+ - **Multi-turn conversations** — state accumulates across calls using the same
13
+ `thread_id`.
14
+ - **Fault tolerance** — if a graph fails mid-execution, the last checkpoint is
15
+ available to resume from.
16
+ - **Human-in-the-loop** — interrupts pause execution; the state is saved so a
17
+ human can inspect, modify, and resume.
18
+ - **Time travel** — list past checkpoints to replay or debug execution history.
19
+
20
+ ---
21
+
22
+ ## InMemorySaver
23
+
24
+ `GraphAgent::Checkpoint::InMemorySaver` stores checkpoints in a Ruby Hash. It
25
+ is suitable for development, testing, and single-process applications.
26
+
27
+ ```ruby
28
+ checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
29
+ app = graph.compile(checkpointer: checkpointer)
30
+ ```
31
+
32
+ > **Note:** Data is lost when the process exits. For production, implement a
33
+ > custom saver backed by a database (see below).
34
+
35
+ ---
36
+
37
+ ## Thread-Based Conversations
38
+
39
+ A **thread** is identified by `thread_id` in the config. All invocations with
40
+ the same `thread_id` share state:
41
+
42
+ ```ruby
43
+ config = { configurable: { thread_id: "user-42" } }
44
+
45
+ # First turn
46
+ result1 = app.invoke(
47
+ { messages: [{ role: "user", content: "Hi, I'm Alice" }] },
48
+ config: config
49
+ )
50
+
51
+ # Second turn — state includes messages from the first turn
52
+ result2 = app.invoke(
53
+ { messages: [{ role: "user", content: "What's my name?" }] },
54
+ config: config
55
+ )
56
+ # result2[:messages] contains all messages from both turns
57
+ ```
58
+
59
+ Different `thread_id` values create independent conversations:
60
+
61
+ ```ruby
62
+ config_a = { configurable: { thread_id: "thread-a" } }
63
+ config_b = { configurable: { thread_id: "thread-b" } }
64
+
65
+ # These are completely independent
66
+ app.invoke({ messages: [{ role: "user", content: "Hello" }] }, config: config_a)
67
+ app.invoke({ messages: [{ role: "user", content: "Bonjour" }] }, config: config_b)
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Checkpoint Lifecycle
73
+
74
+ During execution, checkpoints are saved at specific points with a `source`
75
+ metadata field:
76
+
77
+ | Source | When |
78
+ |--------|------|
79
+ | `:input` | After initializing state from input, before any nodes run |
80
+ | `:loop` | After each superstep completes |
81
+ | `:interrupt` | When an interrupt fires (before or after a node) |
82
+ | `:exit` | After the graph finishes (reaches `END_NODE`) |
83
+ | `:update` | After a manual `update_state` call |
84
+
85
+ Each checkpoint stores:
86
+
87
+ - `channel_values` — the full state at that point.
88
+ - `next_nodes` — which nodes would execute next.
89
+ - `id` — a UUID identifying this checkpoint.
90
+
91
+ ---
92
+
93
+ ## get_state
94
+
95
+ Retrieve the current state snapshot for a thread:
96
+
97
+ ```ruby
98
+ snapshot = app.get_state(config)
99
+ ```
100
+
101
+ Returns a `GraphAgent::StateSnapshot` with:
102
+
103
+ | Attribute | Type | Description |
104
+ |-----------|------|-------------|
105
+ | `values` | Hash | Current state values |
106
+ | `next_nodes` | Array | Nodes that would execute next |
107
+ | `config` | Hash | Config including `checkpoint_id` |
108
+ | `metadata` | Hash | Step number, source, etc. |
109
+ | `parent_config` | Hash/nil | Config of the previous checkpoint |
110
+ | `tasks` | Array | Pending tasks |
111
+ | `interrupts` | Array | Active interrupts |
112
+
113
+ ```ruby
114
+ snapshot = app.get_state(config)
115
+ puts snapshot.values[:messages]
116
+ puts "Next: #{snapshot.next_nodes}"
117
+ puts "Step: #{snapshot.metadata[:step]}"
118
+ ```
119
+
120
+ Returns `nil` if no checkpoint exists for the given config.
121
+
122
+ ---
123
+
124
+ ## update_state
125
+
126
+ Manually modify the state of a thread:
127
+
128
+ ```ruby
129
+ app.update_state(config, { approved: true, reviewer: "Alice" })
130
+ ```
131
+
132
+ This creates a new checkpoint with the updated state. The metadata `source` is
133
+ set to `:update`.
134
+
135
+ `update_state` respects reducers: if a field has a reducer, the update value is
136
+ merged via that reducer, not replaced.
137
+
138
+ ```ruby
139
+ # If :messages has an ADD reducer:
140
+ app.update_state(config, {
141
+ messages: [{ role: "system", content: "Human approved this action" }]
142
+ })
143
+ # The new message is appended, not replaced
144
+ ```
145
+
146
+ Returns the new checkpoint config (with the new `checkpoint_id`), or `nil` if
147
+ no checkpoint exists.
148
+
149
+ ---
150
+
151
+ ## StateSnapshot
152
+
153
+ `GraphAgent::StateSnapshot` is a read-only object returned by `get_state`:
154
+
155
+ ```ruby
156
+ snapshot = app.get_state(config)
157
+
158
+ snapshot.values # => { messages: [...], count: 5 }
159
+ snapshot.next_nodes # => ["process"]
160
+ snapshot.config # => { configurable: { thread_id: "t1", checkpoint_id: "..." } }
161
+ snapshot.metadata # => { source: :loop, step: 3 }
162
+ snapshot.parent_config # => { configurable: { ... } } or nil
163
+ snapshot.created_at # => Time or nil
164
+ snapshot.tasks # => []
165
+ snapshot.interrupts # => []
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Listing Checkpoints
171
+
172
+ `InMemorySaver#list` returns all checkpoints for a thread (most recent first):
173
+
174
+ ```ruby
175
+ checkpoints = checkpointer.list(config)
176
+
177
+ checkpoints.each do |tuple|
178
+ puts "ID: #{tuple.config.dig(:configurable, :checkpoint_id)}"
179
+ puts "Step: #{tuple.metadata[:step]}"
180
+ puts "Source: #{tuple.metadata[:source]}"
181
+ puts "---"
182
+ end
183
+ ```
184
+
185
+ Options:
186
+
187
+ ```ruby
188
+ # Filter by metadata
189
+ checkpointer.list(config, filter: { source: :loop })
190
+
191
+ # Limit results
192
+ checkpointer.list(config, limit: 5)
193
+
194
+ # Before a specific checkpoint
195
+ checkpointer.list(config, before: {
196
+ configurable: { checkpoint_id: "some-uuid" }
197
+ })
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Deleting Threads
203
+
204
+ Remove all checkpoints and writes for a thread:
205
+
206
+ ```ruby
207
+ checkpointer.delete_thread("user-42")
208
+ ```
209
+
210
+ ---
211
+
212
+ ## Implementing a Custom CheckpointSaver
213
+
214
+ Subclass `GraphAgent::Checkpoint::BaseSaver` and implement four methods:
215
+
216
+ ```ruby
217
+ class PostgresSaver < GraphAgent::Checkpoint::BaseSaver
218
+ def get_tuple(config)
219
+ thread_id = config.dig(:configurable, :thread_id)
220
+ checkpoint_id = config.dig(:configurable, :checkpoint_id)
221
+
222
+ # Query your database for the checkpoint
223
+ row = db_query(thread_id, checkpoint_id)
224
+ return nil unless row
225
+
226
+ GraphAgent::Checkpoint::CheckpointTuple.new(
227
+ config: config,
228
+ checkpoint: row[:checkpoint],
229
+ metadata: row[:metadata],
230
+ parent_config: row[:parent_config],
231
+ pending_writes: row[:pending_writes] || []
232
+ )
233
+ end
234
+
235
+ def list(config, filter: nil, before: nil, limit: nil)
236
+ # Return an array of CheckpointTuple, newest first
237
+ end
238
+
239
+ def put(config, checkpoint, metadata, new_versions)
240
+ thread_id = config.dig(:configurable, :thread_id)
241
+ checkpoint_id = checkpoint[:id]
242
+
243
+ # Insert into your database
244
+ db_insert(thread_id, checkpoint_id, checkpoint, metadata)
245
+
246
+ # Return the new config
247
+ {
248
+ configurable: {
249
+ thread_id: thread_id,
250
+ checkpoint_ns: config.dig(:configurable, :checkpoint_ns) || "",
251
+ checkpoint_id: checkpoint_id
252
+ }
253
+ }
254
+ end
255
+
256
+ def put_writes(config, writes, task_id)
257
+ # Store pending writes
258
+ end
259
+
260
+ def delete_thread(thread_id)
261
+ # Delete all data for the thread
262
+ end
263
+ end
264
+ ```
265
+
266
+ ### CheckpointTuple
267
+
268
+ `GraphAgent::Checkpoint::CheckpointTuple` is a `Data.define` with these fields:
269
+
270
+ | Field | Type | Description |
271
+ |-------|------|-------------|
272
+ | `config` | Hash | Config with `thread_id`, `checkpoint_ns`, `checkpoint_id` |
273
+ | `checkpoint` | Hash | Serialized state (`channel_values`, `id`, etc.) |
274
+ | `metadata` | Hash/nil | Step, source, parents |
275
+ | `parent_config` | Hash/nil | Config pointing to the previous checkpoint |
276
+ | `pending_writes` | Array | Pending writes as `[task_id, channel, value]` triples |
@@ -0,0 +1,154 @@
1
+ # Quickstart
2
+
3
+ ## Installation
4
+
5
+ Add to your Gemfile:
6
+
7
+ ```ruby
8
+ gem "graph-agent"
9
+ ```
10
+
11
+ Then run:
12
+
13
+ ```bash
14
+ bundle install
15
+ ```
16
+
17
+ Or install directly:
18
+
19
+ ```bash
20
+ gem install graph-agent
21
+ ```
22
+
23
+ Requires Ruby >= 3.1.0.
24
+
25
+ ---
26
+
27
+ ## Hello World
28
+
29
+ The simplest possible graph: one node, in → out.
30
+
31
+ ```ruby
32
+ require "graph_agent"
33
+
34
+ graph = GraphAgent::Graph::StateGraph.new({ greeting: {} })
35
+
36
+ graph.add_node("greet") do |state|
37
+ { greeting: "Hello, #{state[:name]}!" }
38
+ end
39
+
40
+ graph.add_edge(GraphAgent::START, "greet")
41
+ graph.add_edge("greet", GraphAgent::END_NODE)
42
+
43
+ app = graph.compile
44
+ result = app.invoke({ name: "World" })
45
+
46
+ puts result[:greeting]
47
+ # => "Hello, World!"
48
+ ```
49
+
50
+ **What happened:**
51
+
52
+ 1. We defined a state with a single field `:greeting` (last-value semantics — no reducer).
53
+ 2. We added a node `"greet"` that reads `:name` from state and writes `:greeting`.
54
+ 3. We wired `START → greet → END_NODE`.
55
+ 4. We compiled and invoked.
56
+
57
+ ---
58
+
59
+ ## Calculator Agent
60
+
61
+ A more realistic example: a loop that processes arithmetic operations until there are none left.
62
+
63
+ ```ruby
64
+ require "graph_agent"
65
+
66
+ schema = GraphAgent::State::Schema.new do
67
+ field :operations, type: Array, reducer: GraphAgent::Reducers::REPLACE, default: []
68
+ field :results, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
69
+ field :done, type: :boolean, default: false
70
+ end
71
+
72
+ graph = GraphAgent::Graph::StateGraph.new(schema)
73
+
74
+ graph.add_node("compute") do |state|
75
+ op = state[:operations].first
76
+ remaining = state[:operations][1..]
77
+
78
+ answer = case op[:op]
79
+ when "+" then op[:a] + op[:b]
80
+ when "-" then op[:a] - op[:b]
81
+ when "*" then op[:a] * op[:b]
82
+ when "/" then op[:a].to_f / op[:b]
83
+ end
84
+
85
+ {
86
+ operations: remaining,
87
+ results: [{ expression: "#{op[:a]} #{op[:op]} #{op[:b]}", answer: answer }],
88
+ done: remaining.empty?
89
+ }
90
+ end
91
+
92
+ should_continue = ->(state) do
93
+ state[:done] ? GraphAgent::END_NODE.to_s : "compute"
94
+ end
95
+
96
+ graph.add_edge(GraphAgent::START, "compute")
97
+ graph.add_conditional_edges("compute", should_continue)
98
+
99
+ app = graph.compile
100
+
101
+ result = app.invoke({
102
+ operations: [
103
+ { op: "+", a: 2, b: 3 },
104
+ { op: "*", a: 4, b: 5 },
105
+ { op: "-", a: 10, b: 7 }
106
+ ]
107
+ })
108
+
109
+ result[:results].each do |r|
110
+ puts "#{r[:expression]} = #{r[:answer]}"
111
+ end
112
+ # 2 + 3 = 5
113
+ # 4 * 5 = 20
114
+ # 10 - 7 = 3
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Running with Streaming
120
+
121
+ Instead of waiting for the final result, stream intermediate states:
122
+
123
+ ```ruby
124
+ app.stream(
125
+ { operations: [{ op: "+", a: 1, b: 2 }, { op: "*", a: 3, b: 4 }] },
126
+ stream_mode: :updates
127
+ ) do |updates|
128
+ puts "Step updates: #{updates}"
129
+ end
130
+ ```
131
+
132
+ Or use an `Enumerator` (no block):
133
+
134
+ ```ruby
135
+ events = app.stream(
136
+ { operations: [{ op: "+", a: 1, b: 2 }] },
137
+ stream_mode: :values
138
+ )
139
+
140
+ events.each do |state|
141
+ puts "Results so far: #{state[:results]}"
142
+ end
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Where to Go Next
148
+
149
+ - [Core Concepts](concepts.md) — understand graphs, state, nodes, edges, and supersteps.
150
+ - [State Management](state.md) — deep dive into schemas, reducers, and defaults.
151
+ - [Edges](edges.md) — all edge types including conditional and waiting edges.
152
+ - [Persistence](persistence.md) — checkpointing and multi-turn conversations.
153
+ - [Human-in-the-Loop](human_in_the_loop.md) — interrupt, inspect, modify, resume.
154
+ - [API Reference](api_reference.md) — every class and method.