activeagent 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1193 -0
- data/README.md +9 -1
- data/lib/active_agent/base.rb +22 -1
- data/lib/active_agent/concerns/delegation.rb +4 -0
- data/lib/active_agent/concerns/input_requests.rb +178 -0
- data/lib/active_agent/concerns/parameterized.rb +7 -4
- data/lib/active_agent/concerns/provider.rb +1 -0
- data/lib/active_agent/concerns/tooling.rb +7 -2
- data/lib/active_agent/delegation/runner.rb +31 -0
- data/lib/active_agent/evals/catalog.rb +305 -0
- data/lib/active_agent/evals/diagnosis.rb +3 -3
- data/lib/active_agent/evals/format.rb +115 -0
- data/lib/active_agent/evals/judge.rb +2 -0
- data/lib/active_agent/evals/report.rb +232 -31
- data/lib/active_agent/evals/report_html.rb +316 -149
- data/lib/active_agent/evals/result.rb +40 -0
- data/lib/active_agent/evals/ruby_llm.rb +411 -0
- data/lib/active_agent/evals/runner.rb +6 -2
- data/lib/active_agent/evals/scenario.rb +10 -3
- data/lib/active_agent/evals/scenario_parser.rb +38 -10
- data/lib/active_agent/evals.rb +2 -0
- data/lib/active_agent/generation.rb +62 -2
- data/lib/active_agent/generation_job.rb +14 -3
- data/lib/active_agent/input_request/resume.rb +199 -0
- data/lib/active_agent/input_request.rb +279 -0
- data/lib/active_agent/providers/_base_provider.rb +153 -31
- data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
- data/lib/active_agent/providers/anthropic_provider.rb +102 -20
- data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
- data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
- data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
- data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
- data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
- data/lib/active_agent/providers/deepseek/_types.rb +4 -0
- data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
- data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
- data/lib/active_agent/providers/deepseek/options.rb +53 -0
- data/lib/active_agent/providers/deepseek_provider.rb +46 -0
- data/lib/active_agent/providers/log_subscriber.rb +13 -0
- data/lib/active_agent/providers/mcp_bridge.rb +691 -0
- data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
- data/lib/active_agent/providers/open_ai/_base.rb +1 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
- data/lib/active_agent/providers/open_ai/options.rb +1 -1
- data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
- data/lib/active_agent/providers/open_router/options.rb +6 -2
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
- data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
- data/lib/active_agent/schema_tools.rb +12 -0
- data/lib/active_agent/telemetry/instrumentation.rb +10 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/schema_tools/USAGE +11 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
- metadata +32 -2
|
@@ -2,7 +2,9 @@ require "active_support/delegation"
|
|
|
2
2
|
|
|
3
3
|
require_relative "common/response"
|
|
4
4
|
require_relative "concerns/exception_handler"
|
|
5
|
+
require_relative "concerns/input_requests"
|
|
5
6
|
require_relative "concerns/instrumentation"
|
|
7
|
+
require_relative "concerns/mcp_serving"
|
|
6
8
|
require_relative "concerns/previewable"
|
|
7
9
|
require_relative "concerns/tool_choice_clearing"
|
|
8
10
|
|
|
@@ -14,13 +16,28 @@ GEM_LOADERS = {
|
|
|
14
16
|
ruby_llm: [ "ruby_llm", [ ">= 1.16", "< 3" ], "ruby_llm" ]
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
# Gems that define the same top-level constant as the client a provider asks
|
|
20
|
+
# for. A bundle can then look like it already satisfies the provider while the
|
|
21
|
+
# gem it actually needs is missing.
|
|
22
|
+
#
|
|
23
|
+
# `openai` and `ruby-openai` both define `OpenAI`, and only the former carries
|
|
24
|
+
# the typed request models every OpenAI-family provider is written against — so
|
|
25
|
+
# "add the openai gem" reads as nonsense to someone whose bundle already has an
|
|
26
|
+
# `OpenAI` constant.
|
|
27
|
+
#
|
|
28
|
+
# @private
|
|
29
|
+
GEM_CONFLICTS = {
|
|
30
|
+
openai: { gem: "ruby-openai", constant: "OpenAI" }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
17
33
|
# Requires a provider's gem dependency.
|
|
18
34
|
#
|
|
19
35
|
# @param type [Symbol] provider type (:anthropic, :openai)
|
|
20
36
|
# @param file_name [String] for error context
|
|
21
37
|
# @return [void]
|
|
22
|
-
# @raise [LoadError] when the gem is not installed,
|
|
23
|
-
#
|
|
38
|
+
# @raise [LoadError] when the gem is not installed, when the loaded version is
|
|
39
|
+
# outside the supported range, or when something else already defines the
|
|
40
|
+
# client constant
|
|
24
41
|
def require_gem!(type, file_name)
|
|
25
42
|
gem_name, requirement, package_name = GEM_LOADERS.fetch(type)
|
|
26
43
|
requirements = Array(requirement)
|
|
@@ -36,10 +53,58 @@ def require_gem!(type, file_name)
|
|
|
36
53
|
"Add `gem \"#{gem_name}\", #{requirements.map(&:inspect).join(', ')}` to your Gemfile and run `bundle update #{gem_name}`."
|
|
37
54
|
end
|
|
38
55
|
|
|
56
|
+
if (conflict = gem_conflict_for(type))
|
|
57
|
+
if conflict[:gem]
|
|
58
|
+
raise LoadError, "#{provider_name} needs the '#{gem_name}' gem, but this bundle has '#{conflict[:gem]}'. " \
|
|
59
|
+
"Both define #{conflict[:constant]}, so the two cannot be installed together — " \
|
|
60
|
+
"replace `gem \"#{conflict[:gem]}\"` with `gem \"#{gem_name}\"` in your Gemfile and run `bundle install`."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
where = conflict[:file] ? "in #{conflict[:file]}" : "elsewhere"
|
|
64
|
+
raise LoadError, "#{provider_name} needs the '#{gem_name}' gem, which defines #{conflict[:constant]}, " \
|
|
65
|
+
"but #{conflict[:constant]} is already defined #{where}. " \
|
|
66
|
+
"Rename or remove that definition, then add `gem \"#{gem_name}\"` to your Gemfile and run `bundle install`."
|
|
67
|
+
end
|
|
68
|
+
|
|
39
69
|
raise LoadError, "The '#{gem_name}' gem is required for #{provider_name}. Please add it to your Gemfile and run `bundle install`."
|
|
40
70
|
end
|
|
41
71
|
end
|
|
42
72
|
|
|
73
|
+
# Finds what already defines the constant the provider's client gem needs,
|
|
74
|
+
# when that is not the gem itself.
|
|
75
|
+
#
|
|
76
|
+
# @param type [Symbol] provider type
|
|
77
|
+
# @return [Hash, nil] `{ gem:, constant: }` when the conflicting gem in
|
|
78
|
+
# GEM_CONFLICTS is present, `{ constant:, file: }` when something else
|
|
79
|
+
# defines the constant (`file` is nil when Ruby cannot tell where), or nil
|
|
80
|
+
# @api private
|
|
81
|
+
def gem_conflict_for(type)
|
|
82
|
+
conflict = GEM_CONFLICTS[type]
|
|
83
|
+
return unless conflict
|
|
84
|
+
return conflict if Gem.loaded_specs.key?(conflict[:gem])
|
|
85
|
+
|
|
86
|
+
# With the provider's own gem activated, requiring it is what failed. The
|
|
87
|
+
# gem may have defined the constant itself before failing, and a clash with
|
|
88
|
+
# a definition from anywhere else raises TypeError, not LoadError.
|
|
89
|
+
return if Gem.loaded_specs.key?(GEM_LOADERS.fetch(type).first)
|
|
90
|
+
|
|
91
|
+
constant = conflict[:constant]
|
|
92
|
+
location = Object.const_source_location(constant)
|
|
93
|
+
return unless location
|
|
94
|
+
|
|
95
|
+
# `const_source_location` places a pending autoload, which is how Zeitwerk
|
|
96
|
+
# defines an application's constants, at the line that registered it rather
|
|
97
|
+
# than in the file that will define the constant.
|
|
98
|
+
file = Object.autoload?(constant) || location.first
|
|
99
|
+
|
|
100
|
+
# RubyGems installs a gem into a directory named `<name>-<version>`
|
|
101
|
+
# (`ruby-openai-8.3.0`), so the path identifies the gem even when its files
|
|
102
|
+
# were loaded without activating it.
|
|
103
|
+
return conflict if file&.match?(%r{/#{Regexp.escape(conflict[:gem])}-\d[^/]*/})
|
|
104
|
+
|
|
105
|
+
{ constant: constant, file: file }
|
|
106
|
+
end
|
|
107
|
+
|
|
43
108
|
module ActiveAgent
|
|
44
109
|
module Providers
|
|
45
110
|
# Orchestrates LLM provider API requests, streaming, and multi-turn tool calling.
|
|
@@ -54,22 +119,24 @@ module ActiveAgent
|
|
|
54
119
|
extend ActiveSupport::Delegation
|
|
55
120
|
|
|
56
121
|
include ExceptionHandler
|
|
122
|
+
include InputRequests
|
|
57
123
|
include Instrumentation
|
|
124
|
+
include MCPServing
|
|
58
125
|
include Previewable
|
|
59
126
|
include ToolChoiceClearing
|
|
60
127
|
|
|
61
128
|
class ProvidersError < StandardError; end
|
|
62
129
|
|
|
63
|
-
attr_internal :options, :context, :trace_id,
|
|
64
|
-
:request, :message_stack,
|
|
65
|
-
:stream_broadcaster, :streaming,
|
|
66
|
-
:stream_completion_pending,
|
|
67
|
-
:stream_completion_result,
|
|
68
|
-
:tools_function,
|
|
69
|
-
:usage_stack,
|
|
70
|
-
:stream_usage_index,
|
|
71
|
-
:max_tool_turns, :tool_turns,
|
|
72
|
-
:instrumentation_enabled
|
|
130
|
+
attr_internal :options, :context, :trace_id, # Setup
|
|
131
|
+
:request, :message_stack, # Runtime
|
|
132
|
+
:stream_broadcaster, :streaming, # Callback (Streams)
|
|
133
|
+
:stream_completion_pending, # Callback (Streams)
|
|
134
|
+
:stream_completion_result, # Callback (Streams)
|
|
135
|
+
:tools_function, # Callback (Tools)
|
|
136
|
+
:usage_stack, # Usage Tracking
|
|
137
|
+
:stream_usage_index, # Usage Tracking (Streams)
|
|
138
|
+
:max_tool_turns, :tool_turns, # Tool-loop safety
|
|
139
|
+
:instrumentation_enabled # Per-generation privacy
|
|
73
140
|
|
|
74
141
|
# Upper bound on tool-calling round-trips within one generation. A
|
|
75
142
|
# model that keeps emitting tool calls otherwise recurses until the
|
|
@@ -128,6 +195,10 @@ module ActiveAgent
|
|
|
128
195
|
self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
|
|
129
196
|
self.tool_turns = 0
|
|
130
197
|
self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
|
|
198
|
+
self.generation_action_name = kwargs.delete(:action_name)
|
|
199
|
+
self.tool_approvals = normalize_tool_approvals(kwargs.delete(:requires_approval))
|
|
200
|
+
self.input_request_resume = kwargs.delete(:input_request_resume)
|
|
201
|
+
self.announce_input_requests = kwargs.delete(:announce_input_requests) != false
|
|
131
202
|
self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
|
|
132
203
|
self.context = kwargs
|
|
133
204
|
self.message_stack = []
|
|
@@ -141,22 +212,29 @@ module ActiveAgent
|
|
|
141
212
|
#
|
|
142
213
|
# @return [String] markdown-formatted preview
|
|
143
214
|
def preview
|
|
144
|
-
self.request = prompt_request_type.cast(
|
|
215
|
+
self.request = prompt_request_type.cast(preview_context.except(:trace_id))
|
|
145
216
|
preview_prompt
|
|
146
217
|
end
|
|
147
218
|
|
|
148
219
|
# Executes prompt request with error handling and instrumentation.
|
|
149
220
|
#
|
|
221
|
+
# The generation is wrapped so that a client-side MCP bridge is released
|
|
222
|
+
# however it ends. A bridged server holds a live connection — for a
|
|
223
|
+
# `command:` server, a process — and the tool loop that uses it finishes
|
|
224
|
+
# here, inside `resolve_prompt`, streaming included.
|
|
225
|
+
#
|
|
150
226
|
# @return [ActiveAgent::Providers::Common::PromptResponse]
|
|
151
227
|
def prompt
|
|
152
|
-
self.request = prompt_request_type.cast(
|
|
228
|
+
self.request = prompt_request_type.cast(prompt_context.except(:trace_id))
|
|
153
229
|
|
|
154
230
|
instrument("prompt.active_agent") do |payload|
|
|
155
|
-
response = resolve_prompt
|
|
231
|
+
response = input_request_resume ? resume_prompt : resolve_prompt
|
|
156
232
|
instrumentation_prompt_payload(payload, request, response)
|
|
157
233
|
|
|
158
234
|
response
|
|
159
235
|
end
|
|
236
|
+
ensure
|
|
237
|
+
mcp_release_bridge!
|
|
160
238
|
end
|
|
161
239
|
|
|
162
240
|
# Executes embedding request with error handling and instrumentation.
|
|
@@ -175,6 +253,40 @@ module ActiveAgent
|
|
|
175
253
|
|
|
176
254
|
protected
|
|
177
255
|
|
|
256
|
+
# Request parameters for a real prompt, with `mcps:` resolved.
|
|
257
|
+
#
|
|
258
|
+
# Declarations the provider can serve itself are left in `mcps:` for it to
|
|
259
|
+
# translate; the rest are served client-side by {MCPServing}, which exposes
|
|
260
|
+
# their tools as ordinary tools the provider can already call.
|
|
261
|
+
#
|
|
262
|
+
# @return [Hash]
|
|
263
|
+
def prompt_context = mcp_resolved_context
|
|
264
|
+
|
|
265
|
+
# Request parameters for a preview, which resolves no client-side server:
|
|
266
|
+
# discovering a server's tools means connecting to it, and a preview must
|
|
267
|
+
# not perform I/O.
|
|
268
|
+
#
|
|
269
|
+
# @return [Hash]
|
|
270
|
+
def preview_context = mcp_preview_context
|
|
271
|
+
|
|
272
|
+
# Invokes a tool, on an MCP server when one provides it and on the agent's
|
|
273
|
+
# own tool function otherwise.
|
|
274
|
+
#
|
|
275
|
+
# @param name [String] tool name
|
|
276
|
+
# @param kwargs [Hash] tool arguments
|
|
277
|
+
# @return [Object] the tool's result, or an {MCPBridge::ErrorResult} for
|
|
278
|
+
# a call that failed on an MCP server
|
|
279
|
+
# @raise [ActiveAgent::InputRequest::UnsupportedProviderError] when the
|
|
280
|
+
# tool needs approval and the provider's tool loop never asked for it
|
|
281
|
+
def call_tool_function(name, **kwargs)
|
|
282
|
+
assert_approval_gate_reached!(name)
|
|
283
|
+
return mcp_call_tool(name, **kwargs) if mcp_owns_tool?(name)
|
|
284
|
+
|
|
285
|
+
result = isolate_undispatched_tool_call { tools_function.call(name, **kwargs) }
|
|
286
|
+
assert_input_request_supported!(result)
|
|
287
|
+
result
|
|
288
|
+
end
|
|
289
|
+
|
|
178
290
|
# @param name [String, nil]
|
|
179
291
|
# @raise [RuntimeError] when service name doesn't match provider
|
|
180
292
|
def assert_service!(name)
|
|
@@ -463,6 +575,8 @@ module ActiveAgent
|
|
|
463
575
|
|
|
464
576
|
if (tool_calls = process_prompt_finished_extract_function_calls)&.any? && tool_turn_allowed?
|
|
465
577
|
process_function_calls(tool_calls)
|
|
578
|
+
return paused_prompt_response(api_response) if awaiting_input?
|
|
579
|
+
|
|
466
580
|
resolve_prompt
|
|
467
581
|
else
|
|
468
582
|
|
|
@@ -471,25 +585,33 @@ module ActiveAgent
|
|
|
471
585
|
# as they continue to work.
|
|
472
586
|
broadcast_stream_close
|
|
473
587
|
|
|
474
|
-
|
|
475
|
-
# stack and then cast them to the provider type, so we can cast them out to common.
|
|
476
|
-
messages = prompt_request_type.cast(
|
|
477
|
-
messages: [ *request.messages, *message_stack ]
|
|
478
|
-
).messages
|
|
479
|
-
|
|
480
|
-
# Create response object with usage_stack array for multi-turn cumulative tracking.
|
|
481
|
-
# This will returned as it closes up the recursive stack
|
|
482
|
-
Common::PromptResponse.new(
|
|
483
|
-
context:,
|
|
484
|
-
format: request.response_format,
|
|
485
|
-
messages:,
|
|
486
|
-
raw_request: prompt_request_type.serialize(request),
|
|
487
|
-
raw_response: api_response,
|
|
488
|
-
usages: usage_stack
|
|
489
|
-
)
|
|
588
|
+
build_prompt_response(api_response)
|
|
490
589
|
end
|
|
491
590
|
end
|
|
492
591
|
|
|
592
|
+
# @param api_response [Object, nil] provider-specific response
|
|
593
|
+
# @param attributes [Hash] further response attributes
|
|
594
|
+
# @return [Common::PromptResponse]
|
|
595
|
+
def build_prompt_response(api_response, **attributes)
|
|
596
|
+
# To convert the messages into common format we first need to merge the current
|
|
597
|
+
# stack and then cast them to the provider type, so we can cast them out to common.
|
|
598
|
+
messages = prompt_request_type.cast(
|
|
599
|
+
messages: [ *request.messages, *message_stack ]
|
|
600
|
+
).messages
|
|
601
|
+
|
|
602
|
+
# Create response object with usage_stack array for multi-turn cumulative tracking.
|
|
603
|
+
# This will returned as it closes up the recursive stack
|
|
604
|
+
Common::PromptResponse.new(
|
|
605
|
+
context:,
|
|
606
|
+
format: request.response_format,
|
|
607
|
+
messages:,
|
|
608
|
+
raw_request: prompt_request_type.serialize(request),
|
|
609
|
+
raw_response: api_response,
|
|
610
|
+
usages: usage_stack,
|
|
611
|
+
**attributes
|
|
612
|
+
)
|
|
613
|
+
end
|
|
614
|
+
|
|
493
615
|
# Counts a tool round-trip against the per-generation cap. When the
|
|
494
616
|
# cap is hit the loop finishes cleanly with the messages gathered so
|
|
495
617
|
# far (a partial result) instead of recursing indefinitely.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/hash/deep_merge"
|
|
3
4
|
require "active_support/core_ext/hash/keys"
|
|
4
5
|
|
|
5
6
|
module ActiveAgent
|
|
@@ -11,6 +12,13 @@ module ActiveAgent
|
|
|
11
12
|
# - Expand: shortcuts → API format (string → content blocks, consecutive messages → grouped)
|
|
12
13
|
# - Compress: API format → shortcuts (single content blocks → strings for efficiency)
|
|
13
14
|
module Transforms
|
|
15
|
+
# Keys Anthropic accepts on a request message. `MessageParam` carries
|
|
16
|
+
# `role` and `content`; the beta `BetaMessageParam` adds `clear_at` and
|
|
17
|
+
# `output_config`, both of which it permits only on `role: "system"`
|
|
18
|
+
# messages. `clear_at` is the enum `next_user_message`/`never` — a
|
|
19
|
+
# timestamp is not a value the API takes.
|
|
20
|
+
MESSAGE_PARAM_KEYS = %i[role content clear_at output_config].freeze
|
|
21
|
+
|
|
14
22
|
class << self
|
|
15
23
|
# Converts gem model object to hash via JSON round-trip.
|
|
16
24
|
#
|
|
@@ -34,7 +42,14 @@ module ActiveAgent
|
|
|
34
42
|
# Normalize json_schema response_format → output_config (Anthropic's native structured output field)
|
|
35
43
|
if params[:response_format]
|
|
36
44
|
output_config = normalize_response_format(params.delete(:response_format))
|
|
37
|
-
|
|
45
|
+
|
|
46
|
+
# Merge rather than replace: `output_config` also carries `effort`,
|
|
47
|
+
# and a caller who sets both expects both. The caller's own keys
|
|
48
|
+
# win, being the more explicit of the two. A caller-supplied
|
|
49
|
+
# `output_config` with no `response_format` is left untouched, and
|
|
50
|
+
# nothing is written when there is neither — the gem rejects an
|
|
51
|
+
# explicit nil here.
|
|
52
|
+
params[:output_config] = output_config.deep_merge(params[:output_config] || {}) if output_config
|
|
38
53
|
end
|
|
39
54
|
|
|
40
55
|
# Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
|
|
@@ -222,13 +237,19 @@ module ActiveAgent
|
|
|
222
237
|
#
|
|
223
238
|
# Supported (ActiveAgent common format):
|
|
224
239
|
# { type: "json_schema", json_schema: { name: "...", schema: {...}, strict: true } }
|
|
240
|
+
# { type: "json_schema", name: "...", schema: {...}, strict: true }
|
|
225
241
|
# → { format: { type: "json_schema", schema: {...} } }
|
|
226
242
|
#
|
|
227
243
|
# Notes:
|
|
244
|
+
# - The schema is read from the top level or from +json_schema+, as the
|
|
245
|
+
# OpenAI transforms do; a top-level schema wins.
|
|
246
|
+
# - Anthropic's `format.type` is only ever `json_schema`, and `schema`
|
|
247
|
+
# is required, so anything else has no output_config to build.
|
|
228
248
|
# - Anthropic requires `additionalProperties: false` on all object schemas.
|
|
229
249
|
# This is auto-injected into any object schemas that don't have it set.
|
|
230
250
|
# - Anthropic does not use OpenAI's `name` or `strict` fields in output_config.format.
|
|
231
|
-
# - json_object is not handled here; it remains prompt-emulated
|
|
251
|
+
# - json_object is not handled here; it remains prompt-emulated, because
|
|
252
|
+
# Anthropic has no schema-less JSON mode to request.
|
|
232
253
|
# - text is not handled here; Anthropic returns plain text by default.
|
|
233
254
|
#
|
|
234
255
|
# @param format [Hash, Symbol, String] ActiveAgent common response_format
|
|
@@ -239,25 +260,24 @@ module ActiveAgent
|
|
|
239
260
|
format_hash = format.deep_symbolize_keys
|
|
240
261
|
|
|
241
262
|
if format_hash[:type].to_s == "json_schema"
|
|
242
|
-
schema
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
elsif format_hash
|
|
253
|
-
#
|
|
254
|
-
nil
|
|
255
|
-
elsif format_hash[:type].to_s == "text"
|
|
256
|
-
# text is not handled here; it remains prompt-emulated.
|
|
257
|
-
nil
|
|
258
|
-
else
|
|
259
|
-
# Pass through (already properly structured or Anthropic native format)
|
|
263
|
+
# A named schema (a String) is resolved to its Hash by the prompt
|
|
264
|
+
# layer before it reaches here, so anything that is not a Hash
|
|
265
|
+
# carries no inline schema to send. The schema sits at the top
|
|
266
|
+
# level in the flat shape, or under +json_schema+.
|
|
267
|
+
json_schema = format_hash[:json_schema]
|
|
268
|
+
schema = format_hash[:schema]
|
|
269
|
+
schema = json_schema[:schema] if schema.nil? && json_schema.is_a?(Hash)
|
|
270
|
+
return nil unless schema
|
|
271
|
+
|
|
272
|
+
{ format: { type: "json_schema", schema: inject_additional_properties(schema.deep_dup) } }
|
|
273
|
+
elsif format_hash.key?(:format)
|
|
274
|
+
# Already in Anthropic's own output_config shape; pass it through.
|
|
260
275
|
format_hash
|
|
276
|
+
else
|
|
277
|
+
# `json_object`, `text`, and anything else: there is no
|
|
278
|
+
# output_config to build. Sending one would be rejected, so a
|
|
279
|
+
# schema-less json_schema falls back rather than 400s.
|
|
280
|
+
nil
|
|
261
281
|
end
|
|
262
282
|
when Symbol, String
|
|
263
283
|
# Bare :json_schema without a schema cannot use native output_config.
|
|
@@ -570,16 +590,25 @@ module ActiveAgent
|
|
|
570
590
|
# @param gem_object [Object] original gem object (unused but for consistency)
|
|
571
591
|
# @return [Hash] cleaned request hash
|
|
572
592
|
def cleanup_serialized_request(hash, defaults, gem_object = nil)
|
|
573
|
-
#
|
|
593
|
+
# Reduce every message to the keys a request message may carry.
|
|
594
|
+
#
|
|
595
|
+
# Anthropic returns response-only fields on every message, and
|
|
596
|
+
# multi-turn requests plus the json_object emulation retry re-submit
|
|
597
|
+
# prior assistant responses verbatim, so each one would otherwise be
|
|
598
|
+
# sent straight back and rejected with
|
|
599
|
+
# "messages.N.<field>: Extra inputs are not permitted".
|
|
600
|
+
#
|
|
601
|
+
# An allowlist rather than a denylist because the response model
|
|
602
|
+
# keeps growing: `container` and then `diagnostics` each arrived in a
|
|
603
|
+
# gem release, and the beta API adds `context_management` and
|
|
604
|
+
# `input_transformations` beyond both. Enumerating the fields the
|
|
605
|
+
# request accepts cannot fall behind that way.
|
|
606
|
+
#
|
|
607
|
+
# The request-level `container` parameter is a separate, valid field
|
|
608
|
+
# and is deliberately left alone.
|
|
574
609
|
if hash[:messages]
|
|
575
610
|
hash[:messages].each do |msg|
|
|
576
|
-
msg.
|
|
577
|
-
msg.delete(:model)
|
|
578
|
-
msg.delete(:stop_reason)
|
|
579
|
-
msg.delete(:stop_sequence)
|
|
580
|
-
msg.delete(:stop_details)
|
|
581
|
-
msg.delete(:type)
|
|
582
|
-
msg.delete(:usage)
|
|
611
|
+
msg.select! { |key, _| MESSAGE_PARAM_KEYS.include?(key) }
|
|
583
612
|
end
|
|
584
613
|
end
|
|
585
614
|
|
|
@@ -20,6 +20,24 @@ module ActiveAgent
|
|
|
20
20
|
# Lead-in message for JSON response format emulation
|
|
21
21
|
JSON_RESPONSE_FORMAT_LEAD_IN = "Here is the JSON requested:\n{"
|
|
22
22
|
|
|
23
|
+
# Models that accept a prefilled response: Claude Haiku 4.5, Sonnet 4.5,
|
|
24
|
+
# Opus 4.5, Opus 4.1, Opus 4, Sonnet 4 and the Claude 3 family. Every model
|
|
25
|
+
# from Claude Opus 4.6 and Sonnet 4.6 on refuses one, with or without
|
|
26
|
+
# thinking, so a new model needs no entry here.
|
|
27
|
+
#
|
|
28
|
+
# An id matches in its Anthropic form (`claude-sonnet-4-5`, with a dated
|
|
29
|
+
# snapshot or `-latest`), its Amazon Bedrock form (`anthropic.` or a region
|
|
30
|
+
# prefix such as `us.anthropic.`, and a `-v1:0` suffix) and its Vertex AI
|
|
31
|
+
# form (`claude-sonnet-4-5@20250929`). A family name must be followed by
|
|
32
|
+
# the end of the id or one of those suffixes, so `claude-opus-4` matches
|
|
33
|
+
# `claude-opus-4-20250514` but not `claude-opus-4-6`.
|
|
34
|
+
PREFILL_MODELS = /
|
|
35
|
+
\A(?:(?:[a-z-]+\.)?anthropic\.)?claude-
|
|
36
|
+
(?:3-(?:haiku|sonnet|opus)|3-5-(?:haiku|sonnet)|3-7-sonnet|
|
|
37
|
+
(?:opus|sonnet)-4(?:-0)?|opus-4-1|(?:haiku|sonnet|opus)-4-5)
|
|
38
|
+
(?=\z|@|-(?:\d{8}|latest|v\d))
|
|
39
|
+
/x
|
|
40
|
+
|
|
23
41
|
attr_internal :json_format_retry_count
|
|
24
42
|
|
|
25
43
|
def initialize(kwargs = {})
|
|
@@ -34,6 +52,14 @@ module ActiveAgent
|
|
|
34
52
|
::Anthropic::Client.new(**options.serialize)
|
|
35
53
|
end
|
|
36
54
|
|
|
55
|
+
# Anthropic takes remote MCP servers through `mcp_servers`, so those are
|
|
56
|
+
# served by the provider as they always were — its own tool loop, with no
|
|
57
|
+
# tool schemas in the prompt. It cannot be handed a local process, so a
|
|
58
|
+
# `command:` declaration runs client-side instead.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Symbol>]
|
|
61
|
+
def mcp_native_transports = [ :url ]
|
|
62
|
+
|
|
37
63
|
protected
|
|
38
64
|
|
|
39
65
|
# Removes forced tool choice after first use to prevent endless looping.
|
|
@@ -77,9 +103,19 @@ module ActiveAgent
|
|
|
77
103
|
[ true, tool_name ]
|
|
78
104
|
end
|
|
79
105
|
|
|
106
|
+
# Emulates the json_object response format. When {#json_object_prefill?}
|
|
107
|
+
# holds, the request ends on an assistant turn holding
|
|
108
|
+
# {JSON_RESPONSE_FORMAT_LEAD_IN}, so the model continues an object that is
|
|
109
|
+
# already open.
|
|
110
|
+
#
|
|
111
|
+
# Every other request is sent without the lead-in, and only its prompt
|
|
112
|
+
# asks for JSON. {Common::Messages::Assistant#parsed_json} then finds the
|
|
113
|
+
# object in the answer, inside a Markdown code fence or not.
|
|
114
|
+
#
|
|
80
115
|
# @api private
|
|
81
116
|
def prepare_prompt_request_response_format
|
|
82
117
|
return unless request.response_format&.dig(:type) == "json_object"
|
|
118
|
+
return unless json_object_prefill?
|
|
83
119
|
|
|
84
120
|
self.message_stack.push({
|
|
85
121
|
role: "assistant",
|
|
@@ -87,6 +123,33 @@ module ActiveAgent
|
|
|
87
123
|
})
|
|
88
124
|
end
|
|
89
125
|
|
|
126
|
+
# Whether the json_object emulation prefills the answer with the lead-in:
|
|
127
|
+
# only for a model in {PREFILL_MODELS}, and only with thinking off, since
|
|
128
|
+
# a prefill is refused while thinking is on. A missing or unrecognised
|
|
129
|
+
# model id gets no prefill, because every model accepts a request without
|
|
130
|
+
# one.
|
|
131
|
+
#
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def json_object_prefill?
|
|
134
|
+
PREFILL_MODELS.match?(request.model.to_s) && !thinking_enabled?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether the request turns thinking on, in any mode: a manual budget
|
|
138
|
+
# (`enabled`), `adaptive`, or any other type but `disabled`.
|
|
139
|
+
#
|
|
140
|
+
# The type is read from the request as serialized. An anthropic gem older
|
|
141
|
+
# than a thinking type coerces it into another variant, whose `type`
|
|
142
|
+
# reader can then disagree with what is sent: anthropic 1.12 holds
|
|
143
|
+
# `adaptive` in a `ThinkingConfigDisabled`.
|
|
144
|
+
#
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def thinking_enabled?
|
|
147
|
+
return false unless request.thinking
|
|
148
|
+
|
|
149
|
+
type = Anthropic::Transforms.gem_to_hash(request.thinking)[:type]
|
|
150
|
+
type.present? && type.to_s != "disabled"
|
|
151
|
+
end
|
|
152
|
+
|
|
90
153
|
# Selects between Anthropic's stable and beta message APIs.
|
|
91
154
|
#
|
|
92
155
|
# Uses beta API when explicitly requested via anthropic_beta option or when
|
|
@@ -184,8 +247,9 @@ module ActiveAgent
|
|
|
184
247
|
|
|
185
248
|
self.message_stack[-1] = api_message
|
|
186
249
|
|
|
187
|
-
#
|
|
188
|
-
|
|
250
|
+
# Completion, tool loop included, runs from stream_finished! once
|
|
251
|
+
# the stream has drained, so each turn's tool calls run once.
|
|
252
|
+
self.stream_completion_pending = true if message_stack.last[:stop_reason]
|
|
189
253
|
when :ping
|
|
190
254
|
# No-Op Keep Awake
|
|
191
255
|
when :overloaded_error
|
|
@@ -202,14 +266,25 @@ module ActiveAgent
|
|
|
202
266
|
end
|
|
203
267
|
end
|
|
204
268
|
|
|
205
|
-
# Executes tool calls and appends user message
|
|
269
|
+
# Executes tool calls and appends one user message holding every result
|
|
270
|
+
# to message_stack. Pushes nothing when a tool asked the user for input.
|
|
206
271
|
#
|
|
207
272
|
# @param api_function_calls [Array<Hash>] with :name, :input, and :id keys
|
|
208
273
|
# @return [void]
|
|
209
274
|
def process_function_calls(api_function_calls)
|
|
210
|
-
|
|
275
|
+
results = dispatch_tool_calls(api_function_calls) do |api_function_call|
|
|
211
276
|
process_tool_call_function(api_function_call)
|
|
212
277
|
end
|
|
278
|
+
return unless results
|
|
279
|
+
|
|
280
|
+
content = api_function_calls.zip(results).map do |api_function_call, result|
|
|
281
|
+
::Anthropic::Models::ToolResultBlockParam.new(
|
|
282
|
+
type: "tool_result",
|
|
283
|
+
tool_use_id: api_function_call[:id],
|
|
284
|
+
content: result.to_json,
|
|
285
|
+
is_error: result.is_a?(MCPBridge::ErrorResult)
|
|
286
|
+
)
|
|
287
|
+
end
|
|
213
288
|
|
|
214
289
|
api_message = ::Anthropic::Models::MessageParam.new(role: "user", content:)
|
|
215
290
|
message = Anthropic::Transforms.gem_to_hash(api_message)
|
|
@@ -217,29 +292,26 @@ module ActiveAgent
|
|
|
217
292
|
message_stack.push(message)
|
|
218
293
|
end
|
|
219
294
|
|
|
220
|
-
# Executes a single tool call via callback.
|
|
295
|
+
# Executes a single tool call via callback. A call that failed on a
|
|
296
|
+
# bridged MCP server returns an +MCPBridge::ErrorResult+, which
|
|
297
|
+
# {#process_function_calls} flags with `is_error` so the model reads its
|
|
298
|
+
# content as the failure it describes.
|
|
221
299
|
#
|
|
222
300
|
# @param api_function_call [Hash] with :name, :input, and :id keys
|
|
223
|
-
# @return [
|
|
301
|
+
# @return [Object] the tool's result
|
|
224
302
|
def process_tool_call_function(api_function_call)
|
|
225
303
|
instrument("tool_call.active_agent", tool_name: api_function_call[:name]) do
|
|
226
|
-
|
|
227
|
-
api_function_call[:name], **api_function_call[:input]
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
::Anthropic::Models::ToolResultBlockParam.new(
|
|
231
|
-
type: "tool_result",
|
|
232
|
-
tool_use_id: api_function_call[:id],
|
|
233
|
-
content: results.to_json,
|
|
234
|
-
is_error: false
|
|
235
|
-
)
|
|
304
|
+
call_tool_function(api_function_call[:name], **api_function_call[:input])
|
|
236
305
|
end
|
|
237
306
|
end
|
|
238
307
|
|
|
239
308
|
# Processes completed API response and handles JSON format retries.
|
|
240
309
|
#
|
|
241
310
|
# When response_format is json_object and the response fails JSON validation,
|
|
242
|
-
# recursively retries the request to obtain well-formed JSON.
|
|
311
|
+
# recursively retries the request to obtain well-formed JSON. With the
|
|
312
|
+
# lead-in (see {#json_object_prefill?}), the unparseable answer stays in the
|
|
313
|
+
# conversation and the retry ends on a new lead-in after it. Without it,
|
|
314
|
+
# the answer is dropped and the same request is sent again.
|
|
243
315
|
#
|
|
244
316
|
# @see BaseProvider#process_prompt_finished
|
|
245
317
|
# @param api_response [Anthropic::Models::Message]
|
|
@@ -249,11 +321,16 @@ module ActiveAgent
|
|
|
249
321
|
api_response_hash = api_response ? Anthropic::Transforms.gem_to_hash(api_response) : nil
|
|
250
322
|
|
|
251
323
|
common_response = super(api_response_hash)
|
|
324
|
+
return common_response if common_response.awaiting_input?
|
|
252
325
|
|
|
253
326
|
# If we failed to get the expected well formed JSON Object Response, recursively try again
|
|
254
327
|
if request.response_format&.dig(:type) == "json_object" && common_response.message.parsed_json.nil? && json_format_retry_count > 0
|
|
255
328
|
self.json_format_retry_count -= 1
|
|
256
329
|
|
|
330
|
+
# No lead-in follows the answer here, so keeping it would end the
|
|
331
|
+
# retry on an assistant turn: a prefill.
|
|
332
|
+
message_stack.pop unless json_object_prefill?
|
|
333
|
+
|
|
257
334
|
resolve_prompt
|
|
258
335
|
else
|
|
259
336
|
common_response
|
|
@@ -267,6 +344,11 @@ module ActiveAgent
|
|
|
267
344
|
# then send the response back for completion. This method detects and reverses
|
|
268
345
|
# that workaround by stripping the lead-in message and prepending "{" to the response.
|
|
269
346
|
#
|
|
347
|
+
# The response to a request sent without the lead-in (see
|
|
348
|
+
# {#json_object_prefill?}) is returned as is. The brace goes on the last
|
|
349
|
+
# block that carries `text`. Blocks without it, such as `thinking`, are
|
|
350
|
+
# skipped.
|
|
351
|
+
#
|
|
270
352
|
# @see BaseProvider#process_prompt_finished_extract_messages
|
|
271
353
|
# @param api_response [Hash] API response with content blocks
|
|
272
354
|
# @return [Array<Hash>, nil]
|
|
@@ -283,9 +365,9 @@ module ActiveAgent
|
|
|
283
365
|
# Remove the lead-in message from the request
|
|
284
366
|
request.messages.pop
|
|
285
367
|
|
|
286
|
-
# Prepend "{" to the response's
|
|
287
|
-
if api_response[:content]
|
|
288
|
-
|
|
368
|
+
# Prepend "{" to the response's JSON text block
|
|
369
|
+
if (text_block = Array(api_response[:content]).reverse.find { |block| block.is_a?(Hash) && block[:text].is_a?(String) })
|
|
370
|
+
text_block[:text] = "{#{text_block[:text]}"
|
|
289
371
|
end
|
|
290
372
|
end
|
|
291
373
|
|
|
@@ -31,10 +31,31 @@ module ActiveAgent
|
|
|
31
31
|
|
|
32
32
|
attribute :format, Types::FormatType.new, writable: false, default: {}
|
|
33
33
|
|
|
34
|
+
# @!attribute [r] input_requests
|
|
35
|
+
# The questions tools put to the user, one per paused tool call.
|
|
36
|
+
# @return [Array<ActiveAgent::InputRequest>]
|
|
37
|
+
attribute :input_requests, default: -> { [] }, writable: false
|
|
38
|
+
|
|
39
|
+
# @!attribute [r] checkpoint
|
|
40
|
+
# What ActiveAgent::Generation#resume_now continues from, as a
|
|
41
|
+
# JSON-safe hash. It holds the conversation, so store it as
|
|
42
|
+
# carefully as the conversation itself.
|
|
43
|
+
# @return [Hash, nil]
|
|
44
|
+
attribute :checkpoint, writable: false
|
|
45
|
+
|
|
34
46
|
# The most recent message in the conversational stack
|
|
35
47
|
def message
|
|
36
48
|
messages.last
|
|
37
49
|
end
|
|
50
|
+
|
|
51
|
+
# Whether the generation paused because a tool asked the user for
|
|
52
|
+
# input. The last message is then the assistant turn that made the
|
|
53
|
+
# tool calls, not an answer.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def awaiting_input?
|
|
57
|
+
input_requests.present?
|
|
58
|
+
end
|
|
38
59
|
end
|
|
39
60
|
end
|
|
40
61
|
end
|