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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1193 -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/diagnosis.rb +3 -3
- data/lib/active_agent/evals/format.rb +115 -0
- data/lib/active_agent/evals/judge.rb +2 -0
- data/lib/active_agent/evals/report.rb +232 -31
- data/lib/active_agent/evals/report_html.rb +316 -149
- data/lib/active_agent/evals/result.rb +40 -0
- data/lib/active_agent/evals/ruby_llm.rb +411 -0
- data/lib/active_agent/evals/runner.rb +6 -2
- 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 +2 -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 +32 -2
|
@@ -49,6 +49,37 @@ module ActiveAgent
|
|
|
49
49
|
diagnosis&.dig("judge", "suggested_tool")
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Where the replay's cost came from, as the caller recorded it in the
|
|
53
|
+
# replay metadata: "reported" (the caller priced it), "estimated"
|
|
54
|
+
# (tokens × a model rate), "no_usage" (nothing was generated, so $0.00)
|
|
55
|
+
# or "unpriced" (nothing to price it from). A cost with no record is
|
|
56
|
+
# the caller's own figure; no cost at all is unpriced.
|
|
57
|
+
def cost_source
|
|
58
|
+
recorded = replay_meta("cost_source").to_s
|
|
59
|
+
return recorded if recorded.present?
|
|
60
|
+
|
|
61
|
+
replay.cost.nil? ? "unpriced" : "reported"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def estimated_cost?
|
|
65
|
+
cost_source == "estimated"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The rate an estimated cost was worked out at, `{ "input", "output",
|
|
69
|
+
# "source" }` in $ per million tokens, or nil.
|
|
70
|
+
def cost_rate
|
|
71
|
+
rate = replay_meta("cost_rate")
|
|
72
|
+
rate.is_a?(Hash) ? rate.transform_keys(&:to_s) : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# What the judge spent on this result — `{ "calls", "input_tokens",
|
|
76
|
+
# "output_tokens", "cost", "model", "by_kind", "source" }` — or nil
|
|
77
|
+
# when no judge was asked about it.
|
|
78
|
+
def judge_usage
|
|
79
|
+
usage = replay_meta("judge_usage")
|
|
80
|
+
usage.is_a?(Hash) ? usage.transform_keys(&:to_s) : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
52
83
|
def to_h
|
|
53
84
|
{
|
|
54
85
|
"scenario_key" => scenario.key,
|
|
@@ -70,9 +101,18 @@ module ActiveAgent
|
|
|
70
101
|
"fault" => fault,
|
|
71
102
|
"recommendation" => recommendation,
|
|
72
103
|
"diagnosis" => diagnosis,
|
|
104
|
+
"judge_usage" => judge_usage,
|
|
73
105
|
"metadata" => replay.metadata
|
|
74
106
|
}.compact
|
|
75
107
|
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# A caller may key the replay metadata with symbols.
|
|
112
|
+
def replay_meta(key)
|
|
113
|
+
metadata = replay.metadata
|
|
114
|
+
metadata[key] || metadata[key.to_sym]
|
|
115
|
+
end
|
|
76
116
|
end
|
|
77
117
|
end
|
|
78
118
|
end
|
|
@@ -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
|
|
@@ -51,10 +51,13 @@ module ActiveAgent
|
|
|
51
51
|
# @param require_judge_scores [Boolean] fail an otherwise passing result
|
|
52
52
|
# when a requested task/LLM grade is unavailable, rather than falling
|
|
53
53
|
# back to rule scores. Does not require a judge for rules-only runs.
|
|
54
|
+
# @param release [Hash, nil] the release of the agent under evaluation,
|
|
55
|
+
# `{ "digest", "revision", "label" }`, carried onto the Report so a
|
|
56
|
+
# published run names the code it scored (see Report.new)
|
|
54
57
|
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
55
58
|
instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
|
|
56
59
|
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
57
|
-
around_evaluation: nil, require_judge_scores: false, metadata: {})
|
|
60
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
|
|
58
61
|
@scenarios = scenarios
|
|
59
62
|
@models = models
|
|
60
63
|
@replay = replay
|
|
@@ -71,6 +74,7 @@ module ActiveAgent
|
|
|
71
74
|
@around_evaluation = around_evaluation
|
|
72
75
|
@require_judge_scores = require_judge_scores
|
|
73
76
|
@metadata = metadata
|
|
77
|
+
@release = release
|
|
74
78
|
@judge_calls = 0
|
|
75
79
|
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
76
80
|
end
|
|
@@ -83,7 +87,7 @@ module ActiveAgent
|
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
|
|
86
|
-
threshold: @threshold, metadata: @metadata)
|
|
90
|
+
threshold: @threshold, metadata: @metadata, release: @release)
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# Scores and diagnoses one replay. Public so a caller that has already run
|
|
@@ -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,8 +15,10 @@ 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"
|
|
21
|
+
require_relative "evals/format"
|
|
20
22
|
require_relative "evals/scorer"
|
|
21
23
|
require_relative "evals/diagnosis"
|
|
22
24
|
require_relative "evals/judge"
|