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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +1 -1
- data/README.md +3 -3
- data/docs/architecture.md +1 -1
- data/docs/concepts/agents.md +16 -0
- data/docs/concepts/tools.md +1 -1
- data/docs/guides/instrumentation.md +54 -5
- data/docs/guides/rails-integration.md +2 -2
- data/docs/guides/state-persistence.md +2 -2
- data/docs/guides/structured-output.md +3 -3
- data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +5 -5
- data/examples/collaborative-copilot/tools/get_article_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_contact_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_conversation_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/search_contacts_tool.rb +3 -2
- data/examples/collaborative-copilot/tools/search_conversations_tool.rb +2 -2
- data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +4 -3
- data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +5 -4
- data/examples/isp-support/agents_factory.rb +4 -4
- data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
- data/examples/isp-support/tools/create_lead_tool.rb +3 -3
- data/examples/isp-support/tools/crm_lookup_tool.rb +1 -1
- data/examples/isp-support/tools/search_docs_tool.rb +1 -1
- data/lib/agents/agent.rb +19 -6
- data/lib/agents/agent_tool.rb +1 -1
- data/lib/agents/handoff.rb +2 -5
- data/lib/agents/helpers/message_extractor.rb +24 -8
- data/lib/agents/instrumentation/constants.rb +6 -0
- data/lib/agents/instrumentation/tracing_callbacks.rb +153 -32
- data/lib/agents/instrumentation.rb +19 -1
- data/lib/agents/run_context.rb +5 -4
- data/lib/agents/runner.rb +78 -57
- data/lib/agents/tool.rb +8 -1
- data/lib/agents/tool_wrapper.rb +16 -13
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +4 -2
- 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
|
|
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, :
|
|
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,
|
|
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
|
-
|
|
74
|
-
|
|
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,
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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?(:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
parts << text if text && !text.empty?
|
|
457
|
+
append_attachment_summary(content.text.to_s, content.attachments)
|
|
458
|
+
end
|
|
340
459
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
parts << "Attachments: #{urls.join(", ")}"
|
|
344
|
-
end
|
|
460
|
+
def append_attachment_summary(text, attachments)
|
|
461
|
+
return text unless attachments&.any?
|
|
345
462
|
|
|
346
|
-
|
|
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 [
|
|
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)
|
data/lib/agents/run_context.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
104
|
+
tokens = response.tokens if response.respond_to?(:tokens)
|
|
105
|
+
return unless tokens
|
|
105
106
|
|
|
106
|
-
input =
|
|
107
|
-
output =
|
|
107
|
+
input = tokens.input || 0
|
|
108
|
+
output = tokens.output || 0
|
|
108
109
|
|
|
109
110
|
@input_tokens += input
|
|
110
111
|
@output_tokens += output
|