activeagent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1193 -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/diagnosis.rb +3 -3
  13. data/lib/active_agent/evals/format.rb +115 -0
  14. data/lib/active_agent/evals/judge.rb +2 -0
  15. data/lib/active_agent/evals/report.rb +232 -31
  16. data/lib/active_agent/evals/report_html.rb +316 -149
  17. data/lib/active_agent/evals/result.rb +40 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  19. data/lib/active_agent/evals/runner.rb +6 -2
  20. data/lib/active_agent/evals/scenario.rb +10 -3
  21. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  22. data/lib/active_agent/evals.rb +2 -0
  23. data/lib/active_agent/generation.rb +62 -2
  24. data/lib/active_agent/generation_job.rb +14 -3
  25. data/lib/active_agent/input_request/resume.rb +199 -0
  26. data/lib/active_agent/input_request.rb +279 -0
  27. data/lib/active_agent/providers/_base_provider.rb +153 -31
  28. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  29. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  30. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  31. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  32. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  33. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  34. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  35. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  36. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  37. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  38. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  39. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  41. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  42. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  43. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  44. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  45. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  46. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  47. data/lib/active_agent/providers/open_router/options.rb +6 -2
  48. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  49. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  50. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  51. data/lib/active_agent/schema_tools.rb +12 -0
  52. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  53. data/lib/active_agent/version.rb +1 -1
  54. data/lib/active_agent.rb +2 -0
  55. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  56. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  57. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  58. metadata +32 -2
@@ -103,6 +103,65 @@ module ActiveAgent
103
103
  end
104
104
  alias generate_later prompt_later
105
105
 
106
+ # Continues a generation that paused for user input.
107
+ #
108
+ # Call it on the generation that paused, or on one built the same way —
109
+ # same agent, action, arguments and params — in another request or
110
+ # process. A new generation runs the action again, so tools, instructions
111
+ # and options are rebuilt. Either way the conversation is replaced with
112
+ # the checkpoint's, and each paused tool call is dispatched again with
113
+ # its answer readable through {InputRequest.answer_for}. Calls that
114
+ # finished before the pause keep their results.
115
+ #
116
+ # @param checkpoint [Hash] the paused response's
117
+ # {Providers::Common::Responses::Prompt#checkpoint checkpoint}
118
+ # @param answers [Hash{String => Object}] an answer per paused tool call
119
+ # id; `false` declines
120
+ # @return [ActiveAgent::Providers::Common::PromptResponse] which may itself
121
+ # be awaiting input again
122
+ # @raise [InputRequest::ResumeError] before any tool runs or any request
123
+ # is sent, when an answer is missing or does not fit its request, or the
124
+ # checkpoint was taken by another action, provider or model
125
+ #
126
+ # @example
127
+ # response = SupportAgent.with(ticket:).triage.generate_now
128
+ # if response.awaiting_input?
129
+ # request = response.input_requests.first
130
+ # SupportAgent.with(ticket:).triage.resume_now(
131
+ # checkpoint: response.checkpoint,
132
+ # answers: { request.tool_call_id => true }
133
+ # )
134
+ # end
135
+ def resume_now(checkpoint:, answers:)
136
+ agent.resume_prompt(checkpoint:, answers:)
137
+ end
138
+
139
+ # Queues {#resume_now} for background execution on the agent's
140
+ # generation job, which runs the action again from its arguments, params
141
+ # and actor.
142
+ #
143
+ # The checkpoint and answers become job arguments, stored by the queue
144
+ # backend for as long as the job lives. A `:secret` answer is therefore
145
+ # refused: resume a generation that waits on a secret inside your own
146
+ # job, which reads the answer from where your app keeps it.
147
+ #
148
+ # @param checkpoint [Hash] the paused response's checkpoint
149
+ # @param answers [Hash{String => Object}] an answer per paused tool call
150
+ # id; `false` declines
151
+ # @param options [Hash] job options (queue, priority, wait, etc.)
152
+ # @return [Object] enqueued job instance
153
+ # @raise [InputRequest::ResumeError] before enqueueing, when an answer is
154
+ # missing, does not fit its request, or answers a `:secret` request
155
+ # @raise [RuntimeError] if agent was accessed before queueing
156
+ def resume_later(checkpoint:, answers:, **options)
157
+ if InputRequest::Resume.new(checkpoint:, answers:).secret_answers.any?
158
+ ::Kernel.raise InputRequest::ResumeError, "resume_later cannot carry the answer to a :secret request, because job " \
159
+ "arguments are stored with the job. Call resume_now from your own job, reading the answer from where your app keeps it."
160
+ end
161
+
162
+ enqueue_generation :resume_now, options, resume: { "checkpoint" => checkpoint.as_json, "answers" => answers.as_json }
163
+ end
164
+
106
165
  # Generates a preview of the prompt without executing generation.
107
166
  #
108
167
  # Processes the agent action and renders the prompt configuration as
@@ -153,10 +212,11 @@ module ActiveAgent
153
212
  #
154
213
  # @param generation_method [Symbol, String]
155
214
  # @param options [Hash]
215
+ # @param job_arguments [Hash] further keyword arguments for the job
156
216
  # @return [Object] enqueued job
157
217
  # @raise [RuntimeError] when agent already processed to prevent data loss
158
218
  # @api private
159
- def enqueue_generation(generation_method, options = {})
219
+ def enqueue_generation(generation_method, options = {}, **job_arguments)
160
220
  if processed?
161
221
  ::Kernel.raise "You've accessed the agent before asking to " \
162
222
  "generate it later, so you may have made local changes that would " \
@@ -168,7 +228,7 @@ module ActiveAgent
168
228
  "method*, or 3. use a custom Active Job instead of #prompt_later."
169
229
  else
170
230
  agent_class.generation_job.set(options).perform_later(
171
- agent_class.name, action_name.to_s, generation_method.to_s, args: args, kwargs: kwargs
231
+ agent_class.name, action_name.to_s, generation_method.to_s, args: args, kwargs: kwargs, **job_arguments
172
232
  )
173
233
  end
174
234
  end
@@ -18,6 +18,10 @@ module ActiveAgent
18
18
 
19
19
  rescue_from StandardError, with: :handle_exception_with_agent_class
20
20
 
21
+ # The arguments can hold a paused generation's whole conversation and the
22
+ # user's answers.
23
+ self.log_arguments = false
24
+
21
25
  # +actor+ is the caller the generation runs on behalf of
22
26
  # (ActiveAgent::Authorization). ActiveJob serializes it like any other
23
27
  # argument, so a record arrives as the same record the caller passed and
@@ -28,14 +32,17 @@ module ActiveAgent
28
32
  # Agent.embed(...), which have no action to call: +agent_method+ is then
29
33
  # the synthetic +__direct_*__+ name, so the generation is rebuilt from
30
34
  # +direct_args+ and +direct_options+ instead.
35
+ #
36
+ # +resume+ holds the +checkpoint+ and +answers+ a paused generation
37
+ # continues from (Generation#resume_later).
31
38
  def perform(agent, agent_method, generation_method, args:, kwargs: nil, params: nil, actor: nil,
32
- direct_generation_type: nil, direct_args: nil, direct_options: nil)
39
+ direct_generation_type: nil, direct_args: nil, direct_options: nil, resume: nil)
33
40
  if direct_generation_type
34
41
  generation = ActiveAgent::Parameterized::DirectGeneration.new(
35
42
  agent.constantize, direct_generation_type.to_sym, params || {},
36
43
  *Array(direct_args), **(direct_options || {}).symbolize_keys
37
44
  )
38
- return generation.public_send(generation_method)
45
+ return resume ? resume_generation(generation, resume) : generation.public_send(generation_method)
39
46
  end
40
47
 
41
48
  agent_class = params ? agent.constantize.with(params) : agent.constantize
@@ -45,11 +52,15 @@ module ActiveAgent
45
52
  else
46
53
  agent_class.public_send(agent_method, *args)
47
54
  end
48
- prompt.send(generation_method)
55
+ resume ? resume_generation(prompt, resume) : prompt.send(generation_method)
49
56
  end
50
57
 
51
58
  private
52
59
 
60
+ def resume_generation(generation, resume)
61
+ generation.resume_now(checkpoint: resume.fetch("checkpoint"), answers: resume.fetch("answers"))
62
+ end
63
+
53
64
  # "Deserialize" the agent class name by hand in case another argument
54
65
  # (like a Global ID reference) raised DeserializationError.
55
66
  def agent_class
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ class InputRequest
5
+ # A paused generation's checkpoint together with the user's answers,
6
+ # checked against each other before anything runs.
7
+ #
8
+ # Checkpoint keys, as {Providers::InputRequests#checkpoint} writes them:
9
+ # - `version` the checkpoint format, {VERSION}
10
+ # - `service`, `provider` the provider service and API that paused, e.g.
11
+ # `"OpenAI"` and `"OpenAI::Chat"`
12
+ # - `model` the model of the paused request
13
+ # - `action_name` the agent action the generation ran
14
+ # - `tool_turns` tool round-trips used, counted against `max_tool_turns`
15
+ # - `tool_choice_cleared` whether a forced `tool_choice` was already
16
+ # cleared because the model used the tool
17
+ # - `messages` the provider-native conversation through the
18
+ # assistant turn that made the tool calls, without
19
+ # the messages the provider derives from instructions
20
+ # - `tool_call_turn_size` how many of the last `messages` make up the turn
21
+ # that made the tool calls: 1, except where the
22
+ # provider sends a turn as several items (OpenAI
23
+ # Responses' reasoning and function_call items)
24
+ # - `completed_results` each finished call's result in JSON form, by tool call id
25
+ # - `input_requests` one {InputRequest#to_h} per paused call
26
+ # - `approval_tool_calls` the paused calls that wait for approval before
27
+ # their tool runs
28
+ # - `approved_tool_calls` the paused calls that were already approved to
29
+ # run and paused again on the tool's own request
30
+ # - `mcp_tool_calls` the paused calls a client-side MCP server serves
31
+ #
32
+ # @api private
33
+ class Resume
34
+ VERSION = 1
35
+
36
+ # @return [Array<InputRequest>] one per paused tool call
37
+ attr_reader :input_requests
38
+
39
+ # @param checkpoint [Hash] string or symbol keys
40
+ # @param answers [Hash] an answer per paused tool call id
41
+ # @raise [ResumeError] when the checkpoint is malformed, or the answers
42
+ # leave a paused call unanswered, name a call that is not paused, or
43
+ # do not fit their request's kind
44
+ def initialize(checkpoint:, answers:)
45
+ @checkpoint = checkpoint.to_h.deep_symbolize_keys
46
+ @answers = answers.to_h.transform_keys(&:to_s)
47
+
48
+ raise ResumeError, "Unsupported checkpoint version #{@checkpoint[:version].inspect}" unless @checkpoint[:version] == VERSION
49
+ raise ResumeError, "The checkpoint has no tool-call turn to resume from" unless valid_tool_call_turn?
50
+
51
+ @input_requests = Array(@checkpoint[:input_requests]).map { InputRequest.from_h(_1) }
52
+ @completed = @checkpoint[:completed_results].to_h.transform_keys(&:to_s)
53
+
54
+ assert_answers!
55
+ end
56
+
57
+ # @return [String, nil]
58
+ def service = @checkpoint[:service]
59
+
60
+ # @return [String, nil]
61
+ def provider = @checkpoint[:provider]
62
+
63
+ # @return [String, nil]
64
+ def model = @checkpoint[:model]
65
+
66
+ # @return [String, nil]
67
+ def action_name = @checkpoint[:action_name]
68
+
69
+ # @return [Integer]
70
+ def tool_turns = @checkpoint[:tool_turns].to_i
71
+
72
+ # @return [Boolean]
73
+ def tool_choice_cleared? = @checkpoint[:tool_choice_cleared] == true
74
+
75
+ # Returns the conversation before the turn that made the tool calls.
76
+ #
77
+ # @return [Array<Hash>]
78
+ def messages = Array(@checkpoint[:messages])[0...-tool_call_turn_size]
79
+
80
+ # Returns the messages of the turn that made the tool calls.
81
+ #
82
+ # @return [Array<Hash>]
83
+ def tool_call_turn = Array(@checkpoint[:messages]).last(tool_call_turn_size)
84
+
85
+ # @param id [String]
86
+ # @return [Boolean]
87
+ def completed?(id) = @completed.key?(id.to_s)
88
+
89
+ # @param id [String]
90
+ # @return [Object] the call's result, in JSON form
91
+ def completed_result(id) = @completed.fetch(id.to_s)
92
+
93
+ # @param id [String]
94
+ # @return [Boolean]
95
+ def declined?(id) = pending?(id) && @answers[id.to_s] == false
96
+
97
+ # @param id [String]
98
+ # @return [Object, nil] the answer for a paused call; nil for any other call
99
+ def answer(id) = pending?(id) ? @answers[id.to_s] : nil
100
+
101
+ # Whether a paused call was approved to run before it paused again on
102
+ # its tool's own request.
103
+ #
104
+ # @param id [String]
105
+ # @return [Boolean]
106
+ def approved?(id) = pending?(id) && approved_ids.include?(id.to_s)
107
+
108
+ # Whether the answers approve a paused call that waited for approval
109
+ # before its tool ran.
110
+ #
111
+ # @param id [String]
112
+ # @return [Boolean]
113
+ def approval_given?(id) = pending?(id) && approval_ids.include?(id.to_s) && @answers[id.to_s] == true
114
+
115
+ # @param id [String]
116
+ # @return [Boolean] whether a client-side MCP server served the paused call
117
+ def mcp_tool_call?(id) = pending?(id) && Array(@checkpoint[:mcp_tool_calls]).map(&:to_s).include?(id.to_s)
118
+
119
+ # Returns the answers given to `:secret` requests, which must not reach
120
+ # the model, telemetry or errors.
121
+ #
122
+ # @return [Array<String>]
123
+ def secret_answers
124
+ input_requests.select(&:secret?).filter_map do |request|
125
+ value = answer(request.tool_call_id)
126
+ value.to_s unless value == false
127
+ end
128
+ end
129
+
130
+ # @param action_name [String, Symbol, nil] the action the agent ran again
131
+ # @raise [ResumeError] when it is not the action that paused
132
+ def assert_action!(action_name)
133
+ return if self.action_name.to_s == action_name.to_s
134
+
135
+ raise ResumeError, "This checkpoint was taken in #{self.action_name.inspect}; it cannot resume #{action_name.to_s.inspect}"
136
+ end
137
+
138
+ # @raise [ResumeError] when the provider or model differ from the pause's,
139
+ # since the checkpoint's messages are in that provider's native format
140
+ def assert_provider!(service:, provider:, model:)
141
+ return if [ self.service, self.provider ] == [ service, provider ] && (self.model.blank? || self.model == model.to_s)
142
+
143
+ raise ResumeError, "This checkpoint was taken on #{self.provider} (#{self.model}); " \
144
+ "it cannot resume on #{provider} (#{model})"
145
+ end
146
+
147
+ # @param ids [Array<String>] the ids of the calls in the tool-call turn
148
+ # @raise [ResumeError] unless every call is either completed or paused
149
+ def assert_tool_calls!(ids)
150
+ ids = ids.map(&:to_s)
151
+ unknown = ids.reject { completed?(_1) || pending?(_1) }
152
+ lost = pending_ids - ids
153
+ return if unknown.empty? && lost.empty?
154
+
155
+ raise ResumeError, "The checkpoint's tool calls do not match its results and input requests " \
156
+ "(unaccounted: #{unknown.join(", ").presence || "none"}; missing: #{lost.join(", ").presence || "none"})"
157
+ end
158
+
159
+ private
160
+
161
+ def tool_call_turn_size = (@checkpoint[:tool_call_turn_size] || 1).to_i
162
+
163
+ def valid_tool_call_turn?
164
+ size = tool_call_turn_size
165
+ size.positive? && Array(@checkpoint[:messages]).size >= size && tool_call_turn.all?(Hash)
166
+ end
167
+
168
+ def approval_ids = Array(@checkpoint[:approval_tool_calls]).map(&:to_s)
169
+
170
+ def approved_ids = Array(@checkpoint[:approved_tool_calls]).map(&:to_s)
171
+
172
+ def pending_ids = input_requests.map(&:tool_call_id)
173
+
174
+ def pending?(id) = pending_ids.include?(id.to_s)
175
+
176
+ def assert_answers!
177
+ unknown = @answers.keys - pending_ids
178
+ raise ResumeError, "No input request is waiting on #{unknown.join(", ")}" if unknown.any?
179
+
180
+ missing = pending_ids.select { @answers[_1].nil? }
181
+ raise ResumeError, "Missing answers for #{missing.join(", ")}" if missing.any?
182
+
183
+ input_requests.each { assert_answer_fits!(_1, @answers[_1.tool_call_id]) }
184
+ end
185
+
186
+ def assert_answer_fits!(request, answer)
187
+ return if answer == false
188
+
189
+ case request.kind
190
+ when :confirm
191
+ raise ResumeError, "Answer #{request.tool_call_id} with true to approve or false to decline" unless answer == true
192
+ when :choice
193
+ allowed = request.choice_values.map(&:to_s)
194
+ raise ResumeError, "#{answer.inspect} is not one of the options for #{request.tool_call_id}" unless allowed.include?(answer.to_s)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ # A question a tool puts to the user in the middle of a generation.
5
+ #
6
+ # A tool returns one in place of a result. It is a value, not an exception.
7
+ # The provider leaves that call unanswered, finishes the other calls of the turn,
8
+ # and ends the generation paused: the response is
9
+ # {Providers::Common::Responses::Prompt#awaiting_input? awaiting input},
10
+ # lists the requests, and carries the checkpoint {Generation#resume_now}
11
+ # continues from. On resume the paused call is dispatched again, and the
12
+ # tool reads the user's answer through {.answer_for}.
13
+ #
14
+ # Kinds:
15
+ # - `:text` free text
16
+ # - `:choice` one of `options`
17
+ # - `:confirm` approve (`true`) or decline (`false`)
18
+ # - `:secret` a value the model must never see, such as a token
19
+ #
20
+ # Answering `false` declines a request of any kind: the tool is not
21
+ # dispatched again and the model reads {DECLINED_RESULT} as its result.
22
+ #
23
+ # `options`, `schema`, `arguments` and `metadata` are stored in their JSON
24
+ # form, so a request survives a round trip through {#to_h} and {.from_h}
25
+ # unchanged.
26
+ #
27
+ # @example A tool that asks before acting
28
+ # def issue_refund(order_id:, amount:)
29
+ # unless input_answer
30
+ # return ActiveAgent::InputRequest.confirm("Refund #{amount} on order #{order_id}?")
31
+ # end
32
+ #
33
+ # Refund.create!(order_id:, amount:)
34
+ # end
35
+ class InputRequest < Data.define(:kind, :prompt, :options, :schema, :tool_call_id, :tool_name, :arguments, :metadata)
36
+ KINDS = %i[text choice confirm secret].freeze
37
+
38
+ # What the model reads for a declined request.
39
+ DECLINED_RESULT = { error: "declined by user" }.freeze
40
+
41
+ # Replaces a registered secret wherever {.scrub} finds it.
42
+ FILTERED = "[FILTERED]"
43
+
44
+ # Raised by {Generation#resume_now} before any tool runs or any request is
45
+ # sent, when the answers or the checkpoint do not fit the generation.
46
+ class ResumeError < ArgumentError; end
47
+
48
+ # Raised when a tool returns an InputRequest under a provider whose tool
49
+ # loop cannot pause.
50
+ class UnsupportedProviderError < StandardError; end
51
+
52
+ # Stands in for an error whose message held a secret, with the secret
53
+ # replaced and the original error's class name at the start of the
54
+ # message. {.raise_scrubbed} raises it in place of a tool's error whose
55
+ # class builds the message from its own state, and sets it as the cause
56
+ # in place of a cause chain that held a secret.
57
+ class ScrubbedError < StandardError; end
58
+
59
+ EXECUTION_STATE_KEY = :active_agent_input_request_tool_call
60
+ private_constant :EXECUTION_STATE_KEY
61
+
62
+ class << self
63
+ # @param prompt [String] the question shown to the user
64
+ # @return [InputRequest]
65
+ def text(prompt, **attributes) = new(kind: :text, prompt:, **attributes)
66
+
67
+ # @param prompt [String]
68
+ # @param options [Array] the answers to choose from, as values or
69
+ # `{ value:, label: }` hashes
70
+ # @return [InputRequest]
71
+ def choice(prompt, options:, **attributes) = new(kind: :choice, prompt:, options:, **attributes)
72
+
73
+ # @param prompt [String]
74
+ # @return [InputRequest]
75
+ def confirm(prompt, **attributes) = new(kind: :confirm, prompt:, **attributes)
76
+
77
+ # @param prompt [String]
78
+ # @return [InputRequest]
79
+ def secret(prompt, **attributes) = new(kind: :secret, prompt:, **attributes)
80
+
81
+ # @param hash [Hash] the output of {#to_h}, with string or symbol keys
82
+ # @return [InputRequest]
83
+ def from_h(hash)
84
+ attributes = hash.to_h.symbolize_keys.slice(*members)
85
+ new(**attributes)
86
+ end
87
+
88
+ # Returns the answer delivered for a tool call while that call is being
89
+ # dispatched again on resume; nil at any other time.
90
+ #
91
+ # @param tool_call_id [String] the provider's id for the call
92
+ # @return [Object, nil]
93
+ def answer_for(tool_call_id)
94
+ state = ActiveSupport::IsolatedExecutionState[EXECUTION_STATE_KEY]
95
+ state[:answer] if state && state[:id] == tool_call_id.to_s
96
+ end
97
+
98
+ # Returns the provider's id for the tool call being dispatched, so a tool
99
+ # can look up its own answer; nil outside a tool call.
100
+ #
101
+ # @return [String, nil]
102
+ def current_tool_call_id
103
+ ActiveSupport::IsolatedExecutionState[EXECUTION_STATE_KEY]&.fetch(:id)
104
+ end
105
+
106
+ # Runs a tool call with its id, and its answer when there is one, in
107
+ # execution state. A nil `tool_call_id` runs the block outside any tool
108
+ # call, so it reads no id and no answer. A nested generation inside the
109
+ # call sees its own calls; the outer call's state is restored when the
110
+ # block returns.
111
+ #
112
+ # @param tool_call_id [String, nil]
113
+ # @param answer [Object, nil]
114
+ # @return [Object] the block's result
115
+ # @api private
116
+ def dispatching(tool_call_id, answer: nil)
117
+ previous = ActiveSupport::IsolatedExecutionState[EXECUTION_STATE_KEY]
118
+ ActiveSupport::IsolatedExecutionState[EXECUTION_STATE_KEY] = tool_call_id.nil? ? nil : { id: tool_call_id.to_s, answer: }
119
+
120
+ yield
121
+ ensure
122
+ ActiveSupport::IsolatedExecutionState[EXECUTION_STATE_KEY] = previous
123
+ end
124
+
125
+ # Returns `value` with every secret replaced by {FILTERED}, in strings at
126
+ # any depth of a Hash or Array. A number or symbol is compared in its
127
+ # string form, and becomes a string only when it holds a secret. A value
128
+ # of any other class is scrubbed in its JSON form, which is what a tool
129
+ # result becomes on its way to the model. An InputRequest is returned as
130
+ # it is.
131
+ #
132
+ # Secrets are matched as substrings, so a short secret also replaces the
133
+ # same characters inside unrelated values.
134
+ #
135
+ # @param value [Object]
136
+ # @param secrets [Array<String>]
137
+ # @return [Object]
138
+ def scrub(value, secrets)
139
+ secrets = normalize_secrets(secrets)
140
+ return value if secrets.empty?
141
+
142
+ scrub_value(value, secrets)
143
+ end
144
+
145
+ # Raises `error` with every secret removed from its message and from the
146
+ # messages of its causes.
147
+ #
148
+ # Raises `error` itself when none of those messages holds a secret.
149
+ # Otherwise raises a copy of `error` with the secrets in its message
150
+ # replaced, or a {ScrubbedError} when the copy's message still holds one.
151
+ # A cause chain that holds a secret is replaced by a single
152
+ # ScrubbedError for the first cause.
153
+ #
154
+ # @param error [Exception]
155
+ # @param secrets [Array<String>]
156
+ # @raise [Exception] always
157
+ def raise_scrubbed(error, secrets)
158
+ secrets = normalize_secrets(secrets)
159
+ raise error unless chain_holds_secret?(error, secrets)
160
+
161
+ cause = error.cause
162
+ cause = scrubbed_error(cause, secrets) if chain_holds_secret?(cause, secrets)
163
+
164
+ raise scrubbed_copy(error, secrets), cause:
165
+ end
166
+
167
+ private
168
+
169
+ def normalize_secrets(secrets)
170
+ Array(secrets).map(&:to_s).reject(&:empty?).uniq.sort_by { -_1.length }
171
+ end
172
+
173
+ def scrub_value(value, secrets)
174
+ case value
175
+ when String
176
+ secrets.reduce(value) { |text, secret| text.gsub(secret, FILTERED) }
177
+ when Hash
178
+ value.to_h { |key, item| [ scrub_value(key, secrets), scrub_value(item, secrets) ] }
179
+ when Array
180
+ value.map { scrub_value(_1, secrets) }
181
+ when Numeric, Symbol
182
+ text = value.to_s
183
+ scrubbed = scrub_value(text, secrets)
184
+ scrubbed == text ? value : scrubbed
185
+ when InputRequest, true, false, nil
186
+ value
187
+ else
188
+ scrub_value(value.as_json, secrets)
189
+ end
190
+ end
191
+
192
+ def holds_secret?(text, secrets) = secrets.any? { text.to_s.include?(_1) }
193
+
194
+ def chain_holds_secret?(error, secrets)
195
+ seen = []
196
+
197
+ while error && seen.none? { _1.equal?(error) }
198
+ return true if holds_secret?(error.message, secrets)
199
+
200
+ seen << error
201
+ error = error.cause
202
+ end
203
+
204
+ false
205
+ end
206
+
207
+ def scrubbed_copy(error, secrets)
208
+ copy = error.exception(scrub_value(error.message, secrets))
209
+ holds_secret?(copy.message, secrets) ? scrubbed_error(error, secrets) : copy
210
+ end
211
+
212
+ def scrubbed_error(error, secrets)
213
+ ScrubbedError.new("#{error.class.name}: #{scrub_value(error.message, secrets)}").tap do |scrubbed|
214
+ scrubbed.set_backtrace(error.backtrace) if error.backtrace
215
+ end
216
+ end
217
+ end
218
+
219
+ # @param kind [Symbol, String] one of {KINDS}
220
+ # @param prompt [String] the question shown to the user
221
+ # @param options [Array, nil] the answers to choose from; required for `:choice`
222
+ # @param schema [Hash, nil] a JSON Schema describing the answer
223
+ # @param tool_call_id [String, nil] set by the provider
224
+ # @param tool_name [String, nil] set by the provider
225
+ # @param arguments [Hash, nil] the arguments the model called the tool
226
+ # with; set by the provider
227
+ # @param metadata [Hash] anything the host wants to keep with the request
228
+ # @raise [ArgumentError] for an unknown kind, a blank prompt, or a choice
229
+ # without options
230
+ def initialize(kind:, prompt:, options: nil, schema: nil, tool_call_id: nil, tool_name: nil, arguments: nil, metadata: {})
231
+ kind = kind.to_s.to_sym
232
+ raise ArgumentError, "Unknown input request kind #{kind.inspect}; expected one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
233
+ raise ArgumentError, "An input request needs a prompt" if prompt.blank?
234
+ raise ArgumentError, "A :choice input request needs options" if kind == :choice && options.blank?
235
+
236
+ super(
237
+ kind:,
238
+ prompt: prompt.to_s,
239
+ options: options&.then { Array.wrap(_1).as_json },
240
+ schema: schema&.as_json,
241
+ tool_call_id: tool_call_id&.to_s,
242
+ tool_name: tool_name&.to_s,
243
+ arguments: arguments&.as_json,
244
+ metadata: (metadata || {}).as_json
245
+ )
246
+ end
247
+
248
+ # Returns a copy bound to the tool call that returned it.
249
+ #
250
+ # @param id [String]
251
+ # @param name [String]
252
+ # @param arguments [Hash, nil]
253
+ # @return [InputRequest]
254
+ # @api private
255
+ def for_tool_call(id:, name:, arguments: nil)
256
+ self.class.new(**attributes.merge(tool_call_id: id, tool_name: name, arguments:))
257
+ end
258
+
259
+ # @return [Boolean]
260
+ def secret? = kind == :secret
261
+
262
+ # Returns the values a `:choice` answer may take: each option, or its
263
+ # `value` when the option is a hash.
264
+ #
265
+ # @return [Array]
266
+ def choice_values
267
+ Array(options).map { _1.is_a?(Hash) ? _1.fetch("value", _1["label"]) : _1 }
268
+ end
269
+
270
+ # @return [Hash{Symbol => Object}] JSON-safe attributes; nil ones are left out
271
+ def to_h = attributes.merge(kind: kind.to_s).compact
272
+
273
+ private
274
+
275
+ def attributes = self.class.members.to_h { [ _1, public_send(_1) ] }
276
+ end
277
+ end
278
+
279
+ require_relative "input_request/resume"