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,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # An immutable, executable snapshot produced by an AssemblyBuilder.
5
+ AssemblyDefinition = Data.define(:kind, :assembly_id, :description, :implementation) do
6
+ def initialize(kind:, assembly_id:, description:, implementation:)
7
+ super(
8
+ kind: kind.to_sym,
9
+ assembly_id: assembly_id.to_s.dup.freeze,
10
+ description: description.to_s.dup.freeze,
11
+ implementation:
12
+ )
13
+ end
14
+ end
15
+
16
+ # Builds an Assembly when its participants or routes are discovered at runtime.
17
+ #
18
+ # Class definitions are the usual, easier-to-find way to declare behavior.
19
+ # Builders expose the underlying dynamic form while preserving the same
20
+ # +ask+, +stream_ask+, +call+, and +stream+ interface:
21
+ #
22
+ # graph = LittleGhost::GraphBuilder.new(id: "support_flow")
23
+ # graph.node :triage, TriageAgent
24
+ # graph.node :respond, CustomerSupportAgent
25
+ # graph.start :triage
26
+ # graph.edge :triage, :respond
27
+ # graph.finish :respond
28
+ # graph.validate!
29
+ #
30
+ # run = graph.ask("Can I get a refund?")
31
+ #
32
+ # A builder remains mutable. Each build or invocation snapshots declaration
33
+ # containers and referenced Assembly definitions, so later builder changes
34
+ # affect only future executions. Executable Ruby closures and the external
35
+ # objects they reference remain live trusted application code.
36
+ class AssemblyBuilder
37
+ # Optional Runtime reused by standalone executions from this builder.
38
+ attr_reader :runtime
39
+
40
+ # Creates a mutable builder with optional identity, runtime, and base class.
41
+ def initialize(id: nil, description: nil, runtime: nil, base: nil)
42
+ @mutex = Mutex.new
43
+ @assembly_id = id&.to_s&.dup&.freeze
44
+ @description = description&.to_s&.dup&.freeze
45
+ @runtime = runtime
46
+ @base = base
47
+ end
48
+
49
+ # Reads or assigns the stable Assembly identifier.
50
+ def assembly_id(value = nil)
51
+ return @mutex.synchronize { @assembly_id || default_assembly_id } if value.nil?
52
+
53
+ @mutex.synchronize { @assembly_id = String(value).dup.freeze }
54
+ self
55
+ end
56
+
57
+ # Reads or assigns the human-readable description.
58
+ def description(value = nil)
59
+ return @mutex.synchronize { @description || base_description } if value.nil?
60
+
61
+ @mutex.synchronize { @description = String(value).dup.freeze }
62
+ self
63
+ end
64
+
65
+ # Returns +:agent+, +:workflow+, +:swarm+, or +:graph+.
66
+ def assembly_kind = self.class.assembly_kind
67
+
68
+ # Validates the current snapshot and returns this mutable builder.
69
+ def validate!
70
+ definition
71
+ self
72
+ end
73
+
74
+ # Returns an immutable, recursively snapshotted definition.
75
+ def definition
76
+ stack = Thread.current[:little_ghost_assembly_definition_stack] ||= []
77
+ identity = base || self
78
+ if stack.include?(identity)
79
+ raise ConfigurationError, "assembly definitions cannot contain themselves recursively"
80
+ end
81
+ stack << identity
82
+ state = @mutex.synchronize { snapshot_state }
83
+ implementation = build_implementation(state)
84
+ prepare_implementation!(implementation)
85
+ validate_implementation!(implementation)
86
+ seal_implementation!(implementation)
87
+ implementation.freeze
88
+ AssemblyDefinition.new(
89
+ kind: assembly_kind,
90
+ assembly_id: implementation.assembly_id,
91
+ description: implementation.description,
92
+ implementation:
93
+ )
94
+ ensure
95
+ stack&.pop if stack&.last.equal?(identity)
96
+ Thread.current[:little_ghost_assembly_definition_stack] = nil if stack && stack.empty?
97
+ end
98
+
99
+ # Builds one execution instance from the current definition snapshot.
100
+ def build(runtime: self.runtime, run: nil)
101
+ snapshot = definition
102
+ return (runtime || run.runtime).build_assembly(snapshot, run:) if run
103
+
104
+ snapshot.implementation.new(runtime:)
105
+ end
106
+
107
+ # Executes a standalone snapshot and returns its Run.
108
+ def ask(message, **options) = build.ask(message, **options)
109
+
110
+ # Lazily streams a standalone snapshot.
111
+ def stream_ask(message, **options)
112
+ snapshot = definition
113
+ Enumerator.new do |events|
114
+ snapshot.implementation.new(runtime:).stream_ask(message, **options).each { |event| events << event }
115
+ end
116
+ end
117
+
118
+ # Executes a snapshot to completion.
119
+ def call(input = nil, **options) = build.call(input, **options)
120
+ # Streams a snapshot as StreamEvent objects.
121
+ def stream(input = nil, **options) = build.stream(input, **options)
122
+ # Starts a supervised execution from a snapshot.
123
+ def start_execution(payload, &block) = build.start_execution(payload, &block)
124
+ # Exposes a snapshot as a Tool.
125
+ def as_tool(**options) = build.as_tool(**options)
126
+
127
+ protected
128
+
129
+ attr_reader :base
130
+
131
+ def snapshot_state
132
+ {
133
+ assembly_id: @assembly_id || default_assembly_id,
134
+ description: @description || base_description,
135
+ base:
136
+ }
137
+ end
138
+
139
+ def configure_implementation(implementation, state)
140
+ implementation.assembly_id(state.fetch(:assembly_id))
141
+ implementation.description(state.fetch(:description))
142
+ implementation
143
+ end
144
+
145
+ def validate_implementation!(_implementation) = nil
146
+
147
+ def prepare_implementation!(_implementation) = nil
148
+
149
+ def snapshot_mutators = %i[assembly_id description]
150
+
151
+ def seal_implementation!(implementation)
152
+ mutators = snapshot_mutators + implementation.singleton_methods.grep(/=\z/)
153
+ implementation.instance_variables.each do |name|
154
+ value = implementation.instance_variable_get(name)
155
+ implementation.instance_variable_set(name, deep_freeze_snapshot_value(value))
156
+ end
157
+ implementation.methods.grep(/_value\z/).each do |reader|
158
+ writer = :"#{reader}="
159
+ next unless implementation.respond_to?(writer)
160
+
161
+ implementation.public_send(writer, deep_freeze_snapshot_value(implementation.public_send(reader)))
162
+ mutators << writer
163
+ end
164
+ mutators.uniq.each do |name|
165
+ implementation.define_singleton_method(name) do |*arguments, **keywords, &block|
166
+ if !name.to_s.end_with?("=") && arguments.empty? && keywords.empty? && !block
167
+ super()
168
+ else
169
+ raise FrozenError, "can't modify immutable Assembly definition"
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ def deep_freeze_snapshot_value(value)
176
+ case value
177
+ when Hash
178
+ value.each do |key, child|
179
+ deep_freeze_snapshot_value(key)
180
+ deep_freeze_snapshot_value(child)
181
+ end
182
+ value.freeze
183
+ when Array
184
+ value.each { |child| deep_freeze_snapshot_value(child) }
185
+ value.freeze
186
+ when String
187
+ value.freeze
188
+ when Data
189
+ value.members.each { |member| deep_freeze_snapshot_value(value.public_send(member)) }
190
+ value.freeze
191
+ else
192
+ value
193
+ end
194
+ end
195
+
196
+ def snapshot_reference(value)
197
+ return value.definition if value.is_a?(AssemblyBuilder)
198
+ return value.definition if value.is_a?(Class) && value <= Assembly
199
+
200
+ value
201
+ end
202
+
203
+ def snapshot_base_class(value)
204
+ return unless value
205
+
206
+ snapshot = value.dup
207
+ source_name = value.name
208
+ snapshot.define_singleton_method(:name) { source_name } if source_name
209
+ snapshot.define_singleton_method(:assembly_source_class) { value }
210
+ value.instance_variables.each do |name|
211
+ snapshot.instance_variable_set(name, Support.deep_dup(value.instance_variable_get(name)))
212
+ end
213
+ value.methods.grep(/_value\z/).each do |reader|
214
+ writer = :"#{reader}="
215
+ next unless snapshot.respond_to?(writer)
216
+
217
+ snapshot.public_send(writer, Support.deep_dup(value.public_send(reader)))
218
+ end
219
+ snapshot.assembly_id(value.assembly_id)
220
+ snapshot.description(value.description)
221
+ if value <= Agent
222
+ path = value.logical_path
223
+ snapshot.define_singleton_method(:logical_path) { path }
224
+ end
225
+ snapshot
226
+ end
227
+
228
+ def base_description = base&.description.to_s
229
+ def default_assembly_id = base&.assembly_id || assembly_kind.to_s
230
+ end
231
+
232
+ # Builds an Agent definition from declarations made at runtime.
233
+ # Supported Agent class-DSL calls are recorded and replayed into each
234
+ # immutable snapshot.
235
+ class AgentBuilder < AssemblyBuilder
236
+ DECLARATIONS = %i[
237
+ model limits result_schema capture_diagnostics system_template system_prompt
238
+ tools prompt_local after_initialize before_invocation after_invocation
239
+ before_model after_model after_model_error before_tool after_tool
240
+ manage_context detect_tool_loops skills subagent subagents
241
+ subagent_long_poll_duration agent_as_tool agents_as_tools
242
+ assembly_as_tool assemblies_as_tools
243
+ ].freeze # :nodoc:
244
+
245
+ class << self
246
+ # :nodoc:
247
+ def assembly_kind = :agent
248
+ end
249
+
250
+ # Creates a mutable dynamic Agent definition.
251
+ def initialize(**options)
252
+ super
253
+ @operations = []
254
+ end
255
+
256
+ # Reads or assigns the Agent identifier.
257
+ def agent_id(value = nil)
258
+ value.nil? ? assembly_id : assembly_id(value)
259
+ end
260
+
261
+ # Records supported Agent class-DSL declarations for the next snapshot.
262
+ def method_missing(name, *arguments, **keywords, &block)
263
+ return super unless DECLARATIONS.include?(name)
264
+
265
+ @mutex.synchronize { @operations << [name, arguments, keywords, block] }
266
+ self
267
+ end
268
+
269
+ # Reports the declarative methods supported by Agent.
270
+ def respond_to_missing?(name, include_private = false)
271
+ DECLARATIONS.include?(name) || super
272
+ end
273
+
274
+ protected
275
+
276
+ def snapshot_mutators = super + [:agent_id, *DECLARATIONS]
277
+
278
+ def snapshot_state
279
+ operations = @operations.map do |name, arguments, keywords, block|
280
+ [name, Support.deep_dup(arguments).freeze, Support.deep_dup(keywords).freeze, block].freeze
281
+ end
282
+ super.merge(operations: operations.freeze)
283
+ end
284
+
285
+ def build_implementation(state)
286
+ implementation = snapshot_base_class(state.fetch(:base)) || Class.new(Agent)
287
+ configure_implementation(implementation, state)
288
+ state.fetch(:operations).each do |name, arguments, keywords, block|
289
+ if keywords.empty?
290
+ implementation.public_send(name, *arguments, &block)
291
+ else
292
+ implementation.public_send(name, *arguments, **keywords, &block)
293
+ end
294
+ end
295
+ implementation
296
+ end
297
+
298
+ def prepare_implementation!(implementation)
299
+ declarations = implementation.assembly_tool_declarations.map do |declaration|
300
+ declaration.merge(assembly: snapshot_reference(declaration.fetch(:assembly))).freeze
301
+ end
302
+ implementation.assembly_tool_declarations_value = declarations.freeze
303
+ subagents = implementation.subagent_declarations.map do |declaration|
304
+ declaration.merge(agent: snapshot_reference(declaration.fetch(:agent))).freeze
305
+ end
306
+ implementation.subagent_declarations_value = subagents.freeze
307
+ end
308
+ end
309
+
310
+ # Builds a Workflow whose Ruby composition block is supplied at runtime.
311
+ class WorkflowBuilder < AssemblyBuilder
312
+ class << self
313
+ # :nodoc:
314
+ def assembly_kind = :workflow
315
+ end
316
+
317
+ # Declares the Ruby composition body for dynamic Workflow executions.
318
+ def perform(&block)
319
+ raise ArgumentError, "perform requires a block" unless block
320
+
321
+ @mutex.synchronize { @performer = block }
322
+ self
323
+ end
324
+
325
+ protected
326
+
327
+ def snapshot_mutators = super + [:perform]
328
+
329
+ def snapshot_state = super.merge(performer: @performer)
330
+
331
+ def build_implementation(state)
332
+ implementation = snapshot_base_class(state.fetch(:base)) || Class.new(Workflow)
333
+ configure_implementation(implementation, state)
334
+ if (performer = state.fetch(:performer))
335
+ implementation.define_method(:perform) { performer.call(self) }
336
+ implementation.send(:private, :perform)
337
+ end
338
+ implementation
339
+ end
340
+
341
+ def validate_implementation!(_implementation)
342
+ raise ConfigurationError, "workflow builder must declare perform" unless @performer || base
343
+ end
344
+ end
345
+
346
+ # Builds a Swarm at runtime while keeping its members Agent-only.
347
+ class SwarmBuilder < AssemblyBuilder
348
+ class << self
349
+ # :nodoc:
350
+ def assembly_kind = :swarm
351
+ end
352
+
353
+ # Creates a mutable dynamic Swarm definition.
354
+ def initialize(**options)
355
+ super
356
+ @declarations = []
357
+ end
358
+
359
+ %i[member start max_steps handoff max_handoff_repeats].each do |name|
360
+ define_method(name) do |*arguments, **keywords, &block|
361
+ @mutex.synchronize { @declarations << [name, arguments, keywords, block] }
362
+ self
363
+ end
364
+ end
365
+
366
+ protected
367
+
368
+ def snapshot_mutators = super + %i[member start max_steps handoff max_handoff_repeats]
369
+
370
+ def snapshot_state = super.merge(declarations: @declarations.map { |value| value.dup.freeze }.freeze)
371
+
372
+ def build_implementation(state)
373
+ implementation = snapshot_base_class(state.fetch(:base)) || Class.new(Swarm)
374
+ configure_implementation(implementation, state)
375
+ replay(implementation, state.fetch(:declarations))
376
+ end
377
+
378
+ def validate_implementation!(implementation) = implementation.swarm_definition!
379
+
380
+ def prepare_implementation!(implementation)
381
+ members = implementation.swarm_members_value.to_h do |id, member|
382
+ copied = Support.deep_dup(member.to_h)
383
+ [id, Swarm::Member.new(**copied.merge(agent: snapshot_reference(member.agent)))]
384
+ end
385
+ implementation.swarm_members_value = members.freeze
386
+ end
387
+
388
+ private
389
+
390
+ def replay(implementation, declarations)
391
+ declarations.each do |name, arguments, keywords, block|
392
+ arguments = arguments.map { |value| snapshot_reference(value) }
393
+ implementation.public_send(name, *arguments, **keywords, &block)
394
+ end
395
+ implementation
396
+ end
397
+
398
+ def snapshot_reference(value)
399
+ if value.is_a?(AssemblyBuilder) && !value.is_a?(AgentBuilder)
400
+ raise ConfigurationError, "swarm members must be Agent definitions"
401
+ end
402
+ if value.is_a?(AssemblyDefinition) && value.kind != :agent
403
+ raise ConfigurationError, "swarm members must be Agent definitions"
404
+ end
405
+
406
+ super
407
+ end
408
+ end
409
+
410
+ # Builds a Graph from nodes and routes discovered at runtime.
411
+ class GraphBuilder < AssemblyBuilder
412
+ class << self
413
+ # :nodoc:
414
+ def assembly_kind = :graph
415
+ end
416
+
417
+ # Creates a mutable dynamic Graph definition.
418
+ def initialize(**options)
419
+ super
420
+ @declarations = []
421
+ end
422
+
423
+ %i[node start edge finish max_steps fork join error_edge].each do |name|
424
+ define_method(name) do |*arguments, **keywords, &block|
425
+ @mutex.synchronize { @declarations << [name, arguments, keywords, block] }
426
+ self
427
+ end
428
+ end
429
+
430
+ # Renders the current validated snapshot as Mermaid flowchart text.
431
+ def to_mermaid = definition.implementation.to_mermaid
432
+
433
+ protected
434
+
435
+ def snapshot_mutators = super + %i[node start edge finish max_steps fork join error_edge]
436
+
437
+ def snapshot_state = super.merge(declarations: @declarations.map { |value| value.dup.freeze }.freeze)
438
+
439
+ def build_implementation(state)
440
+ implementation = snapshot_base_class(state.fetch(:base)) || Class.new(Graph)
441
+ configure_implementation(implementation, state)
442
+ state.fetch(:declarations).each do |name, arguments, keywords, block|
443
+ arguments = arguments.map { |value| value.is_a?(AssemblyBuilder) ? value.definition : value }
444
+ implementation.public_send(name, *arguments, **keywords, &block)
445
+ end
446
+ implementation
447
+ end
448
+
449
+ def validate_implementation!(implementation) = implementation.validate!
450
+
451
+ def prepare_implementation!(implementation)
452
+ nodes = implementation.graph_nodes_value.to_h do |id, node|
453
+ copied = Support.deep_dup(node.to_h)
454
+ [id, Graph::Node.new(**copied.merge(assembly: snapshot_reference(node.assembly)))]
455
+ end
456
+ implementation.graph_nodes_value = nodes.freeze
457
+ end
458
+ end
459
+ end