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