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.
- checksums.yaml +4 -4
- data/README.md +19 -5
- data/docs/guides/core_concepts.md +167 -38
- data/docs/guides/getting_started.md +8 -4
- data/lib/little_ghost/agent/delegation.rb +34 -7
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +115 -156
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/assembly.rb +311 -0
- data/lib/little_ghost/assembly_builder.rb +459 -0
- data/lib/little_ghost/assembly_execution.rb +452 -0
- data/lib/little_ghost/errors.rb +8 -0
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +911 -0
- data/lib/little_ghost/run.rb +120 -32
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +7 -2
- data/lib/little_ghost/runtime.rb +64 -6
- data/lib/little_ghost/sandbox.rb +1 -1
- data/lib/little_ghost/support/executor.rb +14 -2
- data/lib/little_ghost/support/loader.rb +2 -2
- data/lib/little_ghost/support.rb +15 -3
- data/lib/little_ghost/swarm.rb +431 -0
- data/lib/little_ghost/tool.rb +32 -6
- data/lib/little_ghost/tracing/open_telemetry.rb +13 -2
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +198 -73
- data/lib/little_ghost.rb +11 -4
- metadata +11 -4
|
@@ -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
|