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/README.md ADDED
@@ -0,0 +1,464 @@
1
+ # GraphAgent
2
+
3
+ A Ruby framework for building stateful, multi-actor agent workflows. Ruby port of [LangGraph](https://github.com/langchain-ai/langgraph).
4
+
5
+ GraphAgent provides low-level infrastructure for building agents that can persist through failures, support human-in-the-loop workflows, and maintain comprehensive memory across sessions.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'graph-agent'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```ruby
24
+ require "graph_agent"
25
+
26
+ # Define your state schema with reducers
27
+ schema = GraphAgent::State::Schema.new do
28
+ field :messages, type: Array, reducer: ->(a, b) { a + b }, default: []
29
+ end
30
+
31
+ # Create a graph
32
+ graph = GraphAgent::Graph::StateGraph.new(schema)
33
+
34
+ # Add nodes (functions that process state)
35
+ graph.add_node("greet") do |state|
36
+ { messages: [{ role: "ai", content: "Hello! How can I help you?" }] }
37
+ end
38
+
39
+ # Define edges
40
+ graph.add_edge(GraphAgent::START, "greet")
41
+ graph.add_edge("greet", GraphAgent::END_NODE)
42
+
43
+ # Compile and run
44
+ app = graph.compile
45
+ result = app.invoke({ messages: [{ role: "user", content: "Hi!" }] })
46
+ puts result[:messages]
47
+ ```
48
+
49
+ ## Core Concepts
50
+
51
+ ### State
52
+
53
+ State is the shared data structure that represents the current snapshot of your application. Define it using a Schema with fields and optional reducers:
54
+
55
+ ```ruby
56
+ schema = GraphAgent::State::Schema.new do
57
+ field :messages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
58
+ field :count, type: Integer, reducer: GraphAgent::Reducers::ADD, default: 0
59
+ field :last_input, type: String # No reducer = last-value semantics
60
+ end
61
+ ```
62
+
63
+ You can also use a Hash-based schema:
64
+
65
+ ```ruby
66
+ graph = GraphAgent::Graph::StateGraph.new({
67
+ messages: { type: Array, reducer: ->(a, b) { a + b }, default: [] },
68
+ count: { type: Integer, reducer: ->(a, b) { a + b }, default: 0 },
69
+ status: {} # last-value semantics
70
+ })
71
+ ```
72
+
73
+ #### Built-in Reducers
74
+
75
+ | Reducer | Description |
76
+ |---------|-------------|
77
+ | `Reducers::ADD` | Adds/concatenates values (`a + b`) |
78
+ | `Reducers::APPEND` | Appends to array (`Array(a) + Array(b)`) |
79
+ | `Reducers::MERGE` | Merges hashes (`a.merge(b)`) |
80
+ | `Reducers::REPLACE` | Always replaces (`b`) |
81
+ | `Reducers.add_messages` | Smart message merging by ID |
82
+
83
+ ### Nodes
84
+
85
+ Nodes are functions that process the current state and return updates. They receive the state as input and return a Hash of updates:
86
+
87
+ ```ruby
88
+ graph.add_node("process") do |state|
89
+ { count: 1, status: "processed" }
90
+ end
91
+
92
+ # Or with config access:
93
+ graph.add_node("process") do |state, config|
94
+ thread_id = config.dig(:configurable, :thread_id)
95
+ { count: 1 }
96
+ end
97
+
98
+ # Using a method:
99
+ def my_processor(state)
100
+ { count: state[:count] + 1 }
101
+ end
102
+ graph.add_node("process", method(:my_processor))
103
+ ```
104
+
105
+ ### Edges
106
+
107
+ Edges define how the graph transitions between nodes:
108
+
109
+ ```ruby
110
+ # Normal edge: always go from A to B
111
+ graph.add_edge("node_a", "node_b")
112
+
113
+ # Entry point: where to start
114
+ graph.add_edge(GraphAgent::START, "first_node")
115
+ # or
116
+ graph.set_entry_point("first_node")
117
+
118
+ # Exit point: where to finish
119
+ graph.add_edge("last_node", GraphAgent::END_NODE)
120
+ # or
121
+ graph.set_finish_point("last_node")
122
+
123
+ # Conditional edge: route based on state
124
+ graph.add_conditional_edges(
125
+ "classifier",
126
+ ->(state) { state[:urgency] == "high" ? "escalate" : "auto_reply" }
127
+ )
128
+
129
+ # With path map:
130
+ graph.add_conditional_edges(
131
+ "classifier",
132
+ ->(state) { state[:category] },
133
+ { "billing" => "billing_handler", "tech" => "tech_handler", "other" => "general_handler" }
134
+ )
135
+ ```
136
+
137
+ ### Conditional Edges
138
+
139
+ Conditional edges allow dynamic routing based on state:
140
+
141
+ ```ruby
142
+ should_continue = ->(state) do
143
+ last_message = state[:messages].last
144
+ if last_message[:tool_calls]&.any?
145
+ "tool_node"
146
+ else
147
+ GraphAgent::END_NODE.to_s
148
+ end
149
+ end
150
+
151
+ graph.add_conditional_edges("llm_call", should_continue)
152
+ ```
153
+
154
+ ### Send (Map-Reduce Pattern)
155
+
156
+ Use `Send` to dynamically create parallel tasks:
157
+
158
+ ```ruby
159
+ continue_to_jokes = ->(state) do
160
+ state[:subjects].map do |subject|
161
+ GraphAgent::Send.new("generate_joke", { subject: subject })
162
+ end
163
+ end
164
+
165
+ graph.add_conditional_edges("get_subjects", continue_to_jokes)
166
+ ```
167
+
168
+ ### Command (Dynamic Routing + State Updates)
169
+
170
+ Use `Command` to combine state updates with routing decisions:
171
+
172
+ ```ruby
173
+ graph.add_node("router") do |state|
174
+ GraphAgent::Command.new(
175
+ update: { routed: true },
176
+ goto: "target_node"
177
+ )
178
+ end
179
+ ```
180
+
181
+ ## Compilation & Execution
182
+
183
+ ### Compile
184
+
185
+ ```ruby
186
+ # Basic compilation
187
+ app = graph.compile
188
+
189
+ # With checkpointer for persistence
190
+ checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
191
+ app = graph.compile(checkpointer: checkpointer)
192
+
193
+ # With interrupts for human-in-the-loop
194
+ app = graph.compile(
195
+ checkpointer: checkpointer,
196
+ interrupt_before: ["human_review"],
197
+ interrupt_after: ["draft"]
198
+ )
199
+ ```
200
+
201
+ ### Invoke
202
+
203
+ ```ruby
204
+ result = app.invoke(
205
+ { messages: [{ role: "user", content: "Hello" }] },
206
+ config: { configurable: { thread_id: "thread-1" } },
207
+ recursion_limit: 50
208
+ )
209
+ ```
210
+
211
+ ### Stream
212
+
213
+ ```ruby
214
+ # Stream state values after each step
215
+ app.stream(input, stream_mode: :values) do |state|
216
+ puts "State: #{state}"
217
+ end
218
+
219
+ # Stream per-node updates
220
+ app.stream(input, stream_mode: :updates) do |updates|
221
+ puts "Updates: #{updates}"
222
+ end
223
+
224
+ # Using Enumerator
225
+ events = app.stream(input, stream_mode: :values)
226
+ events.each { |state| puts state }
227
+ ```
228
+
229
+ ## Persistence & Checkpointing
230
+
231
+ ### InMemorySaver
232
+
233
+ ```ruby
234
+ checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
235
+ app = graph.compile(checkpointer: checkpointer)
236
+
237
+ config = { configurable: { thread_id: "user-123" } }
238
+
239
+ # First invocation
240
+ result1 = app.invoke({ messages: [{ role: "user", content: "Hi" }] }, config: config)
241
+
242
+ # Second invocation continues from checkpoint
243
+ result2 = app.invoke({ messages: [{ role: "user", content: "What did I say?" }] }, config: config)
244
+
245
+ # Inspect state
246
+ snapshot = app.get_state(config)
247
+ puts snapshot.values
248
+ puts snapshot.metadata
249
+ ```
250
+
251
+ ### State Management
252
+
253
+ ```ruby
254
+ # Get current state
255
+ snapshot = app.get_state(config)
256
+
257
+ # Update state manually
258
+ app.update_state(config, { messages: [{ role: "system", content: "Updated" }] })
259
+ ```
260
+
261
+ ## Human-in-the-Loop
262
+
263
+ Use interrupts to pause execution and wait for human input:
264
+
265
+ ```ruby
266
+ app = graph.compile(
267
+ checkpointer: checkpointer,
268
+ interrupt_before: ["human_review"]
269
+ )
270
+
271
+ config = { configurable: { thread_id: "review-1" } }
272
+
273
+ begin
274
+ result = app.invoke(input, config: config)
275
+ rescue GraphAgent::GraphInterrupt => e
276
+ puts "Interrupted: #{e.interrupts}"
277
+ # Human reviews and updates state...
278
+ app.update_state(config, { approved: true })
279
+ # Resume execution
280
+ result = app.invoke(nil, config: config)
281
+ end
282
+ ```
283
+
284
+ ## Error Handling
285
+
286
+ ```ruby
287
+ begin
288
+ result = app.invoke(input, config: config)
289
+ rescue GraphAgent::GraphRecursionError
290
+ puts "Graph exceeded maximum steps"
291
+ rescue GraphAgent::InvalidUpdateError => e
292
+ puts "Invalid state update: #{e.message}"
293
+ rescue GraphAgent::NodeExecutionError => e
294
+ puts "Error in node '#{e.node_name}': #{e.original_error.message}"
295
+ rescue GraphAgent::GraphInterrupt => e
296
+ puts "Graph interrupted with #{e.interrupts.length} interrupt(s)"
297
+ end
298
+ ```
299
+
300
+ ## Retry Policy
301
+
302
+ Configure automatic retries for nodes:
303
+
304
+ ```ruby
305
+ retry_policy = GraphAgent::RetryPolicy.new(
306
+ max_attempts: 3,
307
+ initial_interval: 0.5,
308
+ backoff_factor: 2.0,
309
+ max_interval: 128.0,
310
+ jitter: true,
311
+ retry_on: [Net::ReadTimeout, Timeout::Error]
312
+ )
313
+
314
+ graph.add_node("api_call", method(:call_api), retry_policy: retry_policy)
315
+ ```
316
+
317
+ ## MessageGraph
318
+
319
+ A convenience wrapper with pre-configured message state:
320
+
321
+ ```ruby
322
+ graph = GraphAgent::Graph::MessageGraph.new
323
+
324
+ graph.add_node("chat") do |state|
325
+ { messages: [{ role: "ai", content: "Hello!" }] }
326
+ end
327
+
328
+ graph.add_edge(GraphAgent::START, "chat")
329
+ graph.add_edge("chat", GraphAgent::END_NODE)
330
+
331
+ app = graph.compile
332
+ result = app.invoke({ messages: [{ role: "user", content: "Hi" }] })
333
+ ```
334
+
335
+ ## Advanced Patterns
336
+
337
+ ### Multi-Agent Handoff
338
+
339
+ ```ruby
340
+ schema = GraphAgent::State::Schema.new do
341
+ field :messages, reducer: GraphAgent::Reducers::ADD, default: []
342
+ field :current_agent, default: "router"
343
+ end
344
+
345
+ graph = GraphAgent::Graph::StateGraph.new(schema)
346
+
347
+ graph.add_node("router") do |state|
348
+ topic = classify_topic(state[:messages].last)
349
+ GraphAgent::Command.new(
350
+ update: { current_agent: topic },
351
+ goto: topic
352
+ )
353
+ end
354
+
355
+ graph.add_node("billing") do |state|
356
+ { messages: [handle_billing(state)] }
357
+ end
358
+
359
+ graph.add_node("technical") do |state|
360
+ { messages: [handle_technical(state)] }
361
+ end
362
+ ```
363
+
364
+ ### Sequences
365
+
366
+ ```ruby
367
+ graph.add_sequence([
368
+ ["step1", ->(s) { { data: "processed" } }],
369
+ ["step2", ->(s) { { data: s[:data] + "_validated" } }],
370
+ ["step3", ->(s) { { result: s[:data] } }]
371
+ ])
372
+ ```
373
+
374
+ ## Visualization
375
+
376
+ ### Mermaid Diagram
377
+
378
+ Generate a Mermaid flowchart to visualize your graph structure:
379
+
380
+ ```ruby
381
+ # Get Mermaid diagram as string
382
+ mermaid = graph.to_mermaid
383
+ puts mermaid
384
+
385
+ # Or print directly
386
+ graph.print_mermaid
387
+ ```
388
+
389
+ Output example:
390
+
391
+ ```mermaid
392
+ graph TD
393
+ classDef start fill:#e1f5e1,stroke:#4caf50,stroke-width:2px
394
+ classDef end fill:#ffebee,stroke:#f44336,stroke-width:2px
395
+ classDef node fill:#e3f2fd,stroke:#2196f3,stroke-width:2px,rx:5px
396
+ classDef condition fill:#fff9c4,stroke:#ffc107,stroke-width:2px
397
+
398
+ __start__["START"] --> step1
399
+ step1 --> step2
400
+ step2 --> __end__["END"]
401
+ ```
402
+
403
+ For conditional edges:
404
+
405
+ ```mermaid
406
+ graph TD
407
+ __start__["START"] --> router
408
+ router --> router_cond_0{"a / b"}
409
+ router_cond_0 -.->|a| handler_a
410
+ router_cond_0 -.->|b| handler_b
411
+ handler_a --> __end__
412
+ handler_b --> __end__
413
+ ```
414
+
415
+ You can copy the output and paste it into [Mermaid Live Editor](https://mermaid.live) or any Markdown viewer that supports Mermaid diagrams.
416
+
417
+ ## Architecture
418
+
419
+ GraphAgent implements the Pregel execution model (Bulk Synchronous Parallel):
420
+
421
+ 1. **PLAN**: Determine which nodes to execute based on edges and state
422
+ 2. **EXECUTE**: Run all active nodes with a frozen state snapshot
423
+ 3. **UPDATE**: Apply all writes atomically via reducers
424
+ 4. **CHECKPOINT**: Save state if checkpointer is configured
425
+ 5. **REPEAT**: Continue until END is reached or recursion limit hit
426
+
427
+ ## API Reference
428
+
429
+ ### `GraphAgent::Graph::StateGraph`
430
+
431
+ | Method | Description |
432
+ |--------|-------------|
433
+ | `new(schema)` | Create a new graph with state schema |
434
+ | `add_node(name, action)` | Add a node to the graph |
435
+ | `add_edge(from, to)` | Add a directed edge |
436
+ | `add_conditional_edges(source, path, path_map)` | Add conditional routing |
437
+ | `add_sequence(nodes)` | Add a sequence of nodes |
438
+ | `set_entry_point(node)` | Set the entry node |
439
+ | `set_finish_point(node)` | Set the exit node |
440
+ | `compile(options)` | Compile into executable graph |
441
+
442
+ ### `GraphAgent::Graph::CompiledStateGraph`
443
+
444
+ | Method | Description |
445
+ |--------|-------------|
446
+ | `invoke(input, config:, recursion_limit:)` | Run graph to completion |
447
+ | `stream(input, config:, stream_mode:)` | Stream execution events |
448
+ | `get_state(config)` | Get current state snapshot |
449
+ | `update_state(config, values)` | Manually update state |
450
+
451
+ ### Constants
452
+
453
+ | Constant | Description |
454
+ |----------|-------------|
455
+ | `GraphAgent::START` | Entry point sentinel |
456
+ | `GraphAgent::END_NODE` | Terminal node sentinel |
457
+
458
+ ## License
459
+
460
+ MIT License. See [LICENSE](LICENSE) for details.
461
+
462
+ ## Acknowledgements
463
+
464
+ Inspired by [LangGraph](https://github.com/langchain-ai/langgraph) by LangChain Inc, which is in turn inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require_relative "lib/graph_agent/version"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task default: :spec
10
+
11
+ # Custom release task
12
+ desc "Release gem to RubyGems"
13
+ task release_gem: [:build] do
14
+ sh "gem push graph-agent-#{GraphAgent::VERSION}.gem"
15
+ end
data/docs/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # GraphAgent Documentation
2
+
3
+ A Ruby framework for building stateful, multi-actor agent workflows.
4
+ Ruby port of [LangGraph](https://github.com/langchain-ai/langgraph).
5
+
6
+ ## Guides
7
+
8
+ | Document | Description |
9
+ |----------|-------------|
10
+ | [Quickstart](quickstart.md) | Installation, minimal examples, and first steps |
11
+ | [Core Concepts](concepts.md) | Graphs, state, nodes, edges, supersteps, channels, and compilation |
12
+ | [State Management](state.md) | Schema DSL, reducers, defaults, and atomic updates |
13
+ | [Edges](edges.md) | Normal, conditional, entry/exit, waiting, and sequence edges |
14
+ | [Send & Command](send_and_command.md) | Map-reduce fan-out/fan-in with `Send` and combined routing with `Command` |
15
+ | [Persistence](persistence.md) | Checkpointing, thread-based conversations, and state snapshots |
16
+ | [Streaming](streaming.md) | Stream modes (`:values`, `:updates`, `:debug`), block and enumerator usage |
17
+ | [Human-in-the-Loop](human_in_the_loop.md) | Interrupts, inspecting/modifying state, and resuming execution |
18
+ | [Error Handling](error_handling.md) | Error classes, recursion limits, retry policies |
19
+ | [API Reference](api_reference.md) | Full reference for every class, method, and constant |
20
+
21
+ ## Source Layout
22
+
23
+ ```
24
+ lib/
25
+ graph_agent.rb # Entry point & requires
26
+ graph_agent/
27
+ constants.rb # START, END_NODE sentinels
28
+ errors.rb # Error hierarchy
29
+ reducers.rb # Built-in reducer functions
30
+ state/
31
+ schema.rb # Schema DSL for state definition
32
+ channels/
33
+ base_channel.rb # Abstract channel interface
34
+ last_value.rb # Single-value channel
35
+ binary_operator_aggregate.rb # Reducer-based aggregation channel
36
+ ephemeral_value.rb # Per-step ephemeral channel
37
+ topic.rb # Multi-value topic channel
38
+ types/
39
+ send.rb # Send (fan-out routing)
40
+ command.rb # Command (update + routing)
41
+ interrupt.rb # Interrupt value type
42
+ retry_policy.rb # Retry configuration
43
+ cache_policy.rb # Cache configuration
44
+ state_snapshot.rb # Snapshot of graph state
45
+ checkpoint/
46
+ base_saver.rb # Abstract checkpoint saver
47
+ in_memory_saver.rb # In-memory checkpoint implementation
48
+ graph/
49
+ node.rb # Node wrapper with retry support
50
+ edge.rb # Static edge
51
+ conditional_edge.rb # Dynamic conditional edge
52
+ state_graph.rb # Graph builder
53
+ compiled_state_graph.rb # Compiled executable graph (Pregel)
54
+ message_graph.rb # Pre-built message-oriented graph
55
+ ```