langgraph_rb 0.1.0 → 0.1.2
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 +190 -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 +119 -12
- 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,71 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module LangGraphRB
|
4
|
+
module Observers
|
5
|
+
# File and stdout logging observer
|
6
|
+
class LoggerObserver < BaseObserver
|
7
|
+
def initialize(logger: nil, level: :info, format: :text)
|
8
|
+
@logger = logger || Logger.new($stdout)
|
9
|
+
@level = level
|
10
|
+
@format = format
|
11
|
+
@logger.level = Logger.const_get(level.to_s.upcase)
|
12
|
+
end
|
13
|
+
|
14
|
+
def on_graph_start(event)
|
15
|
+
log(:info, "Graph execution started", event.to_h)
|
16
|
+
end
|
17
|
+
|
18
|
+
def on_graph_end(event)
|
19
|
+
log(:info, "Graph execution completed", event.to_h)
|
20
|
+
end
|
21
|
+
|
22
|
+
def on_node_start(event)
|
23
|
+
log(:debug, "Node execution started: #{event.node_name}", event.to_h)
|
24
|
+
end
|
25
|
+
|
26
|
+
def on_node_end(event)
|
27
|
+
duration_ms = event.duration ? (event.duration * 1000).round(2) : 'unknown'
|
28
|
+
log(:info, "Node completed: #{event.node_name} (#{duration_ms}ms)", event.to_h)
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_node_error(event)
|
32
|
+
log(:error, "Node error: #{event.node_name} - #{event.error&.message}", event.to_h)
|
33
|
+
end
|
34
|
+
|
35
|
+
def on_step_complete(event)
|
36
|
+
log(:debug, "Step #{event.step_number} completed with #{event.completed_nodes.length} nodes", event.to_h)
|
37
|
+
end
|
38
|
+
|
39
|
+
def on_interrupt(event)
|
40
|
+
log(:warn, "Execution interrupted: #{event[:message]}", event)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def log(level, message, data)
|
46
|
+
case @format
|
47
|
+
when :json
|
48
|
+
@logger.send(level, { message: message, data: sanitize_data(data) }.to_json)
|
49
|
+
else
|
50
|
+
@logger.send(level, "#{message} | #{format_data(data)}")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def format_data(data)
|
55
|
+
relevant_fields = data.select { |k, v| [:thread_id, :step_number, :node_name, :duration_ms].include?(k) && v }
|
56
|
+
relevant_fields.map { |k, v| "#{k}=#{v}" }.join(" ")
|
57
|
+
end
|
58
|
+
|
59
|
+
def sanitize_data(data)
|
60
|
+
data.transform_values do |value|
|
61
|
+
case value
|
62
|
+
when Hash
|
63
|
+
sanitize_state(value, max_size: 500)
|
64
|
+
else
|
65
|
+
value
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module LangGraphRB
|
4
|
+
module Observers
|
5
|
+
# Structured data observer for APM/monitoring integration
|
6
|
+
class StructuredObserver < BaseObserver
|
7
|
+
def initialize(sink: nil, format: :json, include_state: false, async: false)
|
8
|
+
@sink = sink || $stdout
|
9
|
+
@format = format
|
10
|
+
@include_state = include_state
|
11
|
+
@async = async
|
12
|
+
@event_queue = async ? Queue.new : nil
|
13
|
+
@worker_thread = start_worker_thread if async
|
14
|
+
end
|
15
|
+
|
16
|
+
def on_graph_start(event)
|
17
|
+
emit_event(:graph_start, event.to_h)
|
18
|
+
end
|
19
|
+
|
20
|
+
def on_graph_end(event)
|
21
|
+
emit_event(:graph_end, event.to_h)
|
22
|
+
end
|
23
|
+
|
24
|
+
def on_node_start(event)
|
25
|
+
emit_event(:node_start, sanitize_event(event.to_h))
|
26
|
+
end
|
27
|
+
|
28
|
+
def on_node_end(event)
|
29
|
+
emit_event(:node_end, sanitize_event(event.to_h))
|
30
|
+
end
|
31
|
+
|
32
|
+
def on_node_error(event)
|
33
|
+
emit_event(:node_error, sanitize_event(event.to_h))
|
34
|
+
end
|
35
|
+
|
36
|
+
def on_step_complete(event)
|
37
|
+
emit_event(:step_complete, sanitize_event(event.to_h))
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_command_processed(event)
|
41
|
+
emit_event(:command_processed, event)
|
42
|
+
end
|
43
|
+
|
44
|
+
def shutdown
|
45
|
+
if @async && @worker_thread
|
46
|
+
@event_queue << :shutdown
|
47
|
+
@worker_thread.join
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def emit_event(type, data)
|
54
|
+
event = {
|
55
|
+
event_type: type,
|
56
|
+
timestamp: Time.now.utc.iso8601,
|
57
|
+
**data
|
58
|
+
}
|
59
|
+
|
60
|
+
if @async
|
61
|
+
@event_queue << event
|
62
|
+
else
|
63
|
+
write_event(event)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def write_event(event)
|
68
|
+
case @format
|
69
|
+
when :json
|
70
|
+
@sink.puts(JSON.generate(event))
|
71
|
+
when :ndjson
|
72
|
+
@sink.puts(JSON.generate(event))
|
73
|
+
else
|
74
|
+
@sink.puts(event.inspect)
|
75
|
+
end
|
76
|
+
|
77
|
+
@sink.flush if @sink.respond_to?(:flush)
|
78
|
+
end
|
79
|
+
|
80
|
+
def sanitize_event(event_data)
|
81
|
+
result = event_data.dup
|
82
|
+
|
83
|
+
unless @include_state
|
84
|
+
result.delete(:state_before)
|
85
|
+
result.delete(:state_after)
|
86
|
+
result.delete(:state)
|
87
|
+
else
|
88
|
+
result[:state_before] = sanitize_state(result[:state_before]) if result[:state_before]
|
89
|
+
result[:state_after] = sanitize_state(result[:state_after]) if result[:state_after]
|
90
|
+
result[:state] = sanitize_state(result[:state]) if result[:state]
|
91
|
+
end
|
92
|
+
|
93
|
+
result
|
94
|
+
end
|
95
|
+
|
96
|
+
def start_worker_thread
|
97
|
+
Thread.new do
|
98
|
+
loop do
|
99
|
+
event = @event_queue.pop
|
100
|
+
break if event == :shutdown
|
101
|
+
|
102
|
+
write_event(event)
|
103
|
+
rescue => e
|
104
|
+
# Log error but continue processing
|
105
|
+
$stderr.puts "Observer error: #{e.message}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/langgraph_rb/runner.rb
CHANGED
@@ -1,16 +1,19 @@
|
|
1
1
|
require 'thread'
|
2
|
+
require 'json'
|
3
|
+
require 'time'
|
2
4
|
|
3
5
|
module LangGraphRB
|
4
6
|
class Runner
|
5
7
|
attr_reader :graph, :store, :thread_id
|
6
8
|
|
7
|
-
def initialize(graph, store:, thread_id:)
|
9
|
+
def initialize(graph, store:, thread_id:, observers: [])
|
8
10
|
@graph = graph
|
9
11
|
@store = store
|
10
12
|
@thread_id = thread_id
|
11
13
|
@step_number = 0
|
12
14
|
@execution_queue = Queue.new
|
13
15
|
@interrupt_handler = nil
|
16
|
+
@observers = Array(observers)
|
14
17
|
end
|
15
18
|
|
16
19
|
# Synchronous execution
|
@@ -26,6 +29,8 @@ module LangGraphRB
|
|
26
29
|
|
27
30
|
# Streaming execution with optional block for receiving intermediate results
|
28
31
|
def stream(initial_state, context: nil, &block)
|
32
|
+
notify_graph_start(initial_state, context)
|
33
|
+
|
29
34
|
@step_number = 0
|
30
35
|
current_state = initial_state
|
31
36
|
|
@@ -59,7 +64,7 @@ module LangGraphRB
|
|
59
64
|
if dest_name == Graph::FINISH
|
60
65
|
final_state = dest_state
|
61
66
|
else
|
62
|
-
next_active << ExecutionFrame.new(dest_name, dest_state, @step_number)
|
67
|
+
next_active << ExecutionFrame.new(dest_name, dest_state, @step_number, from_node: result[:node_name])
|
63
68
|
end
|
64
69
|
else
|
65
70
|
# Use normal edge routing
|
@@ -73,7 +78,7 @@ module LangGraphRB
|
|
73
78
|
if dest_name == Graph::FINISH
|
74
79
|
final_state = dest_state
|
75
80
|
else
|
76
|
-
next_active << ExecutionFrame.new(dest_name, dest_state, @step_number)
|
81
|
+
next_active << ExecutionFrame.new(dest_name, dest_state, @step_number, from_node: result[:node_name])
|
77
82
|
end
|
78
83
|
end
|
79
84
|
end
|
@@ -82,7 +87,7 @@ module LangGraphRB
|
|
82
87
|
# Handle Send commands (map-reduce)
|
83
88
|
result[:sends].each do |send_cmd|
|
84
89
|
payload_state = result[:state].merge_delta(send_cmd.payload)
|
85
|
-
next_active << ExecutionFrame.new(send_cmd.to, payload_state, @step_number)
|
90
|
+
next_active << ExecutionFrame.new(send_cmd.to, payload_state, @step_number, from_node: result[:node_name])
|
86
91
|
end
|
87
92
|
|
88
93
|
when :interrupt
|
@@ -91,7 +96,7 @@ module LangGraphRB
|
|
91
96
|
user_input = @interrupt_handler.call(result[:interrupt])
|
92
97
|
# Continue with user input merged into state
|
93
98
|
updated_state = result[:state].merge_delta(user_input || {})
|
94
|
-
next_active << ExecutionFrame.new(result[:node_name], updated_state, @step_number)
|
99
|
+
next_active << ExecutionFrame.new(result[:node_name], updated_state, @step_number, from_node: result[:node_name])
|
95
100
|
else
|
96
101
|
# No interrupt handler, treat as completion
|
97
102
|
final_state = result[:state]
|
@@ -124,11 +129,17 @@ module LangGraphRB
|
|
124
129
|
break if final_state
|
125
130
|
end
|
126
131
|
|
127
|
-
{
|
132
|
+
result = {
|
128
133
|
state: current_state,
|
129
134
|
step_number: @step_number,
|
130
135
|
thread_id: @thread_id
|
131
136
|
}
|
137
|
+
|
138
|
+
notify_graph_end(current_state)
|
139
|
+
result
|
140
|
+
rescue => error
|
141
|
+
notify_graph_end(current_state || initial_state)
|
142
|
+
raise
|
132
143
|
end
|
133
144
|
|
134
145
|
# Resume from checkpoint
|
@@ -150,6 +161,82 @@ module LangGraphRB
|
|
150
161
|
|
151
162
|
private
|
152
163
|
|
164
|
+
def notify_observers(method, event)
|
165
|
+
@observers.each do |observer|
|
166
|
+
begin
|
167
|
+
observer.send(method, event)
|
168
|
+
rescue => e
|
169
|
+
# Log observer errors but don't fail execution
|
170
|
+
$stderr.puts "Observer error in #{observer.class}##{method}: #{e.message}"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def notify_graph_start(initial_state, context)
|
176
|
+
event = Observers::GraphEvent.new(
|
177
|
+
type: :start,
|
178
|
+
graph: @graph,
|
179
|
+
initial_state: initial_state,
|
180
|
+
context: context,
|
181
|
+
thread_id: @thread_id
|
182
|
+
)
|
183
|
+
notify_observers(:on_graph_start, event)
|
184
|
+
end
|
185
|
+
|
186
|
+
def notify_graph_end(final_state)
|
187
|
+
event = Observers::GraphEvent.new(
|
188
|
+
type: :end,
|
189
|
+
graph: @graph,
|
190
|
+
initial_state: final_state,
|
191
|
+
thread_id: @thread_id
|
192
|
+
)
|
193
|
+
notify_observers(:on_graph_end, event)
|
194
|
+
end
|
195
|
+
|
196
|
+
def notify_node_start(node, state, context, from_node: nil)
|
197
|
+
event = Observers::NodeEvent.new(
|
198
|
+
type: :start,
|
199
|
+
node_name: node.name,
|
200
|
+
node_class: node.class,
|
201
|
+
state_before: state,
|
202
|
+
context: context,
|
203
|
+
thread_id: @thread_id,
|
204
|
+
step_number: @step_number,
|
205
|
+
from_node: from_node
|
206
|
+
)
|
207
|
+
notify_observers(:on_node_start, event)
|
208
|
+
end
|
209
|
+
|
210
|
+
def notify_node_end(node, state_before, state_after, result, duration, from_node: nil)
|
211
|
+
event = Observers::NodeEvent.new(
|
212
|
+
type: :end,
|
213
|
+
node_name: node.name,
|
214
|
+
node_class: node.class,
|
215
|
+
state_before: state_before,
|
216
|
+
state_after: state_after,
|
217
|
+
result: result,
|
218
|
+
duration: duration,
|
219
|
+
thread_id: @thread_id,
|
220
|
+
step_number: @step_number,
|
221
|
+
from_node: from_node
|
222
|
+
)
|
223
|
+
notify_observers(:on_node_end, event)
|
224
|
+
end
|
225
|
+
|
226
|
+
def notify_node_error(node, state, error, from_node: nil)
|
227
|
+
event = Observers::NodeEvent.new(
|
228
|
+
type: :error,
|
229
|
+
node_name: node.name,
|
230
|
+
node_class: node.class,
|
231
|
+
state_before: state,
|
232
|
+
error: error,
|
233
|
+
thread_id: @thread_id,
|
234
|
+
step_number: @step_number,
|
235
|
+
from_node: from_node
|
236
|
+
)
|
237
|
+
notify_observers(:on_node_error, event)
|
238
|
+
end
|
239
|
+
|
153
240
|
# Execute all nodes in the current super-step in parallel
|
154
241
|
def execute_super_step(active_executions, context)
|
155
242
|
return [] if active_executions.empty?
|
@@ -167,7 +254,7 @@ module LangGraphRB
|
|
167
254
|
# Execute each frame for this node
|
168
255
|
executions.each do |frame|
|
169
256
|
thread = Thread.new do
|
170
|
-
execute_node_safely(node, frame.state, context, frame.step)
|
257
|
+
execute_node_safely(node, frame.state, context, frame.step, from_node: frame.from_node)
|
171
258
|
end
|
172
259
|
threads << thread
|
173
260
|
end
|
@@ -183,11 +270,30 @@ module LangGraphRB
|
|
183
270
|
end
|
184
271
|
|
185
272
|
# Safely execute a single node
|
186
|
-
def execute_node_safely(node, state, context, step)
|
273
|
+
def execute_node_safely(node, state, context, step, from_node: nil)
|
274
|
+
notify_node_start(node, state, context, from_node: from_node)
|
275
|
+
|
276
|
+
start_time = Time.now
|
187
277
|
begin
|
188
278
|
result = node.call(state, context: context)
|
189
|
-
|
279
|
+
duration = Time.now - start_time
|
280
|
+
|
281
|
+
processed_result = process_node_result(node.name, state, result, step)
|
282
|
+
|
283
|
+
# Extract final state from processed result
|
284
|
+
final_state = case processed_result[:type]
|
285
|
+
when :completed
|
286
|
+
processed_result[:state]
|
287
|
+
else
|
288
|
+
state
|
289
|
+
end
|
290
|
+
|
291
|
+
notify_node_end(node, state, final_state, result, duration, from_node: from_node)
|
292
|
+
processed_result
|
190
293
|
rescue => error
|
294
|
+
duration = Time.now - start_time
|
295
|
+
notify_node_error(node, state, error, from_node: from_node)
|
296
|
+
|
191
297
|
{
|
192
298
|
type: :error,
|
193
299
|
node_name: node.name,
|
@@ -318,16 +424,17 @@ module LangGraphRB
|
|
318
424
|
|
319
425
|
# Execution frame for tracking active node executions
|
320
426
|
class ExecutionFrame
|
321
|
-
attr_reader :node_name, :state, :step
|
427
|
+
attr_reader :node_name, :state, :step, :from_node
|
322
428
|
|
323
|
-
def initialize(node_name, state, step)
|
429
|
+
def initialize(node_name, state, step, from_node: nil)
|
324
430
|
@node_name = node_name.to_sym
|
325
431
|
@state = state
|
326
432
|
@step = step
|
433
|
+
@from_node = from_node
|
327
434
|
end
|
328
435
|
|
329
436
|
def to_s
|
330
|
-
"#<ExecutionFrame node: #{@node_name}, step: #{@step}>"
|
437
|
+
"#<ExecutionFrame node: #{@node_name}, step: #{@step}, from: #{@from_node}>"
|
331
438
|
end
|
332
439
|
end
|
333
440
|
end
|
data/lib/langgraph_rb/version.rb
CHANGED
data/lib/langgraph_rb.rb
CHANGED
@@ -6,10 +6,14 @@ require_relative 'langgraph_rb/command'
|
|
6
6
|
require_relative 'langgraph_rb/graph'
|
7
7
|
require_relative 'langgraph_rb/runner'
|
8
8
|
require_relative 'langgraph_rb/stores/memory'
|
9
|
+
require_relative 'langgraph_rb/observers/base'
|
10
|
+
require_relative 'langgraph_rb/observers/logger'
|
11
|
+
require_relative 'langgraph_rb/observers/structured'
|
9
12
|
|
10
13
|
module LangGraphRB
|
11
14
|
class Error < StandardError; end
|
12
15
|
class GraphError < Error; end
|
13
16
|
class NodeError < Error; end
|
14
17
|
class StateError < Error; end
|
18
|
+
class ObserverError < Error; end
|
15
19
|
end
|
data/test_runner.rb
CHANGED
@@ -67,18 +67,18 @@ routing_graph = LangGraphRB::Graph.new do
|
|
67
67
|
{ message: "Number #{state[:number]} is positive!" }
|
68
68
|
end
|
69
69
|
|
70
|
-
node :
|
70
|
+
node :generate_sql do |state|
|
71
71
|
{ message: "Number #{state[:number]} is negative or zero!" }
|
72
72
|
end
|
73
73
|
|
74
74
|
set_entry_point :check_number
|
75
75
|
|
76
76
|
conditional_edge :check_number, ->(state) {
|
77
|
-
state[:is_positive] ? :positive_handler : :
|
77
|
+
state[:is_positive] ? :positive_handler : :generate_sql
|
78
78
|
}
|
79
79
|
|
80
80
|
set_finish_point :positive_handler
|
81
|
-
set_finish_point :
|
81
|
+
set_finish_point :generate_sql
|
82
82
|
end
|
83
83
|
|
84
84
|
routing_graph.compile!
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: langgraph_rb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julian Toro
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-08-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json
|
@@ -104,11 +104,15 @@ executables: []
|
|
104
104
|
extensions: []
|
105
105
|
extra_rdoc_files: []
|
106
106
|
files:
|
107
|
+
- ".gitignore"
|
107
108
|
- Gemfile
|
108
109
|
- README.md
|
109
110
|
- SUMMARY.md
|
110
111
|
- examples/advanced_example.rb
|
111
112
|
- examples/basic_example.rb
|
113
|
+
- examples/initial_state_example.rb
|
114
|
+
- examples/observer_example.rb
|
115
|
+
- examples/reducers_example.rb
|
112
116
|
- examples/simple_test.rb
|
113
117
|
- langgraph_rb.gemspec
|
114
118
|
- lib/langgraph_rb.rb
|
@@ -116,6 +120,9 @@ files:
|
|
116
120
|
- lib/langgraph_rb/edge.rb
|
117
121
|
- lib/langgraph_rb/graph.rb
|
118
122
|
- lib/langgraph_rb/node.rb
|
123
|
+
- lib/langgraph_rb/observers/base.rb
|
124
|
+
- lib/langgraph_rb/observers/logger.rb
|
125
|
+
- lib/langgraph_rb/observers/structured.rb
|
119
126
|
- lib/langgraph_rb/runner.rb
|
120
127
|
- lib/langgraph_rb/state.rb
|
121
128
|
- lib/langgraph_rb/stores/memory.rb
|