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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1058 -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/ruby_llm.rb +411 -0
- 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 +1 -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 +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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|