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