little_ghost 0.2.0 → 0.3.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,911 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "assembly"
5
+
6
+ module LittleGhost
7
+ # Routes a request through named Assembly nodes and declared edges.
8
+ #
9
+ # A graph is an Assembly for flows whose allowed paths should be visible in
10
+ # application code. A node may contain an Agent, Workflow, Swarm, or another
11
+ # Graph. Ordinary edges choose one next node; explicit forks and joins add
12
+ # bounded parallel work.
13
+ #
14
+ # class SupportFlowGraph < LittleGhost::Graph
15
+ # node :triage, TriageAgent
16
+ # node :research, ResearchAgent
17
+ # node :respond, CustomerSupportAgent
18
+ #
19
+ # start :triage
20
+ # edge :triage, :research
21
+ # edge :research, :respond
22
+ # finish :respond
23
+ # end
24
+ #
25
+ # SupportFlowGraph.validate!
26
+ # run = SupportFlowGraph.ask("Why is my transfer pending?")
27
+ #
28
+ # Conditions and input mappers receive immutable Graph::State. Nodes do not
29
+ # receive caller history or application context unless their declaration opts
30
+ # in with +history: true+ or +context: true+. Validate the topology before
31
+ # execution; +to_mermaid+ renders the same definition as a flowchart.
32
+ class Graph < Assembly
33
+ Node = Data.define(:name, :assembly, :policies, :inherit_history, :inherit_context) # :nodoc:
34
+ Edge = Data.define(:from, :to, :condition, :input_mapper) # :nodoc:
35
+ ErrorEdge = Data.define(:from, :to, :errors, :input_mapper) # :nodoc:
36
+ Fork = Data.define(:from, :to, :max_concurrency) # :nodoc:
37
+ Join = Data.define(:from, :to, :input_mapper) # :nodoc:
38
+ BranchResult = Data.define(:terminal, :results, :steps, :usage, :events) # :nodoc:
39
+ EventSink = Data.define(:consumer) do # :nodoc:
40
+ def <<(event)
41
+ consumer.call(event)
42
+ self
43
+ end
44
+ end
45
+
46
+ # Immutable routing data passed to conditions and input mappers.
47
+ class State
48
+ # Original request data, current routing position, completed results,
49
+ # parallel predecessors, branch results, and the routed failure if any.
50
+ attr_reader :input, :history, :context, :step, :current, :previous,
51
+ :predecessors, :results, :branch_results, :error
52
+
53
+ def initialize(input:, history:, context:, step:, current:, previous:, results:,
54
+ predecessors: [], branch_results: {}, error: nil) # :nodoc:
55
+ @input = input
56
+ @history = history
57
+ @context = context
58
+ @step = step
59
+ @current = current
60
+ @previous = previous
61
+ @predecessors = Array(predecessors).map(&:to_sym).freeze
62
+ @results = results.dup.freeze
63
+ @branch_results = branch_results.dup.freeze
64
+ @error = error
65
+ freeze
66
+ end
67
+
68
+ # Returns a completed result by node name.
69
+ def result(node_name) = results[node_name.to_sym]
70
+ # Returns the immediately preceding result when present.
71
+ def previous_result = previous && result(previous)
72
+ end
73
+
74
+ extend Support::ClassAttributes
75
+
76
+ class_attribute :graph_nodes_value, default: {}.freeze
77
+ class_attribute :graph_edges_value, default: [].freeze
78
+ class_attribute :graph_error_edges_value, default: [].freeze
79
+ class_attribute :graph_forks_value, default: [].freeze
80
+ class_attribute :graph_joins_value, default: [].freeze
81
+ class_attribute :graph_start_value
82
+ class_attribute :graph_finish_value
83
+ class_attribute :graph_max_steps_value, default: 20
84
+
85
+ class << self
86
+ # Declares an Assembly node and its optional execution policy.
87
+ def node(name, assembly, timeout: nil, retries: 0, retry_on: nil, retry_delay: 0,
88
+ history: false, context: false)
89
+ name = normalize_node_name(name)
90
+ raise ConfigurationError, "graph node #{name.inspect} is already declared" if graph_nodes_value.key?(name)
91
+ unless [history, context].all? { |value| value == true || value == false }
92
+ raise ArgumentError, "graph node history and context options must be true or false"
93
+ end
94
+
95
+ policies = {timeout:, retries:, retry_on:, retry_delay:}.freeze
96
+ declaration = Node.new(
97
+ name:, assembly:, policies:,
98
+ inherit_history: history,
99
+ inherit_context: context
100
+ )
101
+ self.graph_nodes_value = graph_nodes_value.merge(name => declaration).freeze
102
+ end
103
+
104
+ # Reads or assigns the entry node.
105
+ def start(name = nil)
106
+ return graph_start_value if name.nil?
107
+
108
+ self.graph_start_value = normalize_node_name(name)
109
+ end
110
+
111
+ # Declares one exclusive route with an optional condition and input mapper.
112
+ def edge(from, to, input: nil, **options, &condition)
113
+ condition = extract_condition(options, condition)
114
+ validate_callable!(input, "edge input mapper")
115
+ declaration = Edge.new(
116
+ from: normalize_node_name(from),
117
+ to: normalize_node_name(to),
118
+ condition:,
119
+ input_mapper: input
120
+ )
121
+ self.graph_edges_value = (graph_edges_value + [declaration]).freeze
122
+ declaration
123
+ end
124
+
125
+ # Routes selected node errors after retries are exhausted.
126
+ def error_edge(from, to, on:, input: nil)
127
+ errors = Array(on)
128
+ unless errors.any? && errors.all? { |error| error.is_a?(Class) && error <= Exception }
129
+ raise ArgumentError, "error edge on: must contain exception classes"
130
+ end
131
+ validate_callable!(input, "error edge input mapper")
132
+ declaration = ErrorEdge.new(
133
+ from: normalize_node_name(from),
134
+ to: normalize_node_name(to),
135
+ errors: errors.freeze,
136
+ input_mapper: input
137
+ )
138
+ self.graph_error_edges_value = (graph_error_edges_value + [declaration]).freeze
139
+ declaration
140
+ end
141
+
142
+ # Starts two or more independent branches with bounded concurrency.
143
+ def fork(from, to:, max_concurrency: 8)
144
+ targets = Array(to).map { |name| normalize_node_name(name) }
145
+ raise ArgumentError, "fork requires at least two targets" if targets.length < 2
146
+ raise ArgumentError, "fork targets must be unique" unless targets.uniq.length == targets.length
147
+ max_concurrency = Integer(max_concurrency)
148
+ raise ArgumentError, "max_concurrency must be at least 1" if max_concurrency < 1
149
+
150
+ declaration = Fork.new(from: normalize_node_name(from), to: targets.freeze, max_concurrency:)
151
+ self.graph_forks_value = (graph_forks_value + [declaration]).freeze
152
+ declaration
153
+ end
154
+
155
+ # Joins the terminal results of one declared fork.
156
+ def join(from, to:, input: nil)
157
+ sources = Array(from).map { |name| normalize_node_name(name) }
158
+ raise ArgumentError, "join requires at least two sources" if sources.length < 2
159
+ raise ArgumentError, "join sources must be unique" unless sources.uniq.length == sources.length
160
+ validate_callable!(input, "join input mapper")
161
+ declaration = Join.new(
162
+ from: sources.freeze,
163
+ to: normalize_node_name(to),
164
+ input_mapper: input
165
+ )
166
+ self.graph_joins_value = (graph_joins_value + [declaration]).freeze
167
+ declaration
168
+ end
169
+
170
+ # Reads or assigns the terminal node.
171
+ def finish(name = nil)
172
+ return graph_finish_value if name.nil?
173
+
174
+ self.graph_finish_value = normalize_node_name(name)
175
+ end
176
+
177
+ # Reads or assigns the maximum node executions.
178
+ def max_steps(value = nil)
179
+ return graph_max_steps_value if value.nil?
180
+
181
+ value = Integer(value)
182
+ raise ArgumentError, "max_steps must be at least 1" if value < 1
183
+
184
+ self.graph_max_steps_value = value
185
+ end
186
+
187
+ # Validates the current topology and returns this Graph class.
188
+ def validate!
189
+ graph_definition!
190
+ self
191
+ end
192
+
193
+ def graph_definition! # :nodoc:
194
+ nodes = graph_nodes_value
195
+ start_name = graph_start_value
196
+ finish_name = graph_finish_value
197
+ raise ConfigurationError, "graph must declare at least one node" if nodes.empty?
198
+ raise ConfigurationError, "graph must declare a start node" unless start_name
199
+ raise ConfigurationError, "graph must declare a finish node" unless finish_name
200
+ validate_declared_node!(nodes, start_name, "start")
201
+ validate_declared_node!(nodes, finish_name, "finish")
202
+ nodes.each_value { |node| validate_step_policy!(node.policies) }
203
+
204
+ graph_edges_value.each do |declaration|
205
+ validate_declared_node!(nodes, declaration.from, "edge source")
206
+ validate_declared_node!(nodes, declaration.to, "edge target")
207
+ if declaration.from == finish_name
208
+ raise ConfigurationError, "graph finish node #{finish_name.inspect} cannot have outgoing edges"
209
+ end
210
+ end
211
+ graph_error_edges_value.each do |declaration|
212
+ validate_declared_node!(nodes, declaration.from, "error edge source")
213
+ validate_declared_node!(nodes, declaration.to, "error edge target")
214
+ end
215
+ graph_forks_value.each do |declaration|
216
+ validate_declared_node!(nodes, declaration.from, "fork source")
217
+ declaration.to.each { |target| validate_declared_node!(nodes, target, "fork target") }
218
+ if graph_forks_value.count { |fork| fork.from == declaration.from } > 1
219
+ raise ConfigurationError, "graph node #{declaration.from.inspect} has more than one fork"
220
+ end
221
+ end
222
+ graph_joins_value.each do |declaration|
223
+ declaration.from.each { |source| validate_declared_node!(nodes, source, "join source") }
224
+ validate_declared_node!(nodes, declaration.to, "join target")
225
+ end
226
+ validate_parallel_structure!
227
+ validate_success_routes!(nodes, finish_name)
228
+ validate_reachability!(nodes, start_name, finish_name)
229
+ [
230
+ nodes, graph_edges_value, graph_error_edges_value,
231
+ graph_forks_value, graph_joins_value, start_name, finish_name
232
+ ]
233
+ end
234
+
235
+ # Renders the validated topology as Mermaid flowchart text.
236
+ def to_mermaid
237
+ nodes, edges, error_edges, forks, joins, start_name, finish_name = graph_definition!
238
+ lines = ["flowchart TD"]
239
+ nodes.each_key { |name| lines << " #{mermaid_id(name)}[#{name}]" }
240
+ lines << " START((start)) --> #{mermaid_id(start_name)}"
241
+ lines << " #{mermaid_id(finish_name)} --> FINISH((finish))"
242
+ edges.each do |edge|
243
+ label = edge.condition ? "condition" : nil
244
+ lines << mermaid_edge(edge.from, edge.to, label:)
245
+ end
246
+ error_edges.each { |edge| lines << mermaid_edge(edge.from, edge.to, label: "error", dotted: true) }
247
+ forks.each do |fork|
248
+ fork.to.each { |target| lines << mermaid_edge(fork.from, target, label: "fork") }
249
+ end
250
+ joins.each do |join|
251
+ join.from.each { |source| lines << mermaid_edge(source, join.to, label: "join") }
252
+ end
253
+ lines.join("\n")
254
+ end
255
+
256
+ private
257
+
258
+ def extract_condition(options, block)
259
+ if options.key?(:if)
260
+ raise ArgumentError, "provide an edge condition with if: or a block, not both" if block
261
+
262
+ block = options.delete(:if)
263
+ end
264
+ raise ArgumentError, "unknown edge options: #{options.keys.join(", ")}" unless options.empty?
265
+ validate_callable!(block, "edge condition")
266
+ block
267
+ end
268
+
269
+ def validate_callable!(value, label)
270
+ raise ArgumentError, "#{label} must respond to call" if value && !value.respond_to?(:call)
271
+ end
272
+
273
+ def validate_declared_node!(nodes, name, label)
274
+ raise ConfigurationError, "graph #{label} #{name.inspect} is not declared" unless nodes.key?(name)
275
+ end
276
+
277
+ def validate_parallel_structure!
278
+ used = Set.new
279
+ graph_forks_value.each do |fork|
280
+ overlap = fork.to.select { |target| used.include?(target) }
281
+ raise ConfigurationError, "graph fork branches overlap at #{overlap.first.inspect}" if overlap.any?
282
+
283
+ used.merge(fork.to)
284
+ end
285
+ graph_forks_value.each do |fork|
286
+ if graph_edges_value.any? { |edge| edge.from == fork.from }
287
+ raise ConfigurationError, "graph fork node #{fork.from.inspect} cannot also declare ordinary edges"
288
+ end
289
+ matches = matching_joins_for(fork)
290
+ unless matches.one?
291
+ detail = matches.empty? ? "no matching join" : "more than one matching join"
292
+ raise ConfigurationError, "graph fork at #{fork.from.inspect} has #{detail}"
293
+ end
294
+ end
295
+ graph_joins_value.each do |join|
296
+ matches = graph_forks_value.select { |fork| matching_joins_for(fork).include?(join) }
297
+ unless matches.one?
298
+ detail = matches.empty? ? "no matching fork" : "more than one matching fork"
299
+ raise ConfigurationError, "graph join to #{join.to.inspect} has #{detail}"
300
+ end
301
+ end
302
+ validate_no_nested_forks!
303
+ end
304
+
305
+ def validate_no_nested_forks!
306
+ adjacency = Hash.new { |hash, key| hash[key] = [] }
307
+ graph_edges_value.each { |edge| adjacency[edge.from] << edge.to }
308
+ graph_forks_value.each do |outer|
309
+ join = matching_join_for(outer)
310
+ outer.to.each do |target|
311
+ reachable = reachable_nodes(target, adjacency, stop_at: join.from)
312
+ nested = graph_forks_value.find { |candidate| candidate != outer && reachable.include?(candidate.from) }
313
+ if nested
314
+ raise ConfigurationError, "graph fork #{nested.from.inspect} cannot be nested inside another fork branch"
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ def reachable_nodes(from, adjacency, stop_at: [])
321
+ seen = Set.new
322
+ queue = [from]
323
+ until queue.empty?
324
+ current = queue.shift
325
+ next unless seen.add?(current)
326
+ next if stop_at.include?(current)
327
+
328
+ queue.concat(adjacency[current])
329
+ end
330
+ seen
331
+ end
332
+
333
+ def matching_join_for(fork)
334
+ matching_joins_for(fork).first
335
+ end
336
+
337
+ def matching_joins_for(fork)
338
+ adjacency = Hash.new { |hash, key| hash[key] = [] }
339
+ graph_edges_value.each { |edge| adjacency[edge.from] << edge.to }
340
+ graph_error_edges_value.each { |edge| adjacency[edge.from] << edge.to }
341
+ graph_joins_value.select do |join|
342
+ assignments = fork.to.map do |target|
343
+ join.from.select { |source| reachable?(target, source, adjacency) }
344
+ end
345
+ assignments.all?(&:one?) && assignments.flatten.uniq.length == fork.to.length
346
+ end
347
+ end
348
+
349
+ def validate_success_routes!(nodes, finish_name)
350
+ nodes.each_key do |name|
351
+ next if name == finish_name
352
+ next if graph_edges_value.any? { |edge| edge.from == name }
353
+ next if graph_forks_value.any? { |fork| fork.from == name }
354
+ next if graph_joins_value.any? { |join| join.from.include?(name) }
355
+
356
+ raise ConfigurationError, "graph node #{name.inspect} has no successful outgoing route"
357
+ end
358
+ end
359
+
360
+ def reachable?(from, to, adjacency)
361
+ seen = Set.new
362
+ queue = [from]
363
+ until queue.empty?
364
+ current = queue.shift
365
+ next unless seen.add?(current)
366
+ return true if current == to
367
+
368
+ queue.concat(adjacency[current])
369
+ end
370
+ false
371
+ end
372
+
373
+ def validate_reachability!(nodes, start_name, finish_name)
374
+ adjacency = Hash.new { |hash, key| hash[key] = [] }
375
+ graph_edges_value.each { |edge| adjacency[edge.from] << edge.to }
376
+ graph_error_edges_value.each { |edge| adjacency[edge.from] << edge.to }
377
+ graph_forks_value.each { |fork| adjacency[fork.from].concat(fork.to) }
378
+ graph_joins_value.each { |join| join.from.each { |source| adjacency[source] << join.to } }
379
+ reachable = Set.new
380
+ queue = [start_name]
381
+ until queue.empty?
382
+ current = queue.shift
383
+ next unless reachable.add?(current)
384
+
385
+ queue.concat(adjacency[current])
386
+ end
387
+ unreachable = nodes.keys.reject { |name| reachable.include?(name) }
388
+ raise ConfigurationError, "graph has unreachable node #{unreachable.first.inspect}" if unreachable.any?
389
+ raise ConfigurationError, "graph finish node #{finish_name.inspect} is unreachable" unless reachable.include?(finish_name)
390
+ end
391
+
392
+ def normalize_node_name(value)
393
+ value = value.to_sym
394
+ raise ArgumentError, "graph node name cannot be empty" if value.to_s.empty?
395
+
396
+ value
397
+ rescue NoMethodError
398
+ raise ArgumentError, "graph node name must be a String or Symbol"
399
+ end
400
+
401
+ def mermaid_id(name) = "n_#{name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")}"
402
+
403
+ def mermaid_edge(from, to, label: nil, dotted: false)
404
+ connector = dotted ? "-.->" : "-->"
405
+ annotation = label ? "|#{label}|" : ""
406
+ " #{mermaid_id(from)} #{connector}#{annotation} #{mermaid_id(to)}"
407
+ end
408
+ end
409
+
410
+ def initialize(run: nil, runtime: nil) # :nodoc:
411
+ super(run:, runtime:, standalone: run.nil?)
412
+ @graph_mutex = Mutex.new
413
+ @graph_started = false
414
+ @graph_children = []
415
+ @graph_execution_count = 0
416
+ end
417
+
418
+ # Streams lifecycle events and the finish node's ordinary response events.
419
+ def stream(input = nil, history: nil, context: nil,
420
+ cancellation_token: Support::CancellationToken.new, deadline: nil,
421
+ settings: nil, template_locals: nil, template_paths: nil,
422
+ parent_operation_id: nil, checkpoint: nil, **_options)
423
+ raise ArgumentError, "input is required" if input.nil?
424
+ if standalone?
425
+ return build_run(entrypoint_payload(input, {
426
+ history:, context:, settings:, template_paths:,
427
+ deadline_at: deadline, cancellation_token:
428
+ }.compact)).each
429
+ end
430
+
431
+ reserve_execution!
432
+ original_input = input.is_a?(Message) ? input : Message.new(role: :user, content: input)
433
+ original_history = normalize_history(history)
434
+ original_context = frozen_state(context || {})
435
+ settings ||= {}
436
+ template_locals ||= {}
437
+ template_paths ||= []
438
+
439
+ usage = Usage.new
440
+ error_emitted = false
441
+ Enumerator.new do |events|
442
+ definition = self.class.graph_definition!
443
+ nodes, edges, error_edges, forks, _joins, current, finish = definition
444
+ results = {}
445
+ steps = []
446
+ previous = nil
447
+ incoming_edge = nil
448
+ join_context = nil
449
+ routed_error = nil
450
+ previous_step_id = nil
451
+
452
+ loop do
453
+ count = next_graph_step!(cancellation_token, deadline)
454
+ state = routing_state(
455
+ input: original_input, history: original_history, context: original_context,
456
+ step: count, current:, previous:, results:,
457
+ predecessors: join_context&.fetch(:predecessors, []),
458
+ branch_results: join_context&.fetch(:results, {}) || {},
459
+ error: routed_error
460
+ )
461
+ node_input = if join_context
462
+ join_input_for(state, join_context.fetch(:join))
463
+ else
464
+ node_input_for(state, incoming_edge)
465
+ end
466
+ terminal = current == finish
467
+ begin
468
+ execution = execute_graph_node(
469
+ node: nodes.fetch(current), input: node_input, history: original_history,
470
+ context: original_context, cancellation_token:, deadline:, settings:,
471
+ template_locals:, template_paths:, parent_operation_id:,
472
+ predecessor_ids: join_context&.fetch(:step_ids, []) || Array(previous_step_id),
473
+ terminal:, events:
474
+ )
475
+ rescue => error
476
+ route = select_error_edge(error_edges.select { |edge| edge.from == current }, error)
477
+ raise unless route
478
+
479
+ usage += step_error_usage(error)
480
+ error.instance_variable_set(:@little_ghost_step_usage_accounted, true)
481
+ failed = failed_step(
482
+ error,
483
+ nodes.fetch(current),
484
+ current,
485
+ predecessor_ids: Array(previous_step_id)
486
+ )
487
+ steps << failed
488
+ previous_step_id = failed.id
489
+ incoming_edge = Edge.new(from: current, to: route.to, condition: nil, input_mapper: route.input_mapper)
490
+ events << transition_event(count, current, route.to, error: true)
491
+ previous = current
492
+ current = route.to
493
+ join_context = nil
494
+ routed_error = error
495
+ next
496
+ end
497
+
498
+ results[current] = execution.result
499
+ previous_step_id = execution.step.id
500
+ usage += execution.step.usage
501
+ steps.concat(execution.result.steps)
502
+ if terminal
503
+ final = copy_run_result(execution.result, usage:, steps: steps.freeze)
504
+ execution.events.each do |event|
505
+ event = StreamEvent.build(event.type, **event.data.merge(result: final)) if event.type == :invocation_stop
506
+ error_emitted = true if event.type == :invocation_error
507
+ events << event
508
+ end
509
+ break
510
+ end
511
+
512
+ fork = forks.find { |declaration| declaration.from == current }
513
+ if fork
514
+ join = self.class.send(:matching_join_for, fork)
515
+ events << StreamEvent.build(
516
+ :assembly_fork,
517
+ assembly_id: self.class.assembly_id,
518
+ assembly_kind: :graph,
519
+ from: current,
520
+ branches: fork.to
521
+ )
522
+ branch_outputs = run_graph_branches(
523
+ fork:, join:, nodes:, edges:, error_edges:,
524
+ original_input:, original_history:, original_context:,
525
+ results:, cancellation_token:, deadline:, settings:,
526
+ template_locals:, template_paths:, parent_operation_id:, events:
527
+ )
528
+ unless join.from.sort == branch_outputs.map(&:terminal).sort
529
+ raise AssemblyRoutingError, "graph fork at #{current.inspect} did not reach its declared join"
530
+ end
531
+
532
+ branch_outputs.each do |branch|
533
+ results.merge!(branch.results)
534
+ steps.concat(branch.steps)
535
+ usage += branch.usage
536
+ branch.events.each { |event| events << event }
537
+ end
538
+ events << StreamEvent.build(
539
+ :assembly_join,
540
+ assembly_id: self.class.assembly_id,
541
+ assembly_kind: :graph,
542
+ from: join.from,
543
+ to: join.to
544
+ )
545
+ previous = current
546
+ current = join.to
547
+ incoming_edge = nil
548
+ join_context = {
549
+ join:,
550
+ predecessors: join.from,
551
+ step_ids: branch_outputs.map { |branch| branch.steps.last.id },
552
+ results: join.from.to_h { |name| [name, results.fetch(name)] }
553
+ }
554
+ previous_step_id = nil
555
+ next
556
+ end
557
+
558
+ state = routing_state(
559
+ input: original_input, history: original_history, context: original_context,
560
+ step: count, current:, previous:, results:
561
+ )
562
+ selected = select_edge(edges.select { |edge| edge.from == current }, state)
563
+ events << transition_event(count, current, selected.to)
564
+ previous = current
565
+ current = selected.to
566
+ incoming_edge = selected
567
+ join_context = nil
568
+ routed_error = nil
569
+ end
570
+ rescue => error
571
+ usage += unaccounted_step_error_usage(error)
572
+ unless error_emitted
573
+ events << StreamEvent.build(:invocation_error, error:, usage:, metadata: {})
574
+ end
575
+ raise
576
+ end
577
+ end
578
+
579
+ def close
580
+ children = @graph_mutex.synchronize { @graph_children.reverse }
581
+ first_error = nil
582
+ children.each do |child|
583
+ child.close
584
+ rescue => error
585
+ first_error ||= error
586
+ end
587
+ super
588
+ raise first_error if first_error
589
+ end
590
+
591
+ private
592
+
593
+ def execute_graph_node(node:, input:, history:, context:, cancellation_token:, deadline:,
594
+ settings:, template_locals:, template_paths:, parent_operation_id:,
595
+ predecessor_ids:, terminal:, events:, branch_id: nil)
596
+ step_id = SecureRandom.uuid
597
+ events << StreamEvent.build(
598
+ :assembly_step_start,
599
+ assembly_id: self.class.assembly_id,
600
+ assembly_kind: :graph,
601
+ participant: node.name,
602
+ branch_id:,
603
+ step_id:
604
+ )
605
+ execution = execute_assembly_step(
606
+ reference: node.assembly,
607
+ participant: node.name,
608
+ input:,
609
+ history: node.inherit_history ? history : [],
610
+ context: node.inherit_context ? context : {},
611
+ cancellation_token:, deadline:, settings:,
612
+ template_locals:, template_paths:, parent_operation_id:,
613
+ policies: node.policies,
614
+ predecessor_ids:,
615
+ branch_id:,
616
+ checkpoint: nil,
617
+ step_id:
618
+ ) { |event| events << event }
619
+ events << StreamEvent.build(
620
+ :assembly_step_stop,
621
+ assembly_id: self.class.assembly_id,
622
+ assembly_kind: :graph,
623
+ participant: node.name,
624
+ step_id: execution.step.id,
625
+ branch_id:,
626
+ usage: execution.step.usage
627
+ )
628
+ execution
629
+ end
630
+
631
+ def run_graph_branches(fork:, join:, nodes:, edges:, error_edges:, original_input:,
632
+ original_history:, original_context:, results:, cancellation_token:, deadline:,
633
+ settings:, template_locals:, template_paths:, parent_operation_id:, events:)
634
+ token = cancellation_token.child
635
+ queue = SizedQueue.new(1_000)
636
+ worker = Thread.new do
637
+ completed = []
638
+ results = Support::Executor.new(max_concurrency: fork.max_concurrency).map(
639
+ fork.to,
640
+ cancellation_token: token,
641
+ on_result: ->(_index, result) { completed << result }
642
+ ) do |start|
643
+ run_graph_branch(
644
+ start:, join:, nodes:, edges:, error_edges:, original_input:,
645
+ original_history:, original_context:, parent_results: results,
646
+ cancellation_token: token, deadline:, settings:, template_locals:,
647
+ template_paths:, parent_operation_id:,
648
+ event_consumer: ->(event) { enqueue_assembly_event(queue, [:event, event], token) }
649
+ )
650
+ end
651
+ enqueue_assembly_event(queue, [:done, results], token)
652
+ rescue => error
653
+ token.cancel
654
+ partial_usage = completed.sum(Usage.new) { |result| result.usage }
655
+ partial_usage += step_error_usage(error)
656
+ error.instance_variable_set(:@little_ghost_step_usage, partial_usage)
657
+ enqueue_assembly_terminal(queue, [:error, error])
658
+ end
659
+ loop do
660
+ type, value = queue.pop
661
+ events << value if type == :event
662
+ raise value if type == :error
663
+ break value if type == :done
664
+ end
665
+ ensure
666
+ token&.cancel
667
+ worker&.join
668
+ end
669
+
670
+ def run_graph_branch(start:, join:, nodes:, edges:, error_edges:, original_input:,
671
+ original_history:, original_context:, parent_results:, cancellation_token:,
672
+ deadline:, settings:, template_locals:, template_paths:, parent_operation_id:, event_consumer:)
673
+ current = start
674
+ previous = nil
675
+ incoming_edge = nil
676
+ local_results = {}
677
+ local_steps = []
678
+ local_usage = Usage.new
679
+ local_events = EventSink.new(event_consumer)
680
+ join_sources = join.from.to_set
681
+ terminal = nil
682
+ previous_step_id = nil
683
+ routed_error = nil
684
+
685
+ loop do
686
+ step_number = next_graph_step!(cancellation_token, deadline)
687
+ all_results = parent_results.merge(local_results)
688
+ state = routing_state(
689
+ input: original_input, history: original_history, context: original_context,
690
+ step: step_number, current:, previous:, results: all_results, error: routed_error
691
+ )
692
+ begin
693
+ execution = execute_graph_node(
694
+ node: nodes.fetch(current), input: node_input_for(state, incoming_edge),
695
+ history: original_history, context: original_context, cancellation_token:,
696
+ deadline:, settings:, template_locals:, template_paths:, parent_operation_id:,
697
+ predecessor_ids: Array(previous_step_id),
698
+ terminal: false, events: local_events, branch_id: start
699
+ )
700
+ rescue => error
701
+ route = select_error_edge(error_edges.select { |edge| edge.from == current }, error)
702
+ raise unless route
703
+
704
+ local_usage += step_error_usage(error)
705
+ error.instance_variable_set(:@little_ghost_step_usage_accounted, true)
706
+ failed = failed_step(
707
+ error,
708
+ nodes.fetch(current),
709
+ current,
710
+ branch_id: start,
711
+ predecessor_ids: Array(previous_step_id)
712
+ )
713
+ local_steps << failed
714
+ previous_step_id = failed.id
715
+ previous = current
716
+ current = route.to
717
+ incoming_edge = Edge.new(from: previous, to: current, condition: nil, input_mapper: route.input_mapper)
718
+ routed_error = error
719
+ next
720
+ end
721
+ local_results[current] = execution.result
722
+ previous_step_id = execution.step.id
723
+ local_steps.concat(execution.result.steps)
724
+ local_usage += execution.step.usage
725
+ terminal = current
726
+ break if join_sources.include?(terminal)
727
+
728
+ state = routing_state(
729
+ input: original_input, history: original_history, context: original_context,
730
+ step: step_number, current:, previous:, results: parent_results.merge(local_results)
731
+ )
732
+ selected = select_edge(edges.select { |edge| edge.from == current }, state)
733
+ local_events << transition_event(step_number, current, selected.to, branch_id: start)
734
+ previous = current
735
+ current = selected.to
736
+ incoming_edge = selected
737
+ routed_error = nil
738
+ end
739
+ BranchResult.new(
740
+ terminal:, results: local_results.freeze, steps: local_steps.freeze,
741
+ usage: local_usage, events: [].freeze
742
+ )
743
+ rescue => error
744
+ error.instance_variable_set(
745
+ :@little_ghost_step_usage,
746
+ local_usage + step_error_usage(error)
747
+ )
748
+ raise
749
+ end
750
+
751
+ def reserve_execution!
752
+ @graph_mutex.synchronize do
753
+ raise Error, "graph instances can only be streamed once" if @graph_started
754
+
755
+ @graph_started = true
756
+ end
757
+ end
758
+
759
+ def next_graph_step!(token, deadline)
760
+ token.raise_if_cancelled!
761
+ raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
762
+
763
+ @graph_mutex.synchronize do
764
+ if @graph_execution_count >= self.class.max_steps
765
+ raise AssemblyLimitError, "#{self.class} reached its max_steps limit of #{self.class.max_steps}"
766
+ end
767
+
768
+ @graph_execution_count += 1
769
+ end
770
+ end
771
+
772
+ def node_input_for(state, incoming_edge)
773
+ return state.input unless state.previous
774
+ return incoming_edge.input_mapper.call(state) if incoming_edge&.input_mapper
775
+
776
+ result = state.previous_result
777
+ if state.error && !result
778
+ return Message.new(
779
+ role: :user,
780
+ content: state.input.content + [Content::Text.new(text: "\n\n#{state.previous} failed: #{state.error.class.name}")]
781
+ )
782
+ end
783
+ label = state.previous.to_s.tr("_", " ")
784
+ Message.new(
785
+ role: :user,
786
+ content: state.input.content + [Content::Text.new(text: "\n\n#{label} output:\n#{output_text(result.output)}")]
787
+ )
788
+ end
789
+
790
+ def join_input_for(state, join)
791
+ return join.input_mapper.call(state) if join.input_mapper
792
+
793
+ additions = join.from.map do |name|
794
+ "\n\n#{name.to_s.tr("_", " ")} output:\n#{output_text(state.branch_results.fetch(name).output)}"
795
+ end.join
796
+ Message.new(role: :user, content: state.input.content + [Content::Text.new(text: additions)])
797
+ end
798
+
799
+ def output_text(output)
800
+ output.is_a?(String) ? output : JSON.generate(output)
801
+ rescue JSON::GeneratorError
802
+ output.to_s
803
+ end
804
+
805
+ def select_edge(candidates, state)
806
+ raise AssemblyRoutingError, "graph node #{state.current.inspect} has no outgoing edge" if candidates.empty?
807
+
808
+ conditional = candidates.select { |edge| edge.condition&.call(state) }
809
+ if conditional.length > 1
810
+ raise AssemblyRoutingError, "graph node #{state.current.inspect} matched more than one conditional edge"
811
+ end
812
+ return conditional.first if conditional.one?
813
+
814
+ fallback = candidates.reject(&:condition)
815
+ if fallback.length > 1
816
+ raise AssemblyRoutingError, "graph node #{state.current.inspect} has more than one unconditional edge"
817
+ end
818
+ return fallback.first if fallback.one?
819
+
820
+ raise AssemblyRoutingError, "graph node #{state.current.inspect} did not match an outgoing edge"
821
+ end
822
+
823
+ def select_error_edge(candidates, error)
824
+ return nil if error.is_a?(CancelledError) || error.is_a?(DeadlineExceededError) || error.is_a?(CleanupError)
825
+
826
+ matches = candidates.select { |edge| edge.errors.any? { |type| error.is_a?(type) } }
827
+ raise AssemblyRoutingError, "graph error matched more than one error edge" if matches.length > 1
828
+
829
+ matches.first
830
+ end
831
+
832
+ def failed_step(error, node, participant, branch_id: nil, predecessor_ids: [])
833
+ Step.new(
834
+ id: error.instance_variable_get(:@little_ghost_step_id) || SecureRandom.uuid,
835
+ participant:,
836
+ assembly_id: error.instance_variable_get(:@little_ghost_step_assembly_id) || assembly_reference_id(node.assembly),
837
+ assembly_kind: error.instance_variable_get(:@little_ghost_step_assembly_kind) || :assembly,
838
+ branch_id:,
839
+ predecessor_ids:,
840
+ status: :failed,
841
+ attempts: error.instance_variable_get(:@little_ghost_step_attempts) || [],
842
+ usage: step_error_usage(error)
843
+ )
844
+ end
845
+
846
+ def step_error_usage(error) = error.instance_variable_get(:@little_ghost_step_usage) || Usage.new
847
+
848
+ def unaccounted_step_error_usage(error)
849
+ return Usage.new if error.instance_variable_get(:@little_ghost_step_usage_accounted)
850
+
851
+ step_error_usage(error)
852
+ end
853
+
854
+ def assembly_reference_id(reference)
855
+ return reference.assembly_id if reference.respond_to?(:assembly_id)
856
+ return reference.assembly_id if reference.is_a?(Class) && reference <= Assembly
857
+
858
+ reference.to_s
859
+ end
860
+
861
+ def transition_event(step, from, to, error: false, branch_id: nil)
862
+ StreamEvent.build(
863
+ :assembly_transition,
864
+ assembly_id: self.class.assembly_id,
865
+ assembly_kind: :graph,
866
+ step:, from:, to:, error:, branch_id:
867
+ )
868
+ end
869
+
870
+ def normalize_history(value)
871
+ return [].freeze if value.nil?
872
+
873
+ Array(value).map { |message| Message.coerce(message) }.freeze
874
+ end
875
+
876
+ def frozen_state(value)
877
+ value = isolated_assembly_state(value)
878
+ deep_freeze_assembly_value(value)
879
+ end
880
+
881
+ def routing_state(input:, history:, context:, step:, current:, previous:, results:,
882
+ predecessors: [], branch_results: {}, error: nil)
883
+ immutable_results = results.to_h { |name, result| [name, immutable_result(result)] }
884
+ immutable_branches = branch_results.to_h { |name, result| [name, immutable_result(result)] }
885
+ State.new(
886
+ input:, history:, context:, step:, current:, previous:,
887
+ predecessors:, results: immutable_results,
888
+ branch_results: immutable_branches, error:
889
+ )
890
+ end
891
+
892
+ def immutable_result(result)
893
+ structured = result.structured_result
894
+ if structured
895
+ structured = StructuredResult.new(
896
+ schema_name: structured.schema_name,
897
+ value: frozen_state(structured.value)
898
+ )
899
+ end
900
+ RunResult.new(
901
+ message: result.message,
902
+ stop_reason: result.stop_reason,
903
+ usage: result.usage,
904
+ messages: result.messages.dup.freeze,
905
+ state: frozen_state(result.state),
906
+ structured_result: structured,
907
+ steps: result.steps
908
+ )
909
+ end
910
+ end
911
+ end