ai-agents 0.11.0 → 0.13.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/CLAUDE.md +1 -1
  4. data/README.md +3 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/concepts/agents.md +16 -0
  7. data/docs/concepts/tools.md +1 -1
  8. data/docs/guides/instrumentation.md +54 -5
  9. data/docs/guides/rails-integration.md +2 -2
  10. data/docs/guides/state-persistence.md +2 -2
  11. data/docs/guides/structured-output.md +3 -3
  12. data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +5 -5
  13. data/examples/collaborative-copilot/tools/get_article_tool.rb +1 -1
  14. data/examples/collaborative-copilot/tools/get_contact_tool.rb +1 -1
  15. data/examples/collaborative-copilot/tools/get_conversation_tool.rb +1 -1
  16. data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +1 -1
  17. data/examples/collaborative-copilot/tools/search_contacts_tool.rb +3 -2
  18. data/examples/collaborative-copilot/tools/search_conversations_tool.rb +2 -2
  19. data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +4 -3
  20. data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +5 -4
  21. data/examples/isp-support/agents_factory.rb +4 -4
  22. data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
  23. data/examples/isp-support/tools/create_lead_tool.rb +3 -3
  24. data/examples/isp-support/tools/crm_lookup_tool.rb +1 -1
  25. data/examples/isp-support/tools/search_docs_tool.rb +1 -1
  26. data/lib/agents/agent.rb +19 -6
  27. data/lib/agents/agent_tool.rb +1 -1
  28. data/lib/agents/handoff.rb +2 -5
  29. data/lib/agents/helpers/message_extractor.rb +24 -8
  30. data/lib/agents/instrumentation/constants.rb +6 -0
  31. data/lib/agents/instrumentation/tracing_callbacks.rb +153 -32
  32. data/lib/agents/instrumentation.rb +19 -1
  33. data/lib/agents/run_context.rb +5 -4
  34. data/lib/agents/runner.rb +78 -57
  35. data/lib/agents/tool.rb +8 -1
  36. data/lib/agents/tool_wrapper.rb +16 -13
  37. data/lib/agents/version.rb +1 -1
  38. data/lib/agents.rb +4 -2
  39. metadata +3 -3
@@ -76,20 +76,21 @@ module Agents
76
76
  end
77
77
 
78
78
  def extract_user_or_assistant_message(msg, current_agent)
79
- content_present = message_content?(msg)
80
79
  tool_calls_present = assistant_tool_calls?(msg)
81
- return nil unless content_present || tool_calls_present
80
+ return nil unless extractable_message?(msg, tool_calls_present)
82
81
 
83
- message = {
84
- role: msg.role,
85
- content: content_present ? msg.content : ""
86
- }
82
+ message = { role: msg.role, content: persisted_content(msg) }
87
83
 
88
84
  return message unless msg.role == :assistant
89
85
 
90
86
  attributed_agent_name = attributed_agent_name_for(msg) || current_agent&.name
91
87
  message[:agent_name] = attributed_agent_name if attributed_agent_name
92
88
 
89
+ if msg.respond_to?(:thinking) && msg.thinking&.signature
90
+ message[:thinking] = msg.thinking.text if msg.thinking.text
91
+ message[:thinking_signature] = msg.thinking.signature
92
+ end
93
+
93
94
  if tool_calls_present
94
95
  # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
95
96
  # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
@@ -99,6 +100,21 @@ module Agents
99
100
  message
100
101
  end
101
102
 
103
+ def persisted_content(msg)
104
+ content = message_content?(msg) ? msg.content : ""
105
+ return content if msg.attachments.empty?
106
+
107
+ parts = content.to_s.empty? ? [] : [{ type: "text", text: content }]
108
+ parts + msg.attachments.map do |attachment|
109
+ { type: "image_url", image_url: { url: attachment.source.to_s } }
110
+ end
111
+ end
112
+
113
+ def extractable_message?(msg, tool_calls_present)
114
+ message_content?(msg) || tool_calls_present || msg.attachments.any? ||
115
+ (msg.role == :assistant && msg.respond_to?(:thinking) && msg.thinking&.signature)
116
+ end
117
+
102
118
  def message_content?(msg)
103
119
  msg.content && !content_empty?(msg.content)
104
120
  end
@@ -117,8 +133,8 @@ module Agents
117
133
  }
118
134
  end
119
135
 
120
- private_class_method :extract_user_or_assistant_message, :message_content?, :assistant_tool_calls?,
121
- :extract_tool_message
136
+ private_class_method :extract_user_or_assistant_message, :persisted_content, :extractable_message?,
137
+ :message_content?, :assistant_tool_calls?, :extract_tool_message
122
138
  end
123
139
  end
124
140
  end
@@ -15,21 +15,27 @@ module Agents
15
15
 
16
16
  # GenAI semantic conventions (ONLY on generation spans)
17
17
  ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
18
+ ATTR_GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
18
19
  ATTR_GEN_AI_PROVIDER = "gen_ai.provider.name"
19
20
  ATTR_GEN_AI_USAGE_INPUT = "gen_ai.usage.input_tokens"
20
21
  ATTR_GEN_AI_USAGE_OUTPUT = "gen_ai.usage.output_tokens"
22
+ ATTR_GEN_AI_USAGE_REASONING_OUTPUT = "gen_ai.usage.reasoning.output_tokens"
21
23
 
22
24
  # Langfuse trace-level attributes
23
25
  ATTR_LANGFUSE_USER_ID = "langfuse.user.id"
24
26
  ATTR_LANGFUSE_SESSION_ID = "langfuse.session.id"
27
+ ATTR_LANGFUSE_PREFIX = "langfuse."
25
28
  ATTR_LANGFUSE_TRACE_TAGS = "langfuse.trace.tags"
26
29
  ATTR_LANGFUSE_TRACE_INPUT = "langfuse.trace.input"
27
30
  ATTR_LANGFUSE_TRACE_OUTPUT = "langfuse.trace.output"
31
+ ATTR_LANGFUSE_TRACE_METADATA_PREFIX = "langfuse.trace.metadata."
28
32
 
29
33
  # Langfuse observation-level attributes
30
34
  ATTR_LANGFUSE_OBS_TYPE = "langfuse.observation.type"
31
35
  ATTR_LANGFUSE_OBS_INPUT = "langfuse.observation.input"
32
36
  ATTR_LANGFUSE_OBS_OUTPUT = "langfuse.observation.output"
37
+ ATTR_LANGFUSE_OBS_METADATA_PREFIX = "langfuse.observation.metadata."
38
+ ATTR_LANGFUSE_REASONING_SUMMARY = "langfuse.observation.metadata.reasoning_summary"
33
39
  end
34
40
  end
35
41
  end
@@ -18,6 +18,15 @@ module Agents
18
18
  class TracingCallbacks
19
19
  include Constants
20
20
 
21
+ CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES = [
22
+ ATTR_LANGFUSE_TRACE_INPUT,
23
+ ATTR_LANGFUSE_TRACE_OUTPUT,
24
+ ATTR_LANGFUSE_OBS_INPUT,
25
+ ATTR_LANGFUSE_OBS_OUTPUT,
26
+ ATTR_LANGFUSE_OBS_TYPE
27
+ ].freeze
28
+ private_constant :CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES
29
+
21
30
  def initialize(tracer:, trace_name: SPAN_RUN, span_attributes: {}, attribute_provider: nil)
22
31
  @tracer = tracer
23
32
  @trace_name = trace_name
@@ -31,6 +40,7 @@ module Agents
31
40
 
32
41
  def on_run_start(agent_name, input, context_wrapper)
33
42
  attributes = build_root_attributes(agent_name, input, context_wrapper)
43
+ child_attributes = build_child_langfuse_attributes(attributes)
34
44
 
35
45
  root_span = @tracer.start_span(@trace_name, attributes: attributes)
36
46
  root_context = OpenTelemetry::Trace.context_with_span(root_span)
@@ -38,6 +48,7 @@ module Agents
38
48
  store_tracing_state(context_wrapper,
39
49
  root_span: root_span,
40
50
  root_context: root_context,
51
+ child_langfuse_attributes: child_attributes,
41
52
  current_tool_span: nil,
42
53
  current_agent_name: nil,
43
54
  current_agent_span: nil,
@@ -49,6 +60,8 @@ module Agents
49
60
  return unless tracing
50
61
 
51
62
  tracing[:pending_llm_input] = serialize_output(input)
63
+ # The response hook runs after the provider call; preserve its real start time.
64
+ tracing[:llm_started_at] = Time.now
52
65
 
53
66
  return if tracing[:current_agent_name] == agent_name
54
67
 
@@ -66,12 +79,18 @@ module Agents
66
79
  finish_agent_span(tracing)
67
80
  end
68
81
 
69
- def on_chat_created(chat, agent_name, model, context_wrapper)
82
+ def on_chat_created(chat, _agent_name, model, context_wrapper, temperature = nil, protocol = nil, thinking = nil)
70
83
  tracing = tracing_state(context_wrapper)
71
84
  return unless tracing
72
85
 
73
- chat.on_end_message do |message|
74
- handle_end_message(chat, agent_name, model, message, context_wrapper)
86
+ tracing[:request_attributes] = { model: model, temperature: temperature,
87
+ protocol: protocol, thinking: thinking }
88
+ return if tracing[:instrumented_chat].equal?(chat)
89
+
90
+ tracing[:instrumented_chat] = chat
91
+
92
+ chat.after_message do |message|
93
+ handle_end_message(chat, message, context_wrapper)
75
94
  end
76
95
  end
77
96
 
@@ -84,6 +103,7 @@ module Agents
84
103
  ATTR_LANGFUSE_OBS_TYPE => "tool",
85
104
  ATTR_LANGFUSE_OBS_INPUT => serialize_output(args)
86
105
  }
106
+ attributes.merge!(tracing[:child_langfuse_attributes])
87
107
 
88
108
  parent = handoff_tool?(tool_name) ? tracing[:root_context] : parent_context(tracing)
89
109
  tool_span = @tracer.start_span(
@@ -95,7 +115,7 @@ module Agents
95
115
  tracing[:current_tool_span] = tool_span
96
116
  end
97
117
 
98
- def on_tool_complete(_tool_name, result, context_wrapper)
118
+ def on_tool_complete(_tool_name, result, context_wrapper, error = nil)
99
119
  tracing = tracing_state(context_wrapper)
100
120
  return unless tracing
101
121
 
@@ -103,6 +123,10 @@ module Agents
103
123
  return unless tool_span
104
124
 
105
125
  tool_span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, serialize_output(result))
126
+ if error
127
+ tool_span.record_exception(error)
128
+ tool_span.status = OpenTelemetry::Trace::Status.error(error.message)
129
+ end
106
130
  tool_span.finish
107
131
  tracing[:current_tool_span] = nil
108
132
  end
@@ -139,26 +163,44 @@ module Agents
139
163
 
140
164
  private
141
165
 
142
- def handle_end_message(chat, _agent_name, model, message, context_wrapper)
166
+ def handle_end_message(chat, message, context_wrapper)
143
167
  return unless message.respond_to?(:role) && message.role == :assistant
144
168
 
145
169
  tracing = tracing_state(context_wrapper)
146
170
  return unless tracing
147
171
 
148
- input = format_chat_messages(chat)
149
- attrs = {}
150
- attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input
151
- llm_span = @tracer.start_span(@llm_span_name, with_parent: parent_context(tracing), attributes: attrs)
172
+ llm_span = @tracer.start_span(
173
+ @llm_span_name,
174
+ with_parent: parent_context(tracing),
175
+ start_timestamp: tracing.delete(:llm_started_at),
176
+ attributes: generation_span_attributes(tracing, chat, message, context_wrapper)
177
+ )
152
178
 
153
- llm_span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
179
+ set_llm_request_attributes(llm_span, tracing[:request_attributes])
154
180
 
155
181
  output = llm_output_text(message)
156
- set_llm_response_attributes(llm_span, message, output)
182
+ set_llm_response_attributes(llm_span, message, output, tracing[:request_attributes])
157
183
  tracing[:last_agent_output] = output unless output.empty?
158
184
 
159
185
  llm_span.finish
160
186
  end
161
187
 
188
+ def generation_span_attributes(tracing, chat, message, context_wrapper)
189
+ attrs = tracing[:child_langfuse_attributes].dup
190
+ input = format_chat_messages(chat)
191
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input
192
+ apply_generation_dynamic_attributes(attrs, context_wrapper, chat, message)
193
+ attrs
194
+ end
195
+
196
+ def set_llm_request_attributes(span, request_attributes)
197
+ model = request_attributes[:model]
198
+ temperature = request_attributes[:temperature]
199
+
200
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
201
+ span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, temperature) unless temperature.nil?
202
+ end
203
+
162
204
  def finish_dangling_spans(tracing)
163
205
  if tracing[:current_tool_span]
164
206
  tracing[:current_tool_span].finish
@@ -187,16 +229,23 @@ module Agents
187
229
  root_span.status = OpenTelemetry::Trace::Status.error(error.message)
188
230
  end
189
231
 
190
- def set_llm_response_attributes(span, response, output)
191
- if response.respond_to?(:input_tokens) && response.input_tokens
192
- span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, response.input_tokens)
193
- end
194
- if response.respond_to?(:output_tokens) && response.output_tokens
195
- span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, response.output_tokens)
196
- end
232
+ def set_llm_response_attributes(span, response, output, request_attributes)
233
+ tokens = response.tokens if response.respond_to?(:tokens)
234
+ span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, tokens.input) if tokens&.input
235
+ span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, tokens.output) if tokens&.output
236
+ span.set_attribute(ATTR_GEN_AI_USAGE_REASONING_OUTPUT, tokens.thinking) if tokens&.thinking
237
+ set_reasoning_summary(span, response, request_attributes)
197
238
  span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, output) unless output.empty?
198
239
  end
199
240
 
241
+ def set_reasoning_summary(span, response, request_attributes)
242
+ return unless request_attributes[:protocol] == :responses
243
+ return unless request_attributes.dig(:thinking, :display).to_s == "summarized"
244
+ return unless response.respond_to?(:thinking) && response.thinking&.text
245
+
246
+ span.set_attribute(ATTR_LANGFUSE_REASONING_SUMMARY, response.thinking.text)
247
+ end
248
+
200
249
  # Returns serialized text content if present, otherwise falls back to tool call formatting.
201
250
  # Uses .to_json for Hash/Array (structured output) to avoid Ruby's .to_s format.
202
251
  def llm_output_text(response)
@@ -221,6 +270,7 @@ module Agents
221
270
  def format_single_message(msg)
222
271
  text = serialize_output(msg.content)
223
272
  text = append_tool_calls(msg, text)
273
+ text = append_attachment_summary(text, msg.attachments) if msg.respond_to?(:attachments)
224
274
  { role: msg.role.to_s, content: text }
225
275
  end
226
276
 
@@ -234,7 +284,16 @@ module Agents
234
284
  def serialize_output(value)
235
285
  return serialize_multimodal_content(value) if multimodal_content?(value)
236
286
 
237
- value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
287
+ return value.map { |part| redact_image_source(part) }.to_json if value.is_a?(Array)
288
+
289
+ value.is_a?(Hash) ? redact_image_source(value).to_json : value.to_s
290
+ end
291
+
292
+ def redact_image_source(part)
293
+ return part unless part.is_a?(Hash) && (part[:type] || part["type"]) == "image_url"
294
+
295
+ key = part.key?(:image_url) ? :image_url : "image_url"
296
+ part.merge(key => "[image]")
238
297
  end
239
298
 
240
299
  def format_tool_calls(response)
@@ -250,13 +309,9 @@ module Agents
250
309
  finish_agent_span(tracing) # close previous agent span if missed
251
310
 
252
311
  span_name = format(@agent_span_name, agent_name)
253
- attrs = { "agent.name" => agent_name }
254
- input = tracing[:pending_llm_input]
255
- attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
256
-
257
312
  agent_span = @tracer.start_span(span_name,
258
313
  with_parent: tracing[:root_context],
259
- attributes: attrs)
314
+ attributes: agent_span_attributes(tracing, agent_name))
260
315
  agent_context = OpenTelemetry::Trace.context_with_span(agent_span)
261
316
 
262
317
  tracing[:current_agent_name] = agent_name
@@ -265,6 +320,13 @@ module Agents
265
320
  tracing[:last_agent_output] = nil
266
321
  end
267
322
 
323
+ def agent_span_attributes(tracing, agent_name)
324
+ attrs = tracing[:child_langfuse_attributes].merge("agent.name" => agent_name)
325
+ input = tracing[:pending_llm_input]
326
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
327
+ attrs
328
+ end
329
+
268
330
  def finish_agent_span(tracing)
269
331
  return unless tracing[:current_agent_span]
270
332
 
@@ -297,6 +359,57 @@ module Agents
297
359
  attributes
298
360
  end
299
361
 
362
+ def build_child_langfuse_attributes(root_attributes)
363
+ root_attributes.each_with_object({}) do |(key, value), attrs|
364
+ next if value.nil?
365
+ next unless child_langfuse_attribute?(key)
366
+
367
+ attrs[key] = value
368
+ add_observation_metadata_mirror(attrs, key, value) if mirrored_observation_metadata_attribute?(key)
369
+ end
370
+ end
371
+
372
+ def child_langfuse_attribute?(key)
373
+ key.start_with?(ATTR_LANGFUSE_PREFIX) && !child_langfuse_excluded_attribute?(key)
374
+ end
375
+
376
+ def child_langfuse_excluded_attribute?(key)
377
+ CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES.include?(key)
378
+ end
379
+
380
+ def mirrored_observation_metadata_attribute?(key)
381
+ key == ATTR_LANGFUSE_USER_ID ||
382
+ key == ATTR_LANGFUSE_SESSION_ID ||
383
+ key == ATTR_LANGFUSE_TRACE_TAGS ||
384
+ key.start_with?(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
385
+ end
386
+
387
+ def add_observation_metadata_mirror(attrs, key, value)
388
+ metadata_key = observation_metadata_mirror_key(key)
389
+ attrs[observation_metadata_key(metadata_key)] = serialize_metadata_value(value)
390
+ end
391
+
392
+ def observation_metadata_mirror_key(key)
393
+ case key
394
+ when ATTR_LANGFUSE_USER_ID
395
+ "user_id"
396
+ when ATTR_LANGFUSE_SESSION_ID
397
+ "session_id"
398
+ when ATTR_LANGFUSE_TRACE_TAGS
399
+ "trace_tags"
400
+ else
401
+ key.delete_prefix(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
402
+ end
403
+ end
404
+
405
+ def observation_metadata_key(key)
406
+ "#{ATTR_LANGFUSE_OBS_METADATA_PREFIX}#{key}"
407
+ end
408
+
409
+ def serialize_metadata_value(value)
410
+ value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
411
+ end
412
+
300
413
  def apply_session_id(attributes, context_wrapper)
301
414
  session_id = context_wrapper&.context&.dig(:session_id)&.to_s
302
415
  attributes[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id && !session_id.empty?
@@ -317,6 +430,13 @@ module Agents
317
430
  attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
318
431
  end
319
432
 
433
+ def apply_generation_dynamic_attributes(attributes, context_wrapper, chat, message)
434
+ return unless @attribute_provider.respond_to?(:generation_attributes)
435
+
436
+ dynamic_attrs = @attribute_provider.generation_attributes(context_wrapper, chat, message)
437
+ attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
438
+ end
439
+
320
440
  def store_tracing_state(context_wrapper, **state)
321
441
  context_wrapper.context[:__otel_tracing] = state
322
442
  end
@@ -334,16 +454,17 @@ module Agents
334
454
  end
335
455
 
336
456
  def serialize_multimodal_content(content)
337
- parts = []
338
- text = content.text
339
- parts << text if text && !text.empty?
457
+ append_attachment_summary(content.text.to_s, content.attachments)
458
+ end
340
459
 
341
- if content.attachments&.any?
342
- urls = content.attachments.map { |a| a.respond_to?(:source) ? a.source.to_s : a.to_s }
343
- parts << "Attachments: #{urls.join(", ")}"
344
- end
460
+ def append_attachment_summary(text, attachments)
461
+ return text unless attachments&.any?
345
462
 
346
- parts.join("\n")
463
+ types = attachments.map do |attachment|
464
+ type = attachment.mime_type if attachment.respond_to?(:mime_type)
465
+ type.to_s.empty? ? "file" : type
466
+ end
467
+ [text, "Attachments: #{types.join(", ")}"].reject(&:empty?).join("\n")
347
468
  end
348
469
  end
349
470
  end
@@ -34,6 +34,22 @@ module Agents
34
34
  # { 'langfuse.user.id' => ctx.context[:account_id].to_s }
35
35
  # }
36
36
  # )
37
+ #
38
+ # @example With custom generation attributes
39
+ # class MyAttributeProvider
40
+ # def call(ctx)
41
+ # { 'langfuse.user.id' => ctx.context[:account_id].to_s }
42
+ # end
43
+ #
44
+ # def generation_attributes(_ctx, _chat, message)
45
+ # { 'app.generation.has_tool_calls' => message.tool_calls&.any? }
46
+ # end
47
+ # end
48
+ #
49
+ # Agents::Instrumentation.install(runner,
50
+ # tracer: tracer,
51
+ # attribute_provider: MyAttributeProvider.new
52
+ # )
37
53
  module Instrumentation
38
54
  INSTALL_MUTEX = Mutex.new
39
55
  private_constant :INSTALL_MUTEX
@@ -52,7 +68,9 @@ module Agents
52
68
  # @param tracer [OpenTelemetry::Trace::Tracer] OTel tracer instance
53
69
  # @param trace_name [String] Name for the root span (default: "agents.run")
54
70
  # @param span_attributes [Hash] Static attributes applied to the root span
55
- # @param attribute_provider [Proc, nil] Lambda receiving context_wrapper, returning dynamic attributes
71
+ # @param attribute_provider [#call, nil] Object receiving context_wrapper and returning dynamic root attributes.
72
+ # If it also responds to #generation_attributes, that method receives context_wrapper, chat, and message,
73
+ # and can return dynamic attributes for generation spans.
56
74
  # @return [Agents::AgentRunner, nil] The runner (for chaining), or nil if OTel is unavailable
57
75
  def self.install(runner, tracer:, trace_name: Constants::SPAN_RUN, span_attributes: {},
58
76
  attribute_provider: nil)
@@ -95,16 +95,17 @@ module Agents
95
95
 
96
96
  # Add usage metrics from an LLM response to the running totals.
97
97
  # Only tracks usage for responses that have token data (e.g., RubyLLM::Message).
98
- # Safely skips responses without token methods (e.g., RubyLLM::Tool::Halt).
98
+ # Safely skips values without token usage.
99
99
  #
100
100
  # @param response [RubyLLM::Message] A RubyLLM::Message object with token usage data
101
101
  # @example Adding usage from an LLM response
102
102
  # usage.add(llm_response)
103
103
  def add(response)
104
- return unless response.respond_to?(:input_tokens)
104
+ tokens = response.tokens if response.respond_to?(:tokens)
105
+ return unless tokens
105
106
 
106
- input = response.input_tokens || 0
107
- output = response.output_tokens || 0
107
+ input = tokens.input || 0
108
+ output = tokens.output || 0
108
109
 
109
110
  @input_tokens += input
110
111
  @output_tokens += output