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,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
|
|
@@ -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,79 @@ 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)
|
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
|
+
)
|
206
|
+
notify_observers(:on_node_start, event)
|
207
|
+
end
|
208
|
+
|
209
|
+
def notify_node_end(node, state_before, state_after, result, duration)
|
210
|
+
event = Observers::NodeEvent.new(
|
211
|
+
type: :end,
|
212
|
+
node_name: node.name,
|
213
|
+
node_class: node.class,
|
214
|
+
state_before: state_before,
|
215
|
+
state_after: state_after,
|
216
|
+
result: result,
|
217
|
+
duration: duration,
|
218
|
+
thread_id: @thread_id,
|
219
|
+
step_number: @step_number
|
220
|
+
)
|
221
|
+
notify_observers(:on_node_end, event)
|
222
|
+
end
|
223
|
+
|
224
|
+
def notify_node_error(node, state, error)
|
225
|
+
event = Observers::NodeEvent.new(
|
226
|
+
type: :error,
|
227
|
+
node_name: node.name,
|
228
|
+
node_class: node.class,
|
229
|
+
state_before: state,
|
230
|
+
error: error,
|
231
|
+
thread_id: @thread_id,
|
232
|
+
step_number: @step_number
|
233
|
+
)
|
234
|
+
notify_observers(:on_node_error, event)
|
235
|
+
end
|
236
|
+
|
153
237
|
# Execute all nodes in the current super-step in parallel
|
154
238
|
def execute_super_step(active_executions, context)
|
155
239
|
return [] if active_executions.empty?
|
@@ -184,10 +268,29 @@ module LangGraphRB
|
|
184
268
|
|
185
269
|
# Safely execute a single node
|
186
270
|
def execute_node_safely(node, state, context, step)
|
271
|
+
notify_node_start(node, state, context)
|
272
|
+
|
273
|
+
start_time = Time.now
|
187
274
|
begin
|
188
275
|
result = node.call(state, context: context)
|
189
|
-
|
276
|
+
duration = Time.now - start_time
|
277
|
+
|
278
|
+
processed_result = process_node_result(node.name, state, result, step)
|
279
|
+
|
280
|
+
# Extract final state from processed result
|
281
|
+
final_state = case processed_result[:type]
|
282
|
+
when :completed
|
283
|
+
processed_result[:state]
|
284
|
+
else
|
285
|
+
state
|
286
|
+
end
|
287
|
+
|
288
|
+
notify_node_end(node, state, final_state, result, duration)
|
289
|
+
processed_result
|
190
290
|
rescue => error
|
291
|
+
duration = Time.now - start_time
|
292
|
+
notify_node_error(node, state, error)
|
293
|
+
|
191
294
|
{
|
192
295
|
type: :error,
|
193
296
|
node_name: node.name,
|
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.1
|
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-05 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
|