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
|
@@ -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"
|