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.
@@ -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
@@ -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
- process_node_result(node.name, state, result, step)
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
@@ -1,3 +1,3 @@
1
1
  module LangGraphRB
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
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 :negative_handler do |state|
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 : :negative_handler
77
+ state[:is_positive] ? :positive_handler : :generate_sql
78
78
  }
79
79
 
80
80
  set_finish_point :positive_handler
81
- set_finish_point :negative_handler
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.0
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-07-29 00:00:00.000000000 Z
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