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.
- checksums.yaml +7 -0
- data/Gemfile +9 -0
- data/README.md +350 -0
- data/SUMMARY.md +170 -0
- data/examples/advanced_example.rb +388 -0
- data/examples/basic_example.rb +211 -0
- data/examples/simple_test.rb +266 -0
- data/langgraph_rb.gemspec +43 -0
- data/lib/langgraph_rb/command.rb +132 -0
- data/lib/langgraph_rb/edge.rb +141 -0
- data/lib/langgraph_rb/graph.rb +268 -0
- data/lib/langgraph_rb/node.rb +112 -0
- data/lib/langgraph_rb/runner.rb +360 -0
- data/lib/langgraph_rb/state.rb +70 -0
- data/lib/langgraph_rb/stores/memory.rb +265 -0
- data/lib/langgraph_rb/version.rb +3 -0
- data/lib/langgraph_rb.rb +15 -0
- data/test_runner.rb +160 -0
- metadata +151 -0
@@ -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
|