activeagent 1.0.0 → 1.0.1

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: 330c02d2e1ac16e72413f22401dbc6899f652b794d34090b0f499a37b901fbaf
4
- data.tar.gz: a492f5c88db1f217fe47f9de1477f195c6b150b5dc5ce5791c66fe0f63b0b028
3
+ metadata.gz: 6ee3a092c5c836febf3c2e3045a1c1b2cd448edc18f3e76e4ee1ebc181f895b8
4
+ data.tar.gz: 9850ab912eedaac0a57f9a954648d096d6aa70ad470ff9aa7c84a18183ab76be
5
5
  SHA512:
6
- metadata.gz: d8dac6789ba1f3685c24d711aace46ad5b681e9c795a975e61f07ee3c7d9a9bc44c2fd0568fabcabd739df5efd13b222c5fa6e954384bacb8bbcfdaaab0d7182
7
- data.tar.gz: 8a0f61b2b5a9375c8f1b090bca29af077384079ba008622a1a510d8190acdff034c7f6b8065c1c752a2cee00dabd35d00f017db28954aacc17386fb06be63d50
6
+ metadata.gz: 199061594f115a823037504f84daf5ebc79b084c8a835b3e67f5427a19ffe33bbe208bb627dc26b1abf977c4d35a2977defcfb59e9128e60d94c6a3dc0ce5259
7
+ data.tar.gz: 661bf403014e2a0d1156614249b9fcdc352b8124e63457071ce75399ac69d089cf2d43d8b571e41614d811a5e6dc4ebc9072dfe38d912bc122672bf9823bb331
data/CHANGELOG.md CHANGED
@@ -10,6 +10,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
  Major refactor with breaking changes. Complete provider rewrite. New modular architecture.
11
11
 
12
12
  **Requirements:** Ruby 3.1+, Rails 7.0+/8.0+/8.1+
13
+ ## What's Changed
14
+ * Major Framework Refactor: ActiveAgent v1.0.0 by @sirwolfgang in https://github.com/activeagents/activeagent/pull/259
15
+ * Add API gem version testing and fix Anthropic 1.14.0 compatibility by @sirwolfgang in https://github.com/activeagents/activeagent/pull/265
16
+ * Fix version compatiblity issue for vitepress by @sirwolfgang in https://github.com/activeagents/activeagent/pull/266
17
+ * Add missing API Keys by @sirwolfgang in https://github.com/activeagents/activeagent/pull/267
18
+ * Fix website links by @sirwolfgang in https://github.com/activeagents/activeagent/pull/268
19
+ * chore: remove `standard` from dev dependencies by @okuramasafumi in https://github.com/activeagents/activeagent/pull/272
20
+ * Add thread safety tests by @sirwolfgang in https://github.com/activeagents/activeagent/pull/275
21
+ * Refactor: Leverage Native Gem Types Across All Providers by @sirwolfgang in https://github.com/activeagents/activeagent/pull/271
22
+ * Improved Usage Tracking by @sirwolfgang in https://github.com/activeagents/activeagent/pull/274
23
+
24
+ ## New Contributors
25
+ * @okuramasafumi made their first contribution in https://github.com/activeagents/activeagent/pull/272
26
+
27
+ **Full Changelog**: https://github.com/activeagents/activeagent/compare/v0.6.3...v1.0.0
28
+
29
+ ### Added
30
+
31
+ **Universal Tools Format**
32
+ ```ruby
33
+ # Single format works across all providers (Anthropic, OpenAI, OpenRouter, Ollama, Mock)
34
+ tools: [{
35
+ name: "get_weather",
36
+ description: "Get current weather",
37
+ parameters: {
38
+ type: "object",
39
+ properties: {
40
+ location: { type: "string", description: "City and state" }
41
+ },
42
+ required: ["location"]
43
+ }
44
+ }]
45
+
46
+ # Tool choice normalization
47
+ tool_choice: "auto" # Let model decide
48
+ tool_choice: "required" # Force tool use
49
+ tool_choice: { name: "get_weather" } # Force specific tool
50
+ ```
51
+
52
+ Automatic conversion to provider-specific formats. Old formats still work (backward compatible).
53
+
54
+ **Model Context Protocol (MCP) Support**
55
+ ```ruby
56
+ # Universal MCP format works across providers (Anthropic, OpenAI)
57
+ class MyAgent < ActiveAgent::Base
58
+ generate_with :anthropic, model: "claude-haiku-4-5"
59
+
60
+ def research
61
+ prompt(
62
+ message: "Research AI developments",
63
+ mcps: [{
64
+ name: "github",
65
+ url: "https://api.githubcopilot.com/mcp/",
66
+ authorization: ENV["GITHUB_MCP_TOKEN"]
67
+ }]
68
+ )
69
+ end
70
+ end
71
+ ```
72
+
73
+ - Common format: `{name: "server", url: "https://...", authorization: "token"}`
74
+ - Auto-converts to provider native formats
75
+ - Anthropic: Beta API support, up to 20 servers per request
76
+ - OpenAI: Responses API with pre-built connectors (Dropbox, Google Drive, etc.)
77
+ - Backwards compatible: accepts both `mcps` and `mcp_servers` parameters
78
+ - Comprehensive documentation with tested examples
79
+ - Full VCR test coverage with real MCP endpoints
80
+
81
+ ### Changed
82
+
83
+ - Shared `ToolChoiceClearing` concern eliminates duplication across providers
13
84
 
14
85
  ### Breaking Changes
15
86
 
@@ -4,6 +4,7 @@ require_relative "common/response"
4
4
  require_relative "concerns/exception_handler"
5
5
  require_relative "concerns/instrumentation"
6
6
  require_relative "concerns/previewable"
7
+ require_relative "concerns/tool_choice_clearing"
7
8
 
8
9
  # @private
9
10
  GEM_LOADERS = {
@@ -45,6 +46,7 @@ module ActiveAgent
45
46
  include ExceptionHandler
46
47
  include Instrumentation
47
48
  include Previewable
49
+ include ToolChoiceClearing
48
50
 
49
51
  class ProvidersError < StandardError; end
50
52
 
@@ -28,15 +28,13 @@ module ActiveAgent
28
28
  end
29
29
 
30
30
  def serialize
31
- super.except(:anthropic_beta).tap do |hash|
32
- hash[:extra_headers] = extra_headers unless extra_headers.blank?
33
- end
31
+ super.except(:anthropic_beta)
34
32
  end
35
33
 
34
+ # Anthropic gem handles beta headers differently via client.beta
35
+ # rather than via extra_headers in request_options
36
36
  def extra_headers
37
- deep_compact(
38
- "anthropic-beta" => anthropic_beta.presence,
39
- )
37
+ {}
40
38
  end
41
39
 
42
40
  private
@@ -67,11 +67,13 @@ module ActiveAgent
67
67
  # @option params [Array<Hash>] :messages required
68
68
  # @option params [Integer] :max_tokens (4096)
69
69
  # @option params [Hash] :response_format custom field for JSON mode simulation
70
+ # @option params [String] :anthropic_beta beta version for features like MCP
70
71
  # @raise [ArgumentError] when gem model validation fails
71
72
  def initialize(**params)
72
73
  # Step 1: Extract custom fields that gem doesn't support
73
74
  @response_format = params.delete(:response_format)
74
75
  @stream = params.delete(:stream)
76
+ anthropic_beta = params.delete(:anthropic_beta)
75
77
 
76
78
  # Step 2: Map common format 'instructions' to Anthropic's 'system'
77
79
  if params.key?(:instructions)
@@ -84,10 +86,24 @@ module ActiveAgent
84
86
  # Step 4: Transform params for gem compatibility
85
87
  transformed = Transforms.normalize_params(params)
86
88
 
87
- # Step 5: Create gem model - this validates all parameters!
88
- gem_model = ::Anthropic::Models::MessageCreateParams.new(**transformed)
89
+ # Step 5: Determine if we need beta params (for MCP or other beta features)
90
+ use_beta = anthropic_beta.present? || transformed[:mcp_servers]&.any?
89
91
 
90
- # Step 6: Delegate all method calls to gem model
92
+ # Step 6: Add betas parameter if using beta API
93
+ if use_beta
94
+ # Default to MCP beta version if not specified
95
+ beta_version = anthropic_beta || "mcp-client-2025-04-04"
96
+ transformed[:betas] = [ beta_version ]
97
+ end
98
+
99
+ # Step 7: Create gem model - use Beta version if needed
100
+ gem_model = if use_beta
101
+ ::Anthropic::Models::Beta::MessageCreateParams.new(**transformed)
102
+ else
103
+ ::Anthropic::Models::MessageCreateParams.new(**transformed)
104
+ end
105
+
106
+ # Step 8: Delegate all method calls to gem model
91
107
  super(gem_model)
92
108
  rescue ArgumentError => e
93
109
  # Re-raise with more context
@@ -134,6 +150,15 @@ module ActiveAgent
134
150
  self.system = value
135
151
  end
136
152
 
153
+ # Accessor for MCP servers.
154
+ #
155
+ # Safely returns MCP servers array, defaulting to empty array if not set.
156
+ #
157
+ # @return [Array]
158
+ def mcp_servers
159
+ __getobj__.instance_variable_get(:@data)[:mcp_servers] || []
160
+ end
161
+
137
162
  # Removes the last message from the messages array.
138
163
  #
139
164
  # Used for JSON format simulation to remove the lead-in assistant message.
@@ -28,9 +28,137 @@ module ActiveAgent
28
28
  params = params.dup
29
29
  params[:messages] = normalize_messages(params[:messages]) if params[:messages]
30
30
  params[:system] = normalize_system(params[:system]) if params[:system]
31
+ params[:tools] = normalize_tools(params[:tools]) if params[:tools]
32
+ params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
33
+
34
+ # Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
35
+ if params[:mcps]
36
+ params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps))
37
+ elsif params[:mcp_servers]
38
+ params[:mcp_servers] = normalize_mcp_servers(params[:mcp_servers])
39
+ end
40
+
31
41
  params
32
42
  end
33
43
 
44
+ # Normalizes tools from common format to Anthropic format.
45
+ #
46
+ # Accepts both `parameters` and `input_schema` keys, converting to Anthropic's `input_schema`.
47
+ #
48
+ # @param tools [Array<Hash>]
49
+ # @return [Array<Hash>]
50
+ def normalize_tools(tools)
51
+ return tools unless tools.is_a?(Array)
52
+
53
+ tools.map do |tool|
54
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
55
+
56
+ # If already in Anthropic format (has input_schema), return as-is
57
+ next tool_hash if tool_hash[:input_schema]
58
+
59
+ # Convert common format with 'parameters' to Anthropic format with 'input_schema'
60
+ if tool_hash[:parameters]
61
+ tool_hash = tool_hash.dup
62
+ tool_hash[:input_schema] = tool_hash.delete(:parameters)
63
+ end
64
+
65
+ tool_hash
66
+ end
67
+ end
68
+
69
+ # Normalizes MCP servers from common format to Anthropic format.
70
+ #
71
+ # Common format:
72
+ # {name: "stripe", url: "https://...", authorization: "token"}
73
+ # Anthropic format:
74
+ # {type: "url", name: "stripe", url: "https://...", authorization_token: "token"}
75
+ #
76
+ # @param mcp_servers [Array<Hash>]
77
+ # @return [Array<Hash>]
78
+ def normalize_mcp_servers(mcp_servers)
79
+ return mcp_servers unless mcp_servers.is_a?(Array)
80
+
81
+ mcp_servers.map do |server|
82
+ server_hash = server.is_a?(Hash) ? server.deep_symbolize_keys : server
83
+
84
+ # If already in Anthropic native format (has type: "url"), return as-is
85
+ # Check for absence of common format 'authorization' field OR presence of native 'authorization_token'
86
+ if server_hash[:type] == "url" && (server_hash[:authorization_token] || !server_hash[:authorization])
87
+ next server_hash
88
+ end
89
+
90
+ # Convert common format to Anthropic format
91
+ result = {
92
+ type: "url",
93
+ name: server_hash[:name],
94
+ url: server_hash[:url]
95
+ }
96
+
97
+ # Map 'authorization' to 'authorization_token'
98
+ if server_hash[:authorization]
99
+ result[:authorization_token] = server_hash[:authorization]
100
+ elsif server_hash[:authorization_token]
101
+ result[:authorization_token] = server_hash[:authorization_token]
102
+ end
103
+
104
+ result.compact
105
+ end
106
+ end
107
+
108
+ # Normalizes tool_choice from common format to Anthropic gem model objects.
109
+ #
110
+ # The Anthropic gem expects tool_choice to be a model object (ToolChoiceAuto,
111
+ # ToolChoiceAny, ToolChoiceTool, etc.), not a plain hash.
112
+ #
113
+ # Maps:
114
+ # - "required" → ToolChoiceAny (force tool use)
115
+ # - "auto" → ToolChoiceAuto (let model decide)
116
+ # - { name: "..." } → ToolChoiceTool with name
117
+ #
118
+ # @param tool_choice [String, Hash, Object]
119
+ # @return [Object] Anthropic gem model object
120
+ def normalize_tool_choice(tool_choice)
121
+ # If already a gem model object, return as-is
122
+ return tool_choice if tool_choice.is_a?(::Anthropic::Models::ToolChoiceAuto) ||
123
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceAny) ||
124
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceTool) ||
125
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceNone)
126
+
127
+ case tool_choice
128
+ when "required"
129
+ # Create ToolChoiceAny model for forcing tool use
130
+ ::Anthropic::Models::ToolChoiceAny.new(type: :any)
131
+ when "auto"
132
+ # Create ToolChoiceAuto model for letting model decide
133
+ ::Anthropic::Models::ToolChoiceAuto.new(type: :auto)
134
+ when Hash
135
+ choice_hash = tool_choice.deep_symbolize_keys
136
+
137
+ # If has type field, create appropriate model
138
+ if choice_hash[:type]
139
+ case choice_hash[:type].to_sym
140
+ when :any
141
+ ::Anthropic::Models::ToolChoiceAny.new(**choice_hash)
142
+ when :auto
143
+ ::Anthropic::Models::ToolChoiceAuto.new(**choice_hash)
144
+ when :tool
145
+ ::Anthropic::Models::ToolChoiceTool.new(**choice_hash)
146
+ when :none
147
+ ::Anthropic::Models::ToolChoiceNone.new(**choice_hash)
148
+ else
149
+ choice_hash
150
+ end
151
+ # Convert { name: "..." } to ToolChoiceTool
152
+ elsif choice_hash[:name]
153
+ ::Anthropic::Models::ToolChoiceTool.new(type: :tool, name: choice_hash[:name])
154
+ else
155
+ choice_hash
156
+ end
157
+ else
158
+ tool_choice
159
+ end
160
+ end
161
+
34
162
  # Merges consecutive same-role messages into single messages with multiple content blocks.
35
163
  #
36
164
  # Required by Anthropic API - consecutive messages with the same role must be combined.
@@ -317,9 +445,10 @@ module ActiveAgent
317
445
  # Apply content compression for API efficiency
318
446
  compress_content(hash)
319
447
 
320
- # Remove provider-internal fields that should not be in API request
321
- hash.delete(:mcp_servers) # Provider-level config, not API param
448
+ # Remove provider-internal fields and empty arrays
322
449
  hash.delete(:stop_sequences) if hash[:stop_sequences] == []
450
+ hash.delete(:mcp_servers) if hash[:mcp_servers] == []
451
+ hash.delete(:tool_choice) if hash[:tool_choice].nil? # Don't send null tool_choice
323
452
 
324
453
  # Remove default values (except max_tokens which is required by API)
325
454
  defaults.each do |key, value|
@@ -17,6 +17,17 @@ module ActiveAgent
17
17
  #
18
18
  # @see BaseProvider
19
19
  class AnthropicProvider < BaseProvider
20
+ # Lead-in message for JSON response format emulation
21
+ JSON_RESPONSE_FORMAT_LEAD_IN = "Here is the JSON requested:\n{"
22
+
23
+ attr_internal :json_format_retry_count
24
+
25
+ def initialize(kwargs = {})
26
+ super
27
+
28
+ self.json_format_retry_count = kwargs[:max_retries] || ::Anthropic::Client::DEFAULT_MAX_RETRIES
29
+ end
30
+
20
31
  # @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
21
32
  # @return [Anthropic::Client]
22
33
  def client
@@ -39,22 +50,31 @@ module ActiveAgent
39
50
  super
40
51
  end
41
52
 
42
- # @api private
43
- def prepare_prompt_request_tools
44
- return unless request.tool_choice
45
- return unless request.tool_choice.respond_to?(:type)
53
+ # Extracts function names from Anthropic's tool_use content blocks.
54
+ #
55
+ # @return [Array<String>]
56
+ def extract_used_function_names
57
+ message_stack.pluck(:content).flatten.select { _1[:type] == "tool_use" }.pluck(:name)
58
+ end
46
59
 
47
- functions_used = message_stack.pluck(:content).flatten.select { _1[:type] == "tool_use" }.pluck(:name)
60
+ # Checks if tool_choice requires the model to call any tool.
61
+ #
62
+ # @return [Boolean] true if tool_choice type is :any
63
+ def tool_choice_forces_required?
64
+ return false unless request.tool_choice.respond_to?(:type)
48
65
 
49
- # tool_choice is always a gem model object (ToolChoiceAny, ToolChoiceTool, ToolChoiceAuto)
50
- tool_choice_type = request.tool_choice.type
51
- tool_choice_name = request.tool_choice.respond_to?(:name) ? request.tool_choice.name : nil
66
+ request.tool_choice.type == :any
67
+ end
52
68
 
53
- if (tool_choice_type == :any && functions_used.any?) ||
54
- (tool_choice_type == :tool && tool_choice_name && functions_used.include?(tool_choice_name))
69
+ # Checks if tool_choice requires a specific tool to be called.
70
+ #
71
+ # @return [Array<Boolean, String|nil>] [true, tool_name] if forcing a specific tool, [false, nil] otherwise
72
+ def tool_choice_forces_specific?
73
+ return [ false, nil ] unless request.tool_choice.respond_to?(:type)
74
+ return [ false, nil ] unless request.tool_choice.type == :tool
55
75
 
56
- request.tool_choice = nil
57
- end
76
+ tool_name = request.tool_choice.respond_to?(:name) ? request.tool_choice.name : nil
77
+ [ true, tool_name ]
58
78
  end
59
79
 
60
80
  # @api private
@@ -63,19 +83,30 @@ module ActiveAgent
63
83
 
64
84
  self.message_stack.push({
65
85
  role: "assistant",
66
- content: "Here is the JSON requested:\n{"
86
+ content: JSON_RESPONSE_FORMAT_LEAD_IN
67
87
  })
68
88
  end
69
89
 
90
+ # Selects between Anthropic's stable and beta message APIs.
91
+ #
92
+ # Uses beta API when explicitly requested via anthropic_beta option or when
93
+ # using MCP servers, which require beta features. Falls back to stable API
94
+ # for standard message creation.
95
+ #
70
96
  # @see BaseProvider#api_prompt_executer
71
- # @return [Anthropic::Messages]
97
+ # @return [Anthropic::Messages, Anthropic::Resources::Beta::Messages]
72
98
  def api_prompt_executer
73
- client.messages
99
+ # Use beta API when anthropic_beta option is set or when using MCP servers
100
+ if options.anthropic_beta.present? || request.mcp_servers&.any?
101
+ client.beta.messages
102
+ else
103
+ client.messages
104
+ end
74
105
  end
75
106
 
76
107
  # @see BaseProvider#api_response_normalize
77
108
  # @param api_response [Anthropic::Models::Message]
78
- # @return [Hash] normalized response hash
109
+ # @return [Hash]
79
110
  def api_response_normalize(api_response)
80
111
  return api_response unless api_response
81
112
 
@@ -200,31 +231,57 @@ module ActiveAgent
200
231
  end
201
232
  end
202
233
 
203
- # Converts API response message to hash for message_stack.
204
- # Converts Anthropic gem response object to hash for storage.
234
+ # Processes completed API response and handles JSON format retries.
205
235
  #
236
+ # When response_format is json_object and the response fails JSON validation,
237
+ # recursively retries the request to obtain well-formed JSON.
238
+ #
239
+ # @see BaseProvider#process_prompt_finished
206
240
  # @param api_response [Anthropic::Models::Message]
207
241
  # @return [Common::PromptResponse, nil]
208
242
  def process_prompt_finished(api_response = nil)
209
243
  # Convert gem object to hash so that raw_response[:usage] works
210
244
  api_response_hash = api_response ? Anthropic::Transforms.gem_to_hash(api_response) : nil
211
- super(api_response_hash)
245
+
246
+ common_response = super(api_response_hash)
247
+
248
+ # If we failed to get the expected well formed JSON Object Response, recursively try again
249
+ if request.response_format&.dig(:type) == "json_object" && common_response.message.parsed_json.nil? && json_format_retry_count > 0
250
+ self.json_format_retry_count -= 1
251
+
252
+ resolve_prompt
253
+ else
254
+ common_response
255
+ end
212
256
  end
213
257
 
258
+ # Reconstructs JSON responses that were split due to Anthropic format constraints.
214
259
  #
215
- # Handles JSON response format simulation by prepending `{` to the response
216
- # content after removing the assistant lead-in message.
260
+ # Anthropic's API doesn't natively support json_object response format, so we
261
+ # simulate it by having the assistant echo a JSON lead-in ("Here is the JSON requested:\n{"),
262
+ # then send the response back for completion. This method detects and reverses
263
+ # that workaround by stripping the lead-in message and prepending "{" to the response.
217
264
  #
218
265
  # @see BaseProvider#process_prompt_finished_extract_messages
219
- # @param api_response [Hash] converted response hash
266
+ # @param api_response [Hash] API response with content blocks
220
267
  # @return [Array<Hash>, nil]
221
268
  def process_prompt_finished_extract_messages(api_response)
222
269
  return unless api_response
223
270
 
224
- # Handle JSON response format simulation
225
- if request.response_format&.dig(:type) == "json_object"
226
- request.pop_message!
227
- api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
271
+ # Get the last message (may be either Hash or gem object)
272
+ last_message = request.messages.last
273
+ last_role = last_message.is_a?(Hash) ? last_message[:role] : last_message&.role
274
+ last_content = last_message.is_a?(Hash) ? last_message[:content] : last_message&.content
275
+
276
+ # Check if the last message in request is the JSON lead-in prompt
277
+ if last_role.to_sym == :assistant && last_content == JSON_RESPONSE_FORMAT_LEAD_IN
278
+ # Remove the lead-in message from the request
279
+ request.messages.pop
280
+
281
+ # Prepend "{" to the response's first content text
282
+ if api_response[:content]&.first&.dig(:text)
283
+ api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
284
+ end
228
285
  end
229
286
 
230
287
  [ api_response ]
@@ -37,7 +37,7 @@ module ActiveAgent
37
37
  role = hash[:role]&.to_s
38
38
 
39
39
  case role
40
- when "system"
40
+ when "system", "developer"
41
41
  nil # System messages are dropped in common format, replaced by Instructions
42
42
  when "user", nil
43
43
  # Handle both standard format and format with `text` key
@@ -51,12 +51,6 @@ module ActiveAgent
51
51
  when "assistant"
52
52
  # Filter to only known attributes for Assistant
53
53
  filtered_hash = hash.slice(:role, :content, :name)
54
-
55
- # Compress content array to string if needed (Anthropic format)
56
- if filtered_hash[:content].is_a?(Array)
57
- filtered_hash[:content] = compress_content_array(filtered_hash[:content])
58
- end
59
-
60
54
  Common::Messages::Assistant.new(**filtered_hash)
61
55
  when "tool"
62
56
  # Filter to only known attributes for Tool
@@ -94,29 +88,6 @@ module ActiveAgent
94
88
  raise ArgumentError, "Cannot serialize #{value.class}"
95
89
  end
96
90
  end
97
-
98
- # Compresses Anthropic-style content array into a string.
99
- #
100
- # Anthropic messages can have content as an array of blocks like:
101
- # [{type: "text", text: "..."}, {type: "tool_use", ...}]
102
- # This extracts and joins text blocks into a single string.
103
- #
104
- # @param content_array [Array<Hash>]
105
- # @return [String]
106
- def compress_content_array(content_array)
107
- content_array.map do |block|
108
- case block[:type]&.to_s
109
- when "text"
110
- block[:text]
111
- when "tool_use"
112
- # Tool use blocks don't have readable text content
113
- nil
114
- else
115
- # Unknown block type, try to extract text if present
116
- block[:text]
117
- end
118
- end.compact.join("\n")
119
- end
120
91
  end
121
92
 
122
93
  # Type for Messages array
@@ -124,7 +95,9 @@ module ActiveAgent
124
95
  def cast(value)
125
96
  case value
126
97
  when Array
127
- value.map { |v| message_type.cast(v) }.compact
98
+ messages = value.map { |v| message_type.cast(v) }.compact
99
+ # Split messages with array content into separate messages
100
+ messages.flat_map { |msg| split_content_blocks(msg) }
128
101
  when nil
129
102
  []
130
103
  else
@@ -152,6 +125,40 @@ module ActiveAgent
152
125
  def message_type
153
126
  @message_type ||= MessageType.new
154
127
  end
128
+
129
+ # Splits an assistant message with array content into separate messages
130
+ # for each content block.
131
+ #
132
+ # @param message [Common::Messages::Base]
133
+ # @return [Array<Common::Messages::Base>]
134
+ def split_content_blocks(message)
135
+ # Only split assistant messages with array content
136
+ return [ message ] unless message.is_a?(Common::Messages::Assistant) && message.content.is_a?(Array)
137
+
138
+ message.content.map do |block|
139
+ case block[:type]&.to_s
140
+ when "text"
141
+ # Create a message for text blocks
142
+ Common::Messages::Assistant.new(role: "assistant", content: block[:text], name: message.name)
143
+ when "tool_use"
144
+ # Create a message with tool use info as string representation
145
+ tool_info = "[Tool Use: #{block[:name]}]\nID: #{block[:id]}\nInput: #{JSON.pretty_generate(block[:input])}"
146
+ Common::Messages::Assistant.new(role: "assistant", content: tool_info, name: message.name)
147
+ when "mcp_tool_use"
148
+ # Create a message with MCP tool use info
149
+ tool_info = "[MCP Tool Use: #{block[:name]}]\nID: #{block[:id]}\nServer: #{block[:server_name]}\nInput: #{JSON.pretty_generate(block[:input] || {})}"
150
+ Common::Messages::Assistant.new(role: "assistant", content: tool_info, name: message.name)
151
+ when "mcp_tool_result"
152
+ # Create a message with MCP tool result
153
+ result_info = "[MCP Tool Result]\n#{block[:content]}"
154
+ Common::Messages::Assistant.new(role: "assistant", content: result_info, name: message.name)
155
+ else
156
+ # For unknown block types, try to extract text
157
+ content = block[:text] || block.to_s
158
+ Common::Messages::Assistant.new(role: "assistant", content:, name: message.name)
159
+ end
160
+ end.compact
161
+ end
155
162
  end
156
163
  end
157
164
  end
@@ -9,7 +9,7 @@ module ActiveAgent
9
9
  # Represents messages sent by the AI assistant in a conversation.
10
10
  class Assistant < Base
11
11
  attribute :role, :string, as: "assistant"
12
- attribute :content, :string
12
+ attribute :content # Accept both string and array (provider-native formats)
13
13
  attribute :name, :string
14
14
 
15
15
  validates :content, presence: true
@@ -24,9 +24,16 @@ module ActiveAgent
24
24
  # @param normalize_names [Symbol, nil] key normalization method (e.g., :underscore)
25
25
  # @return [Hash, Array, nil] parsed JSON structure or nil if parsing fails
26
26
  def parsed_json(symbolize_names: true, normalize_names: :underscore)
27
- start_char = [ content.index("{"), content.index("[") ].compact.min
28
- end_char = [ content.rindex("}"), content.rindex("]") ].compact.max
29
- content_stripped = content[start_char..end_char] if start_char && end_char
27
+ # Handle array content (from content blocks) by searching through each block
28
+ content_str = if content.is_a?(Array)
29
+ content.map { |block| block.is_a?(Hash) ? block[:text] : block.to_s }.join("\n")
30
+ else
31
+ content.to_s
32
+ end
33
+
34
+ start_char = [ content_str.index("{"), content_str.index("[") ].compact.min
35
+ end_char = [ content_str.rindex("}"), content_str.rindex("]") ].compact.max
36
+ content_stripped = content_str[start_char..end_char] if start_char && end_char
30
37
  return unless content_stripped
31
38
 
32
39
  content_parsed = JSON.parse(content_stripped)
@@ -48,6 +55,15 @@ module ActiveAgent
48
55
  nil
49
56
  end
50
57
 
58
+ # Returns content as a string, handling both string and array formats
59
+ def text
60
+ if content.is_a?(Array)
61
+ content.map { |block| block.is_a?(Hash) ? block[:text] : block.to_s }.join("\n")
62
+ else
63
+ content.to_s
64
+ end
65
+ end
66
+
51
67
  alias_method :json_object, :parsed_json
52
68
  alias_method :parse_json, :parsed_json
53
69
  end
@@ -86,7 +86,13 @@ module ActiveAgent
86
86
  "### Message #{index} (#{role.capitalize})\n#{content}"
87
87
  end
88
88
 
89
- # Renders available tools with descriptions and parameter schemas.
89
+ # Renders tools section for preview.
90
+ #
91
+ # Handles multiple tool formats:
92
+ # - Common format: {name: "...", description: "...", parameters: {...}}
93
+ # - Anthropic format: {name: "...", description: "...", input_schema: {...}}
94
+ # - Chat API format: {type: "function", function: {name: "...", description: "...", parameters: {...}}}
95
+ # - Responses API format: {type: "function", name: "...", description: "...", parameters: {...}}
90
96
  #
91
97
  # @param tools [Array<Hash>]
92
98
  # @return [String]
@@ -96,17 +102,45 @@ module ActiveAgent
96
102
  content = +"## Tools\n\n"
97
103
 
98
104
  tools.each_with_index do |tool, index|
99
- content << "### #{tool[:name] || "Tool #{index + 1}"}\n"
100
- content << "**Description:** #{tool[:description] || 'No description'}\n\n"
105
+ # Extract name and description from different formats
106
+ tool_name, tool_description, tool_params = extract_tool_details(tool)
107
+
108
+ content << "### #{tool_name || "Tool #{index + 1}"}\n"
109
+ content << "**Description:** #{tool_description || 'No description'}\n\n"
101
110
 
102
- if tool[:parameters]
103
- content << "**Parameters:**\n```json\n#{JSON.pretty_generate(tool[:parameters])}\n```\n\n"
111
+ if tool_params
112
+ content << "**Parameters:**\n```json\n#{JSON.pretty_generate(tool_params)}\n```\n\n"
104
113
  end
105
114
  end
106
115
 
107
116
  content.chomp
108
117
  end
109
118
 
119
+ # Extracts tool details from different formats.
120
+ #
121
+ # @param tool [Hash]
122
+ # @return [Array<String, String, Hash>] [name, description, parameters]
123
+ def extract_tool_details(tool)
124
+ tool_hash = tool.is_a?(Hash) ? tool : {}
125
+
126
+ # Chat API nested format: {type: "function", function: {...}}
127
+ if tool_hash[:type] == "function" && tool_hash[:function]
128
+ func = tool_hash[:function]
129
+ return [
130
+ func[:name],
131
+ func[:description],
132
+ func[:parameters] || func[:input_schema]
133
+ ]
134
+ end
135
+
136
+ # Flat formats (common, Anthropic, Responses)
137
+ [
138
+ tool_hash[:name],
139
+ tool_hash[:description],
140
+ tool_hash[:parameters] || tool_hash[:input_schema]
141
+ ]
142
+ end
143
+
110
144
  # Extracts text content from various message formats.
111
145
  #
112
146
  # Handles string messages, hash messages with :content key, and
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Providers
5
+ # Provides unified logic for clearing tool_choice after tool execution.
6
+ #
7
+ # When a tool_choice is set to "required" or to a specific tool name,
8
+ # it forces the model to use that tool. After the tool is executed,
9
+ # we need to clear the tool_choice to prevent infinite loops where
10
+ # the model keeps calling the same tool repeatedly.
11
+ #
12
+ # Each provider implements:
13
+ # - `extract_used_function_names`: Returns array of tool names that have been called
14
+ # - `tool_choice_forces_required?`: Returns true if tool_choice forces any tool use
15
+ # - `tool_choice_forces_specific?`: Returns [true, name] if tool_choice forces specific tool
16
+ module ToolChoiceClearing
17
+ extend ActiveSupport::Concern
18
+
19
+ # @api private
20
+ def prepare_prompt_request_tools
21
+ return unless request.tool_choice
22
+
23
+ functions_used = extract_used_function_names
24
+
25
+ # Clear if forcing required and any tool was used
26
+ if tool_choice_forces_required? && functions_used.any?
27
+ request.tool_choice = nil
28
+ return
29
+ end
30
+
31
+ # Clear if forcing specific tool and that tool was used
32
+ forces_specific, tool_name = tool_choice_forces_specific?
33
+ if forces_specific && tool_name && functions_used.include?(tool_name)
34
+ request.tool_choice = nil
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # Extracts the list of function names that have been called.
41
+ #
42
+ # @return [Array<String>] function names
43
+ def extract_used_function_names
44
+ raise NotImplementedError, "#{self.class} must implement #extract_used_function_names"
45
+ end
46
+
47
+ # Returns true if tool_choice forces any tool to be used (e.g., "required", "any").
48
+ #
49
+ # @return [Boolean]
50
+ def tool_choice_forces_required?
51
+ raise NotImplementedError, "#{self.class} must implement #tool_choice_forces_required?"
52
+ end
53
+
54
+ # Returns [true, tool_name] if tool_choice forces a specific tool, [false, nil] otherwise.
55
+ #
56
+ # @return [Array<Boolean, String|nil>]
57
+ def tool_choice_forces_specific?
58
+ raise NotImplementedError, "#{self.class} must implement #tool_choice_forces_specific?"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -24,8 +24,8 @@ module ActiveAgent
24
24
  # Normalizes all request parameters for OpenAI Chat API
25
25
  #
26
26
  # Handles instructions mapping to developer messages, message normalization,
27
- # and response_format conversion. This is the main entry point for parameter
28
- # transformation.
27
+ # tools normalization, and response_format conversion. This is the main entry point
28
+ # for parameter transformation.
29
29
  #
30
30
  # @param params [Hash]
31
31
  # @return [Hash] normalized parameters
@@ -41,6 +41,12 @@ module ActiveAgent
41
41
  # Normalize messages for gem compatibility
42
42
  params[:messages] = normalize_messages(params[:messages]) if params[:messages]
43
43
 
44
+ # Normalize tools from common format to Chat API format
45
+ params[:tools] = normalize_tools(params[:tools]) if params[:tools]
46
+
47
+ # Normalize tool_choice from common format
48
+ params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
49
+
44
50
  # Normalize response_format if present
45
51
  params[:response_format] = normalize_response_format(params[:response_format]) if params[:response_format]
46
52
 
@@ -68,7 +74,8 @@ module ActiveAgent
68
74
  messages.each do |msg|
69
75
  normalized = normalize_message(msg)
70
76
 
71
- if grouped.empty? || grouped.last.role != normalized.role
77
+ # Don't merge tool messages - each needs its own tool_call_id
78
+ if grouped.empty? || grouped.last.role != normalized.role || normalized.role.to_s == "tool"
72
79
  grouped << normalized
73
80
  else
74
81
  # Merge consecutive same-role messages
@@ -307,6 +314,79 @@ module ActiveAgent
307
314
  end
308
315
  end
309
316
 
317
+ # Normalizes tools from common format to OpenAI Chat API format.
318
+ #
319
+ # Accepts tools in multiple formats:
320
+ # - Common format: `{name: "...", description: "...", parameters: {...}}`
321
+ # - Common format alt: `{name: "...", description: "...", input_schema: {...}}`
322
+ # - Nested format: `{type: "function", function: {name: "...", parameters: {...}}}`
323
+ #
324
+ # Always outputs nested Chat API format: `{type: "function", function: {...}}`
325
+ #
326
+ # @param tools [Array<Hash>]
327
+ # @return [Array<Hash>]
328
+ def normalize_tools(tools)
329
+ return tools unless tools.is_a?(Array)
330
+
331
+ tools.map do |tool|
332
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
333
+
334
+ # Already in nested format - return as is
335
+ if tool_hash[:type] == "function" && tool_hash[:function]
336
+ tool_hash
337
+ # Common format - convert to nested format
338
+ elsif tool_hash[:name]
339
+ {
340
+ type: "function",
341
+ function: {
342
+ name: tool_hash[:name],
343
+ description: tool_hash[:description],
344
+ parameters: tool_hash[:parameters] || tool_hash[:input_schema]
345
+ }.compact
346
+ }
347
+ else
348
+ tool_hash
349
+ end
350
+ end
351
+ end
352
+
353
+ # Normalizes tool_choice from common format to OpenAI Chat API format.
354
+ #
355
+ # Accepts:
356
+ # - "auto" (common) → "auto" (passthrough)
357
+ # - "required" (common) → "required" (passthrough)
358
+ # - `{name: "..."}` (common) → `{type: "function", function: {name: "..."}}`
359
+ # - Already nested format → passthrough
360
+ #
361
+ # @param tool_choice [String, Hash, Symbol]
362
+ # @return [String, Hash, Symbol]
363
+ def normalize_tool_choice(tool_choice)
364
+ case tool_choice
365
+ when "auto", :auto, "required", :required
366
+ # Passthrough - Chat API accepts these directly
367
+ tool_choice.to_s
368
+ when Hash
369
+ tool_choice_hash = tool_choice.deep_symbolize_keys
370
+
371
+ # Already in nested format with type and function keys
372
+ if tool_choice_hash[:type] == "function" && tool_choice_hash[:function]
373
+ tool_choice_hash
374
+ # Common format with just name - convert to nested format
375
+ elsif tool_choice_hash[:name]
376
+ {
377
+ type: "function",
378
+ function: {
379
+ name: tool_choice_hash[:name]
380
+ }
381
+ }
382
+ else
383
+ tool_choice_hash
384
+ end
385
+ else
386
+ tool_choice
387
+ end
388
+ end
389
+
310
390
  # Normalizes instructions to developer message format
311
391
  #
312
392
  # Converts instructions into developer messages with proper content structure.
@@ -12,6 +12,8 @@ module ActiveAgent
12
12
  # @see Base
13
13
  # @see https://platform.openai.com/docs/api-reference/chat
14
14
  class ChatProvider < Base
15
+ include ToolChoiceClearing
16
+
15
17
  # @return [Class] the options class for this provider
16
18
  def self.options_klass
17
19
  Options
@@ -30,6 +32,42 @@ module ActiveAgent
30
32
  client.chat.completions
31
33
  end
32
34
 
35
+ # @see BaseProvider#prepare_prompt_request
36
+ # @return [Request]
37
+ def prepare_prompt_request
38
+ prepare_prompt_request_tools
39
+ super
40
+ end
41
+
42
+ # Extracts function names from Chat API tool_calls in assistant messages.
43
+ #
44
+ # @return [Array<String>]
45
+ def extract_used_function_names
46
+ message_stack
47
+ .select { |msg| msg[:role] == "assistant" && msg[:tool_calls] }
48
+ .flat_map { |msg| msg[:tool_calls] }
49
+ .map { |tc| tc.dig(:function, :name) }
50
+ .compact
51
+ end
52
+
53
+ # Returns true if tool_choice == "required".
54
+ #
55
+ # @return [Boolean]
56
+ def tool_choice_forces_required?
57
+ request.tool_choice == "required"
58
+ end
59
+
60
+ # Returns [true, name] if tool_choice is a hash with nested function name.
61
+ #
62
+ # @return [Array<Boolean, String|nil>]
63
+ def tool_choice_forces_specific?
64
+ if request.tool_choice.is_a?(Hash)
65
+ [ true, request.tool_choice.dig(:function, :name) ]
66
+ else
67
+ [ false, nil ]
68
+ end
69
+ end
70
+
33
71
  # @see BaseProvider#api_response_normalize
34
72
  # @param api_response [OpenAI::Models::ChatCompletion]
35
73
  # @return [Hash] normalized response hash
@@ -73,10 +73,25 @@ module ActiveAgent
73
73
  # Step 6: Normalize input content for gem compatibility
74
74
  params[:input] = Responses::Transforms.normalize_input(params[:input]) if params[:input]
75
75
 
76
- # Step 7: Create gem model - delegates to OpenAI gem
76
+ # Step 7: Normalize tools and tool_choice from common format
77
+ params[:tools] = Responses::Transforms.normalize_tools(params[:tools]) if params[:tools]
78
+ params[:tool_choice] = Responses::Transforms.normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
79
+
80
+ # Step 8: Normalize MCP servers from common format (mcps parameter)
81
+ # OpenAI treats MCP servers as a special type of tool in the tools array
82
+ mcp_param = params[:mcps] || params[:mcp_servers]
83
+ if mcp_param&.any?
84
+ normalized_mcp_tools = Responses::Transforms.normalize_mcp_servers(mcp_param)
85
+ params.delete(:mcps)
86
+ params.delete(:mcp_servers)
87
+ # Merge MCP servers into tools array
88
+ params[:tools] = (params[:tools] || []) + normalized_mcp_tools
89
+ end
90
+
91
+ # Step 9: Create gem model - delegates to OpenAI gem
77
92
  gem_model = ::OpenAI::Models::Responses::ResponseCreateParams.new(**params)
78
93
 
79
- # Step 8: Delegate all method calls to gem model
94
+ # Step 10: Delegate all method calls to gem model
80
95
  super(gem_model)
81
96
  rescue ArgumentError => e
82
97
  # Re-raise with more context
@@ -21,6 +21,141 @@ module ActiveAgent
21
21
  JSON.parse(gem_object.to_json, symbolize_names: true)
22
22
  end
23
23
 
24
+ # Normalizes tools from common format to OpenAI Responses API format.
25
+ #
26
+ # Accepts tools in multiple formats:
27
+ # - Common format: `{name: "...", description: "...", parameters: {...}}`
28
+ # - Nested format: `{type: "function", function: {name: "...", ...}}`
29
+ # - Responses format: `{type: "function", name: "...", parameters: {...}}`
30
+ #
31
+ # Always outputs flat Responses API format.
32
+ #
33
+ # @param tools [Array<Hash>]
34
+ # @return [Array<Hash>]
35
+ def normalize_tools(tools)
36
+ return tools unless tools.is_a?(Array)
37
+
38
+ tools.map do |tool|
39
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
40
+
41
+ # If already in Responses format (flat with type, name, parameters), return as-is
42
+ if tool_hash[:type] == "function" && tool_hash[:name]
43
+ next tool_hash
44
+ end
45
+
46
+ # If in nested Chat API format, flatten it
47
+ if tool_hash[:type] == "function" && tool_hash[:function]
48
+ func = tool_hash[:function]
49
+ next {
50
+ type: "function",
51
+ name: func[:name],
52
+ description: func[:description],
53
+ parameters: func[:parameters] || func[:input_schema]
54
+ }.compact
55
+ end
56
+
57
+ # If in common format (no type field), convert to Responses format
58
+ if tool_hash[:name] && !tool_hash[:type]
59
+ next {
60
+ type: "function",
61
+ name: tool_hash[:name],
62
+ description: tool_hash[:description],
63
+ parameters: tool_hash[:parameters] || tool_hash[:input_schema]
64
+ }.compact
65
+ end
66
+
67
+ # Pass through other formats
68
+ tool_hash
69
+ end
70
+ end
71
+
72
+ # Normalizes MCP servers from common format to OpenAI Responses API format.
73
+ #
74
+ # Common format:
75
+ # {name: "stripe", url: "https://...", authorization: "token"}
76
+ # OpenAI format:
77
+ # {type: "mcp", server_label: "stripe", server_url: "https://...", authorization: "token"}
78
+ #
79
+ # @param mcp_servers [Array<Hash>]
80
+ # @return [Array<Hash>]
81
+ def normalize_mcp_servers(mcp_servers)
82
+ return mcp_servers unless mcp_servers.is_a?(Array)
83
+
84
+ mcp_servers.map do |server|
85
+ server_hash = server.is_a?(Hash) ? server.deep_symbolize_keys : server
86
+
87
+ # If already in OpenAI format (has type: "mcp" and server_label), return as-is
88
+ if server_hash[:type] == "mcp" && server_hash[:server_label]
89
+ next server_hash
90
+ end
91
+
92
+ # Convert common format to OpenAI format
93
+ result = {
94
+ type: "mcp",
95
+ server_label: server_hash[:name] || server_hash[:server_label],
96
+ server_url: server_hash[:url] || server_hash[:server_url]
97
+ }
98
+
99
+ # Keep authorization field (OpenAI uses 'authorization', not 'authorization_token')
100
+ if server_hash[:authorization]
101
+ result[:authorization] = server_hash[:authorization]
102
+ end
103
+
104
+ result.compact
105
+ end
106
+ end
107
+
108
+ # Normalizes tool_choice from common format to OpenAI Responses API format.
109
+ #
110
+ # Responses API uses flat format for specific tool choice, unlike Chat API's nested format.
111
+ # Must return gem model objects for proper serialization.
112
+ #
113
+ # Maps:
114
+ # - "required" → :required symbol (force tool use)
115
+ # - "auto" → :auto symbol (let model decide)
116
+ # - { name: "..." } → ToolChoiceFunction model object
117
+ #
118
+ # @param tool_choice [String, Hash, Object]
119
+ # @return [Symbol, Object] Symbol or gem model object
120
+ def normalize_tool_choice(tool_choice)
121
+ # If already a gem model object, return as-is
122
+ return tool_choice if tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceFunction) ||
123
+ tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceAllowed) ||
124
+ tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceTypes) ||
125
+ tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceMcp) ||
126
+ tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceCustom)
127
+
128
+ case tool_choice
129
+ when "required"
130
+ :required # Return as symbol
131
+ when "auto"
132
+ :auto # Return as symbol
133
+ when "none"
134
+ :none # Return as symbol
135
+ when Hash
136
+ choice_hash = tool_choice.deep_symbolize_keys
137
+
138
+ # If already in proper format with type, try to create gem model
139
+ if choice_hash[:type] == "function" && choice_hash[:name]
140
+ # Create ToolChoiceFunction gem model object
141
+ ::OpenAI::Models::Responses::ToolChoiceFunction.new(
142
+ type: :function,
143
+ name: choice_hash[:name]
144
+ )
145
+ # Convert { name: "..." } to ToolChoiceFunction model
146
+ elsif choice_hash[:name] && !choice_hash[:type]
147
+ ::OpenAI::Models::Responses::ToolChoiceFunction.new(
148
+ type: :function,
149
+ name: choice_hash[:name]
150
+ )
151
+ else
152
+ choice_hash
153
+ end
154
+ else
155
+ tool_choice
156
+ end
157
+ end
158
+
24
159
  # Simplifies input for cleaner API requests
25
160
  #
26
161
  # Unwraps single-element arrays:
@@ -13,6 +13,8 @@ module ActiveAgent
13
13
  # @see Base
14
14
  # @see https://platform.openai.com/docs/api-reference/responses
15
15
  class ResponsesProvider < Base
16
+ include ToolChoiceClearing
17
+
16
18
  # @return [Class]
17
19
  def self.options_klass
18
20
  Options
@@ -25,6 +27,42 @@ module ActiveAgent
25
27
 
26
28
  protected
27
29
 
30
+ # @see BaseProvider#prepare_prompt_request
31
+ # @return [Request]
32
+ def prepare_prompt_request
33
+ prepare_prompt_request_tools
34
+
35
+ super
36
+ end
37
+
38
+ # Extracts function names from Responses API function_call items.
39
+ #
40
+ # @return [Array<String>]
41
+ def extract_used_function_names
42
+ message_stack
43
+ .select { |item| item[:type] == "function_call" }
44
+ .map { |item| item[:name] }
45
+ .compact
46
+ end
47
+
48
+ # Returns true if tool_choice == :required.
49
+ #
50
+ # @return [Boolean]
51
+ def tool_choice_forces_required?
52
+ request.tool_choice == :required
53
+ end
54
+
55
+ # Returns [true, name] if tool_choice is a ToolChoiceFunction model object.
56
+ #
57
+ # @return [Array<Boolean, String|nil>]
58
+ def tool_choice_forces_specific?
59
+ if request.tool_choice.is_a?(::OpenAI::Models::Responses::ToolChoiceFunction)
60
+ [ true, request.tool_choice.name ]
61
+ else
62
+ [ false, nil ]
63
+ end
64
+ end
65
+
28
66
  # @return [Object] OpenAI client's responses endpoint
29
67
  def api_prompt_executer
30
68
  client.responses
@@ -148,6 +148,26 @@ module ActiveAgent
148
148
  self.messages = instructions_messages + current_messages
149
149
  end
150
150
 
151
+ # Gets tool_choice bypassing gem validation
152
+ #
153
+ # OpenRouter supports "any" which isn't valid in OpenAI gem types.
154
+ #
155
+ # @return [String, Hash, nil]
156
+ def tool_choice
157
+ __getobj__.instance_variable_get(:@data)[:tool_choice]
158
+ end
159
+
160
+ # Sets tool_choice bypassing gem validation
161
+ #
162
+ # OpenRouter supports "any" which isn't valid in OpenAI gem types,
163
+ # so we bypass the gem's type validation by setting @data directly.
164
+ #
165
+ # @param value [String, Hash, nil]
166
+ # @return [void]
167
+ def tool_choice=(value)
168
+ __getobj__.instance_variable_get(:@data)[:tool_choice] = value
169
+ end
170
+
151
171
  # Accessor for OpenRouter-specific provider preferences
152
172
  #
153
173
  # @return [Hash, nil]
@@ -62,9 +62,39 @@ module ActiveAgent
62
62
  # Use OpenAI transforms for the base parameters
63
63
  openai_params = OpenAI::Chat::Transforms.normalize_params(params)
64
64
 
65
+ # Override tool_choice normalization for OpenRouter's "any" vs "required" difference
66
+ if openai_params[:tool_choice]
67
+ openai_params[:tool_choice] = normalize_tool_choice(openai_params[:tool_choice])
68
+ end
69
+
65
70
  [ openai_params, openrouter_params ]
66
71
  end
67
72
 
73
+ # Normalizes tools using OpenAI transforms
74
+ #
75
+ # @param tools [Array<Hash>]
76
+ # @return [Array<Hash>]
77
+ def normalize_tools(tools)
78
+ OpenAI::Chat::Transforms.normalize_tools(tools)
79
+ end
80
+
81
+ # Normalizes tool_choice for OpenRouter API differences
82
+ #
83
+ # OpenRouter uses "any" instead of OpenAI's "required" for forcing tool use.
84
+ # Converts common format to OpenRouter-specific format:
85
+ # - "required" (common) → "any" (OpenRouter)
86
+ # - Everything else delegates to OpenAI transforms
87
+ #
88
+ # @param tool_choice [String, Hash, Symbol]
89
+ # @return [String, Hash, Symbol]
90
+ def normalize_tool_choice(tool_choice)
91
+ # Convert "required" to OpenRouter's "any"
92
+ return "any" if tool_choice.to_s == "required"
93
+
94
+ # For everything else, use OpenAI transforms
95
+ OpenAI::Chat::Transforms.normalize_tool_choice(tool_choice)
96
+ end
97
+
68
98
  # Normalizes messages using OpenAI transforms
69
99
  #
70
100
  # @param messages [Array, String, Hash, nil]
@@ -33,6 +33,20 @@ module ActiveAgent
33
33
 
34
34
  protected
35
35
 
36
+ # @see BaseProvider#prepare_prompt_request
37
+ # @return [Request]
38
+ def prepare_prompt_request
39
+ prepare_prompt_request_tools
40
+ super
41
+ end
42
+
43
+ # Returns true if tool_choice == "any" (OpenRouter's equivalent of "required").
44
+ #
45
+ # @return [Boolean]
46
+ def tool_choice_forces_required?
47
+ request.tool_choice == "any"
48
+ end
49
+
36
50
  # Merges streaming delta into the message with role cleanup.
37
51
  #
38
52
  # Overrides parent to handle OpenRouter's role copying behavior which duplicates
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.0.0"
2
+ VERSION = "1.0.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
@@ -129,14 +129,14 @@ dependencies:
129
129
  requirements:
130
130
  - - "~>"
131
131
  - !ruby/object:Gem::Version
132
- version: 8.0.0
132
+ version: 8.1.1
133
133
  type: :development
134
134
  prerelease: false
135
135
  version_requirements: !ruby/object:Gem::Requirement
136
136
  requirements:
137
137
  - - "~>"
138
138
  - !ruby/object:Gem::Version
139
- version: 8.0.0
139
+ version: 8.1.1
140
140
  - !ruby/object:Gem::Dependency
141
141
  name: anthropic
142
142
  requirement: !ruby/object:Gem::Requirement
@@ -386,6 +386,7 @@ files:
386
386
  - lib/active_agent/providers/concerns/exception_handler.rb
387
387
  - lib/active_agent/providers/concerns/instrumentation.rb
388
388
  - lib/active_agent/providers/concerns/previewable.rb
389
+ - lib/active_agent/providers/concerns/tool_choice_clearing.rb
389
390
  - lib/active_agent/providers/log_subscriber.rb
390
391
  - lib/active_agent/providers/mock/_types.rb
391
392
  - lib/active_agent/providers/mock/embedding_request.rb
@@ -472,7 +473,7 @@ licenses:
472
473
  - MIT
473
474
  metadata:
474
475
  bug_tracker_uri: https://github.com/activeagents/activeagent/issues
475
- documentation_uri: https://github.com/activeagents/activeagent
476
+ documentation_uri: https://docs.activeagents.ai
476
477
  source_code_uri: https://github.com/activeagents/activeagent
477
478
  rubygems_mfa_required: 'true'
478
479
  rdoc_options: []