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.
@@ -0,0 +1,268 @@
1
+ require 'set'
2
+ require 'securerandom'
3
+
4
+ module LangGraphRB
5
+ class Graph
6
+ START = :__start__
7
+ FINISH = :__end__
8
+
9
+ attr_reader :nodes, :edges, :state_class, :compiled
10
+
11
+ def initialize(state_class: State, &dsl_block)
12
+ @nodes = {}
13
+ @edges = []
14
+ @state_class = state_class
15
+ @compiled = false
16
+
17
+ # Built-in START and FINISH nodes
18
+ @nodes[START] = Node.new(START) { |state| state }
19
+ @nodes[FINISH] = Node.new(FINISH) { |state| state }
20
+
21
+ instance_eval(&dsl_block) if dsl_block
22
+ end
23
+
24
+ # DSL Methods for building the graph
25
+ def node(name, callable = nil, **options, &block)
26
+ name = name.to_sym
27
+ raise GraphError, "Node '#{name}' already exists" if @nodes.key?(name)
28
+
29
+ if callable.respond_to?(:call)
30
+ @nodes[name] = Node.new(name, callable)
31
+ elsif block
32
+ @nodes[name] = Node.new(name, &block)
33
+ else
34
+ raise GraphError, "Node '#{name}' must have a callable or block"
35
+ end
36
+ end
37
+
38
+ def llm_node(name, llm_client:, system_prompt: nil, &block)
39
+ name = name.to_sym
40
+ raise GraphError, "Node '#{name}' already exists" if @nodes.key?(name)
41
+
42
+ @nodes[name] = LLMNode.new(name, llm_client: llm_client, system_prompt: system_prompt, &block)
43
+ end
44
+
45
+ def tool_node(name, tool:, &block)
46
+ name = name.to_sym
47
+ raise GraphError, "Node '#{name}' already exists" if @nodes.key?(name)
48
+
49
+ @nodes[name] = ToolNode.new(name, tool: tool, &block)
50
+ end
51
+
52
+ def edge(from, to)
53
+ from, to = from.to_sym, to.to_sym
54
+ validate_node_exists!(from)
55
+ validate_node_exists!(to)
56
+
57
+ @edges << Edge.new(from, to)
58
+ end
59
+
60
+ def conditional_edge(from, router, path_map = nil)
61
+ from = from.to_sym
62
+ validate_node_exists!(from)
63
+
64
+ @edges << ConditionalEdge.new(from, router, path_map)
65
+ end
66
+
67
+ def fan_out_edge(from, destinations)
68
+ from = from.to_sym
69
+ validate_node_exists!(from)
70
+ destinations = destinations.map(&:to_sym)
71
+
72
+ destinations.each { |dest| validate_node_exists!(dest) }
73
+
74
+ @edges << FanOutEdge.new(from, destinations)
75
+ end
76
+
77
+ # Set the entry point (typically from START)
78
+ def set_entry_point(node_name)
79
+ edge(START, node_name)
80
+ end
81
+
82
+ # Set exit point (typically to FINISH)
83
+ def set_finish_point(node_name)
84
+ edge(node_name, FINISH)
85
+ end
86
+
87
+ # Compile the graph (validate and prepare for execution)
88
+ def compile!
89
+ validate_graph!
90
+ @compiled = true
91
+ self
92
+ end
93
+
94
+ def compiled?
95
+ @compiled
96
+ end
97
+
98
+ # Execute the graph synchronously
99
+ def invoke(input_state = {}, context: nil, store: nil, thread_id: nil)
100
+ raise GraphError, "Graph must be compiled before execution" unless compiled?
101
+
102
+ store ||= Stores::InMemoryStore.new
103
+ thread_id ||= SecureRandom.hex(8)
104
+
105
+ initial_state = @state_class.new(input_state)
106
+
107
+ Runner.new(self, store: store, thread_id: thread_id).invoke(initial_state, context: context)
108
+ end
109
+
110
+ # Stream execution results
111
+ def stream(input_state = {}, context: nil, store: nil, thread_id: nil)
112
+ raise GraphError, "Graph must be compiled before execution" unless compiled?
113
+
114
+ store ||= Stores::InMemoryStore.new
115
+ thread_id ||= SecureRandom.hex(8)
116
+
117
+ initial_state = @state_class.new(input_state)
118
+
119
+ Runner.new(self, store: store, thread_id: thread_id).stream(initial_state, context: context)
120
+ end
121
+
122
+ # Resume execution from a checkpoint
123
+ def resume(thread_id, input_state = {}, context: nil, store: nil)
124
+ raise GraphError, "Graph must be compiled before execution" unless compiled?
125
+ raise GraphError, "Store required for resuming execution" unless store
126
+
127
+ Runner.new(self, store: store, thread_id: thread_id).resume(input_state, context: context)
128
+ end
129
+
130
+ # Generate Mermaid diagram
131
+ def to_mermaid
132
+ lines = ["graph TD"]
133
+
134
+ # Add nodes
135
+ @nodes.each do |name, node|
136
+ next if [START, FINISH].include?(name)
137
+ lines << " #{name}[\"#{name}\"]"
138
+ end
139
+
140
+ # Add special nodes
141
+ lines << " #{START}((START))"
142
+ lines << " #{FINISH}((FINISH))"
143
+
144
+ # Add edges
145
+ @edges.each do |edge|
146
+ case edge
147
+ when Edge
148
+ lines << " #{edge.from} --> #{edge.to}"
149
+ when ConditionalEdge
150
+ lines << " #{edge.from} --> |condition| #{edge.from}_decision{condition}"
151
+ when FanOutEdge
152
+ edge.destinations.each do |dest|
153
+ lines << " #{edge.from} --> #{dest}"
154
+ end
155
+ end
156
+ end
157
+
158
+ lines.join("\n")
159
+ end
160
+
161
+ # Print Mermaid diagram
162
+ def draw_mermaid
163
+ puts to_mermaid
164
+ end
165
+
166
+ # Get all possible next nodes from a given node
167
+ def get_next_nodes(from_node)
168
+ from_node = from_node.to_sym
169
+ next_nodes = []
170
+
171
+ @edges.each do |edge|
172
+ if edge.from == from_node
173
+ case edge
174
+ when Edge
175
+ next_nodes << edge.to
176
+ when ConditionalEdge, FanOutEdge
177
+ # These require runtime evaluation
178
+ next_nodes << :conditional
179
+ end
180
+ end
181
+ end
182
+
183
+ next_nodes.uniq
184
+ end
185
+
186
+ # Get all edges from a specific node
187
+ def get_edges_from(node_name)
188
+ node_name = node_name.to_sym
189
+ @edges.select { |edge| edge.from == node_name }
190
+ end
191
+
192
+ private
193
+
194
+ def validate_graph!
195
+ # Check that START has outgoing edges
196
+ start_edges = @edges.select { |e| e.from == START }
197
+ raise GraphError, "No entry point defined (START node has no outgoing edges)" if start_edges.empty?
198
+
199
+ # Check that all edge targets exist as nodes
200
+ @edges.each do |edge|
201
+ case edge
202
+ when Edge
203
+ validate_node_exists!(edge.from)
204
+ validate_node_exists!(edge.to)
205
+ when ConditionalEdge
206
+ validate_node_exists!(edge.from)
207
+ # Path map targets will be validated at runtime
208
+ when FanOutEdge
209
+ validate_node_exists!(edge.from)
210
+ edge.destinations.each { |dest| validate_node_exists!(dest) }
211
+ end
212
+ end
213
+
214
+ # Check for orphaned nodes (nodes with no incoming edges except START)
215
+ nodes_with_incoming = @edges.flat_map do |edge|
216
+ case edge
217
+ when Edge
218
+ [edge.to]
219
+ when FanOutEdge
220
+ edge.destinations
221
+ else
222
+ []
223
+ end
224
+ end.uniq
225
+
226
+ orphaned = @nodes.keys - nodes_with_incoming - [START]
227
+ unless orphaned.empty?
228
+ puts "Warning: Orphaned nodes detected: #{orphaned.inspect}"
229
+ end
230
+
231
+ # Verify at least one path leads to FINISH
232
+ reachable = find_reachable_nodes(START)
233
+ unless reachable.include?(FINISH)
234
+ puts "Warning: No path from START to FINISH found"
235
+ end
236
+ end
237
+
238
+ def validate_node_exists!(node_name)
239
+ node_name = node_name.to_sym
240
+ unless @nodes.key?(node_name)
241
+ raise GraphError, "Node '#{node_name}' does not exist"
242
+ end
243
+ end
244
+
245
+ def find_reachable_nodes(start_node, visited = Set.new)
246
+ return [] if visited.include?(start_node)
247
+
248
+ visited.add(start_node)
249
+ reachable = [start_node]
250
+
251
+ edges_from_node = @edges.select { |e| e.from == start_node }
252
+
253
+ edges_from_node.each do |edge|
254
+ case edge
255
+ when Edge
256
+ reachable += find_reachable_nodes(edge.to, visited.dup)
257
+ when FanOutEdge
258
+ edge.destinations.each do |dest|
259
+ reachable += find_reachable_nodes(dest, visited.dup)
260
+ end
261
+ # ConditionalEdge paths are dynamic, so we can't pre-validate them
262
+ end
263
+ end
264
+
265
+ reachable.uniq
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,112 @@
1
+ module LangGraphRB
2
+ class Node
3
+ attr_reader :name, :block
4
+
5
+ def initialize(name, callable = nil, &block)
6
+ @name = name.to_sym
7
+ @callable = callable || block
8
+
9
+ raise NodeError, "Node '#{name}' must have a callable or block" unless @callable
10
+ end
11
+
12
+ # Execute the node with the given state and context
13
+ # Returns either a Hash (state delta), Command, or Send object
14
+ def call(state, context: nil)
15
+ case @callable.arity
16
+ when 0
17
+ @callable.call
18
+ when 1
19
+ @callable.call(state)
20
+ else
21
+ @callable.call(state, context)
22
+ end
23
+ rescue => e
24
+ raise NodeError, "Error executing node '#{@name}': #{e.message}"
25
+ end
26
+
27
+ def to_s
28
+ "#<Node:#{@name}>"
29
+ end
30
+
31
+ def inspect
32
+ to_s
33
+ end
34
+ end
35
+
36
+ # Specialized node for LLM calls
37
+ class LLMNode < Node
38
+ attr_reader :llm_client, :system_prompt
39
+
40
+ def initialize(name, llm_client:, system_prompt: nil, &block)
41
+ @llm_client = llm_client
42
+ @system_prompt = system_prompt
43
+
44
+ super(name, &block)
45
+ end
46
+
47
+ def call(state, context: nil)
48
+ # If no custom block provided, use default LLM behavior
49
+ if @callable.nil? || @callable == method(:default_llm_call)
50
+ default_llm_call(state, context)
51
+ else
52
+ super(state, context: context)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def default_llm_call(state, context)
59
+ messages = state[:messages] || []
60
+ messages = [@system_prompt] + messages if @system_prompt && !messages.empty?
61
+
62
+ response = @llm_client.call(messages)
63
+
64
+ {
65
+ messages: [{ role: 'assistant', content: response }],
66
+ last_response: response
67
+ }
68
+ end
69
+ end
70
+
71
+ # Specialized node for tool calls
72
+ class ToolNode < Node
73
+ attr_reader :tool
74
+
75
+ def initialize(name, tool:, &block)
76
+ @tool = tool
77
+ super(name, &(block || method(:default_tool_call)))
78
+ end
79
+
80
+ private
81
+
82
+ def default_tool_call(state, context)
83
+ # Extract tool call from last message or state
84
+ tool_call = state[:tool_call] || extract_tool_call_from_messages(state[:messages])
85
+
86
+ return { error: "No tool call found" } unless tool_call
87
+
88
+ result = @tool.call(tool_call[:args])
89
+
90
+ {
91
+ messages: [{
92
+ role: 'tool',
93
+ content: result.to_s,
94
+ tool_call_id: tool_call[:id]
95
+ }],
96
+ tool_result: result
97
+ }
98
+ end
99
+
100
+ def extract_tool_call_from_messages(messages)
101
+ return nil unless messages
102
+
103
+ messages.reverse.each do |msg|
104
+ if msg[:tool_calls]
105
+ return msg[:tool_calls].first
106
+ end
107
+ end
108
+
109
+ nil
110
+ end
111
+ end
112
+ end