ai-agents 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d4dc10b4aceae77705002488794ecdd15531f604641adaa03445d72374220f0
4
- data.tar.gz: b056fcf690121a790808e6aa1229dc1a16b6e19b609fbaa09a5e32613f5bc58f
3
+ metadata.gz: 7948199bfccd3d11e42735b9ee9f34a2431d00de5876785fa26e369a128a686c
4
+ data.tar.gz: 7a5a91260825a6c2fc59192dee83e8e050a1260fef3d6a0589a9d1dbd0fbc0fe
5
5
  SHA512:
6
- metadata.gz: b80b45ccaa92140f6155dceeb2227235150f913c90385197878074af9df0c22b913e611c46424f9efa9b3f8f128b8921795c3136685a889a758c4b8dd81f4dbd
7
- data.tar.gz: 63662db9f1dcbed10439dfa64746a868a0ee2cc64266c48e55f613296e90dca0d1661466af01825bbec3015c928209f20f000bc9aeb882f5ff510be7ad7110f7
6
+ metadata.gz: d314e6d427f6c9ac5d38ea39b77191c8535e5d0e400690afb15cff48588b7e969b7cb30e88d12e78c027175cdc497e92a74feb899b99f50416af006e3edabe79
7
+ data.tar.gz: 67b9da18847cddfae2b7866f606d335b51690aeb13c9062b8ae2afa8526ed9ae3343fbf86dd435354ee42b6d3145cefcac3e090ee5ca170895cbfc84f3b510b0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-06-29
11
+
12
+ ### Added
13
+ - Propagate Langfuse trace metadata and filter attributes from root traces to child agent, tool, and generation spans, while keeping span-specific input/output attributes local to each span.
14
+ - Support optional generation-level attributes via `attribute_provider#generation_attributes(context_wrapper, chat, message)`.
15
+ - Add `gen_ai.request.temperature` to generation spans when an agent temperature is configured.
16
+
17
+ ## [0.11.0] - 2026-05-27
18
+
19
+ ### Added
20
+ - Support RubyLLM provider overrides and custom model/deployment IDs on agents, including Azure configuration passthrough (#66)
21
+
22
+ ### Fixed
23
+ - Preserve per-message assistant agent attribution when restoring and snapshotting multi-agent conversation history, preventing earlier assistant messages from being re-labeled as the final active agent after handoffs (#68)
10
24
 
11
25
  ## [0.10.0] - 2026-04-20
12
26
 
data/README.md CHANGED
@@ -193,22 +193,40 @@ ruby examples/isp-support/interactive.rb
193
193
  Agents.configure do |config|
194
194
  # Provider API keys
195
195
  config.openai_api_key = ENV['OPENAI_API_KEY']
196
+ config.azure_api_base = ENV['AZURE_API_BASE']
197
+ config.azure_api_key = ENV['AZURE_API_KEY']
196
198
  config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
197
199
  config.gemini_api_key = ENV['GEMINI_API_KEY']
198
200
 
199
201
  # Defaults
200
- config.default_provider = :openai
201
202
  config.default_model = 'gpt-4o'
202
203
 
203
204
  # Performance
204
205
  config.request_timeout = 120
205
- config.max_turns = 10
206
206
 
207
207
  # Debugging
208
208
  config.debug = true
209
209
  end
210
210
  ```
211
211
 
212
+ ### Azure and Custom Deployments
213
+
214
+ ```ruby
215
+ Agents.configure do |config|
216
+ config.azure_api_base = ENV["AZURE_API_BASE"]
217
+ config.azure_api_key = ENV["AZURE_API_KEY"]
218
+ end
219
+
220
+ agent = Agents::Agent.new(
221
+ name: "Support",
222
+ model: "my-azure-deployment",
223
+ provider: :azure,
224
+ assume_model_exists: true
225
+ )
226
+ ```
227
+
228
+ `provider` is optional for known, unambiguous registry models. Set it for custom deployment names and for model IDs that can exist under multiple providers, such as Azure and OpenAI deployments.
229
+
212
230
  ## 🔍 Observability
213
231
 
214
232
  Optional OpenTelemetry instrumentation for tracing agent execution, compatible with
@@ -24,12 +24,25 @@ Configure your LLM providers in an initializer:
24
24
  # config/initializers/ai_agents.rb
25
25
  Agents.configure do |config|
26
26
  config.openai_api_key = Rails.application.credentials.openai_api_key
27
+ config.azure_api_base = Rails.application.credentials.azure_api_base
28
+ config.azure_api_key = Rails.application.credentials.azure_api_key
27
29
  config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
28
30
  config.default_model = 'gpt-4o-mini'
29
31
  config.debug = Rails.env.development?
30
32
  end
31
33
  ```
32
34
 
35
+ For Azure custom deployment names, pass the provider at the agent level:
36
+
37
+ ```ruby
38
+ support = Agents::Agent.new(
39
+ name: "Support",
40
+ model: "my-azure-deployment",
41
+ provider: :azure,
42
+ assume_model_exists: true
43
+ )
44
+ ```
45
+
33
46
  ## ActiveRecord Integration
34
47
 
35
48
  ### Conversation Persistence
data/docs/index.md CHANGED
@@ -61,6 +61,8 @@ require 'agents'
61
61
  # Configure your API keys
62
62
  Agents.configure do |config|
63
63
  config.openai_api_key = ENV['OPENAI_API_KEY']
64
+ # config.azure_api_base = ENV['AZURE_API_BASE']
65
+ # config.azure_api_key = ENV['AZURE_API_KEY']
64
66
  # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
65
67
  # config.gemini_api_key = ENV['GEMINI_API_KEY']
66
68
  config.default_model = 'gpt-4o-mini'
@@ -87,6 +89,24 @@ result = runner.run("I need help with a technical issue")
87
89
  puts result.output
88
90
  ```
89
91
 
92
+ ### Azure and Custom Deployments
93
+
94
+ ```ruby
95
+ Agents.configure do |config|
96
+ config.azure_api_base = ENV["AZURE_API_BASE"]
97
+ config.azure_api_key = ENV["AZURE_API_KEY"]
98
+ end
99
+
100
+ agent = Agents::Agent.new(
101
+ name: "Support",
102
+ model: "my-azure-deployment",
103
+ provider: :azure,
104
+ assume_model_exists: true
105
+ )
106
+ ```
107
+
108
+ `provider` is optional for known, unambiguous registry models. Set it for custom deployment names and duplicate model IDs, such as Azure and OpenAI deployments.
109
+
90
110
  ## Next Steps
91
111
 
92
112
  - [Learn about Agents](concepts/agents.html)
data/lib/agents/agent.rb CHANGED
@@ -50,24 +50,29 @@ require_relative "helpers/hash_normalizer"
50
50
  # )
51
51
  module Agents
52
52
  class Agent
53
- attr_reader :name, :instructions, :model, :tools, :handoff_agents, :temperature, :response_schema, :headers, :params
53
+ attr_reader :name, :instructions, :model, :provider, :assume_model_exists, :tools, :handoff_agents, :temperature,
54
+ :response_schema, :headers, :params
54
55
 
55
56
  # Initialize a new Agent instance
56
57
  #
57
58
  # @param name [String] The name of the agent
58
59
  # @param instructions [String, Proc, nil] Static string or dynamic Proc that returns instructions
59
60
  # @param model [String] The LLM model to use (default: "gpt-4.1-mini")
61
+ # @param provider [Symbol, String, nil] Optional RubyLLM provider override
62
+ # @param assume_model_exists [Boolean] Whether RubyLLM should skip registry validation for custom model IDs
60
63
  # @param tools [Array<Agents::Tool>] Array of tool instances the agent can use
61
64
  # @param handoff_agents [Array<Agents::Agent>] Array of agents this agent can hand off to
62
65
  # @param temperature [Float] Controls randomness in responses (0.0 = deterministic, 1.0 = very random, default: 0.7)
63
66
  # @param response_schema [Hash, nil] JSON schema for structured output responses
64
67
  # @param headers [Hash, nil] Default HTTP headers applied to LLM requests
65
68
  # @param params [Hash, nil] Default provider-specific parameters applied to LLM requests (e.g., service_tier)
66
- def initialize(name:, instructions: nil, model: "gpt-4.1-mini", tools: [], handoff_agents: [], temperature: 0.7,
67
- response_schema: nil, headers: nil, params: nil)
69
+ def initialize(name:, instructions: nil, model: "gpt-4.1-mini", provider: nil, assume_model_exists: false,
70
+ tools: [], handoff_agents: [], temperature: 0.7, response_schema: nil, headers: nil, params: nil)
68
71
  @name = name
69
72
  @instructions = instructions
70
73
  @model = model
74
+ @provider = provider&.to_sym
75
+ @assume_model_exists = assume_model_exists
71
76
  @tools = tools.dup
72
77
  @handoff_agents = []
73
78
  @temperature = temperature
@@ -155,6 +160,8 @@ module Agents
155
160
  # @option changes [String] :name New agent name
156
161
  # @option changes [String, Proc] :instructions New instructions
157
162
  # @option changes [String] :model New model identifier
163
+ # @option changes [Symbol, String, nil] :provider New provider override
164
+ # @option changes [Boolean] :assume_model_exists Whether to skip model registry validation
158
165
  # @option changes [Array<Agents::Tool>] :tools New tools array (replaces all tools)
159
166
  # @option changes [Array<Agents::Agent>] :handoff_agents New handoff agents
160
167
  # @option changes [Float] :temperature Temperature for LLM responses (0.0-1.0)
@@ -165,6 +172,8 @@ module Agents
165
172
  name: changes.fetch(:name, @name),
166
173
  instructions: changes.fetch(:instructions, @instructions),
167
174
  model: changes.fetch(:model, @model),
175
+ provider: changes.fetch(:provider, @provider),
176
+ assume_model_exists: changes.fetch(:assume_model_exists, @assume_model_exists),
168
177
  tools: changes.fetch(:tools, @tools.dup),
169
178
  handoff_agents: changes.fetch(:handoff_agents, @handoff_agents),
170
179
  temperature: changes.fetch(:temperature, @temperature),
@@ -18,8 +18,30 @@
18
18
  module Agents
19
19
  module Helpers
20
20
  module MessageExtractor
21
+ # RubyLLM::Message has no metadata/extension API, so agent ownership is stored
22
+ # as an SDK-namespaced ivar on the message object until it is extracted.
23
+ #
24
+ # Caveat: RubyLLM::Message#instance_variables is overridden to hide :@raw but does
25
+ # not hide this ivar, so it will appear in instance_variables listings. This is
26
+ # harmless for our own code path (we read it via instance_variable_get and
27
+ # serialize through extract_messages, not Marshal), but external introspection
28
+ # of a message's ivars will see :@agents_authoring_agent.
29
+ AUTHORING_AGENT_IVAR = :@agents_authoring_agent
30
+
21
31
  module_function
22
32
 
33
+ def assign_agent_name(message, agent_name)
34
+ return unless message && agent_name
35
+
36
+ message.instance_variable_set(AUTHORING_AGENT_IVAR, agent_name)
37
+ end
38
+
39
+ def attributed_agent_name_for(message)
40
+ return unless message&.instance_variable_defined?(AUTHORING_AGENT_IVAR)
41
+
42
+ message.instance_variable_get(AUTHORING_AGENT_IVAR)
43
+ end
44
+
23
45
  # Check if content is considered empty (handles both String and Hash content)
24
46
  #
25
47
  # @param content [String, Hash, nil] The content to check
@@ -65,7 +87,8 @@ module Agents
65
87
 
66
88
  return message unless msg.role == :assistant
67
89
 
68
- message[:agent_name] = current_agent.name if current_agent
90
+ attributed_agent_name = attributed_agent_name_for(msg) || current_agent&.name
91
+ message[:agent_name] = attributed_agent_name if attributed_agent_name
69
92
 
70
93
  if tool_calls_present
71
94
  # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
@@ -15,6 +15,7 @@ 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,14 +23,17 @@ module Agents
22
23
  # Langfuse trace-level attributes
23
24
  ATTR_LANGFUSE_USER_ID = "langfuse.user.id"
24
25
  ATTR_LANGFUSE_SESSION_ID = "langfuse.session.id"
26
+ ATTR_LANGFUSE_PREFIX = "langfuse."
25
27
  ATTR_LANGFUSE_TRACE_TAGS = "langfuse.trace.tags"
26
28
  ATTR_LANGFUSE_TRACE_INPUT = "langfuse.trace.input"
27
29
  ATTR_LANGFUSE_TRACE_OUTPUT = "langfuse.trace.output"
30
+ ATTR_LANGFUSE_TRACE_METADATA_PREFIX = "langfuse.trace.metadata."
28
31
 
29
32
  # Langfuse observation-level attributes
30
33
  ATTR_LANGFUSE_OBS_TYPE = "langfuse.observation.type"
31
34
  ATTR_LANGFUSE_OBS_INPUT = "langfuse.observation.input"
32
35
  ATTR_LANGFUSE_OBS_OUTPUT = "langfuse.observation.output"
36
+ ATTR_LANGFUSE_OBS_METADATA_PREFIX = "langfuse.observation.metadata."
33
37
  end
34
38
  end
35
39
  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,
@@ -66,12 +77,14 @@ module Agents
66
77
  finish_agent_span(tracing)
67
78
  end
68
79
 
69
- def on_chat_created(chat, agent_name, model, context_wrapper)
80
+ def on_chat_created(chat, agent_name, model, context_wrapper, temperature = nil)
70
81
  tracing = tracing_state(context_wrapper)
71
82
  return unless tracing
72
83
 
84
+ request_attributes = { model: model, temperature: temperature }
85
+
73
86
  chat.on_end_message do |message|
74
- handle_end_message(chat, agent_name, model, message, context_wrapper)
87
+ handle_end_message(chat, agent_name, request_attributes, message, context_wrapper)
75
88
  end
76
89
  end
77
90
 
@@ -84,6 +97,7 @@ module Agents
84
97
  ATTR_LANGFUSE_OBS_TYPE => "tool",
85
98
  ATTR_LANGFUSE_OBS_INPUT => serialize_output(args)
86
99
  }
100
+ attributes.merge!(tracing[:child_langfuse_attributes])
87
101
 
88
102
  parent = handoff_tool?(tool_name) ? tracing[:root_context] : parent_context(tracing)
89
103
  tool_span = @tracer.start_span(
@@ -139,18 +153,19 @@ module Agents
139
153
 
140
154
  private
141
155
 
142
- def handle_end_message(chat, _agent_name, model, message, context_wrapper)
156
+ def handle_end_message(chat, _agent_name, request_attributes, message, context_wrapper)
143
157
  return unless message.respond_to?(:role) && message.role == :assistant
144
158
 
145
159
  tracing = tracing_state(context_wrapper)
146
160
  return unless tracing
147
161
 
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)
162
+ llm_span = @tracer.start_span(
163
+ @llm_span_name,
164
+ with_parent: parent_context(tracing),
165
+ attributes: generation_span_attributes(tracing, chat, message, context_wrapper)
166
+ )
152
167
 
153
- llm_span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
168
+ set_llm_request_attributes(llm_span, request_attributes)
154
169
 
155
170
  output = llm_output_text(message)
156
171
  set_llm_response_attributes(llm_span, message, output)
@@ -159,6 +174,22 @@ module Agents
159
174
  llm_span.finish
160
175
  end
161
176
 
177
+ def generation_span_attributes(tracing, chat, message, context_wrapper)
178
+ attrs = tracing[:child_langfuse_attributes].dup
179
+ input = format_chat_messages(chat)
180
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input
181
+ apply_generation_dynamic_attributes(attrs, context_wrapper, chat, message)
182
+ attrs
183
+ end
184
+
185
+ def set_llm_request_attributes(span, request_attributes)
186
+ model = request_attributes[:model]
187
+ temperature = request_attributes[:temperature]
188
+
189
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
190
+ span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, temperature) unless temperature.nil?
191
+ end
192
+
162
193
  def finish_dangling_spans(tracing)
163
194
  if tracing[:current_tool_span]
164
195
  tracing[:current_tool_span].finish
@@ -250,13 +281,9 @@ module Agents
250
281
  finish_agent_span(tracing) # close previous agent span if missed
251
282
 
252
283
  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
284
  agent_span = @tracer.start_span(span_name,
258
285
  with_parent: tracing[:root_context],
259
- attributes: attrs)
286
+ attributes: agent_span_attributes(tracing, agent_name))
260
287
  agent_context = OpenTelemetry::Trace.context_with_span(agent_span)
261
288
 
262
289
  tracing[:current_agent_name] = agent_name
@@ -265,6 +292,13 @@ module Agents
265
292
  tracing[:last_agent_output] = nil
266
293
  end
267
294
 
295
+ def agent_span_attributes(tracing, agent_name)
296
+ attrs = tracing[:child_langfuse_attributes].merge("agent.name" => agent_name)
297
+ input = tracing[:pending_llm_input]
298
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
299
+ attrs
300
+ end
301
+
268
302
  def finish_agent_span(tracing)
269
303
  return unless tracing[:current_agent_span]
270
304
 
@@ -297,6 +331,57 @@ module Agents
297
331
  attributes
298
332
  end
299
333
 
334
+ def build_child_langfuse_attributes(root_attributes)
335
+ root_attributes.each_with_object({}) do |(key, value), attrs|
336
+ next if value.nil?
337
+ next unless child_langfuse_attribute?(key)
338
+
339
+ attrs[key] = value
340
+ add_observation_metadata_mirror(attrs, key, value) if mirrored_observation_metadata_attribute?(key)
341
+ end
342
+ end
343
+
344
+ def child_langfuse_attribute?(key)
345
+ key.start_with?(ATTR_LANGFUSE_PREFIX) && !child_langfuse_excluded_attribute?(key)
346
+ end
347
+
348
+ def child_langfuse_excluded_attribute?(key)
349
+ CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES.include?(key)
350
+ end
351
+
352
+ def mirrored_observation_metadata_attribute?(key)
353
+ key == ATTR_LANGFUSE_USER_ID ||
354
+ key == ATTR_LANGFUSE_SESSION_ID ||
355
+ key == ATTR_LANGFUSE_TRACE_TAGS ||
356
+ key.start_with?(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
357
+ end
358
+
359
+ def add_observation_metadata_mirror(attrs, key, value)
360
+ metadata_key = observation_metadata_mirror_key(key)
361
+ attrs[observation_metadata_key(metadata_key)] = serialize_metadata_value(value)
362
+ end
363
+
364
+ def observation_metadata_mirror_key(key)
365
+ case key
366
+ when ATTR_LANGFUSE_USER_ID
367
+ "user_id"
368
+ when ATTR_LANGFUSE_SESSION_ID
369
+ "session_id"
370
+ when ATTR_LANGFUSE_TRACE_TAGS
371
+ "trace_tags"
372
+ else
373
+ key.delete_prefix(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
374
+ end
375
+ end
376
+
377
+ def observation_metadata_key(key)
378
+ "#{ATTR_LANGFUSE_OBS_METADATA_PREFIX}#{key}"
379
+ end
380
+
381
+ def serialize_metadata_value(value)
382
+ value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
383
+ end
384
+
300
385
  def apply_session_id(attributes, context_wrapper)
301
386
  session_id = context_wrapper&.context&.dig(:session_id)&.to_s
302
387
  attributes[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id && !session_id.empty?
@@ -317,6 +402,13 @@ module Agents
317
402
  attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
318
403
  end
319
404
 
405
+ def apply_generation_dynamic_attributes(attributes, context_wrapper, chat, message)
406
+ return unless @attribute_provider.respond_to?(:generation_attributes)
407
+
408
+ dynamic_attrs = @attribute_provider.generation_attributes(context_wrapper, chat, message)
409
+ attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
410
+ end
411
+
320
412
  def store_tracing_state(context_wrapper, **state)
321
413
  context_wrapper.context[:__otel_tracing] = state
322
414
  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)
data/lib/agents/runner.rb CHANGED
@@ -103,7 +103,11 @@ module Agents
103
103
  agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
104
104
 
105
105
  # Create chat and restore conversation history
106
- chat = RubyLLM::Chat.new(model: current_agent.model)
106
+ chat = RubyLLM::Chat.new(
107
+ model: current_agent.model,
108
+ provider: current_agent.provider,
109
+ assume_model_exists: current_agent.assume_model_exists
110
+ )
107
111
  current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
108
112
  current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
109
113
  apply_headers(chat, current_headers)
@@ -111,13 +115,16 @@ module Agents
111
115
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
112
116
  restore_conversation_history(chat, context_wrapper)
113
117
  input_already_in_history = last_message_matches?(chat, input)
114
- context_wrapper.callback_manager.emit_chat_created(chat, current_agent.name, current_agent.model, context_wrapper)
118
+ context_wrapper.callback_manager.emit_chat_created(
119
+ chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
120
+ )
115
121
 
116
122
  loop do
117
123
  current_turn += 1
118
124
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
119
125
 
120
126
  # Get response from LLM (RubyLLM handles tool execution with halting based handoff detection)
127
+ message_count_before_response = chat_message_count(chat)
121
128
  response = if current_turn == 1
122
129
  # Emit agent thinking event for initial message
123
130
  context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
@@ -130,6 +137,7 @@ module Agents
130
137
  context_wrapper)
131
138
  chat.complete
132
139
  end
140
+ assign_agent_name_to_new_assistant_messages(chat, current_agent, message_count_before_response)
133
141
  track_usage(response, context_wrapper)
134
142
 
135
143
  # Emit LLM call complete event with model and response for instrumentation
@@ -172,7 +180,7 @@ module Agents
172
180
  current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
173
181
  apply_params(chat, current_params)
174
182
  context_wrapper.callback_manager.emit_chat_created(
175
- chat, current_agent.name, current_agent.model, context_wrapper
183
+ chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
176
184
  )
177
185
 
178
186
  # Force the new agent to respond to the conversation context
@@ -266,6 +274,7 @@ module Agents
266
274
  next unless message_params # Skip invalid messages
267
275
 
268
276
  message = RubyLLM::Message.new(**message_params)
277
+ assign_restored_agent_name(message, msg)
269
278
  chat.add_message(message)
270
279
 
271
280
  if message.role == :assistant && message_params[:tool_calls]
@@ -377,6 +386,33 @@ module Agents
377
386
  context_wrapper.context.delete(:pending_handoff)
378
387
  end
379
388
 
389
+ def assign_agent_name_to_new_assistant_messages(chat, current_agent, start_index)
390
+ # Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
391
+ # no-op guard for chat-like doubles/adapters that do not expose history.
392
+ return unless chat.respond_to?(:messages)
393
+
394
+ chat.messages[start_index..]&.each do |message|
395
+ next unless message.role == :assistant
396
+
397
+ Helpers::MessageExtractor.assign_agent_name(message, current_agent.name)
398
+ end
399
+ end
400
+
401
+ def chat_message_count(chat)
402
+ # Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
403
+ # fallback for chat-like doubles/adapters where attribution is irrelevant.
404
+ return 0 unless chat.respond_to?(:messages)
405
+
406
+ chat.messages.length
407
+ end
408
+
409
+ def assign_restored_agent_name(message, msg)
410
+ return unless message.role == :assistant
411
+
412
+ restored_agent_name = msg[:agent_name] || msg["agent_name"]
413
+ Helpers::MessageExtractor.assign_agent_name(message, restored_agent_name)
414
+ end
415
+
380
416
  # Configures a RubyLLM chat instance with agent-specific settings.
381
417
  # Uses RubyLLM's replace option to swap agent context while preserving conversation history during handoffs.
382
418
  #
@@ -393,7 +429,13 @@ module Agents
393
429
  all_tools = build_agent_tools(agent, context_wrapper)
394
430
 
395
431
  # Switch model if different (important for handoffs between agents using different models)
396
- chat.with_model(agent.model) if replace
432
+ if replace
433
+ chat.with_model(
434
+ agent.model,
435
+ provider: agent.provider,
436
+ assume_exists: agent.assume_model_exists
437
+ )
438
+ end
397
439
 
398
440
  # Configure chat with instructions, temperature, tools, and schema
399
441
  chat.with_instructions(system_prompt, replace: replace) if system_prompt
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/agents.rb CHANGED
@@ -56,6 +56,9 @@ module Agents
56
56
 
57
57
  # Other providers
58
58
  apply_if_present(config, :anthropic_api_key)
59
+ apply_if_present(config, :azure_api_base)
60
+ apply_if_present(config, :azure_api_key)
61
+ apply_if_present(config, :azure_ai_auth_token)
59
62
  apply_if_present(config, :gemini_api_key)
60
63
  apply_if_present(config, :deepseek_api_key)
61
64
  apply_if_present(config, :openrouter_api_key)
@@ -83,8 +86,9 @@ module Agents
83
86
  class Configuration
84
87
  # Provider API keys and configuration
85
88
  attr_accessor :openai_api_key, :openai_api_base, :openai_organization_id, :openai_project_id
86
- attr_accessor :anthropic_api_key, :gemini_api_key, :deepseek_api_key, :openrouter_api_key, :ollama_api_base,
87
- :bedrock_api_key, :bedrock_secret_key, :bedrock_region, :bedrock_session_token
89
+ attr_accessor :anthropic_api_key, :azure_api_base, :azure_api_key, :azure_ai_auth_token, :gemini_api_key,
90
+ :deepseek_api_key, :openrouter_api_key, :ollama_api_base, :bedrock_api_key, :bedrock_secret_key,
91
+ :bedrock_region, :bedrock_session_token
88
92
 
89
93
  # General configuration
90
94
  attr_accessor :request_timeout, :default_model, :debug
@@ -100,7 +104,7 @@ module Agents
100
104
  def configured?
101
105
  @openai_api_key || @anthropic_api_key || @gemini_api_key ||
102
106
  @deepseek_api_key || @openrouter_api_key || @ollama_api_base ||
103
- @bedrock_api_key
107
+ @bedrock_api_key || (@azure_api_base && (@azure_api_key || @azure_ai_auth_token))
104
108
  end
105
109
  end
106
110
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra