little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LittleGhost
6
+ # Instrumentation turns agent work into structured lifecycle notifications.
7
+ # Applications can measure agents, models, tools, workflows, and sessions with
8
+ # the telemetry backend they already use.
9
+ #
10
+ # class TimingSubscriber < LittleGhost::Instrumentation::Subscriber
11
+ # def finish(name, attributes)
12
+ # puts "#{name}: #{attributes.fetch(:duration_ms)}ms"
13
+ # end
14
+ # end
15
+ #
16
+ # LittleGhost.configure do |config|
17
+ # config.instrument TimingSubscriber.new
18
+ # end
19
+ #
20
+ # A subscriber receives structured attributes when an operation starts,
21
+ # finishes, or emits a point-in-time event. Subscriber failures are reported
22
+ # once and kept separate from agent execution.
23
+ #
24
+ # === Content and trust
25
+ #
26
+ # Diagnostic content is excluded unless the application installs an explicit
27
+ # Support::ContentCapture policy. One process is one telemetry and content
28
+ # policy boundary; applications that need different exporters or data policies
29
+ # should use separate processes.
30
+ module Instrumentation
31
+ # Subclass Subscriber to connect a telemetry backend.
32
+ #
33
+ # Override the callbacks a backend supports. +start+ and +finish+ receive the
34
+ # same operation name and correlated attributes. +emit+ receives point
35
+ # events. Implementations may return a propagation carrier from
36
+ # #trace_context. Callbacks should be thread-safe.
37
+ class Subscriber
38
+ # Called when a lifecycle operation starts.
39
+ def start(_name, _attributes) = nil
40
+ # Called when a lifecycle operation finishes.
41
+ def finish(_name, _attributes) = nil
42
+ # Called for a point-in-time instrumentation event.
43
+ def emit(_name, _attributes) = nil
44
+ # Flushes buffered telemetry within an optional timeout budget.
45
+ def flush(timeout: nil) = nil
46
+ # Releases subscriber resources within an optional timeout budget.
47
+ def shutdown(timeout: nil) = nil
48
+ # Supplies trace fields that should travel with downstream work.
49
+ def trace_context(**) = {}
50
+ end
51
+
52
+ # A Handle represents work between Instrumentation.start and #finish.
53
+ #
54
+ # Non-detached handles are fiber-owned and must finish in LIFO order after
55
+ # their children. Detached handles may finish outside the creating fiber but
56
+ # still cannot finish while local children remain active.
57
+ class Handle
58
+ # Operation identity, inherited attributes, previous local handle, and
59
+ # monotonic start time.
60
+ attr_reader :name, :operation_id, :parent_operation_id, :payload, :previous, :started_at
61
+
62
+ def initialize(bus, name, operation_id:, parent_operation_id:, local_parent:, previous:, payload:, started_at:, detached:) # :nodoc:
63
+ @bus = bus
64
+ @name = name.to_sym
65
+ @operation_id = operation_id
66
+ @parent_operation_id = parent_operation_id
67
+ @local_parent = local_parent
68
+ @previous = previous
69
+ @payload = payload
70
+ @started_at = started_at
71
+ @detached = detached
72
+ @owner = Fiber.current
73
+ end
74
+
75
+ # Completes this operation with additional attributes.
76
+ def finish(**attributes)
77
+ @bus.finish(self, **attributes)
78
+ end
79
+
80
+ # Indicates whether this handle belongs to the current fiber.
81
+ def owner? = @owner.equal?(Fiber.current)
82
+ # Indicates whether this handle is outside the fiber-local operation stack.
83
+ def detached? = @detached
84
+ # Indicates whether the parent is another active handle on this bus.
85
+ def local_parent? = @local_parent
86
+ # Indicates whether this handle is still active.
87
+ def active? = @bus.active?(self)
88
+ end
89
+
90
+ # Thread-safe notification bus used by the process-wide Instrumentation API.
91
+ class Bus
92
+ # Starts an independent bus with ordered subscribers and a content policy.
93
+ def initialize(subscribers: [], content_capture: Support::ContentCapture.disabled)
94
+ @subscribers = []
95
+ @content_capture = content_capture
96
+ @handles = {}
97
+ @children = Hash.new(0)
98
+ @finishing = {}
99
+ @mutex = Mutex.new
100
+ @reported_failures = {}
101
+ @shutdown = false
102
+ Array(subscribers).each { |subscriber| subscribe(subscriber) }
103
+ end
104
+
105
+ # Subscribes a backend once. +prepend+ controls notification order.
106
+ def subscribe(subscriber, prepend: false)
107
+ unless subscriber.is_a?(Subscriber)
108
+ raise ArgumentError, "instrumentation subscriber must be a LittleGhost::Instrumentation::Subscriber"
109
+ end
110
+
111
+ @mutex.synchronize do
112
+ @subscribers.reject! { |listener| listener.equal?(subscriber) }
113
+ prepend ? @subscribers.unshift(subscriber) : @subscribers << subscriber
114
+ end
115
+ subscriber
116
+ end
117
+
118
+ # Unsubscribes a backend by identity.
119
+ def unsubscribe(subscriber)
120
+ @mutex.synchronize { @subscribers.reject! { |listener| listener.equal?(subscriber) } }
121
+ subscriber
122
+ end
123
+
124
+ # Selects the diagnostic content policy used for future notifications.
125
+ def capture_content(policy)
126
+ raise ArgumentError, "content capture policy must respond to capture" unless policy.respond_to?(:capture)
127
+
128
+ @mutex.synchronize { @content_capture = policy }
129
+ policy
130
+ end
131
+
132
+ # Publishes a point event with the current context and operation ID.
133
+ def publish(name, diagnostic: nil, **attributes)
134
+ current_handle = current
135
+ values = context.merge(attributes)
136
+ values[:operation_id] ||= current_handle&.operation_id
137
+ values = prepare_attributes(values.compact, diagnostic:)
138
+ notify(:emit, name.to_sym, values)
139
+ values
140
+ end
141
+
142
+ # Starts an operation. +parent+ may be a local Handle, a remote operation
143
+ # ID, or nil. Set +detached+ for work that will not finish in stack order.
144
+ def start(name, parent: current, operation_id: SecureRandom.uuid, detached: false, diagnostic: nil, **attributes)
145
+ previous = current unless detached
146
+ validate_parent!(parent)
147
+ parent_operation_id = parent.is_a?(Handle) ? parent.operation_id : parent
148
+ payload = context.merge(attributes).merge(operation_id:, parent_operation_id:).compact
149
+ payload = prepare_attributes(payload, diagnostic:)
150
+ handle = Handle.new(
151
+ self,
152
+ name,
153
+ operation_id:,
154
+ parent_operation_id:,
155
+ local_parent: parent.is_a?(Handle),
156
+ previous:,
157
+ payload:,
158
+ started_at: monotonic_time,
159
+ detached:
160
+ )
161
+ @mutex.synchronize do
162
+ raise Error, "instrumentation is shut down" if @shutdown
163
+ raise ArgumentError, "instrumentation operation is already active" if @handles.key?(operation_id)
164
+ if parent.is_a?(Handle)
165
+ raise Error, "instrumentation parent is not active" unless @handles[parent.operation_id].equal?(parent)
166
+ raise Error, "instrumentation parent is finishing" if @finishing.key?(parent.operation_id)
167
+ end
168
+
169
+ @handles[operation_id] = handle
170
+ @children[parent_operation_id] += 1 if parent.is_a?(Handle)
171
+ end
172
+ set_current(handle) unless detached
173
+ notify(:start, handle.name, handle.payload)
174
+ handle
175
+ rescue
176
+ abandon(handle) if handle
177
+ raise
178
+ end
179
+
180
+ # Finishes an active handle and returns the final attribute hash.
181
+ def finish(handle, diagnostic: nil, **attributes)
182
+ validate_finish!(handle)
183
+ validated = true
184
+ values = handle.payload.merge(attributes).merge(duration_ms: elapsed_ms(handle.started_at))
185
+ values = prepare_attributes(values.compact, diagnostic:)
186
+ notify(:finish, handle.name, values)
187
+ values
188
+ ensure
189
+ complete(handle) if validated && active_handle?(handle)
190
+ end
191
+
192
+ # Measures a block and records raised errors before re-raising them.
193
+ def instrument(name, payload = {})
194
+ values = payload.dup
195
+ handle = start(name, **values)
196
+ result = yield values if block_given?
197
+ handle.finish(**values)
198
+ result
199
+ rescue => error
200
+ values ||= payload.dup
201
+ values[:outcome] ||= :error
202
+ values[:error_type] ||= error.class.name
203
+ values[:diagnostic] ||= {exception: diagnostic_exception(error)}
204
+ handle.finish(**values) if handle && active_handle?(handle)
205
+ raise
206
+ end
207
+
208
+ # Adds copied attributes to notifications emitted while the block runs.
209
+ def with_context(attributes)
210
+ values = deep_copy(context).merge(deep_copy(attributes.compact))
211
+ ExecutionState.with(context_key => values) { yield }
212
+ end
213
+
214
+ # Copies the attributes active in the current execution.
215
+ def context
216
+ deep_copy(ExecutionState[context_key] || {})
217
+ end
218
+
219
+ # Finds the current non-detached Handle for this fiber, if any.
220
+ def current
221
+ ExecutionState[current_key]
222
+ end
223
+
224
+ # With a handle, tests whether that exact handle is active. Without one,
225
+ # reports whether the bus owns any active operations.
226
+ def active?(handle = nil)
227
+ @mutex.synchronize do
228
+ handle ? @handles[handle.operation_id].equal?(handle) : !@handles.empty?
229
+ end
230
+ end
231
+
232
+ # Flushes subscribers in registration order within an optional total
233
+ # timeout budget.
234
+ def flush(timeout: nil)
235
+ deadline = monotonic_time + Float(timeout) if timeout
236
+ subscribers.each do |subscriber|
237
+ remaining = deadline && [deadline - monotonic_time, 0].max
238
+ notify_subscriber(subscriber, :flush, timeout: remaining)
239
+ end
240
+ end
241
+
242
+ # Permanently shuts down this bus after all operations have finished.
243
+ def shutdown(timeout: nil)
244
+ should_shutdown = @mutex.synchronize do
245
+ return if @shutdown
246
+ raise Error, "cannot shut down instrumentation with active operations" unless @handles.empty?
247
+
248
+ @shutdown = true
249
+ end
250
+ return unless should_shutdown
251
+
252
+ deadline = monotonic_time + Float(timeout) if timeout
253
+ subscribers.reverse_each do |subscriber|
254
+ remaining = deadline && [deadline - monotonic_time, 0].max
255
+ notify_subscriber(subscriber, :shutdown, timeout: remaining)
256
+ end
257
+ end
258
+
259
+ # Uses the first non-empty downstream trace context supplied by a subscriber.
260
+ def trace_context(**attributes)
261
+ subscribers.each do |subscriber|
262
+ value = subscriber.trace_context(**attributes)
263
+ return value unless value.nil? || value.empty?
264
+ rescue => error
265
+ warn_failure(error, component: :subscriber)
266
+ end
267
+ {}
268
+ end
269
+
270
+ private
271
+
272
+ def validate_parent!(parent)
273
+ return unless parent
274
+ unless parent.is_a?(Handle) || parent.is_a?(String)
275
+ raise ArgumentError, "instrumentation parent must be a handle or remote operation ID"
276
+ end
277
+ end
278
+
279
+ def validate_finish!(handle)
280
+ raise ArgumentError, "instrumentation handle is required" unless handle.is_a?(Handle)
281
+ unless handle.detached?
282
+ raise Error, "instrumentation handle belongs to another fiber" unless handle.owner?
283
+ raise Error, "instrumentation operations must finish in nesting order" unless current.equal?(handle)
284
+ end
285
+
286
+ @mutex.synchronize do
287
+ raise Error, "instrumentation operation is not active" unless @handles[handle.operation_id].equal?(handle)
288
+ raise Error, "instrumentation operation is already finishing" if @finishing.key?(handle.operation_id)
289
+ raise Error, "instrumentation operation has active children" unless @children[handle.operation_id].zero?
290
+
291
+ @finishing[handle.operation_id] = true
292
+ end
293
+ end
294
+
295
+ def active_handle?(handle)
296
+ active?(handle)
297
+ end
298
+
299
+ def complete(handle)
300
+ @mutex.synchronize do
301
+ @handles.delete(handle.operation_id)
302
+ @children.delete(handle.operation_id)
303
+ @finishing.delete(handle.operation_id)
304
+ if handle.local_parent?
305
+ @children[handle.parent_operation_id] -= 1
306
+ end
307
+ end
308
+ set_current(handle.previous) if current.equal?(handle)
309
+ end
310
+
311
+ def abandon(handle)
312
+ complete(handle) if active_handle?(handle)
313
+ end
314
+
315
+ def set_current(handle)
316
+ if handle
317
+ ExecutionState[current_key] = handle
318
+ else
319
+ ExecutionState.delete(current_key)
320
+ end
321
+ end
322
+
323
+ def prepare_attributes(attributes, diagnostic:)
324
+ return attributes unless diagnostic
325
+
326
+ policy = @mutex.synchronize { @content_capture }
327
+ attributes.merge(policy.capture(diagnostic))
328
+ rescue => error
329
+ warn_failure(error, component: :content_capture)
330
+ attributes
331
+ end
332
+
333
+ def notify(method, name, attributes)
334
+ event_subscribers.each do |subscriber|
335
+ notify_subscriber(subscriber, method, name, deep_copy(attributes))
336
+ end
337
+ end
338
+
339
+ def notify_subscriber(subscriber, method, ...)
340
+ subscriber.public_send(method, ...)
341
+ rescue => error
342
+ warn_failure(error, component: :subscriber)
343
+ end
344
+
345
+ def subscribers
346
+ @mutex.synchronize { @subscribers.dup }
347
+ end
348
+
349
+ def event_subscribers
350
+ scoped = ExecutionState[:instrumentation_subscribers] || []
351
+ prepended, appended = scoped.partition { |entry| entry.fetch(:prepend) }
352
+ prepended.map { |entry| entry.fetch(:subscriber) } + subscribers +
353
+ appended.map { |entry| entry.fetch(:subscriber) }
354
+ end
355
+
356
+ def deep_copy(value)
357
+ case value
358
+ when Hash
359
+ value.to_h { |key, item| [key, deep_copy(item)] }
360
+ when Array
361
+ value.map { |item| deep_copy(item) }
362
+ when String
363
+ value.dup
364
+ else
365
+ value
366
+ end
367
+ end
368
+
369
+ def context_key = :little_ghost_instrumentation_context
370
+ def current_key = :little_ghost_instrumentation_current
371
+
372
+ def monotonic_time
373
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
374
+ end
375
+
376
+ def elapsed_ms(started_at)
377
+ ((monotonic_time - started_at) * 1_000).round(3)
378
+ end
379
+
380
+ def diagnostic_exception(error)
381
+ {
382
+ type: error.class.name,
383
+ message: error.message,
384
+ stacktrace: Array(error.backtrace).join("\n")
385
+ }
386
+ end
387
+
388
+ def warn_failure(error, component:)
389
+ return if ExecutionState[:little_ghost_instrumentation_failure_event]
390
+
391
+ failure = [component, error.class.name]
392
+ report = @mutex.synchronize do
393
+ next false if @reported_failures.key?(failure)
394
+
395
+ @reported_failures[failure] = true
396
+ end
397
+ return unless report
398
+
399
+ ExecutionState.with(little_ghost_instrumentation_failure_event: true) do
400
+ Events.warn(
401
+ "little_ghost.instrumentation.listener_failed",
402
+ component:,
403
+ error_type: error.class.name
404
+ )
405
+ end
406
+ rescue
407
+ nil
408
+ end
409
+ end
410
+
411
+ class << self
412
+ # Accesses the process-wide Bus.
413
+ def notifier = bus
414
+
415
+ # Replaces the process-wide bus when it has no active operations.
416
+ def notifier=(value)
417
+ raise ArgumentError, "notifier must be an instrumentation bus" unless value.is_a?(Bus)
418
+
419
+ notifier_mutex.synchronize do
420
+ if @bus && !@bus.equal?(value) && @bus.active?
421
+ raise Error, "cannot replace instrumentation notifier with active operations"
422
+ end
423
+
424
+ @bus = value
425
+ end
426
+ end
427
+
428
+ # Subscribes a process-wide backend.
429
+ def subscribe(...) = bus.subscribe(...)
430
+ # Unsubscribes a process-wide backend.
431
+ def unsubscribe(...) = bus.unsubscribe(...)
432
+ # Installs the process-wide diagnostic content policy.
433
+ def capture_content(...) = bus.capture_content(...)
434
+ # Publishes a point event on the process-wide bus.
435
+ def publish(...) = bus.publish(...)
436
+ # Starts a lifecycle operation on the process-wide bus.
437
+ def start(...) = bus.start(...)
438
+ # Wraps a block in a lifecycle operation.
439
+ def instrument(...) = bus.instrument(...)
440
+ # Returns the current fiber's active Handle.
441
+ def current = bus.current
442
+ # Adds attributes while the block runs.
443
+ def with_context(attributes, &block) = bus.with_context(attributes, &block)
444
+ # Copies the current instrumentation context.
445
+ def context = bus.context
446
+ # Flushes process-wide subscribers.
447
+ def flush(...) = bus.flush(...)
448
+ # Shuts down the process-wide bus.
449
+ def shutdown(...) = bus.shutdown(...)
450
+ # Gets downstream trace fields from process-wide subscribers.
451
+ def trace_context(...) = bus.trace_context(...)
452
+
453
+ # Temporarily subscribes a backend for the block's execution state.
454
+ def subscribed(subscriber, prepend: false)
455
+ unless subscriber.is_a?(Subscriber)
456
+ raise ArgumentError, "instrumentation subscriber must be a LittleGhost::Instrumentation::Subscriber"
457
+ end
458
+
459
+ subscribers = ExecutionState[:instrumentation_subscribers] || []
460
+ entry = {subscriber:, prepend:}
461
+ ExecutionState.with(instrumentation_subscribers: subscribers + [entry]) { yield }
462
+ end
463
+
464
+ private
465
+
466
+ def bus
467
+ @bus ||= Bus.new
468
+ end
469
+
470
+ def notifier_mutex
471
+ @notifier_mutex ||= Mutex.new
472
+ end
473
+ end
474
+ end
475
+ end