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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +10 -4
- data/lib/active_agent/base.rb +3 -2
- data/lib/active_agent/concerns/provider.rb +6 -2
- data/lib/active_agent/concerns/rescue.rb +39 -0
- data/lib/active_agent/concerns/streaming.rb +2 -1
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
- data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
- data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
- data/lib/active_agent/dashboard/config/routes.rb +78 -0
- data/lib/active_agent/dashboard/engine.rb +39 -0
- data/lib/active_agent/dashboard.rb +151 -0
- data/lib/active_agent/providers/_base_provider.rb +4 -1
- data/lib/active_agent/providers/anthropic/options.rb +4 -6
- data/lib/active_agent/providers/anthropic/request.rb +28 -3
- data/lib/active_agent/providers/anthropic/transforms.rb +131 -2
- data/lib/active_agent/providers/anthropic_provider.rb +97 -30
- data/lib/active_agent/providers/azure/_types.rb +5 -0
- data/lib/active_agent/providers/azure/options.rb +111 -0
- data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
- data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
- data/lib/active_agent/providers/azure_provider.rb +133 -0
- data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
- data/lib/active_agent/providers/bedrock/_types.rb +8 -0
- data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
- data/lib/active_agent/providers/bedrock/options.rb +77 -0
- data/lib/active_agent/providers/bedrock_provider.rb +84 -0
- data/lib/active_agent/providers/common/messages/_types.rb +42 -31
- data/lib/active_agent/providers/common/messages/assistant.rb +20 -4
- data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
- data/lib/active_agent/providers/concerns/previewable.rb +39 -5
- data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +62 -0
- data/lib/active_agent/providers/gemini/_types.rb +19 -0
- data/lib/active_agent/providers/gemini/options.rb +41 -0
- data/lib/active_agent/providers/gemini_provider.rb +94 -0
- data/lib/active_agent/providers/open_ai/chat/transforms.rb +120 -4
- data/lib/active_agent/providers/open_ai/chat_provider.rb +40 -0
- data/lib/active_agent/providers/open_ai/responses/request.rb +17 -2
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +135 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +38 -0
- data/lib/active_agent/providers/open_router/request.rb +20 -0
- data/lib/active_agent/providers/open_router/transforms.rb +30 -0
- data/lib/active_agent/providers/open_router_provider.rb +14 -0
- data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
- data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
- data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
- data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
- data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
- data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
- data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
- data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
- data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
- data/lib/active_agent/railtie.rb +32 -1
- data/lib/active_agent/telemetry/configuration.rb +213 -0
- data/lib/active_agent/telemetry/instrumentation.rb +155 -0
- data/lib/active_agent/telemetry/reporter.rb +176 -0
- data/lib/active_agent/telemetry/span.rb +267 -0
- data/lib/active_agent/telemetry/tracer.rb +184 -0
- data/lib/active_agent/telemetry.rb +162 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
- data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
- data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
- data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
- data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
- 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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|