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,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LittleGhost
6
+ class Assembly
7
+ MAX_STEP_OUTPUT_BYTES = 64 * 1024 # :nodoc:
8
+ MAX_STEP_EVENTS = 10_000 # :nodoc:
9
+ MAX_STEP_EVENT_BYTES = 10 * 1024 * 1024 # :nodoc:
10
+
11
+ Attempt = Data.define(:number, :status, :started_at, :finished_at, :usage, :error) do # :nodoc:
12
+ def initialize(number:, status:, started_at:, finished_at:, usage: Usage.new, error: nil)
13
+ super(number:, status: status.to_sym, started_at:, finished_at:, usage:, error: error&.to_s&.freeze)
14
+ end
15
+ end
16
+
17
+ Step = Data.define( # :nodoc:
18
+ :id, :parent_id, :predecessor_ids, :branch_id, :participant,
19
+ :assembly_id, :assembly_kind, :status, :attempts, :usage, :output, :output_truncated
20
+ ) do
21
+ def initialize(id:, participant:, assembly_id:, assembly_kind:, status:, attempts:, usage:,
22
+ parent_id: nil, predecessor_ids: [], branch_id: nil, output: nil, output_truncated: false)
23
+ super(
24
+ id: id.to_s.freeze,
25
+ parent_id: parent_id&.to_s&.freeze,
26
+ predecessor_ids: Array(predecessor_ids).map { |value| value.to_s.freeze }.freeze,
27
+ branch_id: branch_id&.to_s&.freeze,
28
+ participant: participant.to_s.freeze,
29
+ assembly_id: assembly_id.to_s.freeze,
30
+ assembly_kind: assembly_kind.to_sym,
31
+ status: status.to_sym,
32
+ attempts: Array(attempts).freeze,
33
+ usage:,
34
+ output:,
35
+ output_truncated: output_truncated == true
36
+ )
37
+ end
38
+ end
39
+
40
+ # One bounded attempt to execute a child Assembly step.
41
+ #
42
+ # It records timing, normalized usage, terminal status, and a sanitized error
43
+ # description suitable for the public coordination trajectory.
44
+ class Attempt < Data # :doc:
45
+ ##
46
+ # :attr_reader: number
47
+ # The one-based attempt number.
48
+
49
+ ##
50
+ # :attr_reader: status
51
+ # The normalized terminal status for this attempt.
52
+
53
+ ##
54
+ # :attr_reader: started_at
55
+ # The wall-clock start time.
56
+
57
+ ##
58
+ # :attr_reader: finished_at
59
+ # The wall-clock finish time.
60
+
61
+ ##
62
+ # :attr_reader: usage
63
+ # The Usage recorded by this attempt.
64
+
65
+ ##
66
+ # :attr_reader: error
67
+ # A sanitized error description, or +nil+.
68
+ end
69
+
70
+ # One logical child execution in a composite Assembly result.
71
+ #
72
+ # Steps identify the participant, relationships to other steps, attempts,
73
+ # usage, and a bounded semantic output. Use RunResult#trajectory for queries
74
+ # over several steps.
75
+ class Step < Data # :doc:
76
+ ##
77
+ # :attr_reader: id
78
+ # The stable identifier for this step occurrence.
79
+
80
+ ##
81
+ # :attr_reader: parent_id
82
+ # The containing step identifier for nested coordination, or +nil+.
83
+
84
+ ##
85
+ # :attr_reader: predecessor_ids
86
+ # Step identifiers whose results led to this step.
87
+
88
+ ##
89
+ # :attr_reader: branch_id
90
+ # The parallel branch identifier, or +nil+.
91
+
92
+ ##
93
+ # :attr_reader: participant
94
+ # The participant name used by the parent Assembly.
95
+
96
+ ##
97
+ # :attr_reader: assembly_id
98
+ # The invoked Assembly's stable identifier.
99
+
100
+ ##
101
+ # :attr_reader: assembly_kind
102
+ # The invoked Assembly kind.
103
+
104
+ ##
105
+ # :attr_reader: status
106
+ # The logical step's terminal status.
107
+
108
+ ##
109
+ # :attr_reader: attempts
110
+ # Immutable Attempt values, including retries.
111
+
112
+ ##
113
+ # :attr_reader: usage
114
+ # Usage accumulated across the step's attempts.
115
+
116
+ ##
117
+ # :attr_reader: output
118
+ # The bounded semantic output retained for coordination inspection.
119
+
120
+ ##
121
+ # :attr_reader: output_truncated
122
+ # Indicates that +output+ exceeded the public result limit.
123
+ end
124
+
125
+ # Immutable queries over the steps returned by one assembly invocation.
126
+ class Trajectory
127
+ include Enumerable
128
+
129
+ # Immutable steps in execution order.
130
+ attr_reader :steps
131
+
132
+ # Builds query indexes for +steps+.
133
+ def initialize(steps)
134
+ @steps = Array(steps).freeze
135
+ @by_id = @steps.to_h { |step| [step.id, step] }.freeze
136
+ freeze
137
+ end
138
+
139
+ # Iterates through steps in execution order.
140
+ def each(&block) = steps.each(&block)
141
+ # Finds one step by its stable ID.
142
+ def step(id) = @by_id[id.to_s]
143
+ # Returns steps whose parent is +id+.
144
+ def children(id) = steps.select { |item| item.parent_id == id.to_s }.freeze
145
+ # Returns attempts belonging to one step.
146
+ def attempts_for(id) = step(id)&.attempts || [].freeze
147
+
148
+ # Returns declared predecessor-to-step ID pairs.
149
+ def transitions
150
+ steps.flat_map do |item|
151
+ item.predecessor_ids.map { |predecessor| [predecessor, item.id].freeze }
152
+ end.freeze
153
+ end
154
+
155
+ # Indicates whether attempts from two steps overlapped in time.
156
+ def concurrent?(first_id, second_id)
157
+ first = step(first_id)
158
+ second = step(second_id)
159
+ return false unless first && second
160
+
161
+ first.attempts.any? do |left|
162
+ second.attempts.any? do |right|
163
+ left.started_at < right.finished_at && right.started_at < left.finished_at
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ StepExecution = Data.define(:result, :step, :events, :transition) # :nodoc:
170
+
171
+ private
172
+
173
+ def execute_assembly_step(
174
+ reference:,
175
+ participant:,
176
+ input:,
177
+ history:,
178
+ context:,
179
+ cancellation_token:,
180
+ deadline:,
181
+ settings:,
182
+ template_locals:,
183
+ template_paths:,
184
+ parent_operation_id:,
185
+ policies: {},
186
+ parent_id: nil,
187
+ predecessor_ids: [],
188
+ branch_id: nil,
189
+ checkpoint: nil,
190
+ build_options: {},
191
+ step_id: SecureRandom.uuid
192
+ )
193
+ policy = normalize_step_policy(policies)
194
+ attempts = []
195
+ usage = Usage.new
196
+
197
+ (policy.fetch(:retries) + 1).times do |index|
198
+ attempt_number = index + 1
199
+ started_at = Time.now
200
+ child = if build_options.any? && runtime.respond_to?(:build_agent)
201
+ runtime.build_agent(reference, run:, **build_options)
202
+ else
203
+ runtime.build_assembly(reference, run:, **build_options)
204
+ end
205
+ child_deadline = step_deadline(deadline, policy[:timeout])
206
+ attempt_events = []
207
+ attempt_event_bytes = 0
208
+ result = nil
209
+ attempt_usage = Usage.new
210
+ begin
211
+ with_active_assembly(child) do
212
+ child.stream(
213
+ input,
214
+ history:,
215
+ context: isolated_assembly_state(context),
216
+ cancellation_token:,
217
+ deadline: child_deadline,
218
+ settings:,
219
+ template_locals: template_locals.merge(runtime.template_locals(run:, agent: child)),
220
+ template_paths:,
221
+ parent_operation_id:,
222
+ checkpoint:
223
+ ).each do |event|
224
+ result = event.data[:result] if event.type == :invocation_stop
225
+ attempt_usage = event.data[:usage] || attempt_usage if event.type == :invocation_error
226
+ attempt_event_bytes = buffer_assembly_event!(attempt_events, event, bytes: attempt_event_bytes)
227
+ end
228
+ end
229
+ raise ProtocolError, "assembly step #{participant.inspect} did not return a result" unless result
230
+
231
+ attempt_usage = result.usage
232
+ usage += attempt_usage
233
+ attempts << Attempt.new(
234
+ number: attempt_number,
235
+ status: :completed,
236
+ started_at:,
237
+ finished_at: Time.now,
238
+ usage: attempt_usage
239
+ )
240
+ output, truncated = projected_step_output(result.output)
241
+ nested_steps = reparent_steps(result.steps, step_id)
242
+ step = Step.new(
243
+ id: step_id,
244
+ parent_id:,
245
+ predecessor_ids:,
246
+ branch_id:,
247
+ participant:,
248
+ assembly_id: child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant,
249
+ assembly_kind: child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly,
250
+ status: :completed,
251
+ attempts:,
252
+ usage:,
253
+ output:,
254
+ output_truncated: truncated
255
+ )
256
+ combined = copy_run_result(result, steps: [step, *nested_steps])
257
+ events = attempt_events.map do |event|
258
+ (event.type == :invocation_stop) ? StreamEvent.build(event.type, **event.data.merge(result: combined)) : event
259
+ end
260
+ transition = child.assembly_transition if child.respond_to?(:assembly_transition)
261
+ return StepExecution.new(result: combined, step:, events: events.freeze, transition:)
262
+ rescue => error
263
+ if own_step_timeout?(error, deadline, child_deadline, policy[:timeout])
264
+ error = AssemblyStepTimeoutError.new("assembly step #{participant.inspect} exceeded its timeout")
265
+ end
266
+ attempt_usage = error_usage(attempt_events, attempt_usage)
267
+ usage += attempt_usage
268
+ attempts << Attempt.new(
269
+ number: attempt_number,
270
+ status: :failed,
271
+ started_at:,
272
+ finished_at: Time.now,
273
+ usage: attempt_usage,
274
+ error: error.class.name
275
+ )
276
+ retrying = retry_step?(error, policy, index)
277
+ annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
278
+ if block_given?
279
+ yield StreamEvent.build(
280
+ :assembly_step_error,
281
+ step_id:,
282
+ participant: participant.to_s,
283
+ attempt: attempt_number,
284
+ error_type: error.class.name,
285
+ usage:,
286
+ terminal: !retrying
287
+ )
288
+ end
289
+ raise error unless retrying
290
+
291
+ if block_given?
292
+ yield StreamEvent.build(
293
+ :assembly_step_retry,
294
+ step_id:,
295
+ participant: participant.to_s,
296
+ attempt: attempt_number,
297
+ error_type: error.class.name,
298
+ delay: policy.fetch(:retry_delay)
299
+ )
300
+ end
301
+ wait_for_retry(policy.fetch(:retry_delay), cancellation_token, deadline)
302
+ ensure
303
+ begin
304
+ child.close
305
+ rescue => cleanup_error
306
+ cleanup_usage = usage
307
+ annotate_step_error(
308
+ cleanup_error,
309
+ step_id:,
310
+ attempts:,
311
+ usage: cleanup_usage,
312
+ child:,
313
+ participant:
314
+ )
315
+ if block_given?
316
+ yield StreamEvent.build(
317
+ :assembly_step_error,
318
+ step_id:,
319
+ participant: participant.to_s,
320
+ attempt: attempt_number,
321
+ error_type: cleanup_error.class.name,
322
+ usage: cleanup_usage,
323
+ terminal: true
324
+ )
325
+ end
326
+ raise
327
+ end
328
+ end
329
+ end
330
+ end
331
+
332
+ def normalize_step_policy(values)
333
+ self.class.validate_step_policy!(values)
334
+ end
335
+
336
+ class << self
337
+ def validate_step_policy!(values) # :nodoc:
338
+ values = values.compact
339
+ retries = Integer(values.fetch(:retries, 0))
340
+ raise ArgumentError, "retries must be at least 0" if retries.negative?
341
+
342
+ retry_on = Array(values[:retry_on])
343
+ if retries.positive? && retry_on.empty?
344
+ raise ArgumentError, "retry_on is required when retries is greater than 0"
345
+ end
346
+ unless retry_on.all? { |error| error.is_a?(Class) && error <= Exception }
347
+ raise ArgumentError, "retry_on must contain exception classes"
348
+ end
349
+ timeout = Float(values[:timeout]) if values[:timeout]
350
+ retry_delay = Float(values.fetch(:retry_delay, 0))
351
+ raise ArgumentError, "timeout must be positive" if timeout && (!timeout.positive? || !timeout.finite?)
352
+ raise ArgumentError, "retry_delay must be non-negative" if retry_delay.negative? || !retry_delay.finite?
353
+
354
+ {retries:, retry_on: retry_on.freeze, timeout:, retry_delay:}.freeze
355
+ end
356
+ end
357
+
358
+ def retry_step?(error, policy, index)
359
+ return false if index >= policy.fetch(:retries)
360
+ return false if error.is_a?(CancelledError) || error.is_a?(CleanupError)
361
+
362
+ policy.fetch(:retry_on).any? { |type| error.is_a?(type) }
363
+ end
364
+
365
+ def step_deadline(parent_deadline, timeout)
366
+ local = Time.now + timeout if timeout
367
+ [parent_deadline, local].compact.min
368
+ end
369
+
370
+ def own_step_timeout?(error, parent_deadline, child_deadline, timeout)
371
+ error.is_a?(DeadlineExceededError) && timeout && child_deadline && child_deadline != parent_deadline
372
+ end
373
+
374
+ def wait_for_retry(delay, cancellation_token, deadline)
375
+ return if delay.zero?
376
+
377
+ stop_at = Time.now + delay
378
+ loop do
379
+ cancellation_token.raise_if_cancelled!
380
+ raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
381
+
382
+ remaining = stop_at - Time.now
383
+ break unless remaining.positive?
384
+
385
+ cancellation_token.wait([remaining, 0.05].min)
386
+ end
387
+ end
388
+
389
+ def error_usage(events, fallback)
390
+ events.reverse_each do |event|
391
+ return event.data[:usage] if event.type == :invocation_error && event.data[:usage]
392
+ end
393
+ fallback
394
+ end
395
+
396
+ def annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
397
+ error.instance_variable_set(:@little_ghost_step_id, step_id)
398
+ error.instance_variable_set(:@little_ghost_step_attempts, attempts.dup.freeze)
399
+ error.instance_variable_set(:@little_ghost_step_usage, usage)
400
+ error.instance_variable_set(
401
+ :@little_ghost_step_assembly_id,
402
+ child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant.to_s
403
+ )
404
+ error.instance_variable_set(
405
+ :@little_ghost_step_assembly_kind,
406
+ child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly
407
+ )
408
+ end
409
+
410
+ def projected_step_output(output)
411
+ text = output.is_a?(String) ? output : JSON.generate(output)
412
+ return [deep_frozen_assembly_value(output), false] if text.bytesize <= MAX_STEP_OUTPUT_BYTES
413
+
414
+ [nil, true]
415
+ rescue JSON::GeneratorError, TypeError
416
+ [nil, true]
417
+ end
418
+
419
+ def reparent_steps(steps, parent_id)
420
+ Array(steps).map do |step|
421
+ next step if step.parent_id
422
+
423
+ Step.new(**step.to_h.merge(parent_id:))
424
+ end
425
+ end
426
+
427
+ def copy_run_result(result, usage: result.usage, steps: result.steps)
428
+ RunResult.new(
429
+ message: result.message,
430
+ stop_reason: result.stop_reason,
431
+ usage:,
432
+ messages: result.messages,
433
+ state: result.state,
434
+ structured_result: result.structured_result,
435
+ steps:
436
+ )
437
+ end
438
+
439
+ def isolated_assembly_state(value)
440
+ case value
441
+ when Hash
442
+ value.to_h { |key, item| [isolated_assembly_state(key), isolated_assembly_state(item)] }
443
+ when Array
444
+ value.map { |item| isolated_assembly_state(item) }
445
+ when String
446
+ value.dup
447
+ when NilClass, TrueClass, FalseClass, Numeric, Symbol
448
+ value
449
+ else
450
+ raise ArgumentError, "assembly context must contain only JSON-like state"
451
+ end
452
+ end
453
+
454
+ def deep_frozen_assembly_value(value)
455
+ copied = isolated_assembly_state(value)
456
+ case copied
457
+ when Hash
458
+ copied.each do |key, item|
459
+ deep_freeze_assembly_value(key)
460
+ deep_freeze_assembly_value(item)
461
+ end
462
+ when Array
463
+ copied.each { |item| deep_freeze_assembly_value(item) }
464
+ end
465
+ copied.freeze
466
+ end
467
+
468
+ def deep_freeze_assembly_value(value)
469
+ case value
470
+ when Hash
471
+ value.each do |key, item|
472
+ deep_freeze_assembly_value(key)
473
+ deep_freeze_assembly_value(item)
474
+ end
475
+ when Array
476
+ value.each { |item| deep_freeze_assembly_value(item) }
477
+ end
478
+ value.freeze
479
+ end
480
+
481
+ def buffer_assembly_event!(buffer, event, bytes:)
482
+ raise AssemblyLimitError, "assembly step emitted too many events" if buffer.length >= MAX_STEP_EVENTS
483
+
484
+ bytes += buffered_assembly_size(event)
485
+ raise AssemblyLimitError, "assembly step emitted too much event data" if bytes > MAX_STEP_EVENT_BYTES
486
+
487
+ buffer << event
488
+ bytes
489
+ end
490
+
491
+ def buffered_assembly_size(value, ancestors = {}, depth = 0)
492
+ return MAX_STEP_EVENT_BYTES + 1 if depth > 32
493
+
494
+ identity = value.object_id
495
+ return 0 if ancestors.key?(identity)
496
+
497
+ case value
498
+ when String
499
+ value.bytesize
500
+ when Hash
501
+ ancestors[identity] = true
502
+ value.sum do |key, item|
503
+ buffered_assembly_size(key, ancestors, depth + 1) + buffered_assembly_size(item, ancestors, depth + 1)
504
+ end
505
+ when Array
506
+ ancestors[identity] = true
507
+ value.sum { |item| buffered_assembly_size(item, ancestors, depth + 1) }
508
+ when Data
509
+ ancestors[identity] = true
510
+ value.members.sum { |member| buffered_assembly_size(value.public_send(member), ancestors, depth + 1) }
511
+ else
512
+ 64
513
+ end
514
+ ensure
515
+ ancestors.delete(identity) if identity
516
+ end
517
+
518
+ def enqueue_assembly_event(queue, value, cancellation_token)
519
+ loop do
520
+ cancellation_token.raise_if_cancelled!
521
+ queue.push(value, true)
522
+ return
523
+ rescue ThreadError
524
+ cancellation_token.wait(0.01)
525
+ end
526
+ end
527
+
528
+ def enqueue_assembly_terminal(queue, value)
529
+ queue.push(value, true)
530
+ rescue ThreadError
531
+ queue.pop(true)
532
+ retry
533
+ end
534
+ end
535
+ end