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
data/README.md CHANGED
@@ -57,6 +57,9 @@ bundle add openai
57
57
  # Requesty (uses OpenAI-compatible API)
58
58
  bundle add openai
59
59
 
60
+ # DeepSeek (uses OpenAI-compatible API)
61
+ bundle add openai
62
+
60
63
  # RubyLLM (unified API for 15+ providers)
61
64
  bundle add ruby_llm
62
65
  ```
@@ -140,6 +143,11 @@ development:
140
143
  service: "Ollama"
141
144
  model: "llama3.2"
142
145
 
146
+ deepseek:
147
+ service: "DeepSeek"
148
+ api_key: <%= Rails.application.credentials.dig(:deepseek, :api_key) %>
149
+ model: "deepseek-flash"
150
+
143
151
  ruby_llm:
144
152
  service: "RubyLLM"
145
153
  ```
@@ -179,7 +187,7 @@ a free low-volume trial.
179
187
  ## Features
180
188
 
181
189
  - **Agent-Oriented Programming**: Build AI applications using familiar Rails patterns
182
- - **Multiple Provider Support**: Works with OpenAI, Anthropic, Ollama, RubyLLM, and more
190
+ - **Multiple Provider Support**: Works with OpenAI, Anthropic, Ollama, DeepSeek, RubyLLM, and more
183
191
  - **Action-Based Design**: Define agent capabilities through actions
184
192
  - **View Templates**: Use ERB templates for prompts (text, JSON, HTML)
185
193
  - **Streaming Support**: Real-time response streaming with ActionCable
@@ -7,6 +7,7 @@ require "active_support/core_ext/string/inflections"
7
7
  require "active_agent/concerns/authorization"
8
8
  require "active_agent/concerns/callbacks"
9
9
  require "active_agent/concerns/delegation"
10
+ require "active_agent/concerns/input_requests"
10
11
  require "active_agent/concerns/observers"
11
12
  require "active_agent/concerns/parameterized"
12
13
  require "active_agent/concerns/preview"
@@ -47,6 +48,7 @@ module ActiveAgent
47
48
  include Callbacks
48
49
  # After Rescue: the refusal handler is registered with rescue_from.
49
50
  include Delegation
51
+ include InputRequests
50
52
  include Parameterized
51
53
  include Provider
52
54
  include Queueing
@@ -261,7 +263,9 @@ module ActiveAgent
261
263
  run_callbacks(:prompting) do
262
264
  parameters = prepare_prompt_parameters
263
265
 
264
- prompt_provider_klass.new(**parameters).prompt
266
+ prompt_provider_klass.new(**parameters).prompt.tap do |response|
267
+ run_input_request_callbacks(response)
268
+ end
265
269
  end
266
270
  end
267
271
  end
@@ -332,6 +336,9 @@ module ActiveAgent
332
336
  exception_handler:,
333
337
  stream_broadcaster:,
334
338
  tools_function:,
339
+ action_name: generation_action_name,
340
+ input_request_resume: @_input_request_resume,
341
+ announce_input_requests: !input_requests_silenced?
335
342
  )
336
343
 
337
344
  # Apply Templates
@@ -389,6 +396,7 @@ module ActiveAgent
389
396
  end
390
397
 
391
398
  if response_format[:type].to_sym == :json_schema
399
+ response_format = nest_flat_json_schema(response_format)
392
400
  response_format[:json_schema] = prompt_view_schema(response_format[:json_schema])
393
401
  response_format[:json_schema].deep_transform_keys! { _1.to_s.camelize(:lower) }
394
402
  end
@@ -396,6 +404,19 @@ module ActiveAgent
396
404
  response_format
397
405
  end
398
406
 
407
+ # Moves a json_schema given flat, with its name, schema and strict flag
408
+ # beside +type+, under +json_schema+, so it is not mistaken for a
409
+ # json_schema that names a view. Without a top-level schema, or with a
410
+ # +json_schema+ already, the format is returned as it came.
411
+ #
412
+ # @api private
413
+ def nest_flat_json_schema(response_format)
414
+ return response_format if response_format.key?(:json_schema) || response_format[:schema].nil?
415
+
416
+ flat_keys = %i[name schema strict]
417
+ response_format.except(*flat_keys).merge(json_schema: response_format.slice(*flat_keys))
418
+ end
419
+
399
420
  # @api private
400
421
  def instrument_payload(key)
401
422
  {
@@ -92,6 +92,10 @@ module ActiveAgent
92
92
  # schema and the policy is +:raise+.
93
93
  class InvalidResultError < StandardError; end
94
94
 
95
+ # Raised when a sub-agent pauses to ask the user for input and the budget
96
+ # policy is +:raise+.
97
+ class InputRequiredError < StandardError; end
98
+
95
99
  included do
96
100
  # Contracts this agent exposes to callers, keyed by action.
97
101
  class_attribute :delegation_contracts, instance_accessor: false, default: {}.freeze
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/input_request"
4
+
5
+ module ActiveAgent
6
+ # Lets a generation pause for the user and continue with the answers.
7
+ #
8
+ # A tool asks by returning an {InputRequest}. The generation then ends
9
+ # paused, and {Generation#resume_now} continues it from the response's
10
+ # checkpoint. See {InputRequest} for the kinds of request and what an
11
+ # answer does.
12
+ #
13
+ # @example Asking for confirmation before a side effect
14
+ # class SupportAgent < ApplicationAgent
15
+ # on_input_request :notify_reviewer
16
+ #
17
+ # def issue_refund(order_id:, amount:)
18
+ # return ActiveAgent::InputRequest.confirm("Refund #{amount} on order #{order_id}?") unless input_answer
19
+ #
20
+ # Refund.create!(order_id:, amount:)
21
+ # end
22
+ #
23
+ # private
24
+ #
25
+ # def notify_reviewer(response)
26
+ # ReviewMailer.pending(response.input_requests.map(&:prompt)).deliver_later
27
+ # end
28
+ # end
29
+ module InputRequests
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ define_callbacks :input_request
34
+ end
35
+
36
+ class_methods do
37
+ # Registers callbacks run when a generation pauses for input, before the
38
+ # paused response is returned. A callback that takes an argument receives
39
+ # the paused response.
40
+ #
41
+ # @param names [Array<Symbol>] methods to call
42
+ # @param block [Proc] optional block to call
43
+ # @return [void]
44
+ def on_input_request(*names, &block)
45
+ _insert_callbacks(names, block) do |callback, options|
46
+ set_callback(:input_request, :before, ->(agent) { agent.send(:run_input_request_callback, callback) }, **options)
47
+ end
48
+
49
+ # The first callback a class sets makes ActiveSupport alias the chain's
50
+ # runner into that class as a public method, which would list it among
51
+ # the agent's actions and in its release digest.
52
+ private :_run_input_request_callbacks if public_method_defined?(:_run_input_request_callbacks, false)
53
+ end
54
+ end
55
+
56
+ # Stops a pause of this generation from being announced with
57
+ # `input_requested.active_agent` or passed to on_input_request callbacks.
58
+ #
59
+ # @return [void]
60
+ # @api private
61
+ def silence_input_requests!
62
+ @_input_requests_silenced = true
63
+ end
64
+
65
+ # Whether this generation continues one that paused for input.
66
+ #
67
+ # @return [Boolean]
68
+ def resuming?
69
+ !@_input_request_resume.nil?
70
+ end
71
+
72
+ # Returns the answer for the tool call being run, when the call is being
73
+ # dispatched again on resume; nil otherwise.
74
+ #
75
+ # @return [Object, nil]
76
+ def input_answer
77
+ InputRequest.answer_for(InputRequest.current_tool_call_id)
78
+ end
79
+
80
+ # Continues a generation that paused for input. The action has already
81
+ # run, so tools, instructions and options are current; the conversation
82
+ # is replaced with the checkpoint's, not merged with what the action
83
+ # added.
84
+ #
85
+ # Answers to `:secret` requests are scrubbed from every tool result and
86
+ # tool error of this generation, so they reach neither the model nor
87
+ # telemetry.
88
+ #
89
+ # @param checkpoint [Hash] the paused response's checkpoint
90
+ # @param answers [Hash] an answer per paused tool call id
91
+ # @return [ActiveAgent::Providers::Common::PromptResponse]
92
+ # @raise [InputRequest::ResumeError] before any tool runs or any request
93
+ # is sent, when the answers or the checkpoint do not fit
94
+ def resume_prompt(checkpoint:, answers:)
95
+ resume = InputRequest::Resume.new(checkpoint:, answers:)
96
+ resume.assert_action!(generation_action_name)
97
+
98
+ input_request_secrets.concat(resume.secret_answers)
99
+ prompt_options[:messages] = resume.messages
100
+ @_input_request_resume = resume
101
+
102
+ process_prompt
103
+ ensure
104
+ @_input_request_resume = nil
105
+ end
106
+
107
+ private
108
+
109
+ # Records the action the generation runs. A tool call runs through
110
+ # `process` as well, and replaces `action_name` with the tool's name.
111
+ def process(method_name, ...)
112
+ @_generation_action_name = method_name.to_s unless tool_call?
113
+ super
114
+ end
115
+
116
+ # @return [String, nil] the action this agent's generation runs
117
+ def generation_action_name
118
+ @_generation_action_name
119
+ end
120
+
121
+ # @return [Boolean] whether a pause is kept from the host
122
+ def input_requests_silenced?
123
+ @_input_requests_silenced == true
124
+ end
125
+
126
+ # @return [Array<String>] the secret answers this generation has received
127
+ def input_request_secrets
128
+ @_input_request_secrets ||= []
129
+ end
130
+
131
+ # @param value [Object]
132
+ # @return [Object] `value` without any secret answer in it
133
+ def scrub_input_request_secrets(value)
134
+ InputRequest.scrub(value, input_request_secrets)
135
+ end
136
+
137
+ # @param error [Exception]
138
+ # @raise [Exception] `error`, or a replacement without the secret answers
139
+ # (see InputRequest.raise_scrubbed)
140
+ def raise_without_input_request_secrets(error)
141
+ InputRequest.raise_scrubbed(error, input_request_secrets)
142
+ end
143
+
144
+ # Calls one on_input_request callback, with the paused response when it
145
+ # takes an argument.
146
+ #
147
+ # @param callback [Symbol, Proc]
148
+ # @return [void]
149
+ def run_input_request_callback(callback)
150
+ if callback.is_a?(Proc)
151
+ callback.arity.zero? ? instance_exec(&callback) : instance_exec(@_input_request_response, &callback)
152
+ else
153
+ method(callback).arity.zero? ? send(callback) : send(callback, @_input_request_response)
154
+ end
155
+ end
156
+
157
+ # Runs the on_input_request callbacks for a paused response.
158
+ #
159
+ # @param response [ActiveAgent::Providers::Common::PromptResponse]
160
+ # @return [void]
161
+ def run_input_request_callbacks(response)
162
+ return if input_requests_silenced?
163
+ return unless response.respond_to?(:awaiting_input?) && response.awaiting_input?
164
+
165
+ # `only:` and `except:` compare against action_name, which the tool
166
+ # calls before the pause replaced with their own names.
167
+ tool_action_name, self.action_name = action_name, generation_action_name
168
+ @_input_request_response = response
169
+
170
+ begin
171
+ run_callbacks(:input_request)
172
+ ensure
173
+ self.action_name = tool_action_name
174
+ @_input_request_response = nil
175
+ end
176
+ end
177
+ end
178
+ end
@@ -208,8 +208,9 @@ module ActiveAgent
208
208
  #
209
209
  # @param generation_method [Symbol, String] the generation method to call
210
210
  # @param job_options [Hash] options to pass to the job (e.g., queue, priority)
211
+ # @param job_arguments [Hash] further keyword arguments for the job
211
212
  # @return [Object] the enqueued job instance
212
- def enqueue_generation(generation_method, job_options = {})
213
+ def enqueue_generation(generation_method, job_options = {}, **job_arguments)
213
214
  if processed?
214
215
  super
215
216
  else
@@ -218,7 +219,7 @@ module ActiveAgent
218
219
  # another machine authorizes as the same caller.
219
220
  agent_class.generation_job.set(job_options).perform_later(
220
221
  agent_class.name, action_name.to_s, generation_method.to_s,
221
- params: @params, args: args, kwargs: kwargs, actor: @actor
222
+ params: @params, args: args, kwargs: kwargs, actor: @actor, **job_arguments
222
223
  )
223
224
  end
224
225
  end
@@ -305,8 +306,9 @@ module ActiveAgent
305
306
  #
306
307
  # @param generation_method [Symbol, String] the generation method to call
307
308
  # @param job_options [Hash] options to pass to the job
309
+ # @param job_arguments [Hash] further keyword arguments for the job
308
310
  # @return [Object] the enqueued job instance
309
- def enqueue_generation(generation_method, job_options = {})
311
+ def enqueue_generation(generation_method, job_options = {}, **job_arguments)
310
312
  if processed?
311
313
  super
312
314
  else
@@ -320,7 +322,8 @@ module ActiveAgent
320
322
  kwargs: kwargs,
321
323
  direct_generation_type: @generation_type,
322
324
  direct_args: @direct_args,
323
- direct_options: @direct_options
325
+ direct_options: @direct_options,
326
+ **job_arguments
324
327
  )
325
328
  end
326
329
  end
@@ -11,6 +11,7 @@ module ActiveAgent
11
11
  PROVIDER_SERVICE_NAMES_REMAPS = {
12
12
  "Openrouter" => "OpenRouter",
13
13
  "Openai" => "OpenAI",
14
+ "Deepseek" => "DeepSeek",
14
15
  "AzureOpenai" => "AzureOpenAI",
15
16
  "Azureopenai" => "AzureOpenAI",
16
17
  "Rubyllm" => "RubyLLM",
@@ -11,14 +11,19 @@ module ActiveAgent
11
11
  # Returns a proc that handles tool/function calls from providers.
12
12
  #
13
13
  # The proc routes tool calls to the appropriate action method using
14
- # the {#process} method.
14
+ # the {#process} method. Secret answers the generation received (see
15
+ # InputRequests) are scrubbed from the result and from any error, before
16
+ # either reaches the provider or telemetry.
15
17
  #
16
18
  # @return [Proc] callback proc that accepts (action_name, *args, **kwargs)
17
19
  def tools_function
18
20
  proc do |action_name, *args, **kwargs|
19
21
  # Marked as a tool call so a refusal is reported to the model as a
20
22
  # result rather than raised through the run (see Authorization).
21
- with_tool_call { process(action_name, *args, **kwargs) }
23
+ result = with_tool_call { process(action_name, *args, **kwargs) }
24
+ scrub_input_request_secrets(result)
25
+ rescue StandardError => error
26
+ raise_without_input_request_secrets(error)
22
27
  end
23
28
  end
24
29
  end
@@ -32,6 +32,8 @@ module ActiveAgent
32
32
  # @return [Object] the sub-agent's result, or a structured error the
33
33
  # calling model can act on
34
34
  # @raise [BudgetExceededError] when the budget policy is +:raise+
35
+ # @raise [InputRequiredError] when the sub-agent pauses for input and
36
+ # the budget policy is +:raise+
35
37
  # @raise [InvalidResultError] when the returns policy is +:raise+
36
38
  def call(**arguments)
37
39
  if (violation = budget_violation)
@@ -123,6 +125,9 @@ module ActiveAgent
123
125
  definition.backend.apply(agent)
124
126
  apply_returns_format(agent)
125
127
  inherit_trace_id(agent)
128
+ # A pause becomes this call's result (see #input_required), and its
129
+ # checkpoint is dropped, so nothing outside the call may act on it.
130
+ agent.silence_input_requests!
126
131
 
127
132
  agent.process_prompt
128
133
  end
@@ -193,6 +198,8 @@ module ActiveAgent
193
198
  # @param payload [Hash] instrumentation payload
194
199
  # @return [Object]
195
200
  def result(response, payload)
201
+ return input_required(response, payload) if response.try(:awaiting_input?)
202
+
196
203
  message = response.message
197
204
  return nil if message.nil?
198
205
 
@@ -251,6 +258,30 @@ module ActiveAgent
251
258
  }
252
259
  end
253
260
 
261
+ # A delegated agent cannot pause for the user: nothing holds its
262
+ # checkpoint once the tool call returns. Its questions go back to the
263
+ # calling model instead.
264
+ #
265
+ # @param response [ActiveAgent::Providers::Common::PromptResponse] a paused response
266
+ # @param payload [Hash] instrumentation payload
267
+ # @return [Hash]
268
+ # @raise [InputRequiredError] when the budget policy is +:raise+
269
+ def input_required(response, payload)
270
+ payload[:status] = :input_required
271
+
272
+ if budget.policy == :raise
273
+ raise InputRequiredError, "#{definition.agent_class}##{definition.action} stopped to ask the user for input " \
274
+ "(#{response.input_requests.map(&:tool_name).join(", ")}), which a delegated agent cannot do"
275
+ end
276
+
277
+ {
278
+ error: "input_required",
279
+ questions: response.input_requests.map(&:prompt),
280
+ message: "The #{definition.tool_name} delegation stopped to ask the user for input, which a delegated agent cannot do. " \
281
+ "Answer with the information you already have, or ask the user yourself."
282
+ }
283
+ end
284
+
254
285
  # @param missing [Array<Symbol>]
255
286
  # @param content [String]
256
287
  # @return [Hash]