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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1044 -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,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by an explicit `require "active_agent/evals/ruby_llm"` — never by
|
|
4
|
+
# `require "active_agent/evals"` — so the evaluation module itself stays free
|
|
5
|
+
# of the ruby_llm gem.
|
|
6
|
+
require "ruby_llm"
|
|
7
|
+
require_relative "../evals"
|
|
8
|
+
|
|
9
|
+
module ActiveAgent
|
|
10
|
+
module Evals
|
|
11
|
+
# The RubyLLM side of an evaluation: a Judge that asks a RubyLLM chat for
|
|
12
|
+
# its completions, and a Replay built from the messages a RubyLLM
|
|
13
|
+
# conversation produced. Both are the glue a host that drives RubyLLM
|
|
14
|
+
# chats would otherwise write itself.
|
|
15
|
+
#
|
|
16
|
+
# require "active_agent/evals/ruby_llm"
|
|
17
|
+
#
|
|
18
|
+
# judge = ActiveAgent::Evals::RubyLLM.judge(label: "claude-opus-5", model: "claude-opus-5",
|
|
19
|
+
# provider: :anthropic, correlation: correlation)
|
|
20
|
+
#
|
|
21
|
+
# report = correlation.with_run("suite" => "support") do |metadata|
|
|
22
|
+
# ActiveAgent::Evals::Runner.new(
|
|
23
|
+
# scenarios: scenarios, models: models, metadata: metadata, judge: judge,
|
|
24
|
+
# around_evaluation: correlation,
|
|
25
|
+
# replay: ->(scenario, spec) {
|
|
26
|
+
# chat = correlation.replay { SupportChat.run(scenario.prompt, model: spec.model) }
|
|
27
|
+
# ActiveAgent::Evals::RubyLLM.replay(chat.messages.order(:id), duration_ms: chat.elapsed_ms)
|
|
28
|
+
# }
|
|
29
|
+
# ).call.tap(&:verdict) # the verdict is a judge call too: settle it while the run is open
|
|
30
|
+
# end
|
|
31
|
+
module RubyLLM
|
|
32
|
+
# How much of a tool's error a replay keeps, in bytes — the cap the
|
|
33
|
+
# dashboard puts on a run's tool event detail.
|
|
34
|
+
DETAIL_LIMIT = 1_200
|
|
35
|
+
|
|
36
|
+
# `chat` keywords the judge sets itself, so they are not `chat_options`.
|
|
37
|
+
JUDGE_CHAT_KEYWORDS = %i[model provider assume_model_exists context].freeze
|
|
38
|
+
|
|
39
|
+
# An `error` key holding a String, as Ruby inspects it: RubyLLM 1.x
|
|
40
|
+
# keeps a tool result's `to_s`, so a tool that returned
|
|
41
|
+
# `{ error: "..." }` stores `{:error=>"..."}`, or `{error: "..."}` on
|
|
42
|
+
# Ruby 3.4. Tried only at the top level of a whole inspected Hash.
|
|
43
|
+
INSPECTED_ERROR_KEY = /\G\s*(?::error\s*=>|error:|"error"\s*=>)\s*("(?:[^"\\]|\\.)*")/m
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Builds a Judge whose completions come from a RubyLLM chat.
|
|
47
|
+
#
|
|
48
|
+
# `context` is anything answering to `#chat` the way RubyLLM does: the
|
|
49
|
+
# `::RubyLLM` module itself, or a `RubyLLM.context` built with the
|
|
50
|
+
# host's own keys (see `ActionAgent::ProviderKey.apply_to`). With a
|
|
51
|
+
# `correlation`, every call is traced through `Correlation#judge` under
|
|
52
|
+
# the kind it serves (`score`, `recommend`, `verdict`), so the judge
|
|
53
|
+
# trace lands on the result it graded.
|
|
54
|
+
#
|
|
55
|
+
# `chat_options` go to `chat` itself, so they must be keywords
|
|
56
|
+
# `RubyLLM::Chat.new` takes (`protocol:` on RubyLLM 2.x); anything else
|
|
57
|
+
# raises ArgumentError here rather than failing every judge call
|
|
58
|
+
# later. Configure the chat through `temperature:` or `configure:`:
|
|
59
|
+
#
|
|
60
|
+
# ActiveAgent::Evals::RubyLLM.judge(label: "judge", model: "gpt-5-mini", provider: :openai,
|
|
61
|
+
# temperature: 0,
|
|
62
|
+
# configure: ->(chat) { chat.with_headers("X-Request-Tag" => "eval") })
|
|
63
|
+
#
|
|
64
|
+
# @param label [String] how reports name the judge
|
|
65
|
+
# @param model [String] the judge model
|
|
66
|
+
# @param provider [Symbol, String] the RubyLLM provider serving it
|
|
67
|
+
# @param context [#chat] where `chat` is called; defaults to `::RubyLLM`
|
|
68
|
+
# @param correlation [Correlation, nil] traces each call as a judge call
|
|
69
|
+
# @param assume_model_exists [Boolean] skip RubyLLM's model registry check
|
|
70
|
+
# @param temperature [Numeric, nil] applied with `with_temperature`
|
|
71
|
+
# @param configure [#call, nil] called with each chat before it is asked,
|
|
72
|
+
# for any other `with_*` setting
|
|
73
|
+
# @param on_usage [#call, nil] called after each answered call with
|
|
74
|
+
# `{ "kind", "model", "input_tokens", "output_tokens", "cost" }`, for a
|
|
75
|
+
# host that meters its judge's spend; an error it raises is logged,
|
|
76
|
+
# not propagated, so bookkeeping never costs a grade
|
|
77
|
+
# @param chat_options [Hash] other `chat` keywords, such as `protocol:`
|
|
78
|
+
# @return [Judge]
|
|
79
|
+
# @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take
|
|
80
|
+
def judge(label:, model:, provider:, context: ::RubyLLM, correlation: nil, assume_model_exists: true,
|
|
81
|
+
temperature: nil, configure: nil, on_usage: nil, **chat_options)
|
|
82
|
+
check_chat_options!(chat_options)
|
|
83
|
+
|
|
84
|
+
Judge.new(label: label) do |instructions:, prompt:, kind:|
|
|
85
|
+
complete = lambda do
|
|
86
|
+
chat = context.chat(model: model, provider: provider.to_sym, assume_model_exists: assume_model_exists,
|
|
87
|
+
**chat_options)
|
|
88
|
+
chat.with_temperature(temperature) unless temperature.nil?
|
|
89
|
+
configure&.call(chat)
|
|
90
|
+
response = chat.with_instructions(instructions).ask(prompt)
|
|
91
|
+
report_usage(on_usage, kind, model, response)
|
|
92
|
+
response.content
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
correlation ? correlation.judge(kind.to_s, &complete) : complete.call
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Builds a Replay from the ordered messages of a RubyLLM conversation:
|
|
100
|
+
# `acts_as_chat` message records of either RubyLLM generation (read
|
|
101
|
+
# through their own `to_llm`), `RubyLLM::Message` values, or anything
|
|
102
|
+
# shaped like them (`role`, `content`, `tool_calls`, `tool_call_id`,
|
|
103
|
+
# and `tokens` or `input_tokens`/`output_tokens`).
|
|
104
|
+
#
|
|
105
|
+
# - **Tool calls** are every message's calls in the order the model made
|
|
106
|
+
# them, each `{ "name", "arguments", "error", "detail" }`. A call is
|
|
107
|
+
# errored when the `tool` message answering it (the next one with its
|
|
108
|
+
# `tool_call_id`) reports an error at its top level: JSON or a Hash
|
|
109
|
+
# whose `"error"` is a non-empty String, Hash or Array, or `true`; an
|
|
110
|
+
# MCP result with `"isError": true`; or the inspected Hash RubyLLM 1.x
|
|
111
|
+
# stores for a tool that returned `{ error: "..." }`. `detail` is the
|
|
112
|
+
# error, JSON-encoded unless it is a String, cut to DETAIL_LIMIT bytes.
|
|
113
|
+
# - **Tokens** sum the assistant messages. `input_tokens` is the whole
|
|
114
|
+
# prompt: RubyLLM's `input` plus its cache reads and writes, which it
|
|
115
|
+
# counts apart.
|
|
116
|
+
# - **Cost** sums each assistant message's RubyLLM `cost.total` when
|
|
117
|
+
# every one of them is priced, and is nil otherwise.
|
|
118
|
+
# - **The answer** is the content of the last assistant message after the
|
|
119
|
+
# last user message. A conversation that stopped before answering —
|
|
120
|
+
# its last message is a tool call, a tool result or the user's own
|
|
121
|
+
# message — has no answer, and the replay records why as its error
|
|
122
|
+
# rather than scoring an empty or earlier reply. On RubyLLM 1.x a tool
|
|
123
|
+
# that ends the turn with `halt` leaves its result last too: pass the
|
|
124
|
+
# content `ask` returned as `answer:` for such scenarios.
|
|
125
|
+
#
|
|
126
|
+
# `answer:`, `error:` and `cost:` override what the messages say.
|
|
127
|
+
#
|
|
128
|
+
# @param messages [Array, #to_a] the conversation's messages, oldest first
|
|
129
|
+
# @param answer [String, nil] overrides the last assistant message's content
|
|
130
|
+
# @param duration_ms [Numeric, nil]
|
|
131
|
+
# @param error [String, Exception, nil] when the run raised before answering
|
|
132
|
+
# @param cost [Numeric, nil] overrides the cost RubyLLM priced
|
|
133
|
+
# @param metadata [Hash] carried onto the Result
|
|
134
|
+
# @return [Replay]
|
|
135
|
+
def replay(messages, answer: nil, duration_ms: nil, error: nil, cost: nil, metadata: {})
|
|
136
|
+
messages = messages.to_a.map { |message| message.respond_to?(:to_llm) ? message.to_llm : message }
|
|
137
|
+
unfinished = unfinished_reason(messages)
|
|
138
|
+
tokens = token_totals(messages)
|
|
139
|
+
|
|
140
|
+
Replay.new(
|
|
141
|
+
answer: answer.nil? && unfinished.nil? ? final_answer(messages) : answer,
|
|
142
|
+
tool_calls: extract_tool_calls(messages),
|
|
143
|
+
duration_ms: duration_ms,
|
|
144
|
+
input_tokens: tokens[:input],
|
|
145
|
+
output_tokens: tokens[:output],
|
|
146
|
+
error: replay_error(error, answer, unfinished),
|
|
147
|
+
cost: cost.nil? ? conversation_cost(messages) : cost,
|
|
148
|
+
metadata: metadata
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def check_chat_options!(options)
|
|
155
|
+
return if options.empty?
|
|
156
|
+
|
|
157
|
+
parameters = ::RubyLLM::Chat.instance_method(:initialize).parameters
|
|
158
|
+
return if parameters.any? { |type, _| type == :keyrest }
|
|
159
|
+
|
|
160
|
+
accepted = parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) } - JUDGE_CHAT_KEYWORDS
|
|
161
|
+
unknown = options.keys.map(&:to_sym) - accepted
|
|
162
|
+
return if unknown.empty?
|
|
163
|
+
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"RubyLLM::Chat.new (ruby_llm #{::RubyLLM::VERSION}) does not take #{unknown.map(&:inspect).join(', ')}; " \
|
|
166
|
+
"it takes #{accepted.map(&:inspect).join(', ').presence || 'no other keywords'}. " \
|
|
167
|
+
"Pass temperature: to judge, or configure the chat with configure: ->(chat) { chat.with_... }."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def report_usage(callback, kind, model, response)
|
|
171
|
+
return unless callback
|
|
172
|
+
|
|
173
|
+
callback.call(
|
|
174
|
+
"kind" => kind.to_s,
|
|
175
|
+
"model" => response_model(response) || model.to_s,
|
|
176
|
+
"input_tokens" => prompt_tokens(response),
|
|
177
|
+
"output_tokens" => token_count(response, :output),
|
|
178
|
+
"cost" => message_cost(response)
|
|
179
|
+
)
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
warn_failure("judge on_usage callback failed: #{e.class}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def response_model(response)
|
|
185
|
+
model = response.model if response.respond_to?(:model)
|
|
186
|
+
model = response.model_id if model.nil? && response.respond_to?(:model_id)
|
|
187
|
+
model.presence&.to_s
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Each result is paired with the call it answers: the latest call with
|
|
191
|
+
# its id that no earlier result has answered, so an id a provider
|
|
192
|
+
# reuses in a later turn is still told apart.
|
|
193
|
+
def extract_tool_calls(messages)
|
|
194
|
+
open_calls = {}
|
|
195
|
+
|
|
196
|
+
messages.each_with_object([]) do |message, calls|
|
|
197
|
+
if role_of(message) == "tool"
|
|
198
|
+
call = open_calls.delete(message.tool_call_id) if message.respond_to?(:tool_call_id)
|
|
199
|
+
detail = call && tool_error(message.content)
|
|
200
|
+
call&.merge!("error" => true, "detail" => detail) if detail
|
|
201
|
+
else
|
|
202
|
+
ordered_tool_calls(message).each do |tool_call|
|
|
203
|
+
call = { "name" => tool_call.name.to_s, "arguments" => tool_call.arguments, "error" => false }
|
|
204
|
+
open_calls[tool_call.id] = call
|
|
205
|
+
calls << call
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A message's calls in the order the model made them. RubyLLM keys a
|
|
212
|
+
# message's calls by the provider's call id in the order they arrived,
|
|
213
|
+
# and those ids (`call_…`, `toolu_…`) say nothing about order; only
|
|
214
|
+
# record primary keys, which are all Integers, are sorted.
|
|
215
|
+
def ordered_tool_calls(message)
|
|
216
|
+
calls = tool_calls_of(message)
|
|
217
|
+
calls.all? { |call| call.id.is_a?(Integer) } ? calls.sort_by(&:id) : calls
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# `tool_calls` is a Hash keyed by call id on a RubyLLM::Message (and on
|
|
221
|
+
# anything `to_llm` returns), a has_many elsewhere, and nil on a
|
|
222
|
+
# message with no calls.
|
|
223
|
+
def tool_calls_of(message)
|
|
224
|
+
return [] unless message.respond_to?(:tool_calls)
|
|
225
|
+
|
|
226
|
+
calls = message.tool_calls
|
|
227
|
+
calls = calls.values if calls.is_a?(Hash)
|
|
228
|
+
calls.nil? ? [] : calls.to_a
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The detail of the error a tool reported, or nil when it succeeded.
|
|
232
|
+
def tool_error(content)
|
|
233
|
+
content = content.text if !content.is_a?(String) && content.respond_to?(:text)
|
|
234
|
+
content = content.scrub if content.is_a?(String)
|
|
235
|
+
payload = content.is_a?(Hash) ? content : parse_json(content)
|
|
236
|
+
return error_from_payload(payload) if payload.is_a?(Hash)
|
|
237
|
+
return unless content.is_a?(String)
|
|
238
|
+
|
|
239
|
+
literal = inspected_error(content.strip)
|
|
240
|
+
truncate_detail(unquote(literal)) if literal
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def error_from_payload(payload)
|
|
244
|
+
error = payload.key?("error") ? payload["error"] : payload[:error]
|
|
245
|
+
if (error.is_a?(String) || error.is_a?(Hash) || error.is_a?(Array)) && error.present?
|
|
246
|
+
return truncate_detail(error.is_a?(String) ? error : error.to_json)
|
|
247
|
+
end
|
|
248
|
+
if error == true
|
|
249
|
+
message = payload["message"] || payload[:message]
|
|
250
|
+
return truncate_detail(message.is_a?(String) && message.present? ? message : payload.to_json)
|
|
251
|
+
end
|
|
252
|
+
return unless (payload["isError"] || payload[:isError]) == true
|
|
253
|
+
|
|
254
|
+
texts = Array(payload["content"] || payload[:content]).filter_map do |part|
|
|
255
|
+
part["text"] || part[:text] if part.is_a?(Hash)
|
|
256
|
+
end
|
|
257
|
+
truncate_detail(texts.any? ? texts.join("\n") : payload.to_json)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The String literal an inspected Hash holds under a top-level `error`
|
|
261
|
+
# key, or nil. Only a whole Hash (`{...}`) qualifies, and a key is
|
|
262
|
+
# tried only where one starts at the Hash's own level: after its
|
|
263
|
+
# opening brace or a comma outside any string or nested structure.
|
|
264
|
+
def inspected_error(text)
|
|
265
|
+
return unless text.start_with?("{") && text.end_with?("}")
|
|
266
|
+
|
|
267
|
+
depth = 0
|
|
268
|
+
quoted = false
|
|
269
|
+
index = 0
|
|
270
|
+
while index < text.length
|
|
271
|
+
char = text[index]
|
|
272
|
+
if quoted
|
|
273
|
+
index += 1 if char == "\\"
|
|
274
|
+
quoted = false if char == '"'
|
|
275
|
+
elsif char == '"'
|
|
276
|
+
quoted = true
|
|
277
|
+
elsif char == "{" || char == "["
|
|
278
|
+
depth += 1
|
|
279
|
+
elsif char == "}" || char == "]"
|
|
280
|
+
depth -= 1
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if !quoted && depth == 1 && (char == "{" || char == ",")
|
|
284
|
+
literal = text.match(INSPECTED_ERROR_KEY, index + 1)&.[](1)
|
|
285
|
+
return literal if literal
|
|
286
|
+
end
|
|
287
|
+
index += 1
|
|
288
|
+
end
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Reads back a Ruby String literal from an inspected Hash. `undump`
|
|
293
|
+
# takes only ASCII, so the characters beyond it are escaped first.
|
|
294
|
+
def unquote(literal)
|
|
295
|
+
literal.gsub(/[^\x00-\x7F]/) { |char| format("\\u{%x}", char.ord) }.undump.force_encoding(Encoding::UTF_8).scrub
|
|
296
|
+
rescue RuntimeError, EncodingError, ArgumentError
|
|
297
|
+
literal[1...-1].scrub
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def truncate_detail(detail)
|
|
301
|
+
detail.to_s.truncate_bytes(DETAIL_LIMIT)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def token_totals(messages)
|
|
305
|
+
assistants = messages.select { |message| role_of(message) == "assistant" }
|
|
306
|
+
{
|
|
307
|
+
input: assistants.sum { |message| prompt_tokens(message) },
|
|
308
|
+
output: assistants.sum { |message| token_count(message, :output) }
|
|
309
|
+
}
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The whole prompt: RubyLLM counts cache reads and writes apart from
|
|
313
|
+
# `input` (`cache_read`/`cache_write` on 2.x, `cached`/`cache_creation`
|
|
314
|
+
# on 1.x), so they are added back.
|
|
315
|
+
def prompt_tokens(message)
|
|
316
|
+
tokens = message.tokens if message.respond_to?(:tokens)
|
|
317
|
+
if tokens.respond_to?(:input)
|
|
318
|
+
tokens.input.to_i + first_count(tokens, :cache_read, :cached) + first_count(tokens, :cache_write, :cache_creation)
|
|
319
|
+
else
|
|
320
|
+
token_count(message, :input) + first_count(message, :cached_tokens) + first_count(message, :cache_creation_tokens)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def token_count(message, direction)
|
|
325
|
+
tokens = message.tokens if message.respond_to?(:tokens)
|
|
326
|
+
return tokens.public_send(direction).to_i if tokens.respond_to?(direction)
|
|
327
|
+
|
|
328
|
+
first_count(message, :"#{direction}_tokens")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def first_count(object, *readers)
|
|
332
|
+
reader = readers.find { |name| object.respond_to?(name) }
|
|
333
|
+
reader ? object.public_send(reader).to_i : 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def conversation_cost(messages)
|
|
337
|
+
costs = messages.select { |message| role_of(message) == "assistant" }.map { |message| message_cost(message) }
|
|
338
|
+
costs.sum if costs.any? && costs.none?(&:nil?)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def message_cost(message)
|
|
342
|
+
return unless message.respond_to?(:cost)
|
|
343
|
+
|
|
344
|
+
cost = message.cost
|
|
345
|
+
total = cost.respond_to?(:total) ? cost.total : cost
|
|
346
|
+
total.to_f if total.is_a?(Numeric)
|
|
347
|
+
rescue StandardError
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Why the conversation has no answer, or nil when it has one: it
|
|
352
|
+
# stopped at a tool call or a tool's result, or on the user's own
|
|
353
|
+
# message. System messages do not count as the last word.
|
|
354
|
+
def unfinished_reason(messages)
|
|
355
|
+
last = messages.reverse.find { |message| role_of(message) != "system" }
|
|
356
|
+
return if last.nil?
|
|
357
|
+
|
|
358
|
+
case role_of(last)
|
|
359
|
+
when "user"
|
|
360
|
+
"The conversation ended on a user message without a reply"
|
|
361
|
+
when "tool", "assistant"
|
|
362
|
+
return if role_of(last) == "assistant" && tool_calls_of(last).none?
|
|
363
|
+
|
|
364
|
+
assistant = messages.reverse.find { |message| role_of(message) == "assistant" }
|
|
365
|
+
names = (assistant ? tool_calls_of(assistant) : []).map { |call| call.name.to_s }.uniq
|
|
366
|
+
"The conversation stopped at #{names.any? ? "a call to #{names.join(', ')}" : 'a tool result'} " \
|
|
367
|
+
"without a final answer"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def replay_error(error, answer, unfinished)
|
|
372
|
+
return "#{error.class}: #{error.message}" if error.is_a?(Exception)
|
|
373
|
+
return error unless error.nil?
|
|
374
|
+
|
|
375
|
+
unfinished if answer.nil?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The last assistant message after the last user message, so an
|
|
379
|
+
# earlier turn's reply never stands in for this one.
|
|
380
|
+
def final_answer(messages)
|
|
381
|
+
last_prompt = messages.rindex { |message| role_of(message) == "user" }
|
|
382
|
+
turn = last_prompt ? messages.drop(last_prompt + 1) : messages
|
|
383
|
+
content = turn.reverse.find { |message| role_of(message) == "assistant" }&.content
|
|
384
|
+
content = content.text if !content.nil? && !content.is_a?(String) && content.respond_to?(:text)
|
|
385
|
+
content
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def role_of(message)
|
|
389
|
+
message.role.to_s
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def parse_json(content)
|
|
393
|
+
return nil unless content.is_a?(String) && content.present?
|
|
394
|
+
|
|
395
|
+
JSON.parse(content)
|
|
396
|
+
rescue JSON::ParserError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def warn_failure(message)
|
|
401
|
+
message = "[ActiveAgent::Evals] #{message}"
|
|
402
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
403
|
+
Rails.logger.warn(message)
|
|
404
|
+
else
|
|
405
|
+
warn(message)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -20,8 +20,13 @@ module ActiveAgent
|
|
|
20
20
|
# @return [Array<String>] substrings or patterns the answer must avoid
|
|
21
21
|
# @!attribute production_only
|
|
22
22
|
# @return [Boolean] whether a local environment cannot answer with real data
|
|
23
|
+
# @!attribute tags
|
|
24
|
+
# @return [Array<String>] labels a catalog files the scenario under ("billing")
|
|
25
|
+
# @!attribute params
|
|
26
|
+
# @return [Hash] free-form parameters a replay may read; kept as written
|
|
23
27
|
Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
|
|
24
|
-
:forbidden_patterns, :notes, :production_only, :position,
|
|
28
|
+
:forbidden_patterns, :notes, :production_only, :position, :tags, :params,
|
|
29
|
+
keyword_init: true) do
|
|
25
30
|
# Builds a scenario from a hash — ScenarioParser output, a suite entry, or
|
|
26
31
|
# a persisted record's attributes. Expectations are read from an
|
|
27
32
|
# `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
|
|
@@ -40,7 +45,9 @@ module ActiveAgent
|
|
|
40
45
|
forbidden_patterns: list(expect["not_contains"] || attrs["not_contains"]),
|
|
41
46
|
notes: attrs["notes"].presence,
|
|
42
47
|
production_only: attrs["production_only"] == true,
|
|
43
|
-
position: attrs["position"]
|
|
48
|
+
position: attrs["position"],
|
|
49
|
+
tags: list(attrs["tags"]),
|
|
50
|
+
params: (attrs["params"] || {}).to_h.deep_stringify_keys
|
|
44
51
|
)
|
|
45
52
|
end
|
|
46
53
|
|
|
@@ -61,7 +68,7 @@ module ActiveAgent
|
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
def to_h
|
|
64
|
-
super.compact
|
|
71
|
+
super.compact.reject { |field, value| %i[tags params].include?(field) && value.blank? }
|
|
65
72
|
end
|
|
66
73
|
end
|
|
67
74
|
end
|
|
@@ -21,6 +21,18 @@ module ActiveAgent
|
|
|
21
21
|
# Every scenario gets a key unique within the paste, derived from its group
|
|
22
22
|
# and position ("history_3"), unless the line names one. The result is an
|
|
23
23
|
# array of string-keyed hashes; `Scenario.from_hash` builds the structs.
|
|
24
|
+
#
|
|
25
|
+
# Two options shape the generated keys, and leave named keys alone:
|
|
26
|
+
#
|
|
27
|
+
# reserved_keys: keys already in use, such as the suite a paste is
|
|
28
|
+
# merged into. A generated key skips them, ignoring
|
|
29
|
+
# case, so a second paste's first "Orders" line becomes
|
|
30
|
+
# `orders_3` after a suite holding `orders_1` and
|
|
31
|
+
# `Orders_2`.
|
|
32
|
+
# key_prefix: a namespace in front of every generated key:
|
|
33
|
+
# `key_prefix: "Batch 2"` turns `orders_1` into
|
|
34
|
+
# `batch_2_orders_1`. A prefix with no letters or
|
|
35
|
+
# digits a key can use raises ParseError.
|
|
24
36
|
class ScenarioParser
|
|
25
37
|
class ParseError < ArgumentError; end
|
|
26
38
|
|
|
@@ -31,18 +43,24 @@ module ActiveAgent
|
|
|
31
43
|
# that merely mentions `some_tool` is kept whole.
|
|
32
44
|
BACKTICK_PROMPT = /\A`([^`]+)`/
|
|
33
45
|
OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
|
|
46
|
+
MAX_KEY_PREFIX = 40
|
|
34
47
|
|
|
35
|
-
def self.parse(text, include_production_only: true)
|
|
36
|
-
new(text).parse(include_production_only: include_production_only)
|
|
48
|
+
def self.parse(text, include_production_only: true, reserved_keys: [], key_prefix: nil)
|
|
49
|
+
new(text, reserved_keys: reserved_keys, key_prefix: key_prefix).parse(include_production_only: include_production_only)
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
# Parses and builds Scenario structs in one step.
|
|
40
|
-
def self.scenarios(text, include_production_only: true)
|
|
41
|
-
parse(text, include_production_only: include_production_only
|
|
53
|
+
def self.scenarios(text, include_production_only: true, reserved_keys: [], key_prefix: nil)
|
|
54
|
+
parse(text, include_production_only: include_production_only, reserved_keys: reserved_keys, key_prefix: key_prefix)
|
|
55
|
+
.map { |attrs| Scenario.from_hash(attrs) }
|
|
42
56
|
end
|
|
43
57
|
|
|
44
|
-
def initialize(text)
|
|
58
|
+
def initialize(text, reserved_keys: [], key_prefix: nil)
|
|
45
59
|
@text = text.to_s
|
|
60
|
+
# Generated keys are lowercase, and a database whose collation
|
|
61
|
+
# ignores case holds `Orders_1` and `orders_1` as one key.
|
|
62
|
+
@reserved_keys = Array(reserved_keys).map { |key| key.to_s.downcase }.to_set
|
|
63
|
+
@key_prefix = normalize_key_prefix(key_prefix)
|
|
46
64
|
end
|
|
47
65
|
|
|
48
66
|
# @return [Array<Hash>] scenario attributes with string keys
|
|
@@ -63,6 +81,15 @@ module ActiveAgent
|
|
|
63
81
|
|
|
64
82
|
private
|
|
65
83
|
|
|
84
|
+
def normalize_key_prefix(value)
|
|
85
|
+
return nil if value.blank?
|
|
86
|
+
|
|
87
|
+
prefix = value.to_s.parameterize(separator: "_").first(MAX_KEY_PREFIX).sub(/_+\z/, "")
|
|
88
|
+
raise ParseError, "key_prefix #{value.to_s.truncate(40).inspect} has no letters or digits a key can use" if prefix.empty?
|
|
89
|
+
|
|
90
|
+
prefix
|
|
91
|
+
end
|
|
92
|
+
|
|
66
93
|
def json?(text)
|
|
67
94
|
text.start_with?("[", "{")
|
|
68
95
|
end
|
|
@@ -236,9 +263,10 @@ module ActiveAgent
|
|
|
236
263
|
}
|
|
237
264
|
end
|
|
238
265
|
|
|
239
|
-
# A key named on a line is kept
|
|
240
|
-
# named key anywhere in the paste
|
|
241
|
-
#
|
|
266
|
+
# A key named on a line is kept, even when it is reserved; a generated
|
|
267
|
+
# one never collides with a named key anywhere in the paste or with a
|
|
268
|
+
# reserved key; and a named key that repeats an earlier line's is
|
|
269
|
+
# treated as missing, so no two scenarios share one.
|
|
242
270
|
def assign_keys(scenarios)
|
|
243
271
|
named = scenarios.filter_map { |s| s["key"].presence }.to_set
|
|
244
272
|
taken = Set.new
|
|
@@ -250,11 +278,11 @@ module ActiveAgent
|
|
|
250
278
|
key = nil if key && taken.include?(key)
|
|
251
279
|
|
|
252
280
|
unless key
|
|
253
|
-
base = scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario"
|
|
281
|
+
base = [ @key_prefix, scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario" ].compact.join("_")
|
|
254
282
|
loop do
|
|
255
283
|
counters[base] += 1
|
|
256
284
|
key = "#{base}_#{counters[base]}"
|
|
257
|
-
break unless named.include?(key) || taken.include?(key)
|
|
285
|
+
break unless named.include?(key) || taken.include?(key) || @reserved_keys.include?(key)
|
|
258
286
|
end
|
|
259
287
|
end
|
|
260
288
|
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -15,6 +15,7 @@ require "active_support/core_ext/enumerable"
|
|
|
15
15
|
require_relative "evals/scenario"
|
|
16
16
|
require_relative "evals/scenario_parser"
|
|
17
17
|
require_relative "evals/suite"
|
|
18
|
+
require_relative "evals/catalog"
|
|
18
19
|
require_relative "evals/model_spec"
|
|
19
20
|
require_relative "evals/replay"
|
|
20
21
|
require_relative "evals/format"
|
|
@@ -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
|