activeagent 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +10 -4
  4. data/lib/active_agent/base.rb +3 -2
  5. data/lib/active_agent/concerns/provider.rb +6 -2
  6. data/lib/active_agent/concerns/rescue.rb +39 -0
  7. data/lib/active_agent/concerns/streaming.rb +2 -1
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
  9. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
  10. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
  11. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
  12. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
  13. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
  14. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
  15. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
  16. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
  17. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
  18. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
  19. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
  20. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
  21. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
  22. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
  23. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
  24. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
  25. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
  26. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
  27. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
  28. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
  29. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
  30. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
  31. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
  32. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
  33. data/lib/active_agent/dashboard/config/routes.rb +78 -0
  34. data/lib/active_agent/dashboard/engine.rb +39 -0
  35. data/lib/active_agent/dashboard.rb +151 -0
  36. data/lib/active_agent/providers/_base_provider.rb +4 -1
  37. data/lib/active_agent/providers/anthropic/options.rb +4 -6
  38. data/lib/active_agent/providers/anthropic/request.rb +28 -3
  39. data/lib/active_agent/providers/anthropic/transforms.rb +131 -2
  40. data/lib/active_agent/providers/anthropic_provider.rb +97 -30
  41. data/lib/active_agent/providers/azure/_types.rb +5 -0
  42. data/lib/active_agent/providers/azure/options.rb +111 -0
  43. data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
  44. data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
  45. data/lib/active_agent/providers/azure_provider.rb +133 -0
  46. data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
  47. data/lib/active_agent/providers/bedrock/_types.rb +8 -0
  48. data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
  49. data/lib/active_agent/providers/bedrock/options.rb +77 -0
  50. data/lib/active_agent/providers/bedrock_provider.rb +84 -0
  51. data/lib/active_agent/providers/common/messages/_types.rb +42 -31
  52. data/lib/active_agent/providers/common/messages/assistant.rb +20 -4
  53. data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
  54. data/lib/active_agent/providers/concerns/previewable.rb +39 -5
  55. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +62 -0
  56. data/lib/active_agent/providers/gemini/_types.rb +19 -0
  57. data/lib/active_agent/providers/gemini/options.rb +41 -0
  58. data/lib/active_agent/providers/gemini_provider.rb +94 -0
  59. data/lib/active_agent/providers/open_ai/chat/transforms.rb +120 -4
  60. data/lib/active_agent/providers/open_ai/chat_provider.rb +40 -0
  61. data/lib/active_agent/providers/open_ai/responses/request.rb +17 -2
  62. data/lib/active_agent/providers/open_ai/responses/transforms.rb +135 -0
  63. data/lib/active_agent/providers/open_ai/responses_provider.rb +38 -0
  64. data/lib/active_agent/providers/open_router/request.rb +20 -0
  65. data/lib/active_agent/providers/open_router/transforms.rb +30 -0
  66. data/lib/active_agent/providers/open_router_provider.rb +14 -0
  67. data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
  68. data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
  69. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
  70. data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
  71. data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
  72. data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
  73. data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
  74. data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
  75. data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
  76. data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
  77. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
  78. data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
  79. data/lib/active_agent/railtie.rb +32 -1
  80. data/lib/active_agent/telemetry/configuration.rb +213 -0
  81. data/lib/active_agent/telemetry/instrumentation.rb +155 -0
  82. data/lib/active_agent/telemetry/reporter.rb +176 -0
  83. data/lib/active_agent/telemetry/span.rb +267 -0
  84. data/lib/active_agent/telemetry/tracer.rb +184 -0
  85. data/lib/active_agent/telemetry.rb +162 -0
  86. data/lib/active_agent/version.rb +1 -1
  87. data/lib/active_agent.rb +2 -0
  88. data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
  89. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
  90. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
  91. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
  92. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
  93. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
  94. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
  95. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
  96. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
  97. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
  98. data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
  99. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
  100. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
  101. metadata +101 -14
@@ -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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+ require_relative "../open_ai/chat/_types"
5
+ require_relative "../open_ai/embedding/_types"
6
+
7
+ module ActiveAgent
8
+ module Providers
9
+ module Gemini
10
+ # Reuse OpenAI Chat request type (same API format)
11
+ RequestType = OpenAI::Chat::RequestType
12
+
13
+ # Reuse OpenAI Embedding types (same API format)
14
+ module Embedding
15
+ RequestType = OpenAI::Embedding::RequestType
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Gemini
8
+ # Configuration options for Gemini provider
9
+ #
10
+ # Extends OpenAI::Options with Gemini-specific settings including
11
+ # the default base URL for Gemini's OpenAI-compatible API endpoint.
12
+ #
13
+ # @example Basic configuration
14
+ # options = Options.new(api_key: 'your-api-key')
15
+ #
16
+ # @example With environment variable
17
+ # # Set GEMINI_API_KEY or GOOGLE_API_KEY
18
+ # options = Options.new({})
19
+ #
20
+ # @see https://ai.google.dev/gemini-api/docs/openai
21
+ class Options < ActiveAgent::Providers::OpenAI::Options
22
+ GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
23
+
24
+ attribute :base_url, :string, fallback: GEMINI_BASE_URL
25
+
26
+ private
27
+
28
+ def resolve_api_key(kwargs)
29
+ kwargs[:api_key] ||
30
+ kwargs[:access_token] ||
31
+ ENV["GEMINI_API_KEY"] ||
32
+ ENV["GOOGLE_API_KEY"]
33
+ end
34
+
35
+ # Not used as part of Gemini
36
+ def resolve_organization_id(kwargs) = nil
37
+ def resolve_project_id(kwargs) = nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,94 @@
1
+ require_relative "_base_provider"
2
+
3
+ require_gem!(:openai, __FILE__)
4
+
5
+ require_relative "open_ai_provider"
6
+ require_relative "gemini/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Google's Gemini API via OpenAI-compatible endpoint.
11
+ #
12
+ # Extends OpenAI provider to work with Gemini's OpenAI-compatible API,
13
+ # enabling access to Gemini models through a familiar interface.
14
+ #
15
+ # @see OpenAI::ChatProvider
16
+ # @see https://ai.google.dev/gemini-api/docs/openai
17
+ class GeminiProvider < OpenAI::ChatProvider
18
+ # @return [String]
19
+ def self.service_name
20
+ "Gemini"
21
+ end
22
+
23
+ # @return [Class]
24
+ def self.options_klass
25
+ namespace::Options
26
+ end
27
+
28
+ # @return [ActiveModel::Type::Value]
29
+ def self.prompt_request_type
30
+ namespace::RequestType.new
31
+ end
32
+
33
+ # @return [ActiveModel::Type::Value]
34
+ def self.embed_request_type
35
+ namespace::Embedding::RequestType.new
36
+ end
37
+
38
+ protected
39
+
40
+ # Executes chat completion request with Gemini-specific error handling.
41
+ #
42
+ # @see OpenAI::ChatProvider#api_prompt_execute
43
+ # @param parameters [Hash]
44
+ # @return [Object, nil] response object or nil for streaming
45
+ # @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
46
+ def api_prompt_execute(parameters)
47
+ super
48
+
49
+ rescue ::OpenAI::Errors::APIConnectionError => exception
50
+ log_connection_error(exception)
51
+ raise exception
52
+ end
53
+
54
+ # Executes embedding request with Gemini-specific error handling.
55
+ #
56
+ # @param parameters [Hash]
57
+ # @return [Hash] symbolized API response
58
+ # @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
59
+ def api_embed_execute(parameters)
60
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
61
+ rescue ::OpenAI::Errors::APIConnectionError => exception
62
+ log_connection_error(exception)
63
+ raise exception
64
+ end
65
+
66
+ # Merges streaming delta into the message with role cleanup.
67
+ #
68
+ # Overrides parent to handle Gemini's role copying behavior which duplicates
69
+ # the role field in every streaming chunk, requiring manual cleanup to prevent
70
+ # message corruption.
71
+ #
72
+ # @see OpenAI::ChatProvider#message_merge_delta
73
+ # @param message [Hash]
74
+ # @param delta [Hash]
75
+ # @return [Hash]
76
+ def message_merge_delta(message, delta)
77
+ message[:role] = delta.delete(:role) if delta[:role]
78
+
79
+ hash_merge_delta(message, delta)
80
+ end
81
+
82
+ # Logs connection failures with Gemini API details for debugging.
83
+ #
84
+ # @param error [Exception]
85
+ # @return [void]
86
+ def log_connection_error(error)
87
+ instrument("connection_error.provider.active_agent",
88
+ uri_base: options.base_url,
89
+ exception: error.class,
90
+ message: error.message)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/hash/keys"
4
+ require "uri"
4
5
 
5
6
  module ActiveAgent
6
7
  module Providers
@@ -24,8 +25,8 @@ module ActiveAgent
24
25
  # Normalizes all request parameters for OpenAI Chat API
25
26
  #
26
27
  # Handles instructions mapping to developer messages, message normalization,
27
- # and response_format conversion. This is the main entry point for parameter
28
- # transformation.
28
+ # tools normalization, and response_format conversion. This is the main entry point
29
+ # for parameter transformation.
29
30
  #
30
31
  # @param params [Hash]
31
32
  # @return [Hash] normalized parameters
@@ -41,6 +42,12 @@ module ActiveAgent
41
42
  # Normalize messages for gem compatibility
42
43
  params[:messages] = normalize_messages(params[:messages]) if params[:messages]
43
44
 
45
+ # Normalize tools from common format to Chat API format
46
+ params[:tools] = normalize_tools(params[:tools]) if params[:tools]
47
+
48
+ # Normalize tool_choice from common format
49
+ params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
50
+
44
51
  # Normalize response_format if present
45
52
  params[:response_format] = normalize_response_format(params[:response_format]) if params[:response_format]
46
53
 
@@ -68,7 +75,8 @@ module ActiveAgent
68
75
  messages.each do |msg|
69
76
  normalized = normalize_message(msg)
70
77
 
71
- if grouped.empty? || grouped.last.role != normalized.role
78
+ # Don't merge tool messages - each needs its own tool_call_id
79
+ if grouped.empty? || grouped.last.role != normalized.role || normalized.role.to_s == "tool"
72
80
  grouped << normalized
73
81
  else
74
82
  # Merge consecutive same-role messages
@@ -117,10 +125,19 @@ module ActiveAgent
117
125
  { type: "text", text: msg_hash[:text] },
118
126
  { type: "image_url", image_url: { url: msg_hash[:image] } }
119
127
  ]
128
+ elsif msg_hash.key?(:text) && msg_hash.key?(:document)
129
+ # Shorthand with both text and document: { text: "...", document: "url" }
130
+ [
131
+ { type: "text", text: msg_hash[:text] },
132
+ build_file_content(msg_hash[:document])
133
+ ]
120
134
  elsif msg_hash.key?(:image)
121
135
  # Shorthand with only image: { image: "url" }
122
136
  # Text comes from adjacent prompt arguments
123
137
  [ { type: "image_url", image_url: { url: msg_hash[:image] } } ]
138
+ elsif msg_hash.key?(:document)
139
+ # Shorthand with only document: { document: "url" }
140
+ [ build_file_content(msg_hash[:document]) ]
124
141
  elsif msg_hash.key?(:text)
125
142
  # Shorthand: { text: "..." } or { role: "...", text: "..." }
126
143
  msg_hash[:text]
@@ -130,13 +147,39 @@ module ActiveAgent
130
147
  end
131
148
 
132
149
  # Create appropriate message param based on role and content
133
- extra_params = msg_hash.except(:role, :content, :text, :image)
150
+ extra_params = msg_hash.except(:role, :content, :text, :image, :document)
134
151
  create_message_param(role, content, extra_params)
135
152
  else
136
153
  raise ArgumentError, "Cannot normalize #{message.class} to message"
137
154
  end
138
155
  end
139
156
 
157
+ # Builds a file content block for the Chat API file type
158
+ #
159
+ # Handles both URL and data URI formats:
160
+ # - URL: "http://example.com/document.pdf" → { type: "file", file: { filename: "...", url: "..." } }
161
+ # - Data URI: "data:application/pdf;base64,..." → { type: "file", file: { filename: "...", file_data: "..." } }
162
+ #
163
+ # @param document [String] URL or data URI
164
+ # @return [Hash] file content block
165
+ def build_file_content(document)
166
+ if document.start_with?("data:")
167
+ # Data URI - extract or infer filename from media type
168
+ media_type = document.match(%r{\Adata:([^;,]+)})&.[](1) || "application/octet-stream"
169
+ extension = media_type.split("/").last&.gsub(/[^a-zA-Z0-9]/, "_") || "bin"
170
+ filename = "document.#{extension}"
171
+ { type: "file", file: { filename: filename, file_data: document } }
172
+ else
173
+ # Regular URL
174
+ filename = File.basename(URI.parse(document).path.to_s)
175
+ filename = "document.pdf" if filename.empty?
176
+ { type: "file", file: { filename: filename, url: document } }
177
+ end
178
+ rescue URI::Error
179
+ # Fallback for invalid URLs
180
+ { type: "file", file: { filename: "document.pdf", url: document } }
181
+ end
182
+
140
183
  # Creates the appropriate gem message param class for the given role
141
184
  #
142
185
  # @param role [String] message role (developer, system, user, assistant, tool, function)
@@ -307,6 +350,79 @@ module ActiveAgent
307
350
  end
308
351
  end
309
352
 
353
+ # Normalizes tools from common format to OpenAI Chat API format.
354
+ #
355
+ # Accepts tools in multiple formats:
356
+ # - Common format: `{name: "...", description: "...", parameters: {...}}`
357
+ # - Common format alt: `{name: "...", description: "...", input_schema: {...}}`
358
+ # - Nested format: `{type: "function", function: {name: "...", parameters: {...}}}`
359
+ #
360
+ # Always outputs nested Chat API format: `{type: "function", function: {...}}`
361
+ #
362
+ # @param tools [Array<Hash>]
363
+ # @return [Array<Hash>]
364
+ def normalize_tools(tools)
365
+ return tools unless tools.is_a?(Array)
366
+
367
+ tools.map do |tool|
368
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
369
+
370
+ # Already in nested format - return as is
371
+ if tool_hash[:type] == "function" && tool_hash[:function]
372
+ tool_hash
373
+ # Common format - convert to nested format
374
+ elsif tool_hash[:name]
375
+ {
376
+ type: "function",
377
+ function: {
378
+ name: tool_hash[:name],
379
+ description: tool_hash[:description],
380
+ parameters: tool_hash[:parameters] || tool_hash[:input_schema]
381
+ }.compact
382
+ }
383
+ else
384
+ tool_hash
385
+ end
386
+ end
387
+ end
388
+
389
+ # Normalizes tool_choice from common format to OpenAI Chat API format.
390
+ #
391
+ # Accepts:
392
+ # - "auto" (common) → "auto" (passthrough)
393
+ # - "required" (common) → "required" (passthrough)
394
+ # - `{name: "..."}` (common) → `{type: "function", function: {name: "..."}}`
395
+ # - Already nested format → passthrough
396
+ #
397
+ # @param tool_choice [String, Hash, Symbol]
398
+ # @return [String, Hash, Symbol]
399
+ def normalize_tool_choice(tool_choice)
400
+ case tool_choice
401
+ when "auto", :auto, "required", :required
402
+ # Passthrough - Chat API accepts these directly
403
+ tool_choice.to_s
404
+ when Hash
405
+ tool_choice_hash = tool_choice.deep_symbolize_keys
406
+
407
+ # Already in nested format with type and function keys
408
+ if tool_choice_hash[:type] == "function" && tool_choice_hash[:function]
409
+ tool_choice_hash
410
+ # Common format with just name - convert to nested format
411
+ elsif tool_choice_hash[:name]
412
+ {
413
+ type: "function",
414
+ function: {
415
+ name: tool_choice_hash[:name]
416
+ }
417
+ }
418
+ else
419
+ tool_choice_hash
420
+ end
421
+ else
422
+ tool_choice
423
+ end
424
+ end
425
+
310
426
  # Normalizes instructions to developer message format
311
427
  #
312
428
  # 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
@@ -57,6 +95,8 @@ module ActiveAgent
57
95
  def process_stream_chunk(api_response_event)
58
96
  instrument("stream_chunk.active_agent")
59
97
 
98
+ broadcast_stream_open
99
+
60
100
  # Called Multiple Times: [Chunk<T>, T]<Content, ToolsCall>
61
101
  case api_response_event.type
62
102
  when :chunk
@@ -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