activeagent 1.8.1 → 1.9.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1058 -0
  3. data/README.md +9 -1
  4. data/lib/active_agent/base.rb +22 -1
  5. data/lib/active_agent/concerns/delegation.rb +4 -0
  6. data/lib/active_agent/concerns/input_requests.rb +178 -0
  7. data/lib/active_agent/concerns/parameterized.rb +7 -4
  8. data/lib/active_agent/concerns/provider.rb +1 -0
  9. data/lib/active_agent/concerns/tooling.rb +7 -2
  10. data/lib/active_agent/delegation/runner.rb +31 -0
  11. data/lib/active_agent/evals/catalog.rb +305 -0
  12. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  13. data/lib/active_agent/evals/scenario.rb +10 -3
  14. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  15. data/lib/active_agent/evals.rb +1 -0
  16. data/lib/active_agent/generation.rb +62 -2
  17. data/lib/active_agent/generation_job.rb +14 -3
  18. data/lib/active_agent/input_request/resume.rb +199 -0
  19. data/lib/active_agent/input_request.rb +279 -0
  20. data/lib/active_agent/providers/_base_provider.rb +153 -31
  21. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  22. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  23. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  24. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  25. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  26. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  27. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  28. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  29. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  30. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  31. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  32. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  33. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  34. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  35. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  36. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  37. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  38. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  39. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  40. data/lib/active_agent/providers/open_router/options.rb +6 -2
  41. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  42. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  43. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  44. data/lib/active_agent/schema_tools.rb +12 -0
  45. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  46. data/lib/active_agent/version.rb +1 -1
  47. data/lib/active_agent.rb +2 -0
  48. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  49. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  50. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  51. metadata +31 -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, or when the loaded
23
- # version is outside the supported range
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, # Setup
64
- :request, :message_stack, # Runtime
65
- :stream_broadcaster, :streaming, # Callback (Streams)
66
- :stream_completion_pending, # Callback (Streams)
67
- :stream_completion_result, # Callback (Streams)
68
- :tools_function, # Callback (Tools)
69
- :usage_stack, # Usage Tracking
70
- :stream_usage_index, # Usage Tracking (Streams)
71
- :max_tool_turns, :tool_turns, # Tool-loop safety
72
- :instrumentation_enabled # Per-generation privacy
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(context.except(:trace_id))
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(context.except(:trace_id))
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
- # To convert the messages into common format we first need to merge the current
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
- params[:output_config] = output_config if output_config
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 = format_hash[:json_schema]&.dig(:schema)
243
- if schema
244
- schema = inject_additional_properties(schema.deep_dup)
245
- end
246
- {
247
- format: {
248
- type: "json_schema",
249
- schema: schema
250
- }.compact
251
- }
252
- elsif format_hash[:type].to_s == "json_object"
253
- # json_object is not handled here; it remains prompt-emulated.
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
- # Remove response-only fields from messages
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.delete(:id)
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
- # Once we are finished, close out and run tooling callbacks (Recursive)
188
- process_prompt_finished if message_stack.last[:stop_reason]
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 with results to message_stack.
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
- content = api_function_calls.map do |api_function_call|
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 [Anthropic::Models::ToolResultBlockParam]
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
- results = tools_function.call(
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 first content text
287
- if api_response[:content]&.first&.dig(:text)
288
- api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
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