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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Providers
5
+ class SSEParser # :nodoc:
6
+ def initialize
7
+ @buffer = +""
8
+ end
9
+
10
+ def <<(chunk)
11
+ @buffer << chunk.to_s
12
+ @buffer.gsub!("\r\n", "\n")
13
+ events = []
14
+
15
+ while (boundary = @buffer.index("\n\n"))
16
+ frame = @buffer.slice!(0, boundary + 2)
17
+ data = frame.lines.filter_map do |line|
18
+ next unless line.start_with?("data:")
19
+
20
+ line.delete_prefix("data:").sub(/\A /, "").chomp
21
+ end
22
+ events << data.join("\n") unless data.empty?
23
+ end
24
+
25
+ events
26
+ end
27
+
28
+ def finish
29
+ return [] if @buffer.empty?
30
+
31
+ self << "\n\n"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LittleGhost
6
+ # Observe one top-level agent or workflow execution from start to finish.
7
+ # A run records its response, outcome, usage, error, and owned resources.
8
+ #
9
+ # run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
10
+ #
11
+ # run.completed? # => true
12
+ # run.outcome # => "completed"
13
+ # run.response # => "Transfer 481 is waiting for the receiving bank."
14
+ #
15
+ # Agent.ask[rdoc-ref:LittleGhost::Agent.ask] or standalone
16
+ # ask[rdoc-ref:LittleGhost::Agent#ask] consumes the event stream and returns
17
+ # the Run. For a live interface,
18
+ # stream_ask[rdoc-ref:LittleGhost::Agent#stream_ask] yields StreamEvent objects
19
+ # and returns the same run after enumeration. A run can execute only once.
20
+ #
21
+ # Completion, failure, deadline, and cancellation become the +completed+,
22
+ # +failed+, +partial+, and +cancelled+ outcomes. Ordinary execution failures
23
+ # are available through +error+ and the terminal stream event; cleanup, event
24
+ # delivery, or instrumentation failures may still raise because the framework
25
+ # cannot safely report a clean stop.
26
+ #
27
+ # The run opens its workspace, sandbox, session, and entrypoint, then closes
28
+ # registered resources in reverse order. +register+ extends that lifecycle for
29
+ # application resources. Interruption is available only while an agent
30
+ # entrypoint is active and unambiguous.
31
+ class Run
32
+ include Enumerable
33
+
34
+ # Runtime and declarations used to execute the run; its request, cancellation
35
+ # token, resources, terminal outcome, response, result, usage, and error.
36
+ attr_reader :runtime, :agent_class, :entrypoint_class, :invocation, :cancellation_token, :result, :operation_id,
37
+ :outcome, :response, :error, :session, :usage, :workspace, :sandbox
38
+
39
+ # Creates a dormant run for +invocation+.
40
+ def initialize(invocation:, agent_class:, runtime:, entrypoint_class: agent_class,
41
+ cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil)
42
+ @runtime = runtime
43
+ @agent_class = agent_class
44
+ @entrypoint_class = entrypoint_class
45
+ @invocation = invocation
46
+ @cancellation_token = cancellation_token
47
+ @workspace = workspace
48
+ @sandbox = sandbox
49
+ @operation_id = SecureRandom.uuid
50
+ @resources = []
51
+ @closed = false
52
+ @started = false
53
+ @mutex = Mutex.new
54
+ @event_mutex = Mutex.new
55
+ @subagent_instrumentation_mutex = Mutex.new
56
+ @subagent_instrumentation = {}
57
+ @exclusive_tools_mutex = Mutex.new
58
+ @once_mutex = Mutex.new
59
+ @once_keys = {}
60
+ @interruption_mutex = Mutex.new
61
+ @interruption_state = :not_started
62
+ @entrypoint = nil
63
+ @usage = Usage.new
64
+ end
65
+
66
+ # Consumes the event stream and returns +self+.
67
+ def call
68
+ each { |_event| }
69
+ self
70
+ end
71
+
72
+ # Yields events and returns +self+ after the terminal event.
73
+ #
74
+ # Without a block, returns an Enumerator. A second execution raises Error.
75
+ def each
76
+ return enum_for(__method__) unless block_given?
77
+
78
+ begin_execution!
79
+ @emitter = ->(event) { yield_event(event) { |value| yield value } }
80
+ Instrumentation.with_context(correlation_attributes.except(:operation_id)) do
81
+ execute { |event| yield event }
82
+ end
83
+ self
84
+ ensure
85
+ @emitter = nil
86
+ end
87
+
88
+ # True after successful completion.
89
+ def completed? = outcome == "completed"
90
+
91
+ # True after execution or cleanup failed.
92
+ def failed? = outcome == "failed"
93
+
94
+ # True when the deadline preserved a partial response.
95
+ def partial? = outcome == "partial"
96
+
97
+ # True when cancellation stopped the run without a response.
98
+ def cancelled? = outcome == "cancelled"
99
+
100
+ # Adds an interruption to the active entrypoint and waits for its response.
101
+ #
102
+ # Raises LittleGhost::AgentInterruptError before the entrypoint is ready,
103
+ # after it finishes, or when the entrypoint does not support interruptions.
104
+ def interrupt_response(
105
+ message,
106
+ interruption_id: nil,
107
+ batch_key: nil,
108
+ metadata: {},
109
+ cancellation_token: Support::CancellationToken.new,
110
+ deadline: nil
111
+ )
112
+ entrypoint = @interruption_mutex.synchronize do
113
+ case @interruption_state
114
+ when :not_started, :starting
115
+ raise AgentInterruptError, "Run entrypoint is not ready for interruptions"
116
+ when :terminal
117
+ raise AgentInterruptError, "Run has already finished"
118
+ end
119
+
120
+ @entrypoint
121
+ end
122
+ unless entrypoint.respond_to?(:interrupt_response)
123
+ raise AgentInterruptError, "Run entrypoint does not support interruptions"
124
+ end
125
+
126
+ entrypoint.interrupt_response(
127
+ message,
128
+ interruption_id:,
129
+ batch_key:,
130
+ metadata:,
131
+ cancellation_token:,
132
+ deadline:
133
+ )
134
+ end
135
+
136
+ # Creates a RunContext with this run's cancellation token and deadline.
137
+ def context(state: {}, metadata: {})
138
+ RunContext.new(
139
+ state:,
140
+ cancellation_token:,
141
+ deadline: invocation.deadline_at,
142
+ metadata:
143
+ )
144
+ end
145
+
146
+ def publish(type, **data) # :nodoc:
147
+ event = StreamEvent.build(type, **data)
148
+ @event_mutex.synchronize { @emitter&.call(event) }
149
+ instrument_event(type, data)
150
+ event
151
+ end
152
+
153
+ # Adds a resource or closer to reverse-order cleanup and returns the resource.
154
+ #
155
+ # A resource must respond to +close+ unless a block supplies the cleanup
156
+ # operation. Registering after the run has closed raises Error.
157
+ def register(resource = nil, &closer)
158
+ callback = closer || close_callback(resource)
159
+ @mutex.synchronize do
160
+ raise Error, "run is already closed" if @closed
161
+ @resources << callback
162
+ end
163
+ resource
164
+ end
165
+
166
+ def synchronize_exclusive_tools(&block) # :nodoc:
167
+ @exclusive_tools_mutex.synchronize(&block)
168
+ end
169
+
170
+ # Performs the block at most once successfully for +key+ during this run.
171
+ #
172
+ # Concurrent callers are serialized. The caller that performs the block
173
+ # receives its value; later callers receive +nil+. If the block raises, the
174
+ # key is not recorded and a later call may retry it.
175
+ def once(key)
176
+ @once_mutex.synchronize do
177
+ return if @once_keys.key?(key)
178
+
179
+ value = yield
180
+ @once_keys[key] = true
181
+ value
182
+ end
183
+ end
184
+
185
+ def prepare_interruption(payload) # :nodoc:
186
+ runtime.prepare_interruption(self, payload)
187
+ end
188
+
189
+ # Closes registered resources in reverse order.
190
+ #
191
+ # The operation is idempotent. It attempts every closer and then raises the
192
+ # first LittleGhost::CleanupError, or otherwise the first cleanup exception.
193
+ def close
194
+ callbacks = @mutex.synchronize do
195
+ return if @closed
196
+ @closed = true
197
+ @resources.reverse
198
+ end
199
+ errors = []
200
+ callbacks.each do |callback|
201
+ callback.call
202
+ rescue => error
203
+ errors << error
204
+ end
205
+ cleanup_error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
206
+ begin
207
+ finish_remaining_subagent_instrumentation(
208
+ outcome: cleanup_error ? :error : :cancelled,
209
+ error_type: cleanup_error&.class&.name
210
+ )
211
+ rescue => error
212
+ errors << error
213
+ end
214
+ error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
215
+ raise error if error
216
+ end
217
+
218
+ private
219
+
220
+ def execute
221
+ started_at = monotonic_time
222
+ current_response = nil
223
+ last_response = +""
224
+ response_before_model_attempt = +""
225
+ terminal = nil
226
+ execution_cleanup_error = nil
227
+ @instrumentation_handle = Instrumentation.start(
228
+ :run,
229
+ parent: nil,
230
+ **correlation_attributes,
231
+ entrypoint_kind: workflow_run? ? :workflow : :agent,
232
+ workflow_name: workflow_run? ? entrypoint_name : nil,
233
+ trace_context: invocation[:parent_trace_context],
234
+ trace_links: invocation[:trace_links],
235
+ diagnostic: {input: diagnostic_invocation_message}
236
+ )
237
+ emit(:run_start, run_id: invocation.run_id, thread_id: invocation.session_id) { |event| yield event }
238
+ trace_context = Instrumentation.trace_context(operation_id:)
239
+ emit(:trace_context, context: trace_context) { |event| yield event } unless trace_context.nil? || trace_context.empty?
240
+ workspace&.open(run: self)
241
+ sandbox&.open(run: self)
242
+ @session = runtime.open_session(self)
243
+ agent = if entrypoint_class <= Agent
244
+ runtime.build_agent(entrypoint_class, run: self)
245
+ else
246
+ entrypoint_class.new(run: self, runtime:)
247
+ end
248
+ @interruption_mutex.synchronize do
249
+ @entrypoint = agent
250
+ end
251
+ register(agent)
252
+ invoke = lambda do
253
+ history = session ? session.history(fallback: invocation.history) : invocation.history
254
+ context = session ? session.state.merge(invocation.context) : invocation.context.dup
255
+ options = {
256
+ history:,
257
+ context:,
258
+ settings: invocation.settings,
259
+ template_locals: runtime.template_locals(run: self, agent:),
260
+ template_paths: Array(invocation[:template_paths]),
261
+ cancellation_token:,
262
+ deadline: invocation.deadline_at,
263
+ parent_operation_id: operation_id,
264
+ checkpoint: lambda do |messages:, state:, parent_operation_id:|
265
+ session&.checkpoint(messages:, state:, parent_operation_id:)
266
+ end
267
+ }
268
+ if agent.is_a?(Agent)
269
+ options[:interrupt_ready] = lambda do
270
+ @interruption_mutex.synchronize { @interruption_state = :active }
271
+ end
272
+ else
273
+ @interruption_mutex.synchronize { @interruption_state = :active }
274
+ end
275
+
276
+ agent.stream(invocation.message, **options).each do |event|
277
+ case event.type
278
+ when :model_start
279
+ response_before_model_attempt = last_response.dup
280
+ when :message_start
281
+ current_response = +""
282
+ when :text_delta
283
+ current_response ||= +""
284
+ current_response << event.data[:text].to_s
285
+ when :message_stop
286
+ completed_response = current_response.to_s.strip
287
+ last_response = completed_response unless completed_response.empty?
288
+ current_response = nil
289
+ when :model_retry
290
+ current_response = nil
291
+ last_response = response_before_model_attempt.dup
292
+ end
293
+ if event.type == :invocation_stop
294
+ @result = event.data[:result]
295
+ @usage = result.usage
296
+ elsif event.type == :invocation_error
297
+ @usage = event.data.fetch(:usage, usage)
298
+ end
299
+ yield event
300
+ end
301
+ session&.checkpoint_result(result) if result
302
+ end
303
+ session ? session.synchronize(&invoke) : invoke.call
304
+ @outcome = "completed"
305
+ @response = result&.text.to_s
306
+ terminal = [:run_stop, {outcome:, response:, result:}]
307
+ rescue DeadlineExceededError => caught
308
+ @error = caught
309
+ @outcome = "partial"
310
+ @response = current_response.to_s.strip
311
+ @response = last_response if @response.empty?
312
+ terminal = [:run_partial, {outcome:, response:, error: caught}]
313
+ rescue CancelledError => caught
314
+ @error = caught
315
+ @outcome = "cancelled"
316
+ @response = ""
317
+ terminal = [:run_cancel, {outcome:, response:, error: caught}]
318
+ rescue => caught
319
+ execution_cleanup_error = caught if caught.is_a?(CleanupError)
320
+ @error = caught
321
+ @outcome = "failed"
322
+ @response = ""
323
+ cleanup_failed = caught.is_a?(CleanupError)
324
+ terminal = [
325
+ :run_error,
326
+ {
327
+ outcome:,
328
+ error: caught,
329
+ message: cleanup_failed ? cleanup_error_message(caught) : error_message(caught),
330
+ cleanup_failed:
331
+ }
332
+ ]
333
+ ensure
334
+ @interruption_mutex.synchronize do
335
+ @last_entrypoint = @entrypoint
336
+ @entrypoint = nil
337
+ @interruption_state = :terminal
338
+ end
339
+ resource_cleanup_error = nil
340
+ begin
341
+ close
342
+ rescue => caught
343
+ resource_cleanup_error = caught
344
+ reported_error = execution_cleanup_error || caught
345
+ @error = reported_error
346
+ @outcome = "failed"
347
+ @response = ""
348
+ terminal = [
349
+ :run_error,
350
+ {outcome:, error: reported_error, message: cleanup_error_message(reported_error), cleanup_failed: true}
351
+ ]
352
+ end
353
+
354
+ stop_error = execution_cleanup_error || resource_cleanup_error || error
355
+ stop_attributes = {
356
+ outcome: ((execution_cleanup_error || resource_cleanup_error) ? "failed" : outcome)&.to_sym,
357
+ duration_ms: duration_ms(started_at),
358
+ error_type: stop_error&.class&.name,
359
+ diagnostic: {
360
+ output: failed? ? last_response : response,
361
+ exception: stop_error && diagnostic_exception(stop_error)
362
+ }.compact,
363
+ **usage_attributes(usage)
364
+ }.compact
365
+ terminal_delivery_error = begin
366
+ emit(terminal.first, **terminal.last) { |event| yield event } if terminal
367
+ nil
368
+ rescue => caught
369
+ caught
370
+ end
371
+ instrumentation_error = begin
372
+ @instrumentation_handle&.finish(**correlation_attributes.merge(stop_attributes))
373
+ nil
374
+ rescue => caught
375
+ caught
376
+ end
377
+ final_errors = [
378
+ execution_cleanup_error,
379
+ resource_cleanup_error,
380
+ terminal_delivery_error,
381
+ instrumentation_error
382
+ ].compact
383
+ final_error = final_errors.find { |caught| caught.is_a?(CleanupError) } || final_errors.first
384
+ raise final_error if final_error
385
+ end
386
+
387
+ def emit(type, **data, &block)
388
+ emit_event(StreamEvent.build(type, **data), &block)
389
+ end
390
+
391
+ def emit_event(event)
392
+ yield event
393
+ end
394
+
395
+ def yield_event(event)
396
+ yield event
397
+ end
398
+
399
+ def instrument(name, attributes = {})
400
+ Instrumentation.publish(name, **correlation_attributes.merge(attributes.compact))
401
+ end
402
+
403
+ def correlation_attributes
404
+ {
405
+ operation_id:,
406
+ run_id: invocation.run_id,
407
+ invocation_id: invocation.invocation_id,
408
+ session_id: invocation.session_id,
409
+ service_name: runtime.service_name,
410
+ agent_id: workflow_run? ? nil : entrypoint_name,
411
+ workflow_name: workflow_run? ? entrypoint_name : nil
412
+ }.compact
413
+ end
414
+
415
+ def workflow_run? = entrypoint_class <= Workflow
416
+
417
+ def entrypoint_name
418
+ return entrypoint_class.name.to_s if workflow_run?
419
+
420
+ return @entrypoint.entrypoint_name if @entrypoint&.respond_to?(:entrypoint_name)
421
+
422
+ entrypoint_class.agent_id
423
+ end
424
+
425
+ def instrument_subagent(data)
426
+ value = data.fetch(:event)
427
+ event = value[:event] || value["event"]
428
+ attributes = {
429
+ subagent_id: value[:subagent_id] || value["subagent_id"],
430
+ conversation_id: value[:conversation_id] || value["conversation_id"],
431
+ resumed: value[:resumed] || value["resumed"],
432
+ kind: value[:kind] || value["kind"],
433
+ turn: value[:turn] || value["turn"],
434
+ status: value[:status] || value["status"],
435
+ error_type: value[:error_type] || value["error_type"]
436
+ }.compact
437
+ parent_operation_id = value[:parent_operation_id] || value["parent_operation_id"] || operation_id
438
+ case event
439
+ when "factory_failed"
440
+ instrument(:subagent_factory_failed, attributes.merge(parent_operation_id:))
441
+ when "spawned", "message_queued"
442
+ subagent_operation_id = value[:operation_id] || value["operation_id"]
443
+ return unless subagent_operation_id
444
+
445
+ values = attributes.merge(
446
+ operation_id: subagent_operation_id,
447
+ parent_operation_id:,
448
+ agent_id: attributes[:subagent_id]
449
+ )
450
+ start_subagent_instrumentation(values)
451
+ instrument(:subagent_spawned, values)
452
+ when "turn_started"
453
+ supplied_operation_id = value[:operation_id] || value["operation_id"]
454
+ return unless supplied_operation_id
455
+
456
+ instrument(
457
+ :subagent_turn_started,
458
+ attributes.merge(operation_id: supplied_operation_id, parent_operation_id:)
459
+ )
460
+ when "turn_finished", "turn_failed", "cancelled"
461
+ supplied_operation_id = value[:operation_id] || value["operation_id"]
462
+ return unless supplied_operation_id
463
+
464
+ outcome = {"turn_finished" => :completed, "turn_failed" => :error, "cancelled" => :cancelled}.fetch(event)
465
+ values = attributes.merge(
466
+ operation_id: supplied_operation_id,
467
+ parent_operation_id:,
468
+ agent_id: attributes[:subagent_id],
469
+ outcome:,
470
+ error_type: (event == "turn_failed") ? attributes[:error_type] || "LittleGhost::SubagentError" : nil
471
+ ).compact
472
+ instrument(:subagent_finished, values)
473
+ finish_subagent_instrumentation(supplied_operation_id, values)
474
+ end
475
+ end
476
+
477
+ def instrument_event(type, data)
478
+ case type.to_sym
479
+ when :subagent
480
+ instrument_subagent(data)
481
+ when :model_retry
482
+ error = data[:error]
483
+ error_class = error.class.name if error.is_a?(Exception)
484
+ error_class ||= error if error.is_a?(String) && error.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
485
+ attributes = data.slice(:attempt, :delay, :error_code, :http_status, :partial_text)
486
+ instrument(:model_retry, attributes.merge(error_class:).compact)
487
+ end
488
+ end
489
+
490
+ def start_subagent_instrumentation(attributes)
491
+ operation_id = attributes.fetch(:operation_id)
492
+ @subagent_instrumentation_mutex.synchronize do
493
+ return if @subagent_instrumentation.key?(operation_id)
494
+
495
+ values = correlation_attributes.merge(attributes).except(:operation_id, :parent_operation_id)
496
+ @subagent_instrumentation[operation_id] = Instrumentation.start(
497
+ :subagent,
498
+ parent: attributes.fetch(:parent_operation_id),
499
+ operation_id:,
500
+ detached: true,
501
+ **values
502
+ )
503
+ end
504
+ end
505
+
506
+ def finish_subagent_instrumentation(operation_id, attributes)
507
+ handle = @subagent_instrumentation_mutex.synchronize do
508
+ @subagent_instrumentation.delete(operation_id)
509
+ end
510
+ return unless handle
511
+
512
+ handle.finish(**attributes.except(:operation_id, :parent_operation_id))
513
+ end
514
+
515
+ def finish_remaining_subagent_instrumentation(outcome:, error_type: nil)
516
+ handles = @subagent_instrumentation_mutex.synchronize do
517
+ @subagent_instrumentation.values.tap { @subagent_instrumentation.clear }
518
+ end
519
+ errors = handles.filter_map do |handle|
520
+ handle.finish(outcome:, error_type:)
521
+ nil
522
+ rescue => error
523
+ error
524
+ end
525
+ raise errors.first if errors.any?
526
+ end
527
+
528
+ def usage_attributes(usage)
529
+ usage.respond_to?(:to_h) ? usage.to_h : {}
530
+ end
531
+
532
+ def monotonic_time
533
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
534
+ end
535
+
536
+ def duration_ms(started_at)
537
+ ((monotonic_time - started_at) * 1_000).round(3)
538
+ end
539
+
540
+ def begin_execution!
541
+ @mutex.synchronize do
542
+ raise Error, "run has already started" if @started
543
+ @started = true
544
+ end
545
+ @interruption_mutex.synchronize { @interruption_state = :starting }
546
+ end
547
+
548
+ def close_callback(resource)
549
+ raise ArgumentError, "resource must respond to close or a block must be provided" unless resource&.respond_to?(:close)
550
+
551
+ -> { resource.close }
552
+ end
553
+
554
+ def cleanup_error_message(error)
555
+ error_message(error)
556
+ rescue
557
+ "The run could not cleanly stop all work."
558
+ end
559
+
560
+ def error_message(error)
561
+ runtime.error_message(error, self)
562
+ end
563
+
564
+ def diagnostic_invocation_message
565
+ message = invocation.message
566
+ return message unless message.respond_to?(:text)
567
+ return message.text unless message.text.empty?
568
+
569
+ {
570
+ role: message.role,
571
+ content: message.content.map { |block| diagnostic_invocation_content(block) }
572
+ }
573
+ end
574
+
575
+ def diagnostic_invocation_content(block)
576
+ case block
577
+ when Content::Text
578
+ {type: "text", text: block.text}
579
+ when Content::Reasoning
580
+ {type: "reasoning", text: block.text}
581
+ when Content::Image
582
+ {type: "image", media_type: block.media_type, bytes: block.data.bytesize}
583
+ when Content::Document
584
+ {type: "document", media_type: block.media_type, name: block.name, bytes: block.data.bytesize}
585
+ when Content::ToolUse
586
+ {type: "tool_use", id: block.id, name: block.name, input: block.input}
587
+ when Content::ToolResult
588
+ {
589
+ type: "tool_result",
590
+ tool_use_id: block.tool_use_id,
591
+ content: block.content.to_s,
592
+ status: block.status
593
+ }
594
+ else
595
+ block.to_s
596
+ end
597
+ end
598
+
599
+ def diagnostic_exception(error)
600
+ {
601
+ type: error.class.name,
602
+ message: error.message,
603
+ stacktrace: Array(error.backtrace).join("\n")
604
+ }
605
+ end
606
+ end
607
+ end