ai-agents 0.9.1 → 0.11.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: 1e4285e8a584df94b230a5e9e65e4d7991bab421c48b02726e7a69882068ca9d
4
- data.tar.gz: 5ebaacb25ed21e880c6393b70ff1a661216b2f7f4f2e946db8d298bb1fe8acc2
3
+ metadata.gz: '071869beb00d53446a27f489c3817c58b0f8d163f334f0030c95b789a7c4895b'
4
+ data.tar.gz: 874227bd4dd05dd2947269bec7da2d593777a8600540ef9ec5a8fb50c346a884
5
5
  SHA512:
6
- metadata.gz: 0c96c699fa544c44ca8200d70d8663adc2f82a39b9f58001d87b0a79f0c64ace068bbb5622d9708aaec7f5e48be0128c9e04dcbe918c899cc03c00ffaa710bd9
7
- data.tar.gz: b103cd93cda31c7adfa874405587a6fdd5fc2c54049179e55651dd21585b4ea047d856596eceaf4108a631e30fc787f943d43247d3765c51950130f6e5126176
6
+ metadata.gz: 7e3814e0e76359be595534eb92ce11d655e19e3997f78aea006be89721aae771c6a1f97ad277ed80581957cdea5a0919506518dfd8e8de1911a03a3641668a8b
7
+ data.tar.gz: 351b99abda0942df5253e1735faca522b5eee988fc248ec16564dea700b2bcfcf31a8ec4ba8f9db73f3f57103e5f8b37a0997c6e71f4b1a1872ca796046cad2a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-05-27
11
+
12
+ ### Added
13
+ - Support RubyLLM provider overrides and custom model/deployment IDs on agents, including Azure configuration passthrough (#66)
14
+
15
+ ### Fixed
16
+ - 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)
17
+
18
+ ## [0.10.0] - 2026-04-20
19
+
20
+ ### Added
21
+ - Support for provider-specific params via `with_params` (#44)
22
+
23
+ ### Changed
24
+ - **Bump `ruby_llm` dependency**: now `~> 1.14` (was `~> 1.9.1`). Trusts upstream semantic versioning by dropping the PATCH-level pin so minor-version fixes are picked up automatically (#61)
25
+ - Various internal refactors to `TracingCallbacks`, `Runner`, and helper modules
26
+
27
+
10
28
  ## [0.9.1] - 2026-02-24
11
29
 
12
30
  ### Fixed
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
@@ -0,0 +1,90 @@
1
+ ---
2
+ layout: default
3
+ title: Provider-Specific Parameters
4
+ parent: Guides
5
+ nav_order: 7
6
+ ---
7
+
8
+ # Provider-Specific Parameters
9
+
10
+ Provider-specific parameters let you pass additional options directly into the LLM request payload via RubyLLM's `with_params` method. This is useful for features like OpenAI's `service_tier`, Anthropic's `reasoning_effort`, or any other provider-specific option that isn't exposed as a first-class SDK attribute.
11
+
12
+ ## Basic Usage
13
+
14
+ ### Agent-Level Params
15
+
16
+ Set default parameters when creating an agent that will be applied to all requests:
17
+
18
+ ```ruby
19
+ agent = Agents::Agent.new(
20
+ name: "Assistant",
21
+ instructions: "You are a helpful assistant",
22
+ params: {
23
+ service_tier: "flex",
24
+ max_completion_tokens: 2048
25
+ }
26
+ )
27
+
28
+ runner = Agents::Runner.with_agents(agent)
29
+ result = runner.run("Hello!")
30
+ # All requests will include the provider-specific params
31
+ ```
32
+
33
+ ### Runtime Params
34
+
35
+ Override or add parameters for specific requests:
36
+
37
+ ```ruby
38
+ agent = Agents::Agent.new(
39
+ name: "Assistant",
40
+ instructions: "You are a helpful assistant"
41
+ )
42
+
43
+ runner = Agents::Runner.with_agents(agent)
44
+
45
+ # Pass params at runtime
46
+ result = runner.run(
47
+ "Explain quantum computing",
48
+ params: {
49
+ service_tier: "default",
50
+ max_completion_tokens: 4096
51
+ }
52
+ )
53
+ ```
54
+
55
+ ### Parameter Precedence
56
+
57
+ When both agent-level and runtime params are provided, **runtime params take precedence**:
58
+
59
+ ```ruby
60
+ agent = Agents::Agent.new(
61
+ name: "Assistant",
62
+ instructions: "You are a helpful assistant",
63
+ params: {
64
+ service_tier: "flex",
65
+ top_p: 0.9
66
+ }
67
+ )
68
+
69
+ runner = Agents::Runner.with_agents(agent)
70
+
71
+ result = runner.run(
72
+ "Hello!",
73
+ params: {
74
+ service_tier: "default", # Overrides agent's flex value
75
+ max_completion_tokens: 1000 # Additional param
76
+ }
77
+ )
78
+
79
+ # Final params sent to LLM API:
80
+ # {
81
+ # service_tier: "default", # Runtime value wins
82
+ # top_p: 0.9, # From agent
83
+ # max_completion_tokens: 1000 # From runtime
84
+ # }
85
+ ```
86
+
87
+ ## See Also
88
+
89
+ - [Custom Request Headers](request-headers.html) - Adding custom HTTP headers using the same two-level precedence pattern
90
+ - [Multi-Agent Systems](multi-agent-systems.html) - Using params across agent handoffs
@@ -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/guides.md CHANGED
@@ -18,4 +18,5 @@ Practical guides for building real-world applications with the AI Agents library
18
18
  - **[State Persistence](guides/state-persistence.html)** - Managing conversation state and context across sessions and processes
19
19
  - **[Structured Output](guides/structured-output.html)** - Enforcing JSON schema validation for reliable agent responses
20
20
  - **[Custom Request Headers](guides/request-headers.html)** - Adding custom HTTP headers for authentication, tracking, and provider-specific features
21
+ - **[Provider-Specific Parameters](guides/provider-params.html)** - Passing provider-specific parameters like service_tier to the underlying LLM request
21
22
  - **[OpenTelemetry Instrumentation](guides/instrumentation.html)** - Trace agent execution with Langfuse and other OTel backends
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
@@ -4,7 +4,7 @@
4
4
  # Agents are immutable, thread-safe objects that can be cloned with modifications.
5
5
  # They encapsulate the configuration needed to interact with an LLM including
6
6
  # instructions, tools, and potential handoff targets.
7
- require_relative "helpers/headers"
7
+ require_relative "helpers/hash_normalizer"
8
8
  # @example Creating a basic agent
9
9
  # agent = Agents::Agent.new(
10
10
  # name: "Assistant",
@@ -50,28 +50,35 @@ require_relative "helpers/headers"
50
50
  # )
51
51
  module Agents
52
52
  class Agent
53
- attr_reader :name, :instructions, :model, :tools, :handoff_agents, :temperature, :response_schema, :headers
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
- def initialize(name:, instructions: nil, model: "gpt-4.1-mini", tools: [], handoff_agents: [], temperature: 0.7,
66
- response_schema: nil, headers: nil)
68
+ # @param params [Hash, nil] Default provider-specific parameters applied to LLM requests (e.g., service_tier)
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)
67
71
  @name = name
68
72
  @instructions = instructions
69
73
  @model = model
74
+ @provider = provider&.to_sym
75
+ @assume_model_exists = assume_model_exists
70
76
  @tools = tools.dup
71
77
  @handoff_agents = []
72
78
  @temperature = temperature
73
79
  @response_schema = response_schema
74
- @headers = Helpers::Headers.normalize(headers, freeze_result: true)
80
+ @headers = Helpers::HashNormalizer.normalize(headers, label: "headers", freeze_result: true)
81
+ @params = Helpers::HashNormalizer.normalize(params, label: "params", freeze_result: true)
75
82
 
76
83
  # Mutex for thread-safe handoff registration
77
84
  # While agents are typically configured at startup, we want to ensure
@@ -153,6 +160,8 @@ module Agents
153
160
  # @option changes [String] :name New agent name
154
161
  # @option changes [String, Proc] :instructions New instructions
155
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
156
165
  # @option changes [Array<Agents::Tool>] :tools New tools array (replaces all tools)
157
166
  # @option changes [Array<Agents::Agent>] :handoff_agents New handoff agents
158
167
  # @option changes [Float] :temperature Temperature for LLM responses (0.0-1.0)
@@ -163,11 +172,14 @@ module Agents
163
172
  name: changes.fetch(:name, @name),
164
173
  instructions: changes.fetch(:instructions, @instructions),
165
174
  model: changes.fetch(:model, @model),
175
+ provider: changes.fetch(:provider, @provider),
176
+ assume_model_exists: changes.fetch(:assume_model_exists, @assume_model_exists),
166
177
  tools: changes.fetch(:tools, @tools.dup),
167
178
  handoff_agents: changes.fetch(:handoff_agents, @handoff_agents),
168
179
  temperature: changes.fetch(:temperature, @temperature),
169
180
  response_schema: changes.fetch(:response_schema, @response_schema),
170
- headers: changes.fetch(:headers, @headers)
181
+ headers: changes.fetch(:headers, @headers),
182
+ params: changes.fetch(:params, @params)
171
183
  )
172
184
  end
173
185
 
@@ -29,6 +29,8 @@ module Agents
29
29
  # can safely register callbacks concurrently without data races.
30
30
  #
31
31
  class AgentRunner
32
+ attr_reader :agents
33
+
32
34
  # Initialize with a list of agents. The first agent becomes the default entry point.
33
35
  #
34
36
  # @param agents [Array<Agents::Agent>] List of agents, first one is the default entry point
@@ -64,8 +66,9 @@ module Agents
64
66
  # @param context [Hash] Conversation context (will be restored if continuing conversation)
65
67
  # @param max_turns [Integer] Maximum turns before stopping (default: 10)
66
68
  # @param headers [Hash, nil] Custom HTTP headers to pass through to the underlying LLM provider
69
+ # @param params [Hash, nil] Provider-specific parameters to pass through to the underlying LLM (e.g., service_tier)
67
70
  # @return [RunResult] Execution result with output, messages, and updated context
68
- def run(input, context: {}, max_turns: Runner::DEFAULT_MAX_TURNS, headers: nil)
71
+ def run(input, context: {}, max_turns: Runner::DEFAULT_MAX_TURNS, headers: nil, params: nil)
69
72
  # Determine which agent should handle this conversation
70
73
  # Uses conversation history to maintain continuity across handoffs
71
74
  current_agent = determine_conversation_agent(context)
@@ -78,6 +81,7 @@ module Agents
78
81
  registry: @registry,
79
82
  max_turns: max_turns,
80
83
  headers: headers,
84
+ params: params,
81
85
  callbacks: @callbacks
82
86
  )
83
87
  end
@@ -97,7 +97,7 @@ module Agents
97
97
  private
98
98
 
99
99
  def transform_agent_name(name)
100
- name.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "")
100
+ Helpers::NameNormalizer.to_tool_name(name)
101
101
  end
102
102
 
103
103
  # Create isolated context that only shares state, not conversation artifacts
@@ -53,7 +53,7 @@ module Agents
53
53
  @target_agent = target_agent
54
54
 
55
55
  # Set up the tool with a standardized name and description
56
- @tool_name = "handoff_to_#{target_agent.name.downcase.gsub(/\s+/, "_")}"
56
+ @tool_name = "handoff_to_#{Helpers::NameNormalizer.to_tool_name(target_agent.name)}"
57
57
  @tool_description = "Transfer conversation to #{target_agent.name}"
58
58
 
59
59
  super()
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ module Helpers
5
+ module HashNormalizer
6
+ module_function
7
+
8
+ # NOTE: freeze_result performs a shallow freeze on the top-level hash only.
9
+ # Nested values remain mutable — e.g. hash[:nested][:key] = "x" would succeed.
10
+ def normalize(input, label:, freeze_result: false)
11
+ return freeze_result ? {}.freeze : {} if input.nil? || (input.respond_to?(:empty?) && input.empty?)
12
+
13
+ hash = input.respond_to?(:to_h) ? input.to_h : input
14
+ raise ArgumentError, "#{label} must be a Hash or respond to #to_h" unless hash.is_a?(Hash)
15
+
16
+ result = hash.transform_keys { |key| key.is_a?(Symbol) ? key : key.to_sym }
17
+ freeze_result ? result.freeze : result
18
+ end
19
+
20
+ def merge(base, override)
21
+ return override if base.empty?
22
+ return base if override.empty?
23
+
24
+ base.merge(override)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ module Helpers
5
+ module NameNormalizer
6
+ module_function
7
+
8
+ def to_tool_name(name)
9
+ name.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -5,5 +5,6 @@ module Agents
5
5
  end
6
6
  end
7
7
 
8
- require_relative "helpers/headers"
8
+ require_relative "helpers/hash_normalizer"
9
+ require_relative "helpers/name_normalizer"
9
10
  require_relative "helpers/message_extractor"
@@ -151,9 +151,9 @@ module Agents
151
151
  llm_span = @tracer.start_span(@llm_span_name, with_parent: parent_context(tracing), attributes: attrs)
152
152
 
153
153
  llm_span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
154
- set_llm_response_attributes(llm_span, message)
155
154
 
156
155
  output = llm_output_text(message)
156
+ set_llm_response_attributes(llm_span, message, output)
157
157
  tracing[:last_agent_output] = output unless output.empty?
158
158
 
159
159
  llm_span.finish
@@ -187,29 +187,25 @@ module Agents
187
187
  root_span.status = OpenTelemetry::Trace::Status.error(error.message)
188
188
  end
189
189
 
190
- def set_llm_response_attributes(span, response)
190
+ def set_llm_response_attributes(span, response, output)
191
191
  if response.respond_to?(:input_tokens) && response.input_tokens
192
192
  span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, response.input_tokens)
193
193
  end
194
194
  if response.respond_to?(:output_tokens) && response.output_tokens
195
195
  span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, response.output_tokens)
196
196
  end
197
- output = llm_output_text(response)
198
197
  span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, output) unless output.empty?
199
198
  end
200
199
 
201
- # Falls back to formatting tool calls when response has no text content,
202
- # and uses .to_json for Hash/Array (structured output) to avoid Ruby's .to_s format.
200
+ # Returns serialized text content if present, otherwise falls back to tool call formatting.
201
+ # Uses .to_json for Hash/Array (structured output) to avoid Ruby's .to_s format.
203
202
  def llm_output_text(response)
204
- return format_tool_calls(response) unless response.respond_to?(:content)
205
-
206
- content = response.content
207
- return format_tool_calls(response) if content.nil?
208
-
209
- text = serialize_content(content)
210
- return format_tool_calls(response) if text.empty?
203
+ if response.respond_to?(:content) && response.content
204
+ text = serialize_output(response.content)
205
+ return text unless text.empty?
206
+ end
211
207
 
212
- text
208
+ format_tool_calls(response)
213
209
  end
214
210
 
215
211
  # Excludes the last message (current response) — returns what was sent to the LLM.
@@ -223,21 +219,15 @@ module Agents
223
219
  end
224
220
 
225
221
  def format_single_message(msg)
226
- text = serialize_content(msg.content)
222
+ text = serialize_output(msg.content)
227
223
  text = append_tool_calls(msg, text)
228
224
  { role: msg.role.to_s, content: text }
229
225
  end
230
226
 
231
- def serialize_content(content)
232
- return serialize_multimodal_content(content) if multimodal_content?(content)
233
-
234
- content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
235
- end
236
-
237
227
  def append_tool_calls(msg, text)
238
228
  return text unless msg.role == :assistant && msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
239
229
 
240
- calls = msg.tool_calls.values.map { |tc| "#{tc.name}(#{tc.arguments.to_json})" }.join(", ")
230
+ calls = msg.tool_calls.values.map { |tc| "#{tc.name}(#{serialize_output(tc.arguments)})" }.join(", ")
241
231
  text.empty? ? "Tool calls: #{calls}" : "#{text}\nTool calls: #{calls}"
242
232
  end
243
233
 
data/lib/agents/runner.rb CHANGED
@@ -81,9 +81,11 @@ module Agents
81
81
  # @param registry [Hash] Registry of agents for handoff resolution
82
82
  # @param max_turns [Integer] Maximum conversation turns before stopping
83
83
  # @param headers [Hash, nil] Custom HTTP headers passed to the underlying LLM provider
84
+ # @param params [Hash, nil] Provider-specific parameters passed to the underlying LLM (e.g., service_tier)
84
85
  # @param callbacks [Hash] Optional callbacks for real-time event notifications
85
86
  # @return [RunResult] The result containing output, messages, and usage
86
- def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS, headers: nil, callbacks: {})
87
+ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS, headers: nil, params: nil,
88
+ callbacks: {})
87
89
  # The starting_agent is already determined by AgentRunner based on conversation history
88
90
  current_agent = starting_agent
89
91
 
@@ -95,13 +97,21 @@ module Agents
95
97
  # Emit run start event
96
98
  context_wrapper.callback_manager.emit_run_start(current_agent.name, input, context_wrapper)
97
99
 
98
- runtime_headers = Helpers::Headers.normalize(headers)
99
- agent_headers = Helpers::Headers.normalize(current_agent.headers)
100
+ runtime_headers = Helpers::HashNormalizer.normalize(headers, label: "headers")
101
+ agent_headers = Helpers::HashNormalizer.normalize(current_agent.headers, label: "headers")
102
+ runtime_params = Helpers::HashNormalizer.normalize(params, label: "params")
103
+ agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
100
104
 
101
105
  # Create chat and restore conversation history
102
- chat = RubyLLM::Chat.new(model: current_agent.model)
103
- current_headers = Helpers::Headers.merge(agent_headers, runtime_headers)
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
+ )
111
+ current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
112
+ current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
104
113
  apply_headers(chat, current_headers)
114
+ apply_params(chat, current_params)
105
115
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
106
116
  restore_conversation_history(chat, context_wrapper)
107
117
  input_already_in_history = last_message_matches?(chat, input)
@@ -112,19 +122,20 @@ module Agents
112
122
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
113
123
 
114
124
  # Get response from LLM (RubyLLM handles tool execution with halting based handoff detection)
115
- result = if current_turn == 1
116
- # Emit agent thinking event for initial message
117
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
118
- # If conversation history already ends with this user message (e.g. passed
119
- # in via context from an external system), use complete to avoid duplicating it.
120
- input_already_in_history ? chat.complete : chat.ask(input)
121
- else
122
- # Emit agent thinking event for continuation
123
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)",
124
- context_wrapper)
125
- chat.complete
126
- end
127
- response = result
125
+ message_count_before_response = chat_message_count(chat)
126
+ response = if current_turn == 1
127
+ # Emit agent thinking event for initial message
128
+ context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
129
+ # If conversation history already ends with this user message (e.g. passed
130
+ # in via context from an external system), use complete to avoid duplicating it.
131
+ input_already_in_history ? chat.complete : chat.ask(input)
132
+ else
133
+ # Emit agent thinking event for continuation
134
+ context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)",
135
+ context_wrapper)
136
+ chat.complete
137
+ end
138
+ assign_agent_name_to_new_assistant_messages(chat, current_agent, message_count_before_response)
128
139
  track_usage(response, context_wrapper)
129
140
 
130
141
  # Emit LLM call complete event with model and response for instrumentation
@@ -140,22 +151,8 @@ module Agents
140
151
  # Validate that the target agent is in our registry
141
152
  # This prevents handoffs to agents that weren't explicitly provided
142
153
  unless registry[next_agent.name]
143
- save_conversation_state(chat, context_wrapper, current_agent)
144
154
  error = AgentNotFoundError.new("Handoff failed: Agent '#{next_agent.name}' not found in registry")
145
-
146
- result = RunResult.new(
147
- output: nil,
148
- messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
149
- usage: context_wrapper.usage,
150
- context: context_wrapper.context,
151
- error: error
152
- )
153
-
154
- # Emit agent complete and run complete events with error
155
- context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, error, context_wrapper)
156
- context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
157
-
158
- return result
155
+ return finalize_run(chat, context_wrapper, current_agent, output: nil, error: error)
159
156
  end
160
157
 
161
158
  # Save current conversation state before switching
@@ -174,9 +171,12 @@ module Agents
174
171
 
175
172
  # Reconfigure existing chat for new agent - preserves conversation history automatically
176
173
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: true)
177
- agent_headers = Helpers::Headers.normalize(current_agent.headers)
178
- current_headers = Helpers::Headers.merge(agent_headers, runtime_headers)
174
+ agent_headers = Helpers::HashNormalizer.normalize(current_agent.headers, label: "headers")
175
+ current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
179
176
  apply_headers(chat, current_headers)
177
+ agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
178
+ current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
179
+ apply_params(chat, current_params)
180
180
  context_wrapper.callback_manager.emit_chat_created(
181
181
  chat, current_agent.name, current_agent.model, context_wrapper
182
182
  )
@@ -189,81 +189,50 @@ module Agents
189
189
 
190
190
  # Handle non-handoff halts - return the halt content as final response
191
191
  if response.is_a?(RubyLLM::Tool::Halt)
192
- save_conversation_state(chat, context_wrapper, current_agent)
193
-
194
- result = RunResult.new(
195
- output: response.content,
196
- messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
197
- usage: context_wrapper.usage,
198
- context: context_wrapper.context
199
- )
200
-
201
- # Emit agent complete and run complete events
202
- context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
203
- context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
204
-
205
- return result
192
+ return finalize_run(chat, context_wrapper, current_agent, output: response.content)
206
193
  end
207
194
 
208
195
  # If tools were called, continue the loop to let them execute
209
196
  next if response.tool_call?
210
197
 
211
198
  # If no tools were called, we have our final response
212
-
213
- # Save final state before returning
214
- save_conversation_state(chat, context_wrapper, current_agent)
215
-
216
- result = RunResult.new(
217
- output: response.content,
218
- messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
219
- usage: context_wrapper.usage,
220
- context: context_wrapper.context
221
- )
222
-
223
- # Emit agent complete and run complete events
224
- context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
225
- context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
226
-
227
- return result
199
+ return finalize_run(chat, context_wrapper, current_agent, output: response.content)
228
200
  end
229
201
  rescue MaxTurnsExceeded => e
230
- # Save state even on error
231
- save_conversation_state(chat, context_wrapper, current_agent) if chat
232
-
233
- result = RunResult.new(
234
- output: "Conversation ended: #{e.message}",
235
- messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
236
- usage: context_wrapper.usage,
237
- error: e,
238
- context: context_wrapper.context
239
- )
202
+ finalize_run(chat, context_wrapper, current_agent,
203
+ output: "Conversation ended: #{e.message}", error: e)
204
+ rescue StandardError => e
205
+ finalize_run(chat, context_wrapper, current_agent, output: nil, error: e)
206
+ end
240
207
 
241
- # Emit agent complete and run complete events with error
242
- context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
243
- context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
208
+ private
244
209
 
245
- result
246
- rescue StandardError => e
247
- # Save state even on error
210
+ # Saves conversation state, builds a RunResult, emits completion callbacks, and returns it.
211
+ # Centralises the finalize-and-return pattern used by the normal path, halt path, and error rescues.
212
+ #
213
+ # @param chat [RubyLLM::Chat, nil] The chat instance (nil in early-failure rescues)
214
+ # @param context_wrapper [RunContext] Context wrapper for state and callbacks
215
+ # @param current_agent [Agents::Agent] The currently active agent
216
+ # @param output [String, nil] The output text for the result
217
+ # @param error [StandardError, nil] Optional error to attach to the result
218
+ # @return [RunResult]
219
+ def finalize_run(chat, context_wrapper, current_agent, output:, error: nil)
248
220
  save_conversation_state(chat, context_wrapper, current_agent) if chat
249
221
 
250
222
  result = RunResult.new(
251
- output: nil,
223
+ output: output,
252
224
  messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
253
225
  usage: context_wrapper.usage,
254
- error: e,
226
+ error: error,
255
227
  context: context_wrapper.context
256
228
  )
257
229
 
258
- # Emit agent complete and run complete events with error
259
- context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
230
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, error, context_wrapper)
260
231
  context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
261
232
 
262
233
  result
263
234
  end
264
235
 
265
- private
266
-
267
236
  # Creates a deep copy of context data for thread safety.
268
237
  # Preserves conversation history array structure while avoiding agent mutation.
269
238
  #
@@ -303,6 +272,7 @@ module Agents
303
272
  next unless message_params # Skip invalid messages
304
273
 
305
274
  message = RubyLLM::Message.new(**message_params)
275
+ assign_restored_agent_name(message, msg)
306
276
  chat.add_message(message)
307
277
 
308
278
  if message.role == :assistant && message_params[:tool_calls]
@@ -414,6 +384,33 @@ module Agents
414
384
  context_wrapper.context.delete(:pending_handoff)
415
385
  end
416
386
 
387
+ def assign_agent_name_to_new_assistant_messages(chat, current_agent, start_index)
388
+ # Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
389
+ # no-op guard for chat-like doubles/adapters that do not expose history.
390
+ return unless chat.respond_to?(:messages)
391
+
392
+ chat.messages[start_index..]&.each do |message|
393
+ next unless message.role == :assistant
394
+
395
+ Helpers::MessageExtractor.assign_agent_name(message, current_agent.name)
396
+ end
397
+ end
398
+
399
+ def chat_message_count(chat)
400
+ # Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
401
+ # fallback for chat-like doubles/adapters where attribution is irrelevant.
402
+ return 0 unless chat.respond_to?(:messages)
403
+
404
+ chat.messages.length
405
+ end
406
+
407
+ def assign_restored_agent_name(message, msg)
408
+ return unless message.role == :assistant
409
+
410
+ restored_agent_name = msg[:agent_name] || msg["agent_name"]
411
+ Helpers::MessageExtractor.assign_agent_name(message, restored_agent_name)
412
+ end
413
+
417
414
  # Configures a RubyLLM chat instance with agent-specific settings.
418
415
  # Uses RubyLLM's replace option to swap agent context while preserving conversation history during handoffs.
419
416
  #
@@ -430,7 +427,13 @@ module Agents
430
427
  all_tools = build_agent_tools(agent, context_wrapper)
431
428
 
432
429
  # Switch model if different (important for handoffs between agents using different models)
433
- chat.with_model(agent.model) if replace
430
+ if replace
431
+ chat.with_model(
432
+ agent.model,
433
+ provider: agent.provider,
434
+ assume_exists: agent.assume_model_exists
435
+ )
436
+ end
434
437
 
435
438
  # Configure chat with instructions, temperature, tools, and schema
436
439
  chat.with_instructions(system_prompt, replace: replace) if system_prompt
@@ -462,6 +465,12 @@ module Agents
462
465
  chat.with_headers(**headers)
463
466
  end
464
467
 
468
+ def apply_params(chat, params)
469
+ return if params.empty?
470
+
471
+ chat.with_params(**params)
472
+ end
473
+
465
474
  def track_usage(response, context_wrapper)
466
475
  return unless context_wrapper&.usage
467
476
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.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.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 1.9.1
18
+ version: '1.14'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 1.9.1
25
+ version: '1.14'
26
26
  description: Ruby AI Agents SDK enables creating complex AI workflows with multi-agent
27
27
  orchestration, tool execution, safety guardrails, and provider-agnostic LLM integration.
28
28
  email:
@@ -61,6 +61,7 @@ files:
61
61
  - docs/guides/agent-as-tool-pattern.md
62
62
  - docs/guides/instrumentation.md
63
63
  - docs/guides/multi-agent-systems.md
64
+ - docs/guides/provider-params.md
64
65
  - docs/guides/rails-integration.md
65
66
  - docs/guides/request-headers.md
66
67
  - docs/guides/state-persistence.md
@@ -106,8 +107,9 @@ files:
106
107
  - lib/agents/callback_manager.rb
107
108
  - lib/agents/handoff.rb
108
109
  - lib/agents/helpers.rb
109
- - lib/agents/helpers/headers.rb
110
+ - lib/agents/helpers/hash_normalizer.rb
110
111
  - lib/agents/helpers/message_extractor.rb
112
+ - lib/agents/helpers/name_normalizer.rb
111
113
  - lib/agents/instrumentation.rb
112
114
  - lib/agents/instrumentation/constants.rb
113
115
  - lib/agents/instrumentation/tracing_callbacks.rb
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agents
4
- module Helpers
5
- module Headers
6
- module_function
7
-
8
- def normalize(headers, freeze_result: false)
9
- return freeze_result ? {}.freeze : {} if headers.nil? || (headers.respond_to?(:empty?) && headers.empty?)
10
-
11
- hash = headers.respond_to?(:to_h) ? headers.to_h : headers
12
- raise ArgumentError, "headers must be a Hash or respond to #to_h" unless hash.is_a?(Hash)
13
-
14
- result = symbolize_keys(hash)
15
- freeze_result ? result.freeze : result
16
- end
17
-
18
- def merge(agent_headers, runtime_headers)
19
- return runtime_headers if agent_headers.empty?
20
- return agent_headers if runtime_headers.empty?
21
-
22
- agent_headers.merge(runtime_headers) { |_key, _agent_value, runtime_value| runtime_value }
23
- end
24
-
25
- def symbolize_keys(hash)
26
- hash.transform_keys do |key|
27
- key.is_a?(Symbol) ? key : key.to_sym
28
- end
29
- end
30
- private_class_method :symbolize_keys
31
- end
32
- end
33
- end