activeagent 1.9.6 → 1.9.7
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 +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/providers/_base_provider.rb +17 -1
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Runs one agent generation and records what the agent did, as a Replay:
|
|
6
|
+
# the answer, every tool it called (arguments, result or error, timing),
|
|
7
|
+
# how long the generation took and the tokens it used. It is the glue
|
|
8
|
+
# between `MyAgent.with(...).action` and the Runner's `replay` callable,
|
|
9
|
+
# and what TestHelper#run_eval returns.
|
|
10
|
+
#
|
|
11
|
+
# replay = ActiveAgent::Evals::Capture.run(SupportAgent.with(question: "Where is order 4821?").help)
|
|
12
|
+
# replay.answer # => "Order 4821 shipped on Monday."
|
|
13
|
+
# replay.tool_names # => ["lookup_order"]
|
|
14
|
+
# replay.tool_calls # => [{ "name" => "lookup_order", "arguments" => { "number" => "4821" }, ... }]
|
|
15
|
+
#
|
|
16
|
+
# Tool calls are captured on the agent instance's `tools_function`, the
|
|
17
|
+
# proc every provider calls to run a tool, which is also where the
|
|
18
|
+
# telemetry tool spans hang. A tool served by a client-side MCP server
|
|
19
|
+
# (`mcp_strategy: :client`, or a provider with no MCP of its own) is
|
|
20
|
+
# dispatched before that proc, so the provider reports it through the
|
|
21
|
+
# agent's `tool_observer` instead, with a failure the server reported
|
|
22
|
+
# marked as one. A tool a provider runs on its own side (Anthropic's or
|
|
23
|
+
# OpenAI's hosted MCP) never reaches this process and is not recorded.
|
|
24
|
+
#
|
|
25
|
+
# `model:` and `provider:` replay the same action under another model,
|
|
26
|
+
# the way a comparison run does. A model is an option on the agent
|
|
27
|
+
# instance. A provider is a subclass of the agent configured with
|
|
28
|
+
# `generate_with provider, model:`, named as its parent so its templates
|
|
29
|
+
# still resolve; the parent's `instructions:` option is carried, and the
|
|
30
|
+
# rest of its options follow `generate_with`'s own rule for a subclass
|
|
31
|
+
# (kept under the same service, dropped under another).
|
|
32
|
+
#
|
|
33
|
+
# A block runs the agent some other way; it may return a PromptResponse,
|
|
34
|
+
# a Replay, a Hash of Replay attributes, or the answer as a String. Tool
|
|
35
|
+
# calls are then whatever the block reports, since nothing wrapped the
|
|
36
|
+
# agent.
|
|
37
|
+
#
|
|
38
|
+
# An exception becomes an errored Replay, as it does under Runner, so a
|
|
39
|
+
# scenario that raised is scored and diagnosed like any other;
|
|
40
|
+
# `raise_errors: true` re-raises it instead, for the backtrace.
|
|
41
|
+
class Capture
|
|
42
|
+
# How much of a tool's result a tool call keeps as its "detail".
|
|
43
|
+
DETAIL_LIMIT = 500
|
|
44
|
+
|
|
45
|
+
# @param generation [ActiveAgent::Generation, nil] the action to run; nil with a block
|
|
46
|
+
# @param model [String, nil] the model to run under instead of the agent's own
|
|
47
|
+
# @param provider [Symbol, String, nil] the provider to run under instead of the agent's own
|
|
48
|
+
# @param metadata [Hash] carried onto the Replay's metadata
|
|
49
|
+
# @param raise_errors [Boolean] re-raise instead of returning an errored Replay
|
|
50
|
+
# @return [Replay]
|
|
51
|
+
def self.run(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false, &block)
|
|
52
|
+
new(generation, model: model, provider: provider, metadata: metadata, raise_errors: raise_errors).call(&block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false)
|
|
56
|
+
@generation = generation
|
|
57
|
+
@model = model
|
|
58
|
+
@provider = provider
|
|
59
|
+
@metadata = (metadata || {}).to_h.transform_keys(&:to_s)
|
|
60
|
+
@raise_errors = raise_errors
|
|
61
|
+
@tool_calls = []
|
|
62
|
+
@agent = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(&block)
|
|
66
|
+
raise ArgumentError, "give Capture a generation to run, or a block that runs the agent" unless @generation || block
|
|
67
|
+
|
|
68
|
+
started = self.class.monotonic_ms
|
|
69
|
+
value =
|
|
70
|
+
begin
|
|
71
|
+
@generation ? run_generation : block.call
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
raise if @raise_errors
|
|
74
|
+
|
|
75
|
+
return Replay.failed(e, tool_calls: @tool_calls, duration_ms: self.class.elapsed_ms(started), metadata: base_metadata)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
replay_for(value, duration_ms: self.class.elapsed_ms(started))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The generation with `model:` and `provider:` applied: the one given
|
|
82
|
+
# when neither is set, otherwise a copy that instantiates the retargeted
|
|
83
|
+
# agent. Public so a host that runs generations itself can retarget the
|
|
84
|
+
# same way.
|
|
85
|
+
def self.retarget(generation, model: nil, provider: nil)
|
|
86
|
+
return generation unless model || provider
|
|
87
|
+
|
|
88
|
+
copy = generation.dup
|
|
89
|
+
# The memoized agent, if the generation was already touched, belongs
|
|
90
|
+
# to the original class and action.
|
|
91
|
+
copy.instance_variable_set(:@agent, nil)
|
|
92
|
+
copy.agent_class = provider ? subclass_under(generation.agent_class, provider, model) : generation.agent_class
|
|
93
|
+
copy
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A subclass of `agent_class` that generates with `provider` (and
|
|
97
|
+
# `model`, when given), named as its parent so `agent_name`, the view
|
|
98
|
+
# prefixes and the telemetry span names stay the parent's.
|
|
99
|
+
def self.subclass_under(agent_class, provider, model)
|
|
100
|
+
parent_name = agent_class.name
|
|
101
|
+
parent_options = agent_class.prompt_options || {}
|
|
102
|
+
|
|
103
|
+
Class.new(agent_class).tap do |klass|
|
|
104
|
+
klass.define_singleton_method(:name) { parent_name }
|
|
105
|
+
klass.generate_with(provider, **{ model: model }.compact)
|
|
106
|
+
# generate_with never inherits a parent's instructions. A replay is
|
|
107
|
+
# the same agent under another model, so they come along.
|
|
108
|
+
klass.prompt_options[:instructions] = parent_options[:instructions] if parent_options.key?(:instructions)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs one tool call and appends its record to +calls+: the name, the
|
|
113
|
+
# arguments, a preview of the result and the time it took. A call that
|
|
114
|
+
# raises is recorded as an error and re-raised. So is one an MCP server
|
|
115
|
+
# reported as failed: it comes back as an MCPBridge::ErrorResult rather
|
|
116
|
+
# than an exception, so the model can recover, and is returned to the
|
|
117
|
+
# provider unchanged.
|
|
118
|
+
#
|
|
119
|
+
# @api private
|
|
120
|
+
# @return [Object] what the call returned
|
|
121
|
+
def self.record_call(calls, name, arguments)
|
|
122
|
+
call = { "name" => name.to_s }
|
|
123
|
+
call["arguments"] = jsonable(arguments) unless arguments.nil?
|
|
124
|
+
started = monotonic_ms
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
result = yield
|
|
128
|
+
if mcp_error?(result)
|
|
129
|
+
call["error"] = true
|
|
130
|
+
call["detail"] = preview(result.message)
|
|
131
|
+
else
|
|
132
|
+
call["detail"] = preview(result)
|
|
133
|
+
end
|
|
134
|
+
result
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
call["error"] = true
|
|
137
|
+
call["detail"] = "#{e.class}: #{e.message}"
|
|
138
|
+
raise
|
|
139
|
+
ensure
|
|
140
|
+
call["duration_ms"] = elapsed_ms(started)
|
|
141
|
+
calls << call
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.mcp_error?(result)
|
|
146
|
+
defined?(ActiveAgent::Providers::MCPBridge::ErrorResult) && result.is_a?(ActiveAgent::Providers::MCPBridge::ErrorResult)
|
|
147
|
+
end
|
|
148
|
+
private_class_method :mcp_error?
|
|
149
|
+
|
|
150
|
+
def self.monotonic_ms
|
|
151
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.elapsed_ms(started)
|
|
155
|
+
monotonic_ms - started
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Tool arguments and results as JSON would carry them (string keys,
|
|
159
|
+
# plain values), so a recorded call compares with what a test writes.
|
|
160
|
+
def self.jsonable(value)
|
|
161
|
+
JSON.parse(JSON.generate(value))
|
|
162
|
+
rescue StandardError
|
|
163
|
+
value.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.preview(value)
|
|
167
|
+
text = value.is_a?(String) ? value : (JSON.generate(value) rescue value.to_s)
|
|
168
|
+
text.length > DETAIL_LIMIT ? "#{text[0, DETAIL_LIMIT]}…" : text
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def run_generation
|
|
174
|
+
generation = self.class.retarget(@generation, model: @model, provider: @provider)
|
|
175
|
+
# Generation#agent is private: nothing outside should touch the agent
|
|
176
|
+
# before prompt_later. A capture runs it now, and needs the instance
|
|
177
|
+
# to hook its tool dispatch, so it reaches in on purpose.
|
|
178
|
+
@agent = generation.send(:agent)
|
|
179
|
+
@agent.prompt_options[:model] = @model if @model && !@provider
|
|
180
|
+
record_tool_calls(@agent)
|
|
181
|
+
generation.generate_now
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Wraps the agent's tools_function so each dispatch lands in
|
|
185
|
+
# @tool_calls. The singleton method takes precedence over the telemetry
|
|
186
|
+
# module prepended on the class, and `base` is that module's wrapped
|
|
187
|
+
# proc, so tool spans still fire.
|
|
188
|
+
#
|
|
189
|
+
# A tool served by a client-side MCP server never reaches tools_function:
|
|
190
|
+
# the provider runs that call inside the agent's `tool_observer`, which
|
|
191
|
+
# records it the same way. The two paths are disjoint, so a call is
|
|
192
|
+
# recorded once, and the observer sees the call without making it again.
|
|
193
|
+
def record_tool_calls(agent)
|
|
194
|
+
calls = @tool_calls
|
|
195
|
+
base = agent.tools_function
|
|
196
|
+
capture = self.class
|
|
197
|
+
|
|
198
|
+
agent.define_singleton_method(:tools_function) do
|
|
199
|
+
proc do |name, *args, **kwargs|
|
|
200
|
+
arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
|
|
201
|
+
capture.record_call(calls, name, arguments) { base.call(name, *args, **kwargs) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
agent.define_singleton_method(:tool_observer) do
|
|
206
|
+
proc { |name, arguments, &call| capture.record_call(calls, name, arguments.presence, &call) }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def replay_for(value, duration_ms:)
|
|
211
|
+
case value
|
|
212
|
+
when Replay
|
|
213
|
+
attributes = value.to_h.transform_keys(&:to_sym).merge(
|
|
214
|
+
tool_calls: value.tool_calls.presence || @tool_calls,
|
|
215
|
+
duration_ms: value.duration_ms || duration_ms,
|
|
216
|
+
metadata: base_metadata.merge(value.metadata)
|
|
217
|
+
)
|
|
218
|
+
Replay.new(**attributes)
|
|
219
|
+
when Hash
|
|
220
|
+
replay_for(Replay.new(**value.to_h.transform_keys(&:to_sym)), duration_ms: duration_ms)
|
|
221
|
+
when String, nil
|
|
222
|
+
Replay.new(answer: value, tool_calls: @tool_calls, duration_ms: duration_ms, metadata: base_metadata)
|
|
223
|
+
else
|
|
224
|
+
unless value.respond_to?(:messages)
|
|
225
|
+
raise ArgumentError, "expected the agent run to return a PromptResponse, a Replay, a Hash or a String, got #{value.class}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
replay_from_response(value, duration_ms: duration_ms)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def replay_from_response(response, duration_ms:)
|
|
233
|
+
usage = response.respond_to?(:usage) ? response.usage : nil
|
|
234
|
+
messages = Array(response.messages)
|
|
235
|
+
|
|
236
|
+
Replay.new(
|
|
237
|
+
answer: final_answer(messages),
|
|
238
|
+
tool_calls: @tool_calls,
|
|
239
|
+
duration_ms: duration_ms,
|
|
240
|
+
input_tokens: usage&.input_tokens,
|
|
241
|
+
output_tokens: usage&.output_tokens,
|
|
242
|
+
metadata: base_metadata.merge({ "prompt" => user_prompt(messages) }.compact)
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The agent's last assistant turn. A tool loop leaves tool calls and
|
|
247
|
+
# results before it; the final text comes last.
|
|
248
|
+
def final_answer(messages)
|
|
249
|
+
message = messages.reverse.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "assistant" }
|
|
250
|
+
return nil unless message
|
|
251
|
+
|
|
252
|
+
message.respond_to?(:text) ? message.text : message.content.to_s
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# What the user asked, for the scenario a test scores the replay
|
|
256
|
+
# against: the first user turn, when it is plain text.
|
|
257
|
+
def user_prompt(messages)
|
|
258
|
+
message = messages.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "user" }
|
|
259
|
+
content = message&.content
|
|
260
|
+
content if content.is_a?(String) && content.present?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def base_metadata
|
|
264
|
+
options = @agent&.prompt_options || {}
|
|
265
|
+
{
|
|
266
|
+
"agent" => @agent&.class&.name,
|
|
267
|
+
"action" => @generation&.action_name&.to_s,
|
|
268
|
+
"provider" => @provider&.to_s,
|
|
269
|
+
"service" => options[:service]&.to_s,
|
|
270
|
+
"model" => (@model || options[:model])&.to_s
|
|
271
|
+
}.compact.merge(@metadata)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -6,8 +6,9 @@ module ActiveAgent
|
|
|
6
6
|
module Evals
|
|
7
7
|
# A catalog of evaluation scenarios: the products an app evaluates, each
|
|
8
8
|
# with the named sets of scenarios it is tested with. One YAML document,
|
|
9
|
-
# or several layered like Suite's: a later document's product, set
|
|
10
|
-
# scenario with a key already present replaces it, a new key
|
|
9
|
+
# or several layered like Suite's: a later document's product, set,
|
|
10
|
+
# scenario or criterion with a key already present replaces it, a new key
|
|
11
|
+
# is appended.
|
|
11
12
|
#
|
|
12
13
|
# catalog: support_desk
|
|
13
14
|
# name: Support Desk
|
|
@@ -16,11 +17,14 @@ module ActiveAgent
|
|
|
16
17
|
# - key: triage
|
|
17
18
|
# name: Triage agent
|
|
18
19
|
# agent: TriageAgent # the agent class, or a dashboard agent's name
|
|
20
|
+
# criteria: { shared: [] } # optional; named criteria lists, as a Suite's
|
|
21
|
+
# pass: { min_pass_rate: 0.8 } # optional; the product's bars (PassThresholds)
|
|
19
22
|
# sets:
|
|
20
23
|
# - key: smoke
|
|
21
24
|
# name: Smoke
|
|
22
25
|
# judge: { kind: rules } # optional; an evaluation's judge_kind and judge_model
|
|
23
26
|
# criteria: [] # optional; an evaluation's criteria
|
|
27
|
+
# pass: { gated: false } # optional; the set's own bars
|
|
24
28
|
# scenarios:
|
|
25
29
|
# - key: refund_request
|
|
26
30
|
# prompt: A customer asks for a refund on order 1042.
|
|
@@ -47,12 +51,12 @@ module ActiveAgent
|
|
|
47
51
|
|
|
48
52
|
# The keys a product, set or scenario may carry. Anything else is kept
|
|
49
53
|
# under `metadata` so a document round-trips.
|
|
50
|
-
PRODUCT_KEYS = %w[key name description agent sets].freeze
|
|
51
|
-
SET_KEYS = %w[key name description judge criteria scenarios].freeze
|
|
52
|
-
SCENARIO_KEYS =
|
|
54
|
+
PRODUCT_KEYS = %w[key name description agent criteria pass sets].freeze
|
|
55
|
+
SET_KEYS = %w[key name description judge criteria pass scenarios].freeze
|
|
56
|
+
SCENARIO_KEYS = Scenario::DOCUMENT_KEYS
|
|
53
57
|
CATALOG_KEYS = %w[catalog name description products suite groups].freeze
|
|
54
58
|
|
|
55
|
-
Product = Struct.new(:key, :name, :description, :agent, :metadata, :sets, :position, keyword_init: true) do
|
|
59
|
+
Product = Struct.new(:key, :name, :description, :agent, :criteria, :pass, :metadata, :sets, :position, keyword_init: true) do
|
|
56
60
|
def set(key)
|
|
57
61
|
sets.find { |set| set.key == key.to_s }
|
|
58
62
|
end
|
|
@@ -62,7 +66,7 @@ module ActiveAgent
|
|
|
62
66
|
end
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
Set = Struct.new(:key, :name, :description, :judge, :criteria, :metadata, :scenarios, :position, keyword_init: true)
|
|
69
|
+
Set = Struct.new(:key, :name, :description, :judge, :criteria, :pass, :metadata, :scenarios, :position, keyword_init: true)
|
|
66
70
|
|
|
67
71
|
attr_reader :key, :name, :description, :metadata, :products
|
|
68
72
|
|
|
@@ -114,14 +118,21 @@ module ActiveAgent
|
|
|
114
118
|
# Scenarios across the catalog, narrowed by product key, set key,
|
|
115
119
|
# scenario keys, or any of them; `production_only` scenarios are
|
|
116
120
|
# dropped unless `include_production_only` is true. Each scenario's
|
|
117
|
-
# group is its set's key and its group_name the set's name.
|
|
118
|
-
|
|
121
|
+
# group is its set's key and its group_name the set's name. With
|
|
122
|
+
# `strict`, every key in `keys` must name a scenario of the catalog (see
|
|
123
|
+
# Suite.select), and a key outside the chosen product or set selects
|
|
124
|
+
# nothing.
|
|
125
|
+
def scenarios(product: nil, set: nil, keys: nil, include_production_only: true, strict: false)
|
|
119
126
|
selected = products
|
|
120
127
|
selected = selected.select { |entry| entry.key == product.to_s } if product.present?
|
|
121
128
|
selected = selected.flat_map(&:sets)
|
|
122
129
|
selected = selected.select { |entry| entry.key == set.to_s } if set.present?
|
|
123
130
|
selected = selected.flat_map(&:scenarios)
|
|
124
|
-
|
|
131
|
+
if strict
|
|
132
|
+
selected = Suite.select(selected, keys: keys, known: all_scenarios, name: key)
|
|
133
|
+
elsif keys.present?
|
|
134
|
+
selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
|
|
135
|
+
end
|
|
125
136
|
selected = selected.reject(&:production_only?) unless include_production_only
|
|
126
137
|
selected
|
|
127
138
|
end
|
|
@@ -136,16 +147,24 @@ module ActiveAgent
|
|
|
136
147
|
|
|
137
148
|
# One product's sets as a Suite (every set a group), or one set alone,
|
|
138
149
|
# for code that takes a suite: `Runner.new(scenarios: catalog.suite_for(:triage).scenarios, ...)`.
|
|
150
|
+
# The product's `pass` is the suite's and each set's its group's.
|
|
139
151
|
def suite_for(product_key, set_key = nil)
|
|
140
152
|
found = product(product_key)
|
|
141
153
|
sets = set_key ? [ set(product_key, set_key) ] : found.sets
|
|
142
154
|
Suite.new([ {
|
|
143
155
|
"suite" => [ key, found.key, set_key ].compact.join("/"),
|
|
144
156
|
"description" => found.description || description,
|
|
157
|
+
"criteria" => found.criteria,
|
|
158
|
+
"pass" => found.pass,
|
|
145
159
|
"groups" => sets.map { |entry| set_document(entry) }
|
|
146
160
|
} ])
|
|
147
161
|
end
|
|
148
162
|
|
|
163
|
+
# The bars a product, or one of its sets, declares (PassThresholds).
|
|
164
|
+
def pass_thresholds(product_key, set_key = nil)
|
|
165
|
+
suite_for(product_key, set_key).pass_thresholds
|
|
166
|
+
end
|
|
167
|
+
|
|
149
168
|
# The canonical document: keys in a fixed order, nothing empty, so the
|
|
150
169
|
# same content always reads the same.
|
|
151
170
|
def to_h
|
|
@@ -182,8 +201,10 @@ module ActiveAgent
|
|
|
182
201
|
"key" => product_key,
|
|
183
202
|
"name" => document["name"].presence || product_key,
|
|
184
203
|
"description" => document["description"],
|
|
204
|
+
"criteria" => document["criteria"],
|
|
205
|
+
"pass" => document["pass"],
|
|
185
206
|
"sets" => Array(document["groups"])
|
|
186
|
-
} ]
|
|
207
|
+
}.compact ]
|
|
187
208
|
}.compact
|
|
188
209
|
end
|
|
189
210
|
|
|
@@ -196,16 +217,70 @@ module ActiveAgent
|
|
|
196
217
|
existing = products.find { |product| product["key"] == incoming["key"].to_s }
|
|
197
218
|
if existing
|
|
198
219
|
%w[name description agent].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
220
|
+
merge_pass(existing, incoming["pass"], "product #{existing['key'].inspect}")
|
|
199
221
|
existing.merge!(incoming.except(*PRODUCT_KEYS))
|
|
222
|
+
merge_criteria_lists(existing, incoming)
|
|
200
223
|
merge_sets(existing, Array(incoming["sets"]))
|
|
201
224
|
else
|
|
202
|
-
products << incoming.merge("key" => incoming["key"].to_s, "sets" => [])
|
|
225
|
+
products << incoming.merge("key" => incoming["key"].to_s, "criteria" => {}, "sets" => [])
|
|
226
|
+
.tap { |product| merge_criteria_lists(product, incoming) }
|
|
203
227
|
.tap { |product| merge_sets(product, Array(incoming["sets"])) }
|
|
204
228
|
end
|
|
205
229
|
end
|
|
206
230
|
end
|
|
207
231
|
end
|
|
208
232
|
|
|
233
|
+
# Layers a product document's named criteria lists over the product's:
|
|
234
|
+
# by name, and within a name by criterion key.
|
|
235
|
+
def merge_criteria_lists(product, incoming)
|
|
236
|
+
criteria_lists(incoming).each do |list_name, list|
|
|
237
|
+
merge_criteria(product["criteria"][list_name] ||= [], list, "list #{list_name.inspect} of product #{product['key'].inspect}")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A product document's `criteria` as `name => list`; a bare list is the
|
|
242
|
+
# one list named "default", and an empty one declares no list.
|
|
243
|
+
def criteria_lists(product)
|
|
244
|
+
value = product["criteria"]
|
|
245
|
+
return {} if value.nil? || value == []
|
|
246
|
+
|
|
247
|
+
value = { "default" => value } if value.is_a?(Array)
|
|
248
|
+
unless value.is_a?(Hash)
|
|
249
|
+
raise InvalidDocument, "the criteria of product #{product['key'].inspect} are not a list or a mapping of lists"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
value.to_h do |list_name, list|
|
|
253
|
+
raise InvalidDocument, "criteria list #{list_name.inspect} of product #{product['key'].inspect} is not a list" unless list.is_a?(Array)
|
|
254
|
+
|
|
255
|
+
[ list_name.to_s, list ]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Layers `incoming` criteria over `existing` by key: a criterion with a
|
|
260
|
+
# key already present replaces it in place and a new key is appended;
|
|
261
|
+
# an empty or missing `incoming` declares none. A criterion without a
|
|
262
|
+
# key is appended once, when `allow_keyless` is true, and refused
|
|
263
|
+
# otherwise.
|
|
264
|
+
def merge_criteria(existing, incoming, owner, allow_keyless: false)
|
|
265
|
+
return if incoming.blank?
|
|
266
|
+
raise InvalidDocument, "the criteria of #{owner} are not a list" unless incoming.is_a?(Array)
|
|
267
|
+
|
|
268
|
+
incoming.each do |criterion|
|
|
269
|
+
raise InvalidDocument, "a criterion in #{owner} is not a mapping" unless criterion.is_a?(Hash)
|
|
270
|
+
|
|
271
|
+
criterion = criterion.deep_stringify_keys
|
|
272
|
+
if criterion["key"].present?
|
|
273
|
+
criterion["key"] = criterion["key"].to_s
|
|
274
|
+
index = existing.index { |entry| entry["key"] == criterion["key"] }
|
|
275
|
+
index ? existing[index] = criterion : existing << criterion
|
|
276
|
+
elsif allow_keyless
|
|
277
|
+
existing << criterion unless existing.include?(criterion)
|
|
278
|
+
else
|
|
279
|
+
raise InvalidDocument, "a criterion in #{owner} has no key"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
209
284
|
def merge_sets(product, incoming_sets)
|
|
210
285
|
incoming_sets.each do |incoming|
|
|
211
286
|
incoming = incoming.to_h.deep_stringify_keys
|
|
@@ -213,16 +288,31 @@ module ActiveAgent
|
|
|
213
288
|
|
|
214
289
|
existing = product["sets"].find { |set| set["key"] == incoming["key"].to_s }
|
|
215
290
|
if existing
|
|
216
|
-
%w[name description judge
|
|
291
|
+
%w[name description judge].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
292
|
+
merge_pass(existing, incoming["pass"], "set #{existing['key'].inspect} of product #{product['key'].inspect}")
|
|
217
293
|
existing.merge!(incoming.except(*SET_KEYS))
|
|
294
|
+
merge_set_criteria(existing, incoming, product)
|
|
218
295
|
merge_scenarios(existing, Array(incoming["scenarios"]))
|
|
219
296
|
else
|
|
220
|
-
product["sets"] << incoming.merge("key" => incoming["key"].to_s, "scenarios" => [])
|
|
297
|
+
product["sets"] << incoming.merge("key" => incoming["key"].to_s, "criteria" => [], "scenarios" => [])
|
|
298
|
+
.tap { |set| merge_set_criteria(set, incoming, product) }
|
|
221
299
|
.tap { |set| merge_scenarios(set, Array(incoming["scenarios"])) }
|
|
222
300
|
end
|
|
223
301
|
end
|
|
224
302
|
end
|
|
225
303
|
|
|
304
|
+
# A later document's `pass` layers over the existing one key by key.
|
|
305
|
+
def merge_pass(existing, incoming, place)
|
|
306
|
+
incoming = pass_block(incoming, place)
|
|
307
|
+
existing["pass"] = pass_block(existing["pass"], place).merge(incoming) if incoming.any?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Layers a set document's `criteria` over the set's by key; a set keeps
|
|
311
|
+
# a criterion without a key.
|
|
312
|
+
def merge_set_criteria(set, incoming, product)
|
|
313
|
+
merge_criteria(set["criteria"], incoming["criteria"], "set #{set['key'].inspect} of product #{product['key'].inspect}", allow_keyless: true)
|
|
314
|
+
end
|
|
315
|
+
|
|
226
316
|
def merge_scenarios(set, incoming_scenarios)
|
|
227
317
|
incoming_scenarios.each do |scenario|
|
|
228
318
|
scenario = scenario.to_h.deep_stringify_keys
|
|
@@ -237,39 +327,67 @@ module ActiveAgent
|
|
|
237
327
|
|
|
238
328
|
def build_products(documents)
|
|
239
329
|
documents.each_with_index.map do |product, position|
|
|
330
|
+
sets = product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position, product["key"]) }
|
|
240
331
|
Product.new(
|
|
241
332
|
key: product["key"],
|
|
242
333
|
name: product["name"].presence || product["key"],
|
|
243
334
|
description: product["description"].presence,
|
|
244
335
|
agent: product["agent"].presence&.to_s,
|
|
336
|
+
criteria: product["criteria"],
|
|
337
|
+
pass: validated_pass(product["pass"], PassThresholds::SUITE_KEYS, "product #{product['key'].inspect}"),
|
|
245
338
|
metadata: product.except(*PRODUCT_KEYS),
|
|
246
339
|
position: position,
|
|
247
|
-
sets:
|
|
340
|
+
sets: sets
|
|
248
341
|
)
|
|
249
342
|
end
|
|
250
343
|
end
|
|
251
344
|
|
|
252
|
-
def build_set(set, position)
|
|
345
|
+
def build_set(set, position, product_key)
|
|
253
346
|
Set.new(
|
|
254
347
|
key: set["key"],
|
|
255
348
|
name: set["name"].presence || set["key"],
|
|
256
349
|
description: set["description"].presence,
|
|
257
350
|
judge: (set["judge"] || {}).to_h.stringify_keys,
|
|
258
|
-
criteria:
|
|
351
|
+
criteria: set["criteria"],
|
|
352
|
+
pass: validated_pass(set["pass"], PassThresholds::GROUP_KEYS, "set #{set['key'].inspect} of product #{product_key.inspect}"),
|
|
259
353
|
metadata: set.except(*SET_KEYS),
|
|
260
354
|
position: position,
|
|
261
355
|
scenarios: set["scenarios"].each_with_index.map do |entry, index|
|
|
262
|
-
Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"]
|
|
356
|
+
Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"],
|
|
357
|
+
metadata: entry.except(*SCENARIO_KEYS))
|
|
263
358
|
end
|
|
264
359
|
)
|
|
265
360
|
end
|
|
266
361
|
|
|
362
|
+
# A `pass` block as a string-keyed Hash, {} when absent, checked the
|
|
363
|
+
# way PassThresholds reads it (`allowed` is SUITE_KEYS for a product,
|
|
364
|
+
# GROUP_KEYS for a set) so a block the suite would refuse fails the
|
|
365
|
+
# catalog as it loads rather than the first run that asks for it; a
|
|
366
|
+
# refusal names the product or set the block sits on.
|
|
367
|
+
def validated_pass(value, allowed, place)
|
|
368
|
+
pass = pass_block(value, place)
|
|
369
|
+
PassThresholds.validate(pass, allowed, place)
|
|
370
|
+
pass
|
|
371
|
+
rescue ArgumentError => e
|
|
372
|
+
raise InvalidDocument, e.message
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A `pass` block as a string-keyed Hash, {} when absent; anything but
|
|
376
|
+
# a mapping is refused naming the product or set it sits on.
|
|
377
|
+
def pass_block(value, place)
|
|
378
|
+
PassThresholds.pass_block(value, place)
|
|
379
|
+
rescue ArgumentError => e
|
|
380
|
+
raise InvalidDocument, e.message
|
|
381
|
+
end
|
|
382
|
+
|
|
267
383
|
def product_document(product)
|
|
268
384
|
compact({
|
|
269
385
|
"key" => product.key,
|
|
270
386
|
"name" => product.name,
|
|
271
387
|
"description" => product.description,
|
|
272
388
|
"agent" => product.agent,
|
|
389
|
+
"criteria" => product.criteria,
|
|
390
|
+
"pass" => product.pass,
|
|
273
391
|
"sets" => product.sets.map { |set| set_document(set) }
|
|
274
392
|
}.merge(product.metadata))
|
|
275
393
|
end
|
|
@@ -281,6 +399,7 @@ module ActiveAgent
|
|
|
281
399
|
"description" => set.description,
|
|
282
400
|
"judge" => set.judge,
|
|
283
401
|
"criteria" => set.criteria,
|
|
402
|
+
"pass" => set.pass,
|
|
284
403
|
"scenarios" => set.scenarios.map { |scenario| scenario_document(scenario) }
|
|
285
404
|
}.merge(set.metadata))
|
|
286
405
|
end
|
|
@@ -294,7 +413,19 @@ module ActiveAgent
|
|
|
294
413
|
"tags" => scenario.tags,
|
|
295
414
|
"params" => scenario.params,
|
|
296
415
|
"production_only" => (true if scenario.production_only?)
|
|
297
|
-
})
|
|
416
|
+
}).merge(canonical_metadata(scenario.metadata.to_h.deep_stringify_keys.except(*SCENARIO_KEYS)))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# JSON object storage may reorder keys; array order remains meaningful.
|
|
420
|
+
def canonical_metadata(value)
|
|
421
|
+
case value
|
|
422
|
+
when Hash
|
|
423
|
+
value.keys.sort.to_h { |key| [ key, canonical_metadata(value[key]) ] }
|
|
424
|
+
when Array
|
|
425
|
+
value.map { |entry| canonical_metadata(entry) }
|
|
426
|
+
else
|
|
427
|
+
value
|
|
428
|
+
end
|
|
298
429
|
end
|
|
299
430
|
|
|
300
431
|
def compact(hash)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# A report's pass gates as one check with the message a failing
|
|
6
|
+
# assertion prints, so TestHelper#assert_meets_pass_gates and the RSpec
|
|
7
|
+
# matcher (`active_agent/evals/rspec`) fail with the same text: how many
|
|
8
|
+
# gates are not met, each of them as a sentence, then the failures to
|
|
9
|
+
# read under a heading per group.
|
|
10
|
+
#
|
|
11
|
+
# check = ActiveAgent::Evals::GateCheck.new(report)
|
|
12
|
+
# check.met? # => false
|
|
13
|
+
# puts check.message
|
|
14
|
+
# # 2 of 5 pass gates not met
|
|
15
|
+
# # - Order support · gpt-5-mini: 1/2 (50%) passed, below 100%
|
|
16
|
+
# # - All · gpt-5-mini: 3/4 (75%) passed, below 80%
|
|
17
|
+
# #
|
|
18
|
+
# # Order support failures:
|
|
19
|
+
# # - `order_status` on `gpt-5-mini`: low quality — Scored 50% against a pass threshold of 70%, weakest on response_present (100%).
|
|
20
|
+
class GateCheck
|
|
21
|
+
# @param report [Report] the report whose gates are checked
|
|
22
|
+
# @param thresholds [PassThresholds, nil] the bars to hold the report
|
|
23
|
+
# to; without them Report#gates decides, taking the report's own,
|
|
24
|
+
# else the defaults. Only their `min_pass_rate` and `gated` apply:
|
|
25
|
+
# whether an answer passed was settled at the threshold the Runner
|
|
26
|
+
# scored it at, so a different `threshold` here changes nothing.
|
|
27
|
+
def initialize(report, thresholds = nil)
|
|
28
|
+
@report = report
|
|
29
|
+
@bars = [ thresholds ].compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether every gate is met.
|
|
33
|
+
def met?
|
|
34
|
+
@report.passed?(*@bars)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every gate, in Report#gates order.
|
|
38
|
+
def gates
|
|
39
|
+
@report.gates(*@bars)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The gates that are not met, in gates order.
|
|
43
|
+
def unmet
|
|
44
|
+
gates.reject(&:met?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The text a failing assertion prints. The first line counts the gates
|
|
48
|
+
# not met, or is the one line "every pass gate met" when none is; then
|
|
49
|
+
# each unmet gate as Gate#to_s; then, after a blank line, the failures
|
|
50
|
+
# to read: for each group with an unmet gate, in gates order, "<group>
|
|
51
|
+
# failures:" over Report#failures_markdown for that group. An unmet All
|
|
52
|
+
# row adds the failures it counts, those in gated groups or in no
|
|
53
|
+
# group: under "Failures:" when no group row is unmet, else the ones
|
|
54
|
+
# outside the groups already listed under "Other failures:", when there
|
|
55
|
+
# are any. An ungated group's failures count toward no gate and are
|
|
56
|
+
# never listed.
|
|
57
|
+
def message
|
|
58
|
+
all = gates
|
|
59
|
+
return "no result reached a pass gate; the run evaluated nothing" if all.empty?
|
|
60
|
+
|
|
61
|
+
unmet = all.reject(&:met?)
|
|
62
|
+
return "every pass gate met" if unmet.empty?
|
|
63
|
+
|
|
64
|
+
lines = [ "#{unmet.size} of #{all.size} pass gates not met", *unmet.map { |gate| "- #{gate}" } ]
|
|
65
|
+
grouped = unmet.reject { |gate| gate.group.nil? }.uniq(&:group)
|
|
66
|
+
grouped.each { |gate| lines << "" << "#{gate.name} failures:" << @report.failures_markdown(group: gate.group) }
|
|
67
|
+
if unmet.any? { |gate| gate.group.nil? }
|
|
68
|
+
ungated = all.reject(&:gated).map(&:group)
|
|
69
|
+
listed = grouped.map(&:group)
|
|
70
|
+
others = @report.failures.reject { |result| (ungated + listed).include?(result.scenario.group) }
|
|
71
|
+
heading = listed.empty? ? "Failures:" : "Other failures:"
|
|
72
|
+
lines << "" << heading << others.map { |result| @report.failure_markdown(result) }.join("\n") if others.any?
|
|
73
|
+
end
|
|
74
|
+
lines.join("\n")
|
|
75
|
+
end
|
|
76
|
+
alias to_s message
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|