langgraph_rb 0.1.0 → 0.1.1
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 +4 -4
- data/.gitignore +10 -0
- data/README.md +1 -1
- data/examples/initial_state_example.rb +646 -0
- data/examples/observer_example.rb +121 -0
- data/examples/reducers_example.rb +337 -0
- data/lib/langgraph_rb/graph.rb +6 -4
- data/lib/langgraph_rb/observers/base.rb +188 -0
- data/lib/langgraph_rb/observers/logger.rb +71 -0
- data/lib/langgraph_rb/observers/structured.rb +111 -0
- data/lib/langgraph_rb/runner.rb +106 -3
- data/lib/langgraph_rb/version.rb +1 -1
- data/lib/langgraph_rb.rb +4 -0
- data/test_runner.rb +3 -3
- metadata +9 -2
@@ -0,0 +1,121 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/langgraph_rb'
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
# Custom observer for metrics collection
|
7
|
+
class MetricsObserver < LangGraphRB::Observers::BaseObserver
|
8
|
+
def initialize(metrics_client)
|
9
|
+
@metrics = metrics_client
|
10
|
+
end
|
11
|
+
|
12
|
+
def on_node_end(event)
|
13
|
+
@metrics.histogram('node.duration', event.duration * 1000, {
|
14
|
+
node_name: event.node_name.to_s,
|
15
|
+
node_type: event.node_class&.name
|
16
|
+
})
|
17
|
+
end
|
18
|
+
|
19
|
+
def on_node_error(event)
|
20
|
+
@metrics.increment('node.error', {
|
21
|
+
node_name: event.node_name.to_s,
|
22
|
+
error_type: event.error&.class&.name
|
23
|
+
})
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Database observer for audit trails
|
28
|
+
class DatabaseObserver < LangGraphRB::Observers::BaseObserver
|
29
|
+
def initialize(db_connection)
|
30
|
+
@db = db_connection
|
31
|
+
end
|
32
|
+
|
33
|
+
def on_graph_start(event)
|
34
|
+
@db.execute(
|
35
|
+
"INSERT INTO graph_executions (thread_id, graph_class, started_at) VALUES (?, ?, ?)",
|
36
|
+
event.thread_id, event.graph.class.name, event.timestamp
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_node_end(event)
|
41
|
+
@db.execute(
|
42
|
+
"INSERT INTO node_executions (thread_id, step_number, node_name, duration_ms, completed_at) VALUES (?, ?, ?, ?, ?)",
|
43
|
+
event.thread_id, event.step_number, event.node_name.to_s, (event.duration * 1000).round(2), event.timestamp
|
44
|
+
)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class MyMetricsClient
|
49
|
+
def histogram(name, value, tags)
|
50
|
+
puts "Histogram: #{name}, #{value}, #{tags}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def increment(name, tags)
|
54
|
+
puts "Increment: #{name}, #{tags}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class MyDBConnection
|
59
|
+
def execute(query, *args)
|
60
|
+
puts "Executing: #{query}, #{args}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def observer_example
|
65
|
+
puts "=== Observer Example ==="
|
66
|
+
|
67
|
+
# Create a simple graph for demonstration
|
68
|
+
initial_state = LangGraphRB::State.new(
|
69
|
+
{ message: "", messages: [], response: "" }
|
70
|
+
)
|
71
|
+
|
72
|
+
# Create the graph using DSL
|
73
|
+
graph = LangGraphRB::Graph.new(state_class: LangGraphRB::State) do
|
74
|
+
# Simple node that processes messages
|
75
|
+
node :process_message do |state|
|
76
|
+
puts "Processing: #{state[:message]}"
|
77
|
+
{
|
78
|
+
messages: state[:messages] + [state[:message]],
|
79
|
+
response: "Processed: #{state[:message]}"
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
# Set up the flow
|
84
|
+
set_entry_point :process_message
|
85
|
+
set_finish_point :process_message
|
86
|
+
end
|
87
|
+
|
88
|
+
# Compile the graph
|
89
|
+
graph.compile!
|
90
|
+
|
91
|
+
# Basic logging observability
|
92
|
+
logger_observer = LangGraphRB::Observers::LoggerObserver.new(
|
93
|
+
logger: Logger.new('graph_execution.log'),
|
94
|
+
level: :info
|
95
|
+
)
|
96
|
+
|
97
|
+
puts "\n--- Running with basic logging observer ---"
|
98
|
+
graph.invoke(
|
99
|
+
{ message: "Hello World", messages: [], response: "" },
|
100
|
+
observers: [logger_observer]
|
101
|
+
)
|
102
|
+
|
103
|
+
my_metrics_client = MyMetricsClient.new
|
104
|
+
my_db_connection = MyDBConnection.new
|
105
|
+
|
106
|
+
# Use multiple observers
|
107
|
+
puts "\n--- Running with multiple observers ---"
|
108
|
+
graph.invoke(
|
109
|
+
{ message: "Testing multiple observers", messages: [], response: "" },
|
110
|
+
observers: [
|
111
|
+
logger_observer,
|
112
|
+
MetricsObserver.new(my_metrics_client),
|
113
|
+
DatabaseObserver.new(my_db_connection)
|
114
|
+
]
|
115
|
+
)
|
116
|
+
|
117
|
+
puts "\nObserver example completed! Check 'graph_execution.log' for logged events."
|
118
|
+
end
|
119
|
+
|
120
|
+
# Run the example
|
121
|
+
observer_example if __FILE__ == $0
|
@@ -0,0 +1,337 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/langgraph_rb'
|
4
|
+
|
5
|
+
# Custom state class for workflow example
|
6
|
+
class UserSetupState < LangGraphRB::State
|
7
|
+
def initialize(schema = {}, reducers = nil)
|
8
|
+
# If reducers are not provided, use our default reducers
|
9
|
+
reducers ||= { user_info: LangGraphRB::State.merge_hash }
|
10
|
+
super(schema, reducers)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Example 1: WITHOUT Reducers - Simple Replacement Behavior
|
15
|
+
def without_reducers_example
|
16
|
+
puts "\n" + "=" * 60
|
17
|
+
puts "🔴 EXAMPLE 1: WITHOUT REDUCERS (Simple Replacement)"
|
18
|
+
puts "=" * 60
|
19
|
+
|
20
|
+
# Create state without any reducers - everything is simple replacement
|
21
|
+
state = LangGraphRB::State.new({
|
22
|
+
counter: 0,
|
23
|
+
messages: [],
|
24
|
+
user_data: { name: "Alice" }
|
25
|
+
})
|
26
|
+
|
27
|
+
puts "Initial state:"
|
28
|
+
puts " Counter: #{state[:counter]}"
|
29
|
+
puts " Messages: #{state[:messages]}"
|
30
|
+
puts " User data: #{state[:user_data]}"
|
31
|
+
|
32
|
+
# First update - this will REPLACE the values completely
|
33
|
+
puts "\n📝 First update: { counter: 5, messages: ['Hello'], user_data: { age: 25 } }"
|
34
|
+
state = state.merge_delta({
|
35
|
+
counter: 5,
|
36
|
+
messages: ['Hello'],
|
37
|
+
user_data: { age: 25 }
|
38
|
+
})
|
39
|
+
|
40
|
+
puts "After first update:"
|
41
|
+
puts " Counter: #{state[:counter]} ← REPLACED with 5"
|
42
|
+
puts " Messages: #{state[:messages]} ← REPLACED with ['Hello']"
|
43
|
+
puts " User data: #{state[:user_data]} ← REPLACED with { age: 25 } (name is LOST!)"
|
44
|
+
|
45
|
+
# Second update - again, simple replacement
|
46
|
+
puts "\n📝 Second update: { counter: 3, messages: ['World'], user_data: { city: 'NYC' } }"
|
47
|
+
state = state.merge_delta({
|
48
|
+
counter: 3,
|
49
|
+
messages: ['World'],
|
50
|
+
user_data: { city: 'NYC' }
|
51
|
+
})
|
52
|
+
|
53
|
+
puts "After second update:"
|
54
|
+
puts " Counter: #{state[:counter]} ← REPLACED with 3 (lost the 5!)"
|
55
|
+
puts " Messages: #{state[:messages]} ← REPLACED with ['World'] (lost 'Hello'!)"
|
56
|
+
puts " User data: #{state[:user_data]} ← REPLACED with { city: 'NYC' } (lost age!)"
|
57
|
+
|
58
|
+
puts "\n❌ PROBLEM: Without reducers, we lose previous data on every update!"
|
59
|
+
end
|
60
|
+
|
61
|
+
# Example 2: WITH Reducers - Intelligent Merging
|
62
|
+
def with_reducers_example
|
63
|
+
puts "\n" + "=" * 60
|
64
|
+
puts "🟢 EXAMPLE 2: WITH REDUCERS (Intelligent Merging)"
|
65
|
+
puts "=" * 60
|
66
|
+
|
67
|
+
# Create state WITH reducers that define how to combine values
|
68
|
+
state = LangGraphRB::State.new(
|
69
|
+
{
|
70
|
+
counter: 0,
|
71
|
+
messages: [],
|
72
|
+
user_data: { name: "Alice" }
|
73
|
+
},
|
74
|
+
{
|
75
|
+
counter: ->(old, new) { (old || 0) + new }, # ADD numbers
|
76
|
+
messages: LangGraphRB::State.add_messages, # APPEND to array
|
77
|
+
user_data: LangGraphRB::State.merge_hash # MERGE hashes
|
78
|
+
}
|
79
|
+
)
|
80
|
+
|
81
|
+
puts "Initial state:"
|
82
|
+
puts " Counter: #{state[:counter]}"
|
83
|
+
puts " Messages: #{state[:messages]}"
|
84
|
+
puts " User data: #{state[:user_data]}"
|
85
|
+
|
86
|
+
# First update - reducers will intelligently combine values
|
87
|
+
puts "\n📝 First update: { counter: 5, messages: ['Hello'], user_data: { age: 25 } }"
|
88
|
+
state = state.merge_delta({
|
89
|
+
counter: 5,
|
90
|
+
messages: ['Hello'],
|
91
|
+
user_data: { age: 25 }
|
92
|
+
})
|
93
|
+
|
94
|
+
puts "After first update:"
|
95
|
+
puts " Counter: #{state[:counter]} ← ADDED: 0 + 5 = 5"
|
96
|
+
puts " Messages: #{state[:messages]} ← APPENDED: [] + ['Hello'] = ['Hello']"
|
97
|
+
puts " User data: #{state[:user_data]} ← MERGED: {name: 'Alice'} + {age: 25}"
|
98
|
+
|
99
|
+
# Second update - reducers continue to combine intelligently
|
100
|
+
puts "\n📝 Second update: { counter: 3, messages: ['World'], user_data: { city: 'NYC' } }"
|
101
|
+
state = state.merge_delta({
|
102
|
+
counter: 3,
|
103
|
+
messages: ['World'],
|
104
|
+
user_data: { city: 'NYC' }
|
105
|
+
})
|
106
|
+
|
107
|
+
puts "After second update:"
|
108
|
+
puts " Counter: #{state[:counter]} ← ADDED: 5 + 3 = 8"
|
109
|
+
puts " Messages: #{state[:messages]} ← APPENDED: ['Hello'] + ['World']"
|
110
|
+
puts " User data: #{state[:user_data]} ← MERGED: keeps all previous data!"
|
111
|
+
|
112
|
+
puts "\n✅ SUCCESS: With reducers, we intelligently combine data instead of losing it!"
|
113
|
+
end
|
114
|
+
|
115
|
+
# Example 3: Built-in Reducers Demonstration
|
116
|
+
def builtin_reducers_example
|
117
|
+
puts "\n" + "=" * 60
|
118
|
+
puts "🛠️ EXAMPLE 3: BUILT-IN REDUCERS"
|
119
|
+
puts "=" * 60
|
120
|
+
|
121
|
+
# Demonstrate each built-in reducer
|
122
|
+
puts "📌 1. add_messages - For building conversation history"
|
123
|
+
messages_state = LangGraphRB::State.new(
|
124
|
+
{ messages: [] },
|
125
|
+
{ messages: LangGraphRB::State.add_messages }
|
126
|
+
)
|
127
|
+
|
128
|
+
messages_state = messages_state.merge_delta({ messages: { role: 'user', content: 'Hi!' } })
|
129
|
+
messages_state = messages_state.merge_delta({ messages: [{ role: 'assistant', content: 'Hello!' }] })
|
130
|
+
messages_state = messages_state.merge_delta({ messages: { role: 'user', content: 'How are you?' } })
|
131
|
+
|
132
|
+
puts "Messages history: #{messages_state[:messages]}"
|
133
|
+
|
134
|
+
puts "\n📌 2. append_string - For building text content"
|
135
|
+
text_state = LangGraphRB::State.new(
|
136
|
+
{ story: "" },
|
137
|
+
{ story: LangGraphRB::State.append_string }
|
138
|
+
)
|
139
|
+
|
140
|
+
text_state = text_state.merge_delta({ story: "Once upon a time, " })
|
141
|
+
text_state = text_state.merge_delta({ story: "there was a brave knight " })
|
142
|
+
text_state = text_state.merge_delta({ story: "who saved the kingdom." })
|
143
|
+
|
144
|
+
puts "Story: \"#{text_state[:story]}\""
|
145
|
+
|
146
|
+
puts "\n📌 3. merge_hash - For building complex objects"
|
147
|
+
profile_state = LangGraphRB::State.new(
|
148
|
+
{ profile: {} },
|
149
|
+
{ profile: LangGraphRB::State.merge_hash }
|
150
|
+
)
|
151
|
+
|
152
|
+
profile_state = profile_state.merge_delta({ profile: { name: "Bob" } })
|
153
|
+
profile_state = profile_state.merge_delta({ profile: { age: 30, city: "Boston" } })
|
154
|
+
profile_state = profile_state.merge_delta({ profile: { job: "Developer", age: 31 } }) # age updated
|
155
|
+
|
156
|
+
puts "Profile: #{profile_state[:profile]}"
|
157
|
+
end
|
158
|
+
|
159
|
+
# Example 4: Custom Reducers
|
160
|
+
def custom_reducers_example
|
161
|
+
puts "\n" + "=" * 60
|
162
|
+
puts "⚙️ EXAMPLE 4: CUSTOM REDUCERS"
|
163
|
+
puts "=" * 60
|
164
|
+
|
165
|
+
# Custom reducer: Keep maximum value
|
166
|
+
max_reducer = ->(old, new) { [old || 0, new || 0].max }
|
167
|
+
|
168
|
+
# Custom reducer: Keep unique items in array
|
169
|
+
unique_array_reducer = ->(old, new) do
|
170
|
+
old_array = old || []
|
171
|
+
new_items = new.is_a?(Array) ? new : [new]
|
172
|
+
(old_array + new_items).uniq
|
173
|
+
end
|
174
|
+
|
175
|
+
# Custom reducer: Track changes with timestamps
|
176
|
+
history_reducer = ->(old, new) do
|
177
|
+
old_history = old || []
|
178
|
+
timestamp = Time.now.strftime("%H:%M:%S")
|
179
|
+
old_history + [{ value: new, timestamp: timestamp }]
|
180
|
+
end
|
181
|
+
|
182
|
+
state = LangGraphRB::State.new(
|
183
|
+
{
|
184
|
+
max_score: 0,
|
185
|
+
unique_tags: [],
|
186
|
+
value_history: []
|
187
|
+
},
|
188
|
+
{
|
189
|
+
max_score: max_reducer,
|
190
|
+
unique_tags: unique_array_reducer,
|
191
|
+
value_history: history_reducer
|
192
|
+
}
|
193
|
+
)
|
194
|
+
|
195
|
+
puts "📊 Testing custom reducers..."
|
196
|
+
|
197
|
+
# Test updates
|
198
|
+
state = state.merge_delta({ max_score: 85, unique_tags: ['ruby', 'programming'], value_history: 'first' })
|
199
|
+
puts "After update 1:"
|
200
|
+
puts " Max score: #{state[:max_score]}"
|
201
|
+
puts " Unique tags: #{state[:unique_tags]}"
|
202
|
+
puts " History: #{state[:value_history]}"
|
203
|
+
|
204
|
+
sleep(1) # Small delay to show different timestamps
|
205
|
+
|
206
|
+
state = state.merge_delta({ max_score: 72, unique_tags: ['ruby', 'web', 'api'], value_history: 'second' })
|
207
|
+
puts "\nAfter update 2:"
|
208
|
+
puts " Max score: #{state[:max_score]} ← Kept the higher value (85)"
|
209
|
+
puts " Unique tags: #{state[:unique_tags]} ← Added new unique tags"
|
210
|
+
puts " History: #{state[:value_history]} ← Tracked all changes with timestamps"
|
211
|
+
|
212
|
+
sleep(1)
|
213
|
+
|
214
|
+
state = state.merge_delta({ max_score: 95, unique_tags: ['programming'], value_history: 'third' })
|
215
|
+
puts "\nAfter update 3:"
|
216
|
+
puts " Max score: #{state[:max_score]} ← New maximum!"
|
217
|
+
puts " Unique tags: #{state[:unique_tags]} ← No duplicates added"
|
218
|
+
puts " History: #{state[:value_history]} ← Complete change history"
|
219
|
+
end
|
220
|
+
|
221
|
+
# Example 5: Real-world Graph Workflow Comparison
|
222
|
+
def workflow_comparison_example
|
223
|
+
puts "\n" + "=" * 60
|
224
|
+
puts "🚀 EXAMPLE 5: REAL-WORLD WORKFLOW COMPARISON"
|
225
|
+
puts "=" * 60
|
226
|
+
|
227
|
+
puts "🔴 WITHOUT Reducers - Data Loss Problem:"
|
228
|
+
|
229
|
+
# Workflow without reducers
|
230
|
+
graph_without = LangGraphRB::Graph.new do
|
231
|
+
node :collect_user_info do |state|
|
232
|
+
{ user_info: { name: state[:name] } }
|
233
|
+
end
|
234
|
+
|
235
|
+
node :collect_preferences do |state|
|
236
|
+
{ user_info: { theme: state[:theme] } } # This OVERWRITES name!
|
237
|
+
end
|
238
|
+
|
239
|
+
node :finalize do |state|
|
240
|
+
{ result: "User setup complete: #{state[:user_info]}" }
|
241
|
+
end
|
242
|
+
|
243
|
+
set_entry_point :collect_user_info
|
244
|
+
edge :collect_user_info, :collect_preferences
|
245
|
+
edge :collect_preferences, :finalize
|
246
|
+
set_finish_point :finalize
|
247
|
+
end
|
248
|
+
|
249
|
+
graph_without.compile!
|
250
|
+
result_without = graph_without.invoke({ name: "Alice", theme: "dark" })
|
251
|
+
puts "❌ Result: #{result_without[:result]} ← Lost the name!"
|
252
|
+
|
253
|
+
puts "\n🟢 WITH Reducers - Data Preservation:"
|
254
|
+
|
255
|
+
# Workflow with reducers
|
256
|
+
graph_with = LangGraphRB::Graph.new(state_class: UserSetupState) do
|
257
|
+
node :collect_user_info do |state|
|
258
|
+
{ user_info: { name: state[:name] } }
|
259
|
+
end
|
260
|
+
|
261
|
+
node :collect_preferences do |state|
|
262
|
+
{ user_info: { theme: state[:theme] } } # This MERGES with name!
|
263
|
+
end
|
264
|
+
|
265
|
+
node :finalize do |state|
|
266
|
+
{ result: "User setup complete: #{state[:user_info]}" }
|
267
|
+
end
|
268
|
+
|
269
|
+
set_entry_point :collect_user_info
|
270
|
+
edge :collect_user_info, :collect_preferences
|
271
|
+
edge :collect_preferences, :finalize
|
272
|
+
set_finish_point :finalize
|
273
|
+
end
|
274
|
+
|
275
|
+
graph_with.compile!
|
276
|
+
result_with = graph_with.invoke({ name: "Alice", theme: "dark" })
|
277
|
+
puts "✅ Result: #{result_with[:result]} ← Preserved all data!"
|
278
|
+
end
|
279
|
+
|
280
|
+
# Example 6: Performance and Memory Considerations
|
281
|
+
def performance_example
|
282
|
+
puts "\n" + "=" * 60
|
283
|
+
puts "⚡ EXAMPLE 6: PERFORMANCE CONSIDERATIONS"
|
284
|
+
puts "=" * 60
|
285
|
+
|
286
|
+
puts "🔍 Memory efficiency with reducers:"
|
287
|
+
|
288
|
+
# Show how reducers create new state objects (immutable)
|
289
|
+
original_state = LangGraphRB::State.new(
|
290
|
+
{ data: [1, 2, 3] },
|
291
|
+
{ data: LangGraphRB::State.add_messages }
|
292
|
+
)
|
293
|
+
|
294
|
+
puts "Original state object_id: #{original_state.object_id}"
|
295
|
+
puts "Original data object_id: #{original_state[:data].object_id}"
|
296
|
+
|
297
|
+
new_state = original_state.merge_delta({ data: [4, 5] })
|
298
|
+
|
299
|
+
puts "New state object_id: #{new_state.object_id} ← Different object (immutable)"
|
300
|
+
puts "New data object_id: #{new_state[:data].object_id} ← New array object"
|
301
|
+
puts "Original data unchanged: #{original_state[:data]} ← Still [1, 2, 3]"
|
302
|
+
puts "New data: #{new_state[:data]} ← Combined result [1, 2, 3, 4, 5]"
|
303
|
+
|
304
|
+
puts "\n💡 Key Benefits:"
|
305
|
+
puts " • Immutable updates (thread-safe)"
|
306
|
+
puts " • Predictable state changes"
|
307
|
+
puts " • Easy to debug and test"
|
308
|
+
puts " • Prevents accidental data loss"
|
309
|
+
end
|
310
|
+
|
311
|
+
# Run all examples
|
312
|
+
def run_all_examples
|
313
|
+
puts "🧪 LangGraphRB State Reducers - Complete Tutorial"
|
314
|
+
puts "=" * 80
|
315
|
+
|
316
|
+
without_reducers_example
|
317
|
+
with_reducers_example
|
318
|
+
builtin_reducers_example
|
319
|
+
custom_reducers_example
|
320
|
+
workflow_comparison_example
|
321
|
+
performance_example
|
322
|
+
|
323
|
+
puts "\n" + "=" * 80
|
324
|
+
puts "🎯 KEY TAKEAWAYS:"
|
325
|
+
puts "1. Without reducers: Simple replacement (data loss risk)"
|
326
|
+
puts "2. With reducers: Intelligent merging (data preservation)"
|
327
|
+
puts "3. Built-in reducers: add_messages, append_string, merge_hash"
|
328
|
+
puts "4. Custom reducers: Define your own combination logic"
|
329
|
+
puts "5. Graph workflows: Reducers prevent data loss between nodes"
|
330
|
+
puts "6. Immutable updates: Thread-safe and predictable"
|
331
|
+
puts "=" * 80
|
332
|
+
end
|
333
|
+
|
334
|
+
# Run the complete tutorial
|
335
|
+
if __FILE__ == $0
|
336
|
+
run_all_examples
|
337
|
+
end
|
data/lib/langgraph_rb/graph.rb
CHANGED
@@ -96,7 +96,7 @@ module LangGraphRB
|
|
96
96
|
end
|
97
97
|
|
98
98
|
# Execute the graph synchronously
|
99
|
-
def invoke(input_state = {}, context: nil, store: nil, thread_id: nil)
|
99
|
+
def invoke(input_state = {}, context: nil, store: nil, thread_id: nil, observers: [])
|
100
100
|
raise GraphError, "Graph must be compiled before execution" unless compiled?
|
101
101
|
|
102
102
|
store ||= Stores::InMemoryStore.new
|
@@ -104,11 +104,12 @@ module LangGraphRB
|
|
104
104
|
|
105
105
|
initial_state = @state_class.new(input_state)
|
106
106
|
|
107
|
-
Runner.new(self, store: store, thread_id: thread_id
|
107
|
+
Runner.new(self, store: store, thread_id: thread_id, observers: observers)
|
108
|
+
.invoke(initial_state, context: context)
|
108
109
|
end
|
109
110
|
|
110
111
|
# Stream execution results
|
111
|
-
def stream(input_state = {}, context: nil, store: nil, thread_id: nil)
|
112
|
+
def stream(input_state = {}, context: nil, store: nil, thread_id: nil, observers: [])
|
112
113
|
raise GraphError, "Graph must be compiled before execution" unless compiled?
|
113
114
|
|
114
115
|
store ||= Stores::InMemoryStore.new
|
@@ -116,7 +117,8 @@ module LangGraphRB
|
|
116
117
|
|
117
118
|
initial_state = @state_class.new(input_state)
|
118
119
|
|
119
|
-
Runner.new(self, store: store, thread_id: thread_id
|
120
|
+
Runner.new(self, store: store, thread_id: thread_id, observers: observers)
|
121
|
+
.stream(initial_state, context: context)
|
120
122
|
end
|
121
123
|
|
122
124
|
# Resume execution from a checkpoint
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
module LangGraphRB
|
5
|
+
module Observers
|
6
|
+
# Abstract base class for observability implementations
|
7
|
+
class BaseObserver
|
8
|
+
# Called when graph execution starts
|
9
|
+
def on_graph_start(event)
|
10
|
+
# Override in subclasses
|
11
|
+
end
|
12
|
+
|
13
|
+
# Called when graph execution completes
|
14
|
+
def on_graph_end(event)
|
15
|
+
# Override in subclasses
|
16
|
+
end
|
17
|
+
|
18
|
+
# Called when a node execution starts
|
19
|
+
def on_node_start(event)
|
20
|
+
# Override in subclasses
|
21
|
+
end
|
22
|
+
|
23
|
+
# Called when a node execution completes successfully
|
24
|
+
def on_node_end(event)
|
25
|
+
# Override in subclasses
|
26
|
+
end
|
27
|
+
|
28
|
+
# Called when a node execution encounters an error
|
29
|
+
def on_node_error(event)
|
30
|
+
# Override in subclasses
|
31
|
+
end
|
32
|
+
|
33
|
+
# Called when a step completes (multiple nodes may execute in parallel)
|
34
|
+
def on_step_complete(event)
|
35
|
+
# Override in subclasses
|
36
|
+
end
|
37
|
+
|
38
|
+
# Called when state changes occur
|
39
|
+
def on_state_change(event)
|
40
|
+
# Override in subclasses
|
41
|
+
end
|
42
|
+
|
43
|
+
# Called when commands are processed (Send, Command, etc.)
|
44
|
+
def on_command_processed(event)
|
45
|
+
# Override in subclasses
|
46
|
+
end
|
47
|
+
|
48
|
+
# Called when interrupts occur
|
49
|
+
def on_interrupt(event)
|
50
|
+
# Override in subclasses
|
51
|
+
end
|
52
|
+
|
53
|
+
# Called when checkpoints are saved
|
54
|
+
def on_checkpoint_saved(event)
|
55
|
+
# Override in subclasses
|
56
|
+
end
|
57
|
+
|
58
|
+
# Shutdown hook for cleanup
|
59
|
+
def shutdown
|
60
|
+
# Override in subclasses if cleanup needed
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
64
|
+
|
65
|
+
# Helper method to create standardized event structure
|
66
|
+
def create_event(type, data = {})
|
67
|
+
{
|
68
|
+
type: type,
|
69
|
+
timestamp: Time.now.utc.iso8601,
|
70
|
+
thread_id: data[:thread_id],
|
71
|
+
step_number: data[:step_number],
|
72
|
+
**data
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
# Helper to sanitize state data (remove sensitive info, limit size)
|
77
|
+
def sanitize_state(state, max_size: 1000)
|
78
|
+
return nil unless state
|
79
|
+
|
80
|
+
state_hash = state.respond_to?(:to_h) ? state.to_h : state
|
81
|
+
serialized = state_hash.to_json
|
82
|
+
|
83
|
+
if serialized.length > max_size
|
84
|
+
{ _truncated: true, _size: serialized.length, _preview: serialized[0...max_size] }
|
85
|
+
else
|
86
|
+
state_hash
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Event data structures
|
92
|
+
class GraphEvent
|
93
|
+
attr_reader :type, :graph, :initial_state, :context, :thread_id, :timestamp
|
94
|
+
|
95
|
+
def initialize(type:, graph:, initial_state: nil, context: nil, thread_id: nil)
|
96
|
+
@type = type
|
97
|
+
@graph = graph
|
98
|
+
@initial_state = initial_state
|
99
|
+
@context = context
|
100
|
+
@thread_id = thread_id
|
101
|
+
@timestamp = Time.now.utc
|
102
|
+
end
|
103
|
+
|
104
|
+
def to_h
|
105
|
+
{
|
106
|
+
type: @type,
|
107
|
+
graph_class: @graph.class.name,
|
108
|
+
node_count: @graph.nodes.size,
|
109
|
+
edge_count: @graph.edges.size,
|
110
|
+
initial_state: @initial_state,
|
111
|
+
context: @context,
|
112
|
+
thread_id: @thread_id,
|
113
|
+
timestamp: @timestamp.iso8601
|
114
|
+
}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
class NodeEvent
|
119
|
+
attr_reader :type, :node_name, :node_class, :state_before, :state_after,
|
120
|
+
:context, :thread_id, :step_number, :duration, :error, :result, :timestamp
|
121
|
+
|
122
|
+
def initialize(type:, node_name:, node_class: nil, state_before: nil, state_after: nil,
|
123
|
+
context: nil, thread_id: nil, step_number: nil, duration: nil,
|
124
|
+
error: nil, result: nil)
|
125
|
+
@type = type
|
126
|
+
@node_name = node_name
|
127
|
+
@node_class = node_class
|
128
|
+
@state_before = state_before
|
129
|
+
@state_after = state_after
|
130
|
+
@context = context
|
131
|
+
@thread_id = thread_id
|
132
|
+
@step_number = step_number
|
133
|
+
@duration = duration
|
134
|
+
@error = error
|
135
|
+
@result = result
|
136
|
+
@timestamp = Time.now.utc
|
137
|
+
end
|
138
|
+
|
139
|
+
def to_h
|
140
|
+
{
|
141
|
+
type: @type,
|
142
|
+
node_name: @node_name,
|
143
|
+
node_class: @node_class&.name,
|
144
|
+
state_before: @state_before,
|
145
|
+
state_after: @state_after,
|
146
|
+
context: @context,
|
147
|
+
thread_id: @thread_id,
|
148
|
+
step_number: @step_number,
|
149
|
+
duration_ms: @duration ? (@duration * 1000).round(2) : nil,
|
150
|
+
error: @error&.message,
|
151
|
+
error_class: @error&.class&.name,
|
152
|
+
result_type: @result&.class&.name,
|
153
|
+
timestamp: @timestamp.iso8601
|
154
|
+
}
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
class StepEvent
|
159
|
+
attr_reader :type, :step_number, :active_nodes, :completed_nodes, :thread_id,
|
160
|
+
:state, :duration, :timestamp
|
161
|
+
|
162
|
+
def initialize(type:, step_number:, active_nodes: [], completed_nodes: [],
|
163
|
+
thread_id: nil, state: nil, duration: nil)
|
164
|
+
@type = type
|
165
|
+
@step_number = step_number
|
166
|
+
@active_nodes = active_nodes
|
167
|
+
@completed_nodes = completed_nodes
|
168
|
+
@thread_id = thread_id
|
169
|
+
@state = state
|
170
|
+
@duration = duration
|
171
|
+
@timestamp = Time.now.utc
|
172
|
+
end
|
173
|
+
|
174
|
+
def to_h
|
175
|
+
{
|
176
|
+
type: @type,
|
177
|
+
step_number: @step_number,
|
178
|
+
active_nodes: @active_nodes,
|
179
|
+
completed_nodes: @completed_nodes,
|
180
|
+
thread_id: @thread_id,
|
181
|
+
state: @state,
|
182
|
+
duration_ms: @duration ? (@duration * 1000).round(2) : nil,
|
183
|
+
timestamp: @timestamp.iso8601
|
184
|
+
}
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|