activeagent 1.8.1 → 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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1044 -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
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/input_request"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ # Pauses a generation when a tool returns an {ActiveAgent::InputRequest},
8
+ # and resumes it from the checkpoint with the user's answers.
9
+ #
10
+ # A provider takes part by running a turn's tool calls through
11
+ # {#dispatch_tool_calls} and building its result messages from what that
12
+ # returns. Under a provider whose tool loop calls `call_tool_function`
13
+ # directly, a tool that returns an InputRequest, or a tool that needs
14
+ # approval, raises {ActiveAgent::InputRequest::UnsupportedProviderError}.
15
+ #
16
+ # A tool needs approval when the `requires_approval:` prompt option names
17
+ # it, or when it is served by a client-side MCP server whose declaration's
18
+ # `require_approval` covers it (see {MCPBridge#requires_approval?}). Its
19
+ # call then pauses with a `:confirm` request before the tool runs, and runs
20
+ # only once the user approves it.
21
+ module InputRequests
22
+ extend ActiveSupport::Concern
23
+
24
+ # The metadata of a request the approval gate makes, as opposed to one
25
+ # a tool returned.
26
+ APPROVAL_METADATA = { "approval" => true }.freeze
27
+
28
+ included do
29
+ # The agent action the generation runs, recorded in a checkpoint.
30
+ attr_internal :generation_action_name
31
+
32
+ # The tool names the `requires_approval:` prompt option lists.
33
+ attr_internal :tool_approvals
34
+
35
+ # The checkpoint and answers being resumed from, until the paused
36
+ # turn's results have been dispatched.
37
+ attr_internal :input_request_resume
38
+
39
+ # The completed results and input requests of a turn that paused.
40
+ attr_internal :paused_tool_turn
41
+
42
+ # Whether a pause publishes `input_requested.active_agent`.
43
+ attr_internal :announce_input_requests
44
+ end
45
+
46
+ # @return [Boolean] whether the last tool turn left calls waiting on the user
47
+ def awaiting_input? = paused_tool_turn.present?
48
+
49
+ protected
50
+
51
+ # Continues a paused generation from where it stopped: the assistant turn
52
+ # that made the tool calls goes back on the message stack, the calls are
53
+ # dispatched with their answers, and the tool loop carries on.
54
+ #
55
+ # @return [Common::PromptResponse]
56
+ # @raise [ActiveAgent::InputRequest::ResumeError] before any tool runs,
57
+ # when the checkpoint was taken on another provider or model, or its
58
+ # tool calls do not match its results and requests
59
+ def resume_prompt
60
+ resume = input_request_resume
61
+ resume.assert_provider!(service: service_name, provider: tag_name, model: request.model)
62
+
63
+ clear_tool_choice if resume.tool_choice_cleared?
64
+ message_stack.push(*resume.tool_call_turn)
65
+ tool_calls = Array(process_prompt_finished_extract_function_calls)
66
+ resume.assert_tool_calls!(tool_calls.map { tool_call_reference(_1).first })
67
+
68
+ self.tool_turns = resume.tool_turns
69
+ process_function_calls(tool_calls)
70
+ return paused_prompt_response if awaiting_input?
71
+
72
+ resolve_prompt
73
+ end
74
+
75
+ # Runs a turn's tool calls, each through the block, and returns their
76
+ # results in call order.
77
+ #
78
+ # Returns nil when a call paused: its tool returned an InputRequest, or
79
+ # it needs approval and has none yet, in which case the tool does not
80
+ # run. The other calls still run, and their results are kept for the
81
+ # checkpoint rather than sent: the turn's results go to the model
82
+ # together, once every call has one.
83
+ #
84
+ # On resume:
85
+ # - a call completed before the pause reuses its result in JSON form
86
+ # - a declined call gets {ActiveAgent::InputRequest::DECLINED_RESULT}
87
+ # without running
88
+ # - a call to a client-side MCP tool its server no longer offers gets
89
+ # an `{ error: }` result without running
90
+ # - a call that waited for approval runs once approved, with no answer
91
+ # in execution state: the approval answers the gate, not the tool
92
+ # - any other paused call runs again with its answer readable through
93
+ # {ActiveAgent::InputRequest.answer_for}
94
+ #
95
+ # @param calls [Array<Hash>] provider-native tool calls
96
+ # @yieldparam call [Hash] one call to run
97
+ # @yieldreturn [Object] the tool's result
98
+ # @return [Array<Object>, nil]
99
+ def dispatch_tool_calls(calls, &block)
100
+ resume = input_request_resume
101
+ paused = []
102
+ gated = []
103
+
104
+ results = calls.map do |call|
105
+ id, name = tool_call_reference(call)
106
+ result = dispatch_tool_call(call, id, name, resume, gated, &block)
107
+ next result unless result.is_a?(ActiveAgent::InputRequest)
108
+
109
+ result = result.for_tool_call(id:, name:, arguments: tool_call_arguments(call))
110
+ paused << [ id, name, result ]
111
+ result
112
+ end
113
+
114
+ self.input_request_resume = nil
115
+ return results if paused.empty?
116
+
117
+ requests = paused.map(&:last)
118
+ self.paused_tool_turn = {
119
+ results: completed_results(calls, results, requests),
120
+ input_requests: requests,
121
+ approval_tool_calls: gated,
122
+ approved_tool_calls: paused.filter_map { |id, _, _| id if resume && (resume.approval_given?(id) || resume.approved?(id)) },
123
+ mcp_tool_calls: paused.filter_map { |id, name, _| id if mcp_owns_tool?(name) }
124
+ }
125
+ nil
126
+ end
127
+
128
+ # Returns the id and tool name of a provider-native tool call. Providers
129
+ # whose calls carry them elsewhere override this.
130
+ #
131
+ # @param call [Hash]
132
+ # @return [Array(String, String)]
133
+ def tool_call_reference(call)
134
+ [ call[:id].to_s, call[:name].to_s ]
135
+ end
136
+
137
+ # Returns the arguments of a provider-native tool call, as the model
138
+ # sent them. Providers whose calls carry them elsewhere override this.
139
+ #
140
+ # @param call [Hash]
141
+ # @return [Hash, nil]
142
+ def tool_call_arguments(call)
143
+ call[:input]
144
+ end
145
+
146
+ # @param json [String, nil] arguments a model sent as a JSON string
147
+ # @return [Hash, String, nil] the parsed arguments, or `json` itself
148
+ # when it is not valid JSON
149
+ def parse_tool_call_arguments(json)
150
+ return json unless json.is_a?(String)
151
+ return {} if json.blank?
152
+
153
+ JSON.parse(json)
154
+ rescue JSON::ParserError
155
+ json
156
+ end
157
+
158
+ # Returns the `requires_approval:` prompt option as tool names.
159
+ #
160
+ # @param value [String, Symbol, Array<String, Symbol>, nil]
161
+ # @return [Array<String>]
162
+ # @raise [ArgumentError] for anything else, such as `true`, which names
163
+ # no tool and would gate nothing
164
+ def normalize_tool_approvals(value)
165
+ names = Array(value)
166
+ return names.map(&:to_s) if names.all? { _1.is_a?(String) || _1.is_a?(Symbol) }
167
+
168
+ raise ArgumentError, "requires_approval: takes a tool name or an array of tool names, not #{value.inspect}"
169
+ end
170
+
171
+ # Whether a call to the tool must be approved before the tool runs.
172
+ #
173
+ # @param name [String, Symbol]
174
+ # @return [Boolean]
175
+ def approval_required?(name)
176
+ Array(tool_approvals).include?(name.to_s) || (mcp_owns_tool?(name) && mcp_bridge.requires_approval?(name))
177
+ end
178
+
179
+ # @param name [String, Symbol] the tool about to run
180
+ # @raise [ActiveAgent::InputRequest::UnsupportedProviderError] when the
181
+ # tool needs approval and the call was not dispatched through
182
+ # {#dispatch_tool_calls}, which is where the approval is asked for
183
+ def assert_approval_gate_reached!(name)
184
+ return if @_dispatching_tool_call || !approval_required?(name)
185
+
186
+ raise ActiveAgent::InputRequest::UnsupportedProviderError,
187
+ "#{tag_name} ran #{name}, which needs approval, outside dispatch_tool_calls, so nobody was asked to approve it."
188
+ end
189
+
190
+ # Runs a tool call made outside {#dispatch_tool_calls} with no tool call
191
+ # in execution state. Such a call can neither pause nor be answered, and
192
+ # it must not read the answer of an enclosing call that started this
193
+ # generation.
194
+ #
195
+ # @return [Object] the block's result
196
+ def isolate_undispatched_tool_call(&block)
197
+ return yield if @_dispatching_tool_call
198
+
199
+ ActiveAgent::InputRequest.dispatching(nil, &block)
200
+ end
201
+
202
+ # @param result [Object] what a tool returned
203
+ # @raise [ActiveAgent::InputRequest::UnsupportedProviderError] when the
204
+ # result is an InputRequest and the call was not dispatched through
205
+ # {#dispatch_tool_calls}
206
+ def assert_input_request_supported!(result)
207
+ return unless result.is_a?(ActiveAgent::InputRequest) && !@_dispatching_tool_call
208
+
209
+ raise ActiveAgent::InputRequest::UnsupportedProviderError,
210
+ "#{tag_name} cannot pause a generation for user input: its tool loop does not run calls through " \
211
+ "dispatch_tool_calls, and a tool returned an ActiveAgent::InputRequest."
212
+ end
213
+
214
+ # Builds the response for a generation that is waiting on the user, and
215
+ # announces it with `input_requested.active_agent` unless
216
+ # `announce_input_requests` is false.
217
+ #
218
+ # @param api_response [Hash, nil] the response that made the tool calls
219
+ # @return [Common::PromptResponse]
220
+ def paused_prompt_response(api_response = nil)
221
+ broadcast_stream_close
222
+
223
+ input_requests = paused_tool_turn[:input_requests]
224
+ instrument("input_requested.active_agent", input_requests:) if announce_input_requests
225
+
226
+ build_prompt_response(api_response, input_requests:, checkpoint:)
227
+ end
228
+
229
+ # Returns a JSON-safe hash {ActiveAgent::Generation#resume_now} continues
230
+ # from; see {ActiveAgent::InputRequest::Resume} for its keys.
231
+ #
232
+ # @return [Hash{String => Object}]
233
+ def checkpoint
234
+ {
235
+ version: ActiveAgent::InputRequest::Resume::VERSION,
236
+ service: service_name,
237
+ provider: tag_name,
238
+ model: request.model&.to_s,
239
+ action_name: generation_action_name,
240
+ tool_turns:,
241
+ tool_choice_cleared: tool_choice_cleared == true,
242
+ messages: checkpoint_messages,
243
+ tool_call_turn_size: serialized_messages(message_stack).size,
244
+ completed_results: paused_tool_turn[:results],
245
+ input_requests: paused_tool_turn[:input_requests].map(&:to_h),
246
+ approval_tool_calls: paused_tool_turn[:approval_tool_calls],
247
+ approved_tool_calls: paused_tool_turn[:approved_tool_calls],
248
+ mcp_tool_calls: paused_tool_turn[:mcp_tool_calls]
249
+ }.as_json
250
+ end
251
+
252
+ # Returns `messages` in the provider's serialized form.
253
+ #
254
+ # @param messages [Array, nil]
255
+ # @param instructions [String, Array, nil]
256
+ # @return [Array<Hash>]
257
+ def serialized_messages(messages, instructions: nil)
258
+ parameters = { messages:, instructions: }.compact
259
+ return [] if parameters.empty?
260
+
261
+ prompt_request_type.serialize(prompt_request_type.cast(parameters))[:messages] || []
262
+ end
263
+
264
+ private
265
+
266
+ # Runs one call of a turn, or answers it without running the tool.
267
+ #
268
+ # Whether a paused call waited for approval is read from the checkpoint,
269
+ # so a resume whose `requires_approval:` differs still treats the answer
270
+ # as an approval and never hands it to the tool.
271
+ #
272
+ # @param gated [Array<String>] collects the ids of calls the approval
273
+ # gate pauses
274
+ # @return [Object] the tool's result, or an InputRequest when the call
275
+ # pauses
276
+ def dispatch_tool_call(call, id, name, resume, gated)
277
+ if resume&.completed?(id)
278
+ resume.completed_result(id)
279
+ elsif resume&.declined?(id)
280
+ ActiveAgent::InputRequest::DECLINED_RESULT
281
+ elsif resume&.mcp_tool_call?(id) && !mcp_owns_tool?(name)
282
+ { error: "#{name} is no longer offered by its MCP server" }
283
+ elsif resume&.approval_given?(id)
284
+ dispatching_tool_call(id, answer: nil) { yield call }
285
+ elsif resume&.approved?(id) || !approval_required?(name)
286
+ dispatching_tool_call(id, answer: resume&.answer(id)) { yield call }
287
+ else
288
+ gated << id
289
+ ActiveAgent::InputRequest.confirm("Allow #{name} to run?", metadata: APPROVAL_METADATA)
290
+ end
291
+ end
292
+
293
+ # Returns the conversation through the assistant tool-call turn, in the
294
+ # provider's serialized form, without the messages the provider derives
295
+ # from instructions (OpenAI Chat's developer message): a resumed
296
+ # generation renders its instructions again. The caller's messages are
297
+ # cast without instructions, and every later message follows them.
298
+ #
299
+ # @return [Array<Hash>]
300
+ def checkpoint_messages
301
+ conversation = serialized_messages([ *request.messages, *message_stack ])
302
+ with_instructions = serialized_messages(context[:messages], instructions: context[:instructions])
303
+ without_instructions = serialized_messages(context[:messages])
304
+
305
+ without_instructions + conversation.drop(with_instructions.size)
306
+ end
307
+
308
+ # @return [Hash{String => Object}] each finished call's result in JSON
309
+ # form, by call id
310
+ # @raise [ArgumentError] when the calls lack unique ids, which a
311
+ # checkpoint needs to match results and answers to calls
312
+ def completed_results(calls, results, requests)
313
+ ids = calls.map { tool_call_reference(_1).first }
314
+ if ids.any?(&:blank?) || ids.uniq.size != ids.size
315
+ raise ArgumentError, "#{tag_name} returned tool calls without unique ids, so the generation cannot pause for input"
316
+ end
317
+
318
+ paused = requests.map(&:tool_call_id)
319
+ ids.zip(results).reject { |id, _| paused.include?(id) }.to_h { |id, result| [ id, result.as_json ] }
320
+ end
321
+
322
+ # @return [Object] the block's result
323
+ def dispatching_tool_call(id, answer:)
324
+ previous, @_dispatching_tool_call = @_dispatching_tool_call, true
325
+
326
+ ActiveAgent::InputRequest.dispatching(id, answer:) { yield }
327
+ ensure
328
+ @_dispatching_tool_call = previous
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mcp_bridge"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ # Serves `mcps:` declarations, natively where a provider's API can and
8
+ # client-side everywhere else.
9
+ #
10
+ # A declaration is passthrough by default: it becomes the provider's own
11
+ # `mcp_servers` parameter, and the provider connects, lists the tools and
12
+ # calls them. That works only where the provider implements MCP, and where it
13
+ # does not the failure is quiet — DeepSeek ignores `mcp_servers`, returns 200,
14
+ # and answers without the server's data, so it reads as a poor answer rather
15
+ # than as a configuration error.
16
+ #
17
+ # The default is therefore inverted here: a provider declares the transports
18
+ # its own API can serve ({#mcp_native_transports}, empty by default) and
19
+ # {MCPBridge} serves the rest, which is what makes `mcps:` work on every
20
+ # provider rather than only the ones that implement it.
21
+ #
22
+ # Each provider may override:
23
+ # - `mcp_native_transports`: the transport kinds its own API can serve
24
+ module MCPServing
25
+ extend ActiveSupport::Concern
26
+
27
+ # The accepted `mcp_strategy:` values.
28
+ STRATEGIES = %i[auto client server].freeze
29
+
30
+ included do
31
+ # @return [MCPBridge, nil] bridge over the client-side declarations
32
+ attr_internal :mcp_bridge
33
+ end
34
+
35
+ # The MCP server transports this provider can serve through its own API.
36
+ #
37
+ # `:url` is a remote server, which a provider that speaks MCP can simply be
38
+ # handed. `:command` is a local process, and no provider can serve one:
39
+ # nothing but this process is going to spawn it, so those always run
40
+ # client-side whatever the provider supports.
41
+ #
42
+ # Empty by default, which is what makes the bridge the universal path —
43
+ # every provider supports `mcps:` whether or not its API does.
44
+ #
45
+ # @return [Array<Symbol>]
46
+ def mcp_native_transports = []
47
+
48
+ protected
49
+
50
+ # Request parameters for a real prompt, with `mcps:` resolved.
51
+ #
52
+ # Declarations the provider can serve itself are left in `mcps:` for it to
53
+ # translate; the rest are served by {MCPBridge}, which exposes their tools
54
+ # as ordinary tools the provider can already call.
55
+ #
56
+ # @return [Hash]
57
+ def mcp_resolved_context
58
+ native, bridged = mcp_partition_servers(context[:mcps])
59
+ parameters = mcp_except_options(context)
60
+
61
+ parameters = parameters.merge(mcps: native) if native.any?
62
+
63
+ # Release a bridge left by an earlier call on this instance before this
64
+ # one replaces the field, or its connections outlive the generation that
65
+ # opened them.
66
+ mcp_release_bridge!
67
+ self.mcp_bridge = bridged.any? ? MCPBridge.new(bridged, cache: context[:mcp_cache]) : nil
68
+
69
+ return parameters if mcp_bridge.nil?
70
+
71
+ parameters.merge(tools: mcp_bridge.merge_tools(parameters[:tools]))
72
+ end
73
+
74
+ # Closes the bridge's server connections, if one was built.
75
+ #
76
+ # A bridged server holds a live connection, and for a `command:` server
77
+ # that connection is a process. It has to be released when the generation
78
+ # that opened it finishes — the garbage collector would never reap it, so
79
+ # a long-lived worker would accumulate orphans until it ran out of PIDs.
80
+ #
81
+ # Safe to call when no bridge was built, and safe to call twice.
82
+ #
83
+ # @return [void]
84
+ def mcp_release_bridge!
85
+ mcp_bridge&.close
86
+ self.mcp_bridge = nil
87
+ end
88
+
89
+ # Request parameters for a preview, keeping only what the provider serves.
90
+ #
91
+ # Discovering a server's tools means connecting to it, and a preview must
92
+ # not perform I/O, so a client-side server does not appear in a preview at
93
+ # all — its tools are unknown until it is connected to.
94
+ #
95
+ # @return [Hash]
96
+ def mcp_preview_context
97
+ native, = mcp_partition_servers(context[:mcps])
98
+ parameters = mcp_except_options(context)
99
+
100
+ native.any? ? parameters.merge(mcps: native) : parameters
101
+ end
102
+
103
+ # @param name [String, Symbol] tool name
104
+ # @return [Boolean] whether a bridged server provides this tool
105
+ def mcp_owns_tool?(name)
106
+ mcp_bridge&.owns?(name) || false
107
+ end
108
+
109
+ # Invokes a tool on a bridged server.
110
+ #
111
+ # @param name [String] tool name
112
+ # @param kwargs [Hash] tool arguments
113
+ # @return [Object] the tool's result
114
+ def mcp_call_tool(name, **kwargs) = mcp_bridge.call(name, **kwargs)
115
+
116
+ # Splits `mcps:` into what the provider serves and what the bridge serves.
117
+ #
118
+ # A declaration whose calls may need approval is always the bridge's,
119
+ # because a provider that serves a server itself runs its tool calls where
120
+ # the approval gate never sees them (see {#mcp_gated?}).
121
+ #
122
+ # @param declarations [Array<Hash>, Hash, nil]
123
+ # @return [Array<Array<Hash>>] the provider's declarations, then the
124
+ # bridge's
125
+ # @raise [ArgumentError] when `mcp_strategy: :server` was asked for and the
126
+ # provider cannot serve one of the declarations, or one may need approval
127
+ def mcp_partition_servers(declarations)
128
+ declarations = mcp_normalize_declarations(declarations)
129
+
130
+ case mcp_strategy
131
+ when :client
132
+ [ [], declarations ]
133
+ when :server
134
+ declarations.each { |declaration| mcp_assert_servable!(declaration) }
135
+
136
+ [ declarations, [] ]
137
+ else
138
+ declarations.partition do |declaration|
139
+ mcp_native_transports.include?(mcp_transport(declaration)) && !mcp_gated?(declaration)
140
+ end
141
+ end
142
+ end
143
+
144
+ # Whether some of a declaration's tool calls may need approval: its
145
+ # `require_approval` covers a tool, or its server may offer a tool the
146
+ # `requires_approval:` prompt option names (see
147
+ # {#mcp_approvals_served_by}).
148
+ #
149
+ # @param declaration [Hash]
150
+ # @return [Boolean]
151
+ def mcp_gated?(declaration)
152
+ return false unless declaration.is_a?(Hash)
153
+
154
+ MCPBridge.approval_policy?(declaration[:require_approval]) || mcp_approvals_served_by(declaration).any?
155
+ end
156
+
157
+ # Returns the names in the `requires_approval:` prompt option that a
158
+ # declaration's server may offer: those among its `allowed_tools`, or,
159
+ # when it lists none, every name that no tool in `tools:` has. The tools
160
+ # of a server without `allowed_tools` are unknown until it is connected
161
+ # to, so it is taken to offer any name.
162
+ #
163
+ # @param declaration [Hash]
164
+ # @return [Array<String>]
165
+ def mcp_approvals_served_by(declaration)
166
+ approvals = Array(tool_approvals)
167
+ return [] if approvals.empty?
168
+
169
+ allowed = Array(declaration[:allowed_tools]).map { |tool| mcp_tool_name(tool) }
170
+ return approvals & allowed if allowed.any?
171
+
172
+ approvals - Array(context[:tools]).map { |tool| mcp_tool_name(tool) }
173
+ end
174
+
175
+ # @param tool [Hash, String, Symbol] a tool definition, in the common
176
+ # format or OpenAI Chat's `{ function: { name: } }`, or a tool name
177
+ # @return [String]
178
+ def mcp_tool_name(tool)
179
+ return tool.to_s unless tool.is_a?(Hash)
180
+
181
+ function = tool[:function] || tool["function"]
182
+ name = tool[:name] || tool["name"]
183
+ name ||= function[:name] || function["name"] if function.is_a?(Hash)
184
+
185
+ name.to_s
186
+ end
187
+
188
+ # @return [Symbol] how to serve `mcps:`: `:auto` (default, native where the
189
+ # provider can and client-side otherwise), `:client` to always run the
190
+ # servers here, or `:server` to require the provider to run them
191
+ # @raise [ArgumentError] for a value outside `STRATEGIES`
192
+ def mcp_strategy
193
+ strategy = (context[:mcp_strategy] || :auto).to_sym
194
+ return strategy if STRATEGIES.include?(strategy)
195
+
196
+ fail ArgumentError, "`mcp_strategy:` must be one of #{STRATEGIES.map(&:inspect).join(', ')}, got #{strategy.inspect}."
197
+ end
198
+
199
+ # Removes the MCP options, which are instructions to this concern rather
200
+ # than parameters any provider accepts.
201
+ #
202
+ # @param parameters [Hash]
203
+ # @return [Hash]
204
+ def mcp_except_options(parameters)
205
+ parameters.except(:mcps, :mcp_strategy, :mcp_cache)
206
+ end
207
+
208
+ # @param declarations [Array<Hash>, Hash, nil]
209
+ # @return [Array<Hash>]
210
+ def mcp_normalize_declarations(declarations)
211
+ return [] if declarations.blank?
212
+ # `Array(hash)` would split a lone declaration into pairs.
213
+ declarations = [ declarations ] if declarations.is_a?(Hash)
214
+
215
+ # A declaration loaded from YAML or JSON arrives with String keys, and `mcp_transport` reads Symbols.
216
+ Array(declarations).map { |declaration| declaration.is_a?(Hash) ? declaration.deep_symbolize_keys : declaration }
217
+ end
218
+
219
+ # @param declaration [Hash]
220
+ # @return [Symbol, nil] `:url`, `:command`, or nil when neither is declared
221
+ def mcp_transport(declaration)
222
+ return nil unless declaration.is_a?(Hash)
223
+ return :url if declaration[:url].present?
224
+ return :command if declaration[:command].present?
225
+
226
+ nil
227
+ end
228
+
229
+ # @param declaration [Hash]
230
+ # @return [void]
231
+ # @raise [ArgumentError] when the provider cannot serve the declaration
232
+ def mcp_assert_servable!(declaration)
233
+ transport = mcp_transport(declaration)
234
+
235
+ unless transport && mcp_native_transports.include?(transport)
236
+ fail ArgumentError,
237
+ "#{service_name} cannot serve #{transport ? "a #{transport.inspect}" : "this"} MCP server itself, " \
238
+ "but `mcp_strategy: :server` requires it to. Servers it can serve: " \
239
+ "#{mcp_native_transports.any? ? mcp_native_transports.inspect : "none"}. " \
240
+ "Use `mcp_strategy: :auto` to run the rest client-side."
241
+ end
242
+
243
+ if MCPBridge.approval_policy?(declaration[:require_approval])
244
+ fail ArgumentError,
245
+ "The #{declaration[:name].to_s.inspect} MCP server's tool calls need approval, which is asked for " \
246
+ "only when ActiveAgent runs the server client-side, but `mcp_strategy: :server` hands it to " \
247
+ "#{service_name}. Use `mcp_strategy: :auto` or `:client`, or set `require_approval: \"never\"`."
248
+ end
249
+
250
+ approvals = mcp_approvals_served_by(declaration)
251
+ return if approvals.empty?
252
+
253
+ remedy = if declaration[:allowed_tools].present?
254
+ "Remove #{approvals.to_sentence} from its `allowed_tools:`"
255
+ else
256
+ "List the tools it may offer in `allowed_tools:`"
257
+ end
258
+
259
+ fail ArgumentError,
260
+ "`requires_approval:` names #{approvals.to_sentence}, which the #{declaration[:name].to_s.inspect} " \
261
+ "MCP server may offer, and approval is asked for only when ActiveAgent runs the server client-side, " \
262
+ "but `mcp_strategy: :server` hands it to #{service_name}. #{remedy}, or use `mcp_strategy: :auto` " \
263
+ "or `:client`."
264
+ end
265
+ end
266
+ end
267
+ end
@@ -16,27 +16,42 @@ module ActiveAgent
16
16
  module ToolChoiceClearing
17
17
  extend ActiveSupport::Concern
18
18
 
19
+ included do
20
+ # Whether this generation has cleared a forced tool_choice. A paused
21
+ # generation's checkpoint records it, since a resumed generation
22
+ # casts its request, tool_choice included, from the action again.
23
+ attr_internal :tool_choice_cleared
24
+ end
25
+
19
26
  # @api private
20
27
  def prepare_prompt_request_tools
21
- return unless request.tool_choice
28
+ # Once cleared, tool_choice is not read again: the Anthropic SDK's
29
+ # request model raises when its tool_choice is read back as nil.
30
+ return if tool_choice_cleared || !request.tool_choice
22
31
 
23
32
  functions_used = extract_used_function_names
24
33
 
25
34
  # Clear if forcing required and any tool was used
26
35
  if tool_choice_forces_required? && functions_used.any?
27
- request.tool_choice = nil
36
+ clear_tool_choice
28
37
  return
29
38
  end
30
39
 
31
40
  # Clear if forcing specific tool and that tool was used
32
41
  forces_specific, tool_name = tool_choice_forces_specific?
33
42
  if forces_specific && tool_name && functions_used.include?(tool_name)
34
- request.tool_choice = nil
43
+ clear_tool_choice
35
44
  end
36
45
  end
37
46
 
38
47
  private
39
48
 
49
+ # @return [void]
50
+ def clear_tool_choice
51
+ request.tool_choice = nil
52
+ self.tool_choice_cleared = true
53
+ end
54
+
40
55
  # Extracts the list of function names that have been called.
41
56
  #
42
57
  # @return [Array<String>] function names
@@ -0,0 +1,6 @@
1
+ # DeepSeek, alias for the brand-cased service name resolution
2
+ #
3
+ # `service: "DeepSeek"` underscores to "deep_seek", so provider_load looks for
4
+ # this file. The provider itself lives in deepseek_provider.rb, which is also
5
+ # what `:deepseek` / "Deepseek" resolves to before the service-name remap.
6
+ require_relative "deepseek_provider"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+ require_relative "chat/_types"