langgraph_rb 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/Gemfile +9 -0
- data/README.md +350 -0
- data/SUMMARY.md +170 -0
- data/examples/advanced_example.rb +388 -0
- data/examples/basic_example.rb +211 -0
- data/examples/simple_test.rb +266 -0
- data/langgraph_rb.gemspec +43 -0
- data/lib/langgraph_rb/command.rb +132 -0
- data/lib/langgraph_rb/edge.rb +141 -0
- data/lib/langgraph_rb/graph.rb +268 -0
- data/lib/langgraph_rb/node.rb +112 -0
- data/lib/langgraph_rb/runner.rb +360 -0
- data/lib/langgraph_rb/state.rb +70 -0
- data/lib/langgraph_rb/stores/memory.rb +265 -0
- data/lib/langgraph_rb/version.rb +3 -0
- data/lib/langgraph_rb.rb +15 -0
- data/test_runner.rb +160 -0
- metadata +151 -0
@@ -0,0 +1,266 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/langgraph_rb'
|
4
|
+
|
5
|
+
def simple_workflow_test
|
6
|
+
puts "=== Simple LangGraphRB Test ==="
|
7
|
+
|
8
|
+
# Create a simple linear workflow
|
9
|
+
graph = LangGraphRB::Graph.new do
|
10
|
+
node :start_process do |state|
|
11
|
+
puts "š Starting process with input: #{state[:input]}"
|
12
|
+
{
|
13
|
+
step: 1,
|
14
|
+
message: "Process started",
|
15
|
+
processed_input: state[:input]&.upcase
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
node :analyze_data do |state|
|
20
|
+
puts "š Analyzing: #{state[:processed_input]}"
|
21
|
+
sleep(0.1) # Simulate work
|
22
|
+
|
23
|
+
analysis_result = case state[:processed_input]&.downcase
|
24
|
+
when /hello/
|
25
|
+
'greeting_detected'
|
26
|
+
when /help/
|
27
|
+
'help_request'
|
28
|
+
when /goodbye/
|
29
|
+
'farewell_detected'
|
30
|
+
else
|
31
|
+
'general_input'
|
32
|
+
end
|
33
|
+
|
34
|
+
{
|
35
|
+
step: 2,
|
36
|
+
analysis: analysis_result,
|
37
|
+
message: "Analysis complete: #{analysis_result}"
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
node :generate_response do |state|
|
42
|
+
puts "š Generating response for: #{state[:analysis]}"
|
43
|
+
|
44
|
+
response = case state[:analysis]
|
45
|
+
when 'greeting_detected'
|
46
|
+
"Hello! Nice to meet you!"
|
47
|
+
when 'help_request'
|
48
|
+
"I'm here to help! What do you need assistance with?"
|
49
|
+
when 'farewell_detected'
|
50
|
+
"Goodbye! Have a great day!"
|
51
|
+
else
|
52
|
+
"I understand. Tell me more about that."
|
53
|
+
end
|
54
|
+
|
55
|
+
{
|
56
|
+
step: 3,
|
57
|
+
final_response: response,
|
58
|
+
message: "Response generated",
|
59
|
+
completed: true
|
60
|
+
}
|
61
|
+
end
|
62
|
+
|
63
|
+
node :generate_response_2 do |state|
|
64
|
+
puts "š Generating response for: #{state[:analysis]}"
|
65
|
+
{
|
66
|
+
step: 4,
|
67
|
+
final_response: "Response generated",
|
68
|
+
message: "Response generated",
|
69
|
+
completed: true
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
# Define simple linear flow
|
74
|
+
set_entry_point :start_process
|
75
|
+
edge :start_process, :analyze_data
|
76
|
+
edge :analyze_data, :generate_response
|
77
|
+
edge :analyze_data, :generate_response_2
|
78
|
+
set_finish_point :generate_response
|
79
|
+
end
|
80
|
+
|
81
|
+
# Compile the graph
|
82
|
+
graph.compile!
|
83
|
+
|
84
|
+
puts "\nš Graph structure:"
|
85
|
+
puts graph.to_mermaid
|
86
|
+
puts
|
87
|
+
|
88
|
+
# Test with different inputs
|
89
|
+
test_inputs = [
|
90
|
+
"Hello there!",
|
91
|
+
"I need help",
|
92
|
+
"Goodbye everyone",
|
93
|
+
"Just a regular message"
|
94
|
+
]
|
95
|
+
|
96
|
+
test_inputs.each_with_index do |input, i|
|
97
|
+
puts "\n--- Test #{i + 1}: '#{input}' ---"
|
98
|
+
|
99
|
+
result = graph.invoke({ input: input })
|
100
|
+
|
101
|
+
puts "ā
Final response: #{result[:final_response]}"
|
102
|
+
puts " Steps completed: #{result[:step]}"
|
103
|
+
puts " Status: #{result[:completed] ? 'Complete' : 'Incomplete'}"
|
104
|
+
end
|
105
|
+
|
106
|
+
puts "\nā
Simple workflow test completed!"
|
107
|
+
end
|
108
|
+
|
109
|
+
def conditional_routing_test
|
110
|
+
puts "\n=== Conditional Routing Test ==="
|
111
|
+
|
112
|
+
graph = LangGraphRB::Graph.new do
|
113
|
+
node :router do |state|
|
114
|
+
input = state[:input]&.to_s&.downcase || ""
|
115
|
+
puts "š Routing input: #{input}"
|
116
|
+
|
117
|
+
{
|
118
|
+
input_type: case input
|
119
|
+
when /urgent/, /emergency/
|
120
|
+
'urgent'
|
121
|
+
when /question/, /help/
|
122
|
+
'question'
|
123
|
+
when /feedback/, /complaint/
|
124
|
+
'feedback'
|
125
|
+
else
|
126
|
+
'general'
|
127
|
+
end,
|
128
|
+
original_input: input
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
node :handle_urgent do |state|
|
133
|
+
puts "šØ Handling urgent request"
|
134
|
+
{
|
135
|
+
response: "Your urgent request has been escalated to our priority team!",
|
136
|
+
priority: "high"
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
node :handle_question do |state|
|
141
|
+
puts "ā Handling question"
|
142
|
+
{
|
143
|
+
response: "Thank you for your question. Let me help you with that.",
|
144
|
+
priority: "medium"
|
145
|
+
}
|
146
|
+
end
|
147
|
+
|
148
|
+
node :handle_feedback do |state|
|
149
|
+
puts "š¬ Handling feedback"
|
150
|
+
{
|
151
|
+
response: "We appreciate your feedback and will review it carefully.",
|
152
|
+
priority: "medium"
|
153
|
+
}
|
154
|
+
end
|
155
|
+
|
156
|
+
node :handle_general do |state|
|
157
|
+
puts "š Handling general request"
|
158
|
+
{
|
159
|
+
response: "Thank you for contacting us. We'll get back to you soon.",
|
160
|
+
priority: "normal"
|
161
|
+
}
|
162
|
+
end
|
163
|
+
|
164
|
+
# Set up routing
|
165
|
+
set_entry_point :router
|
166
|
+
|
167
|
+
conditional_edge :router, ->(state) { state[:input_type] }, {
|
168
|
+
'urgent' => :handle_urgent,
|
169
|
+
'question' => :handle_question,
|
170
|
+
'feedback' => :handle_feedback,
|
171
|
+
'general' => :handle_general
|
172
|
+
}
|
173
|
+
|
174
|
+
# All handlers go to finish
|
175
|
+
set_finish_point :handle_urgent
|
176
|
+
set_finish_point :handle_question
|
177
|
+
set_finish_point :handle_feedback
|
178
|
+
set_finish_point :handle_general
|
179
|
+
end
|
180
|
+
|
181
|
+
graph.compile!
|
182
|
+
|
183
|
+
puts "\nš Conditional routing graph:"
|
184
|
+
puts graph.to_mermaid
|
185
|
+
puts
|
186
|
+
|
187
|
+
# Test different routing scenarios
|
188
|
+
test_cases = [
|
189
|
+
"URGENT: System is down!",
|
190
|
+
"I have a question about your service",
|
191
|
+
"I want to give some feedback",
|
192
|
+
"Just saying hello"
|
193
|
+
]
|
194
|
+
|
195
|
+
test_cases.each_with_index do |input, i|
|
196
|
+
puts "\n--- Routing Test #{i + 1}: '#{input}' ---"
|
197
|
+
|
198
|
+
result = graph.invoke({ input: input })
|
199
|
+
|
200
|
+
puts "š Response: #{result[:response]}"
|
201
|
+
puts "šÆ Priority: #{result[:priority]}"
|
202
|
+
end
|
203
|
+
|
204
|
+
puts "\nā
Conditional routing test completed!"
|
205
|
+
end
|
206
|
+
|
207
|
+
def streaming_test
|
208
|
+
puts "\n=== Streaming Execution Test ==="
|
209
|
+
|
210
|
+
graph = LangGraphRB::Graph.new do
|
211
|
+
node :step_1 do |state|
|
212
|
+
puts " āļø Step 1: Initialize"
|
213
|
+
sleep(0.3)
|
214
|
+
{ step: 1, data: "initialized", progress: 25 }
|
215
|
+
end
|
216
|
+
|
217
|
+
node :step_2 do |state|
|
218
|
+
puts " āļø Step 2: Process"
|
219
|
+
sleep(0.3)
|
220
|
+
{ step: 2, data: state[:data] + " -> processed", progress: 50 }
|
221
|
+
end
|
222
|
+
|
223
|
+
node :step_3 do |state|
|
224
|
+
puts " āļø Step 3: Validate"
|
225
|
+
sleep(0.3)
|
226
|
+
{ step: 3, data: state[:data] + " -> validated", progress: 75 }
|
227
|
+
end
|
228
|
+
|
229
|
+
node :step_4 do |state|
|
230
|
+
puts " āļø Step 4: Finalize"
|
231
|
+
sleep(0.3)
|
232
|
+
{ step: 4, data: state[:data] + " -> finalized", progress: 100 }
|
233
|
+
end
|
234
|
+
|
235
|
+
set_entry_point :step_1
|
236
|
+
edge :step_1, :step_2
|
237
|
+
edge :step_2, :step_3
|
238
|
+
edge :step_3, :step_4
|
239
|
+
set_finish_point :step_4
|
240
|
+
end
|
241
|
+
|
242
|
+
graph.compile!
|
243
|
+
|
244
|
+
puts "š” Streaming execution progress:"
|
245
|
+
start_time = Time.now
|
246
|
+
|
247
|
+
result = graph.stream({ input: "test_data" }) do |step_result|
|
248
|
+
elapsed = (Time.now - start_time).round(2)
|
249
|
+
puts " š [#{elapsed}s] Step #{step_result[:step]}: #{step_result[:active_nodes]}"
|
250
|
+
if step_result[:state][:progress]
|
251
|
+
puts " Progress: #{step_result[:state][:progress]}%"
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
puts "\nš Final result: #{result[:state][:data]}"
|
256
|
+
puts "ā±ļø Total time: #{(Time.now - start_time).round(2)}s"
|
257
|
+
|
258
|
+
puts "\nā
Streaming test completed!"
|
259
|
+
end
|
260
|
+
|
261
|
+
# Run tests
|
262
|
+
if __FILE__ == $0
|
263
|
+
#simple_workflow_test
|
264
|
+
conditional_routing_test
|
265
|
+
#streaming_test
|
266
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require_relative 'lib/langgraph_rb/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "langgraph_rb"
|
5
|
+
spec.version = LangGraphRB::VERSION
|
6
|
+
spec.authors = ["Julian Toro"]
|
7
|
+
spec.email = ["fulit103@gmail.com"]
|
8
|
+
|
9
|
+
spec.summary = "A Ruby library for building stateful, multi-actor applications with directed graphs"
|
10
|
+
spec.description = <<~DESC
|
11
|
+
LangGraphRB is a Ruby library inspired by LangGraph for building stateful, multi-actor applications
|
12
|
+
using directed graphs. It provides a framework for orchestrating complex workflows with support for
|
13
|
+
parallel execution, checkpointing, human-in-the-loop interactions, and map-reduce operations.
|
14
|
+
DESC
|
15
|
+
spec.homepage = "https://github.com/fulit103/langgraph_rb"
|
16
|
+
spec.license = "MIT"
|
17
|
+
spec.required_ruby_version = ">= 3.0.0"
|
18
|
+
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
26
|
+
f.match(%r{\A(?:test|spec|features)/})
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
spec.bindir = "exe"
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
32
|
+
spec.require_paths = ["lib"]
|
33
|
+
|
34
|
+
# Runtime dependencies
|
35
|
+
spec.add_dependency "json", "~> 2.0"
|
36
|
+
|
37
|
+
# Development dependencies
|
38
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
39
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
40
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
41
|
+
spec.add_development_dependency "pry", "~> 0.14"
|
42
|
+
spec.add_development_dependency "rubocop", "~> 1.0"
|
43
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
module LangGraphRB
|
2
|
+
# Command combines a state update with a routing decision
|
3
|
+
class Command
|
4
|
+
attr_reader :update, :goto
|
5
|
+
|
6
|
+
def initialize(update: nil, goto: nil)
|
7
|
+
@update = update || {}
|
8
|
+
@goto = goto
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.update(state_delta)
|
12
|
+
new(update: state_delta)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.goto(destination)
|
16
|
+
new(goto: destination)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.update_and_goto(state_delta, destination)
|
20
|
+
new(update: state_delta, goto: destination)
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
parts = []
|
25
|
+
parts << "update: #{@update.inspect}" unless @update.empty?
|
26
|
+
parts << "goto: #{@goto}" if @goto
|
27
|
+
"#<Command #{parts.join(', ')}>"
|
28
|
+
end
|
29
|
+
|
30
|
+
def inspect
|
31
|
+
to_s
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Send creates a new parallel execution branch with specific payload
|
36
|
+
class Send
|
37
|
+
attr_reader :to, :payload
|
38
|
+
|
39
|
+
def initialize(to:, payload: {})
|
40
|
+
@to = to.to_sym
|
41
|
+
@payload = payload || {}
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
"#<Send to: #{@to}, payload: #{@payload.inspect}>"
|
46
|
+
end
|
47
|
+
|
48
|
+
def inspect
|
49
|
+
to_s
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# MultiSend creates multiple parallel execution branches
|
54
|
+
class MultiSend
|
55
|
+
attr_reader :sends
|
56
|
+
|
57
|
+
def initialize(*sends)
|
58
|
+
@sends = sends.flatten
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.to_multiple(destinations, payload = {})
|
62
|
+
sends = destinations.map { |dest| Send.new(to: dest, payload: payload) }
|
63
|
+
new(sends)
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.fan_out(node, payloads)
|
67
|
+
sends = payloads.map { |payload| Send.new(to: node, payload: payload) }
|
68
|
+
new(sends)
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_s
|
72
|
+
"#<MultiSend #{@sends.map(&:to_s).join(', ')}>"
|
73
|
+
end
|
74
|
+
|
75
|
+
def inspect
|
76
|
+
to_s
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Interrupt execution and wait for human input
|
81
|
+
class Interrupt
|
82
|
+
attr_reader :message, :data
|
83
|
+
|
84
|
+
def initialize(message: "Human input required", data: {})
|
85
|
+
@message = message
|
86
|
+
@data = data
|
87
|
+
end
|
88
|
+
|
89
|
+
def to_s
|
90
|
+
"#<Interrupt: #{@message}>"
|
91
|
+
end
|
92
|
+
|
93
|
+
def inspect
|
94
|
+
to_s
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Helper module for creating commands
|
99
|
+
module Commands
|
100
|
+
def self.update(state_delta)
|
101
|
+
Command.update(state_delta)
|
102
|
+
end
|
103
|
+
|
104
|
+
def self.goto(destination)
|
105
|
+
Command.goto(destination)
|
106
|
+
end
|
107
|
+
|
108
|
+
def self.update_and_goto(state_delta, destination)
|
109
|
+
Command.update_and_goto(state_delta, destination)
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.send_to(destination, payload = {})
|
113
|
+
Send.new(to: destination, payload: payload)
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.send_to_multiple(destinations, payload = {})
|
117
|
+
MultiSend.to_multiple(destinations, payload)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.fan_out(node, payloads)
|
121
|
+
MultiSend.fan_out(node, payloads)
|
122
|
+
end
|
123
|
+
|
124
|
+
def self.interrupt(message: "Human input required", data: {})
|
125
|
+
Interrupt.new(message: message, data: data)
|
126
|
+
end
|
127
|
+
|
128
|
+
def self.end_execution(final_state = {})
|
129
|
+
Command.update_and_goto(final_state, Graph::FINISH)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module LangGraphRB
|
2
|
+
# Simple edge connecting two nodes
|
3
|
+
class Edge
|
4
|
+
attr_reader :from, :to
|
5
|
+
|
6
|
+
def initialize(from, to)
|
7
|
+
@from = from.to_sym
|
8
|
+
@to = to.to_sym
|
9
|
+
end
|
10
|
+
|
11
|
+
def route(state, context: nil)
|
12
|
+
[@to]
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
"#{@from} -> #{@to}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def inspect
|
20
|
+
"#<Edge: #{to_s}>"
|
21
|
+
end
|
22
|
+
|
23
|
+
def ==(other)
|
24
|
+
other.is_a?(Edge) && @from == other.from && @to == other.to
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Conditional edge that uses a router function to determine destination(s)
|
29
|
+
class ConditionalEdge
|
30
|
+
attr_reader :from, :router, :path_map
|
31
|
+
|
32
|
+
def initialize(from, router, path_map = nil)
|
33
|
+
@from = from.to_sym
|
34
|
+
@router = router
|
35
|
+
@path_map = path_map || {}
|
36
|
+
end
|
37
|
+
|
38
|
+
# Route based on the router function result
|
39
|
+
def route(state, context: nil)
|
40
|
+
result = case @router.arity
|
41
|
+
when 0
|
42
|
+
@router.call
|
43
|
+
when 1
|
44
|
+
@router.call(state)
|
45
|
+
else
|
46
|
+
@router.call(state, context)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Convert result to destinations
|
50
|
+
destinations = case result
|
51
|
+
when Array
|
52
|
+
result
|
53
|
+
when String, Symbol
|
54
|
+
[result]
|
55
|
+
when Hash
|
56
|
+
# Support for multiple destinations with different states
|
57
|
+
result.keys
|
58
|
+
else
|
59
|
+
[result]
|
60
|
+
end
|
61
|
+
|
62
|
+
# Map through path_map if provided
|
63
|
+
destinations.map do |dest|
|
64
|
+
mapped = @path_map[dest.to_s] || @path_map[dest.to_sym] || dest
|
65
|
+
mapped.to_sym
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_s
|
70
|
+
"#{@from} -> [conditional]"
|
71
|
+
end
|
72
|
+
|
73
|
+
def inspect
|
74
|
+
"#<ConditionalEdge: #{to_s}>"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Fan-out edge that creates multiple parallel executions
|
79
|
+
class FanOutEdge
|
80
|
+
attr_reader :from, :destinations
|
81
|
+
|
82
|
+
def initialize(from, destinations)
|
83
|
+
@from = from.to_sym
|
84
|
+
@destinations = destinations.map(&:to_sym)
|
85
|
+
end
|
86
|
+
|
87
|
+
def route(state, context: nil)
|
88
|
+
@destinations
|
89
|
+
end
|
90
|
+
|
91
|
+
def to_s
|
92
|
+
"#{@from} -> #{@destinations.inspect}"
|
93
|
+
end
|
94
|
+
|
95
|
+
def inspect
|
96
|
+
"#<FanOutEdge: #{to_s}>"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Helper class for building conditional routing
|
101
|
+
class Router
|
102
|
+
def self.build(&block)
|
103
|
+
new.tap { |r| r.instance_eval(&block) }
|
104
|
+
end
|
105
|
+
|
106
|
+
def initialize
|
107
|
+
@conditions = []
|
108
|
+
end
|
109
|
+
|
110
|
+
def when(condition, destination)
|
111
|
+
@conditions << [condition, destination]
|
112
|
+
self
|
113
|
+
end
|
114
|
+
|
115
|
+
def otherwise(destination)
|
116
|
+
@default = destination
|
117
|
+
self
|
118
|
+
end
|
119
|
+
|
120
|
+
def call(state, context = nil)
|
121
|
+
@conditions.each do |condition, destination|
|
122
|
+
result = case condition.arity
|
123
|
+
when 0
|
124
|
+
condition.call
|
125
|
+
when 1
|
126
|
+
condition.call(state)
|
127
|
+
else
|
128
|
+
condition.call(state, context)
|
129
|
+
end
|
130
|
+
|
131
|
+
return destination if result
|
132
|
+
end
|
133
|
+
|
134
|
+
@default || raise("No matching condition and no default specified")
|
135
|
+
end
|
136
|
+
|
137
|
+
def to_proc
|
138
|
+
method(:call).to_proc
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|