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 +4 -4
- data/CHANGELOG.md +71 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -0
- 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 +83 -26
- data/lib/active_agent/providers/common/messages/_types.rb +38 -31
- data/lib/active_agent/providers/common/messages/assistant.rb +20 -4
- 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/open_ai/chat/transforms.rb +83 -3
- data/lib/active_agent/providers/open_ai/chat_provider.rb +38 -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/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ee3a092c5c836febf3c2e3045a1c1b2cd448edc18f3e76e4ee1ebc181f895b8
|
|
4
|
+
data.tar.gz: 9850ab912eedaac0a57f9a954648d096d6aa70ad470ff9aa7c84a18183ab76be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
-
|
|
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:
|
|
88
|
-
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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:
|
|
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
|
-
|
|
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]
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
216
|
-
#
|
|
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]
|
|
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
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
103
|
-
content << "**Parameters:**\n```json\n#{JSON.pretty_generate(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
@@ -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
|
data/lib/active_agent/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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://
|
|
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: []
|