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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. 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 or
10
- # scenario with a key already present replaces it, a new key is appended.
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 = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
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
- def scenarios(product: nil, set: nil, keys: nil, include_production_only: true)
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
- selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
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 criteria].each { |field| existing[field] = incoming[field] if incoming[field].present? }
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: product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position) }
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: Array(set["criteria"]).map { |criterion| criterion.to_h.deep_stringify_keys },
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