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,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry-api"
5
+
6
+ module LittleGhost
7
+ # Optional adapters for sending LittleGhost instrumentation to tracing tools.
8
+ module Tracing
9
+ # OpenTelemetry turns LittleGhost lifecycle notifications into GenAI spans and
10
+ # events. It brings agents, model calls, tools, workflows, and token usage into
11
+ # the same traces as the rest of an application.
12
+ #
13
+ # LittleGhost.configure do |config|
14
+ # config.instrument LittleGhost::Tracing::OpenTelemetry.new
15
+ # end
16
+ #
17
+ # LittleGhost depends only on +opentelemetry-api+. Install and configure the
18
+ # desired SDK, processors, and exporters before registering this subscriber.
19
+ #
20
+ # === Content and trust
21
+ #
22
+ # Prompts, responses, messages, tool arguments, and exception content are
23
+ # omitted by default. They appear only when Instrumentation has an explicit,
24
+ # scrubbed Support::ContentCapture policy.
25
+ class OpenTelemetry < Instrumentation::Subscriber
26
+ ATTRIBUTE_NAMES = {
27
+ agent_id: "gen_ai.agent.id",
28
+ agent_name: "gen_ai.agent.name",
29
+ model_id: "gen_ai.request.model",
30
+ model_provider: "gen_ai.provider.name",
31
+ response_id: "gen_ai.response.id",
32
+ response_model: "gen_ai.response.model",
33
+ finish_reasons: "gen_ai.response.finish_reasons",
34
+ input_tokens: "gen_ai.usage.input_tokens",
35
+ output_tokens: "gen_ai.usage.output_tokens",
36
+ cache_read_tokens: "gen_ai.usage.cache_read.input_tokens",
37
+ cache_write_tokens: "gen_ai.usage.cache_creation.input_tokens",
38
+ reasoning_tokens: "gen_ai.usage.reasoning.output_tokens",
39
+ time_to_first_token: "gen_ai.response.time_to_first_chunk",
40
+ session_id: "gen_ai.conversation.id",
41
+ tool_name: "gen_ai.tool.name",
42
+ tool_type: "gen_ai.tool.type",
43
+ tool_call_id: "gen_ai.tool.call.id",
44
+ workflow_name: "gen_ai.workflow.name",
45
+ http_response_status_code: "http.response.status_code",
46
+ error_class: "error.type",
47
+ error_type: "error.type"
48
+ }.freeze # :nodoc:
49
+ OPERATIONS = {
50
+ agent: "invoke_agent",
51
+ agent_turn: "agent_turn",
52
+ model: "chat",
53
+ run: "invoke_agent",
54
+ runtime: "runtime_startup",
55
+ session_store: "session_store",
56
+ subagent: "invoke_agent",
57
+ tool: "execute_tool",
58
+ workflow: "invoke_workflow"
59
+ }.freeze # :nodoc:
60
+ REQUEST_SETTING_ATTRIBUTES = {
61
+ frequency_penalty: "gen_ai.request.frequency_penalty",
62
+ max_tokens: "gen_ai.request.max_tokens",
63
+ max_output_tokens: "gen_ai.request.max_tokens",
64
+ presence_penalty: "gen_ai.request.presence_penalty",
65
+ seed: "gen_ai.request.seed",
66
+ temperature: "gen_ai.request.temperature",
67
+ top_k: "gen_ai.request.top_k",
68
+ top_p: "gen_ai.request.top_p"
69
+ }.freeze # :nodoc:
70
+ CONTENT_KEYS = %w[arguments content input input_text message output output_text prompt response text].freeze # :nodoc:
71
+ SENSITIVE_KEY = /(authorization|api[_-]?key|credential|password|secret|(?:^|[_-])token(?:$|[_-])|cookie|private[_-]?key)/i # :nodoc:
72
+ MAX_ATTRIBUTE_LENGTH = 1_024 # :nodoc:
73
+
74
+ # Uses +tracer+ or the global tracer provider.
75
+ def initialize(tracer: nil)
76
+ @tracer = tracer
77
+ @entries = {}
78
+ @mutex = Mutex.new
79
+ end
80
+
81
+ # Starts a span for a lifecycle operation.
82
+ def start(name, attributes)
83
+ start_span(name.to_sym, prepare_attributes(:start, name.to_sym, attributes))
84
+ end
85
+
86
+ # Finishes the span correlated by operation ID.
87
+ def finish(name, attributes)
88
+ finish_span(name.to_sym, prepare_attributes(:finish, name.to_sym, attributes))
89
+ end
90
+
91
+ # Adds a structured event to the correlated active span.
92
+ def emit(name, attributes)
93
+ add_event(name.to_sym, prepare_attributes(:emit, name.to_sym, attributes))
94
+ end
95
+
96
+ # Supplies W3C propagation fields for an active operation when known.
97
+ def trace_context(operation_id: nil, **)
98
+ span = @mutex.synchronize { @entries.dig(operation_id, :span) }
99
+ context = span&.context
100
+ return {} unless context&.valid?
101
+
102
+ carrier = {}
103
+ trace_context_propagator.inject(
104
+ carrier,
105
+ context: ::OpenTelemetry::Trace.context_with_span(span)
106
+ )
107
+ carrier.slice("traceparent", "tracestate").merge(trace_id: context.hex_trace_id)
108
+ rescue
109
+ context&.valid? ? {trace_id: context.hex_trace_id} : {}
110
+ end
111
+
112
+ # Wraps a block in a standalone internal span. Prefer lifecycle
113
+ # Instrumentation methods for normal framework operations.
114
+ def with_span(name, attributes:, parent_operation_id: nil)
115
+ parent = @mutex.synchronize { @entries[parent_operation_id] }
116
+ parent_context = parent && ::OpenTelemetry::Trace.context_with_span(parent.fetch(:span))
117
+ options = {kind: :internal, attributes:}
118
+ options[:with_parent] = parent_context if parent_context
119
+ span = tracer.start_span(name, **options)
120
+ yield span
121
+ rescue => error
122
+ span.status = ::OpenTelemetry::Trace::Status.error(error.class.name) if span
123
+ raise
124
+ ensure
125
+ span&.finish
126
+ end
127
+
128
+ # Flushes through the configured tracer provider when supported.
129
+ def flush(timeout: nil)
130
+ provider = ::OpenTelemetry.tracer_provider
131
+ provider.force_flush(timeout:) if provider.respond_to?(:force_flush)
132
+ end
133
+
134
+ # Finishes spans still owned by this subscriber.
135
+ def shutdown(timeout: nil)
136
+ spans = @mutex.synchronize do
137
+ current = @entries.values.map { |entry| entry.fetch(:span) }.uniq
138
+ @entries = {}
139
+ current
140
+ end
141
+ spans.each(&:finish)
142
+ end
143
+
144
+ private
145
+
146
+ def prepare_attributes(_phase, _name, attributes) = attributes
147
+
148
+ def tracer
149
+ @tracer || ::OpenTelemetry.tracer_provider.tracer("little_ghost", LittleGhost::VERSION)
150
+ end
151
+
152
+ def start_span(name, attributes)
153
+ kind = name.to_sym
154
+ kind = :workflow if kind == :run && attributes[:entrypoint_kind]&.to_sym == :workflow
155
+ parent = @mutex.synchronize { @entries[attributes[:parent_operation_id]] }
156
+ if kind == :agent && parent&.fetch(:kind) == :run &&
157
+ parent[:agent_id].to_s == attributes[:agent_id].to_s
158
+ alias_primary_agent(attributes, parent)
159
+ return
160
+ end
161
+
162
+ parent_context = if parent
163
+ ::OpenTelemetry::Trace.context_with_span(parent.fetch(:span))
164
+ elsif attributes[:trace_context].is_a?(Hash)
165
+ trace_context_propagator.extract(attributes[:trace_context])
166
+ end
167
+ options = {
168
+ kind: span_kind(kind),
169
+ attributes: span_attributes(attributes, kind:).merge(
170
+ "gen_ai.operation.name" => OPERATIONS.fetch(kind, kind.to_s)
171
+ )
172
+ }
173
+ links = trace_links(attributes[:trace_links])
174
+ options[:links] = links unless links.empty?
175
+ span = if parent_context
176
+ tracer.start_span(span_name(kind, attributes), with_parent: parent_context, **options)
177
+ elsif links.empty?
178
+ tracer.start_span(span_name(kind, attributes), **options)
179
+ else
180
+ tracer.start_root_span(span_name(kind, attributes), **options)
181
+ end
182
+ @mutex.synchronize do
183
+ @entries[attributes.fetch(:operation_id)] = {
184
+ span:,
185
+ kind:,
186
+ alias: false,
187
+ agent_id: attributes[:agent_id]
188
+ }
189
+ end
190
+ end
191
+
192
+ def alias_primary_agent(attributes, parent)
193
+ span = parent.fetch(:span)
194
+ span_attributes(attributes, kind: :agent).each do |key, value|
195
+ span.set_attribute(key, value)
196
+ end
197
+ @mutex.synchronize do
198
+ @entries[attributes.fetch(:operation_id)] = {
199
+ span:,
200
+ kind: :agent,
201
+ alias: true,
202
+ agent_id: attributes[:agent_id]
203
+ }
204
+ end
205
+ end
206
+
207
+ def finish_span(name, attributes)
208
+ entry = @mutex.synchronize { @entries.delete(attributes[:operation_id]) }
209
+ return add_event(:orphan_finish, attributes.merge(instrumentation_name: name)) unless entry
210
+
211
+ span = entry.fetch(:span)
212
+ finish_attributes = attributes.except(:diagnostic_input, :diagnostic_tool_definitions)
213
+ span_attributes(finish_attributes, kind: entry.fetch(:kind)).each do |key, value|
214
+ span.set_attribute(key, value)
215
+ end
216
+ return if entry.fetch(:alias)
217
+
218
+ record_status(span, attributes, kind: entry.fetch(:kind))
219
+ span.finish
220
+ end
221
+
222
+ def record_status(span, attributes, kind:)
223
+ error_type = attributes[:error_type] || attributes[:error_class]
224
+ unless error_type
225
+ span.status = ::OpenTelemetry::Trace::Status.ok
226
+ return
227
+ end
228
+
229
+ event_attributes = {"exception.type" => error_type.to_s}
230
+ captured = parse_json(attributes[:diagnostic_exception])
231
+ if captured.is_a?(Hash)
232
+ event_attributes["exception.message"] = attribute_value(:exception_message, captured["message"])
233
+ stacktrace = captured["stacktrace"]
234
+ event_attributes["exception.stacktrace"] = String(stacktrace).slice(0, 64_000) if stacktrace
235
+ end
236
+ description = [error_type, event_attributes["exception.message"]].compact.join(": ")
237
+ span.status = ::OpenTelemetry::Trace::Status.error(description)
238
+ event_name = (kind == :model) ? "gen_ai.client.operation.exception" : "exception"
239
+ span.add_event(event_name, attributes: event_attributes.compact)
240
+ end
241
+
242
+ def add_event(name, attributes)
243
+ owner_ids = [attributes[:operation_id], attributes[:parent_operation_id]].compact.uniq
244
+ span = @mutex.synchronize do
245
+ owner_ids.filter_map { |owner_id| @entries.dig(owner_id, :span) }.first
246
+ end
247
+ kind = attributes[:event_kind]&.to_sym
248
+ if span
249
+ span.add_event("little_ghost.#{name}", attributes: span_attributes(attributes, kind:))
250
+ else
251
+ tracer.in_span("little_ghost.#{name}", attributes: span_attributes(attributes, kind:)) {}
252
+ end
253
+ end
254
+
255
+ def span_name(kind, attributes)
256
+ return attribute_value(:span_name, attributes[:span_name]) if attributes[:span_name]
257
+
258
+ detail = case kind
259
+ when :agent, :run then attributes[:agent_name] || attributes[:agent_id]
260
+ when :workflow then attributes[:workflow_name]
261
+ when :agent_turn then attributes[:turn]
262
+ when :model then attributes[:model_id]
263
+ when :subagent then attributes[:subagent_id] || attributes[:kind]
264
+ when :tool then attributes[:tool_name]
265
+ end
266
+ detail = attribute_value(:span_name, detail) if detail
267
+ [OPERATIONS.fetch(kind, kind.to_s), detail].compact.join(" ")
268
+ end
269
+
270
+ def span_attributes(attributes, kind: nil)
271
+ attributes.each_with_object({}) do |(key, value), result|
272
+ next if internal_attribute?(key)
273
+
274
+ if key.to_sym == :model_settings
275
+ add_model_settings(result, value)
276
+ next
277
+ end
278
+ if key.to_sym == :diagnostic_tool_definitions
279
+ definitions = parse_json(value)
280
+ if kind == :tool
281
+ add_tool_metadata(result, definitions)
282
+ else
283
+ add_tool_definitions(result, definitions)
284
+ end
285
+ next
286
+ end
287
+ if key.to_sym == :total_tokens
288
+ next
289
+ end
290
+ next if %i[available_tools duration_ms outcome diagnostic_exception].include?(key.to_sym)
291
+
292
+ if %i[diagnostic_input diagnostic_output].include?(key.to_sym)
293
+ add_content_attributes(result, key, attribute_value(key, value), kind:, attributes:)
294
+ next
295
+ end
296
+
297
+ name = key.to_s.include?(".") ? key.to_s : ATTRIBUTE_NAMES.fetch(key.to_sym, "little_ghost.#{key}")
298
+ normalized = if key.to_sym == :model_provider
299
+ provider_name(value)
300
+ else
301
+ attribute_value(key, value)
302
+ end
303
+ result[name] = gen_ai_usage_value(key, attributes, normalized)
304
+ if key.to_sym == :stop_reason && value
305
+ result["gen_ai.response.finish_reasons"] = [normalized]
306
+ end
307
+ end
308
+ end
309
+
310
+ def internal_attribute?(key)
311
+ %i[event_kind operation_id parent_operation_id trace_context trace_links entrypoint_kind span_name].include?(key.to_sym)
312
+ end
313
+
314
+ def add_model_settings(attributes, settings)
315
+ settings.to_h.each do |key, value|
316
+ name = REQUEST_SETTING_ATTRIBUTES[key.to_sym]
317
+ attributes[name] = scalar(value) if name && !scalar(value).nil?
318
+ end
319
+ rescue NoMethodError
320
+ nil
321
+ end
322
+
323
+ def add_tool_definitions(attributes, definitions)
324
+ return unless definitions.is_a?(Array)
325
+
326
+ attributes["gen_ai.tool.definitions"] = json_attribute(
327
+ definitions.map do |definition|
328
+ values = definition.to_h.transform_keys(&:to_sym)
329
+ {
330
+ type: "function",
331
+ name: values[:name],
332
+ description: values[:description],
333
+ parameters: values[:input_schema] || {}
334
+ }
335
+ end
336
+ )
337
+ end
338
+
339
+ def add_tool_metadata(attributes, definitions)
340
+ return unless definitions.is_a?(Array) && definitions.first.is_a?(Hash)
341
+
342
+ definition = definitions.first
343
+ attributes["gen_ai.tool.description"] = definition["description"].to_s if definition["description"]
344
+ end
345
+
346
+ def add_content_attributes(result, key, value, kind:, attributes:)
347
+ return unless %i[diagnostic_input diagnostic_output].include?(key.to_sym)
348
+
349
+ prefix = (key.to_sym == :diagnostic_input) ? "input" : "output"
350
+ if %i[agent interrupt tool].include?(kind) &&
351
+ !(kind == :tool && prefix == "output" && attributes[:error_type])
352
+ result["#{prefix}.value"] = value
353
+ result["#{prefix}.mime_type"] = diagnostic_mime_type(value)
354
+ end
355
+ if kind == :tool
356
+ name = (prefix == "input") ? "gen_ai.tool.call.arguments" : "gen_ai.tool.call.result"
357
+ result[name] = value unless prefix == "output" && attributes[:error_type]
358
+ end
359
+ return unless kind == :model
360
+
361
+ semantic_name = (prefix == "input") ? "gen_ai.input.messages" : "gen_ai.output.messages"
362
+ messages = canonical_messages(
363
+ value,
364
+ finish_reason: prefix == "output" && (attributes[:stop_reason] || Array(attributes[:finish_reasons]).first)
365
+ )
366
+ if messages
367
+ result[semantic_name] = messages
368
+ result["#{prefix}.value"] = messages
369
+ result["#{prefix}.mime_type"] = "application/json"
370
+ else
371
+ result["#{prefix}.value"] = value
372
+ result["#{prefix}.mime_type"] = diagnostic_mime_type(value)
373
+ end
374
+ end
375
+
376
+ def diagnostic_mime_type(value)
377
+ return "application/json" if value.is_a?(Hash) || value.is_a?(Array)
378
+
379
+ JSON.parse(value)
380
+ "application/json"
381
+ rescue JSON::ParserError, TypeError
382
+ "text/plain"
383
+ end
384
+
385
+ def canonical_messages(value, finish_reason: nil)
386
+ messages = parse_json(value)
387
+ messages = [messages] if messages.is_a?(Hash)
388
+ return unless messages.is_a?(Array)
389
+
390
+ normalized_messages = messages.filter_map do |message|
391
+ next unless message.is_a?(Hash)
392
+
393
+ role = message["role"].to_s
394
+ next if role.empty?
395
+
396
+ normalized = {
397
+ role:,
398
+ parts: Array(message["content"]).filter_map { |part| canonical_message_part(part) }
399
+ }
400
+ normalized[:finish_reason] = finish_reason.to_s if finish_reason
401
+ normalized
402
+ end
403
+ return if normalized_messages.empty?
404
+
405
+ json_attribute(normalized_messages)
406
+ end
407
+
408
+ def canonical_message_part(part)
409
+ return {type: "text", content: part.to_s} unless part.is_a?(Hash)
410
+
411
+ case part["type"]
412
+ when "text", "reasoning"
413
+ {type: part.fetch("type"), content: part["text"].to_s}
414
+ when "tool_use"
415
+ {
416
+ type: "tool_call",
417
+ id: part["id"].to_s,
418
+ name: part["name"].to_s,
419
+ arguments: part["input"] || {}
420
+ }
421
+ when "tool_result"
422
+ {
423
+ type: "tool_call_response",
424
+ id: part["tool_use_id"].to_s,
425
+ response: part["content"]
426
+ }
427
+ when "image", "document"
428
+ {
429
+ type: "blob",
430
+ mime_type: part["media_type"].to_s,
431
+ name: part["name"],
432
+ size: part["bytes"]
433
+ }.compact
434
+ else
435
+ {type: "text", content: part.to_s}
436
+ end
437
+ end
438
+
439
+ def span_kind(kind)
440
+ %i[model session_store].include?(kind) ? :client : :internal
441
+ end
442
+
443
+ def gen_ai_usage_value(key, attributes, value)
444
+ if key.to_sym == :input_tokens
445
+ return value.to_i + attributes.fetch(:cache_read_tokens, 0).to_i +
446
+ attributes.fetch(:cache_write_tokens, 0).to_i
447
+ end
448
+ return value.to_i + attributes.fetch(:reasoning_tokens, 0).to_i if key.to_sym == :output_tokens
449
+
450
+ value
451
+ end
452
+
453
+ def trace_links(carriers)
454
+ Array(carriers).filter_map do |carrier|
455
+ next unless carrier.is_a?(Hash)
456
+
457
+ context = trace_context_propagator.extract(carrier)
458
+ span_context = ::OpenTelemetry::Trace.current_span(context).context
459
+ ::OpenTelemetry::Trace::Link.new(span_context) if span_context.valid?
460
+ rescue
461
+ nil
462
+ end
463
+ end
464
+
465
+ def attribute_value(key, value)
466
+ return value if key.to_sym == :time_to_first_token && value.is_a?(Numeric)
467
+
468
+ key_name = key.to_s
469
+ normalized_key = key_name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
470
+ return "[REDACTED]" if SENSITIVE_KEY.match?(normalized_key) || CONTENT_KEYS.include?(normalized_key)
471
+
472
+ case value
473
+ when String
474
+ limit = key.to_s.start_with?("diagnostic_") ? value.length : MAX_ATTRIBUTE_LENGTH
475
+ value.slice(0, limit)
476
+ when Symbol then value.to_s
477
+ when Numeric, true, false then value
478
+ when Array then value.filter_map { |item| scalar(item) }
479
+ when Hash then json_attribute(value)
480
+ else value.to_s.slice(0, MAX_ATTRIBUTE_LENGTH)
481
+ end
482
+ end
483
+
484
+ def json_attribute(value)
485
+ JSON.generate(value)
486
+ rescue JSON::GeneratorError, TypeError
487
+ JSON.generate("[UNSERIALIZABLE]")
488
+ end
489
+
490
+ def parse_json(value)
491
+ JSON.parse(value) if value.is_a?(String)
492
+ rescue JSON::ParserError
493
+ nil
494
+ end
495
+
496
+ def provider_name(value)
497
+ {
498
+ "bedrock" => "aws.bedrock",
499
+ "open_router" => "openrouter"
500
+ }.fetch(value.to_s, value.to_s)
501
+ end
502
+
503
+ def trace_context_propagator
504
+ @trace_context_propagator ||=
505
+ ::OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
506
+ end
507
+
508
+ def scalar(value)
509
+ case value
510
+ when String then value.slice(0, MAX_ATTRIBUTE_LENGTH)
511
+ when Symbol then value.to_s
512
+ when Numeric, true, false then value
513
+ end
514
+ end
515
+ end
516
+ end
517
+ end