activeagent 1.9.1 → 1.9.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c47ac88022fb9c9fe8919efaaf8cfb6824dfa7ba102cfc31df2bf42d8f4b19ef
4
- data.tar.gz: 9670044e2fd2fceae17068327a7f0b5e9695ae049f81cd2b00e7f56387c20286
3
+ metadata.gz: 2b52709687c94c3d6c989fc36bba7826b931a6d0aa6e193b1bd4482f916d945f
4
+ data.tar.gz: 1801338ff01afc21837db5382aee49eb4cde1f002b322b479cf2256c283e8668
5
5
  SHA512:
6
- metadata.gz: a2ee2907d4449caef1fd7f22d0b5418dfb19aa0140aaa02b47d7a394671dc16653d315ee80bcd6699a9baa0085c6202726ccbbefe9514689d1b0c53b708482e5
7
- data.tar.gz: f98937727b17855137cbf76434ebd5657dbd8d4c37a94119edafadd9da412e46ec44a068b4cbafbc19d04e2726dfa75e62f4eaad503e9db40709d9ca21aacc88
6
+ metadata.gz: 4d0299374c70e7e66b5edf3b141d171e79cb2f576ec32dcb6a2b4e11e9eb5bccaabbf2a5106fb5afd05f01ece8b6dc75e9a0cd956523c39ffdc4c3b5a17a0b22
7
+ data.tar.gz: b60f377af9288f08cd5166ba7d71edc1060ccddf23c1d2912ed4b9d105e75ce6a6325a1f0e855e2141ded3c2ebdffbb7cd0e2f37796e662cc37ef833054d21d1
data/CHANGELOG.md CHANGED
@@ -5,7 +5,211 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.9.2] - 2026-10-20
9
+
10
+ Releases `activeagent` and `actionagent` 1.9.2, with the matching browser
11
+ sidecar: evaluations at scale (the scenario suite as a build page, model
12
+ comparison with intervals and P95 latency, opt-in parallel scenario runs,
13
+ the judge named after its model), provider streaming fixes (Anthropic
14
+ citations, OpenAI Chat refusals and logprobs, Responses built-in tools),
15
+ nested `has_many` schemas, and delegation contracts from `SchemaGenerator`
16
+ classes.
17
+
18
+ Upgrading: structured-output property names are now sent to the provider,
19
+ and answered, as declared. A `response_format` schema with multiword
20
+ property names (`first_name`) used to reach the provider camelized
21
+ (`firstName`) while `required` stayed snake_case, which strict mode
22
+ rejected; both are now as declared. `parsed_json` is unaffected; code that
23
+ reads `message.content` itself, or matches on request payloads, sees
24
+ `first_name` where it saw `firstName`. In a Rails app, `has_many` is
25
+ available on every `ActiveModel::Model` class, since the railtie includes
26
+ `SchemaGenerator` there, not only on classes that include it themselves.
27
+ Scenario fan-out is off by default: `config.scenario_evaluation_fan_out =
28
+ true` turns it on, and its jobs run on the `:agents` queue. No new
29
+ migration; a host still on 1.9.0 needs 1.9.1's migration 027.
30
+
31
+ ### Added
32
+
33
+ - **P50 and P95 latency per model in evaluation reports** (`activeagent`).
34
+ `Report#summary_by_model` adds `p50_duration_ms` and `p95_duration_ms`
35
+ beside `avg_duration_ms`, by nearest rank as the dashboard's Metrics view
36
+ computes them, and the Markdown and HTML reports show them next to the
37
+ mean, which hides the tail (#428).
38
+
39
+ - **Cost per 1,000 scenario runs in the Markdown report** (`activeagent`).
40
+ The summary table gains `Cost / 1,000`, each model's mean cost per priced
41
+ replay on a common denominator, marked `~` when it is estimated or
42
+ covers only some replays (#428).
43
+
44
+ - **`ActiveAgent::Evals::Comparison`: is the gap between models real?**
45
+ (`activeagent`). A 95% interval for each model's pass rate and for the
46
+ difference between every pair of models, from 5,000 seeded resamples of
47
+ whole scenarios (a paired bootstrap for the pairs, over the scenarios both
48
+ ran). A pair is decisive when its interval excludes zero, and
49
+ `#conclusion` says in one sentence which pairs are, or that the ranking
50
+ is not decisive evidence of superiority (#428).
51
+
52
+ - **The model comparison in evaluation reports** (`activeagent`).
53
+ `Report#comparison` and `to_h["comparison"]` carry it for a report that
54
+ compares models, the Markdown report gains a Comparison section, and
55
+ `Report.new(comparison:)` renders one already recorded for the results
56
+ instead of resampling, as `verdict:` does (#428).
57
+
58
+ - **A ranked interval plot in the HTML evaluation report** (`activeagent`).
59
+ Under the comparison table, each model's pass rate is a dot inside its
60
+ 95% interval on a fixed 0 to 100% axis, best first, with the figures
61
+ beside it, the conclusion, and how the intervals were computed. Plain
62
+ CSS on the design tokens: no script, light and dark (#428).
63
+
64
+ - **Each comparison run keeps its model comparison** (`actionagent`). A
65
+ scenario run that compares models records `Report#comparison` under
66
+ `scores["_comparison"]` when it completes; a published report's is
67
+ worked out from the stored results rather than taken from the payload;
68
+ and the run's report page renders the recorded intervals instead of
69
+ resampling on every view. A run recorded before gets one from its
70
+ results (#428).
71
+
72
+ - **P50 and P95 latency in the dashboard's model comparison**
73
+ (`actionagent`). The comparison table gains a P50 / P95 column and each
74
+ model's scorecard a p50 / p95 figure, from the percentiles the run
75
+ recorded; a run recorded before shows neither (#428).
76
+
77
+ - **The model comparison plotted in the dashboard** (`actionagent`). A
78
+ run that compared models shows, under its comparison table, each model's
79
+ pass rate inside its 95% interval, best first, with the run's
80
+ conclusion and how the intervals were computed, from the comparison the
81
+ run recorded (#428).
82
+
83
+ - Parallel scenario evaluations in `actionagent`: with
84
+ `config.scenario_evaluation_fan_out = true`, a run enqueues one
85
+ `EvaluationReplayJob` per scenario × model, each stored replay is scored by
86
+ its own `EvaluationJudgeJob`, and the last result to be scored completes the
87
+ run with the scores a sequential run writes. Runs as wide as the host's
88
+ `:agents` queue. Off by default; browser and host-adapter runs stay
89
+ sequential (#422).
90
+ - `ActiveAgent::Evals::Runner.new(refine_gate:)`, a callable asked before each
91
+ recommendation call so per-scenario runners share one run-wide judge limit.
92
+
93
+ - **Nested arrays of objects in ActiveModel schemas** (`activeagent`).
94
+ `has_many :addresses, class_name: "Address"` on an ActiveModel class that
95
+ includes `ActiveAgent::SchemaGenerator` makes `to_json_schema` emit the
96
+ attribute as an array of `$ref`s to the item class's schema, which goes
97
+ under the root `$defs`. `strict:` and `additional_properties:` reach the
98
+ nested schema, a nested class's own definitions move up to the root, and
99
+ a class that nests itself raises `ArgumentError`. A length validation on
100
+ the array sets its item count (`min_items` / `max_items`) rather than a
101
+ string's length. `has_many` also declares the attribute with an
102
+ empty-array default, and is added only to a class without one, so
103
+ ActiveRecord's is untouched (#260).
104
+
105
+ - **Evaluation runs record their judge** (`actionagent`), in
106
+ `scores["_judge"]`: the `EvaluationJudgeAgent` it runs as, its provider,
107
+ the model it asked for and where that setting came from (the evaluation,
108
+ the provider key, the host's config, or the provider's default), and the
109
+ models that answered, with call counts. The suite's Models panel shows
110
+ "judged by <model>" with an `auto` badge when no model is pinned, and
111
+ opens the judge's configuration, with a field that pins `judge_model` on
112
+ the evaluation through `PATCH /api/evaluations/:id` (#605).
113
+
114
+
115
+ ### Changed
116
+
117
+ - **A scenario suite's page reads like a build page** (`actionagent`). The
118
+ page opened on a summary, every run, the costs, the models and every
119
+ scenario stacked down the page, so on a suite of 100 scenarios and 12 runs
120
+ the selected run's results started three screens down. It now opens on the
121
+ selected run: a run header with the run's passed count, models, agent and
122
+ judge spend, a to-fix count and a progress bar; beside it a rail of runs,
123
+ each with a pass bar per model, and a box of the run's scenarios that
124
+ scrolls on its own, one line per step (`[+]` passed, `[!]` failed, `[~]`
125
+ running, `[ ]` queued, `[?]` errored, `[-]` skipped) under a sticky header
126
+ and foldable group rows, with a step's detail opening in place. A toolbar
127
+ filters the steps to failed only, puts failed first, shows the tool cells
128
+ and follows the running step until the person scrolls. Models, what to fix
129
+ and the footer follow below; a failed run shows its error in the header
130
+ instead of models. Selecting a run keeps the page where it is, polling
131
+ backs off and stops in a hidden tab, and below 940px the rail becomes a
132
+ strip of the latest runs. The same suite is now 2,786px tall where it was
133
+ 11,354px (#602).
134
+
135
+ ### Fixed
136
+
137
+ - **`max_retries` sets the Anthropic `json_object` retries** (`activeagent`).
138
+ `AnthropicProvider` read the count from its constructor's arguments after
139
+ `BaseProvider` had already moved `max_retries` into the provider's options,
140
+ so an unparseable `json_object` answer was always retried twice, whatever
141
+ `max_retries` said, on Anthropic and on Bedrock, which inherits it. The
142
+ count now comes from the options: `max_retries: 0` sends the request once,
143
+ `max_retries: 5` up to six times, and the default is still two retries
144
+ (#531).
145
+ - **A streamed Anthropic answer with citations no longer raises.** Claude
146
+ streams the citations of a text block as `citations_delta` deltas when it
147
+ cites a document or a web search result, and the Anthropic provider raised
148
+ `Unexpected delta type: citations_delta` on the first one. The delta is now
149
+ skipped like `input_json_delta`: the full block, citations included, arrives
150
+ at `content_block_stop`.
151
+ - **A streamed OpenAI Chat refusal, or a stream with logprobs, no longer
152
+ raises.** The openai gem's chat stream emits `refusal.delta` and
153
+ `refusal.done` when the model refuses, and `logprobs.*` events when the
154
+ request sets `logprobs`, and the Chat provider failed on them with
155
+ `Unexpected Response Event Type`. They are now handled, and `refusal.done`
156
+ ends the generation, since a refused message never gets a `content.done`.
157
+ - **A streamed Responses answer that used a built-in tool no longer raises.**
158
+ With `tools: [{ type: "web_search_preview" }]` (or file search, image
159
+ generation, code interpreter, remote MCP), the stream carries the tool's
160
+ call as an output item (`web_search_call`, ...), its progress events
161
+ (`response.web_search_call.searching`, ...), and
162
+ `response.output_text.annotation.added` for the citations, and the
163
+ Responses provider raised `Unexpected Item Type: web_search_call` on the
164
+ first of them. The tool calls are now kept like in a non-streamed response,
165
+ and their events are skipped.
166
+ - **A streamed Responses answer cut short no longer raises.** When a response
167
+ hits `max_output_tokens`, the stream ends with `response.incomplete` instead
168
+ of `response.completed`, and the Responses provider raised `Unexpected
169
+ Response Chunk Type: response.incomplete`. It now ends the generation with
170
+ the text streamed so far, like `response.completed`.
171
+ - **A streamed Responses refusal no longer raises.** The provider raised
172
+ `Unexpected Response Chunk Type: response.refusal.delta` when the model
173
+ refused; the refusal events are now skipped and the response completes.
174
+ - **A streamed Responses generation reports its token usage.** The usage of a
175
+ streamed response is only on its final event, and the Responses provider
176
+ never read it, so `response.usage` was `nil`. It is now recorded from
177
+ `response.completed` or `response.incomplete`, as the Chat provider does
178
+ from its usage chunk.
179
+ - **Delegation contracts built from a `SchemaGenerator` class**
180
+ (`activeagent`). `delegation ..., schema: TicketForm` and
181
+ `returns TicketForm` produced a contract with no properties, because
182
+ `to_json_schema` wraps the schema in `{ name:, schema:, strict: }` and
183
+ the contract read only a top-level `properties`. The wrapper is now
184
+ unwrapped, and `$defs` are kept so the `$ref`s that nested schemas use
185
+ still resolve. A contract built from a class is sent with the strict
186
+ setting the class declared: `Model.to_json_schema` is not strict unless
187
+ asked, and its `required` then names only the presence-validated
188
+ attributes, which OpenAI's strict mode refuses, so `returns Model` goes
189
+ out with `strict: false` and `returns Model.to_json_schema(strict: true)`
190
+ strict with every attribute required.
191
+
192
+ - **Structured-output schema normalization preserves field names**
193
+ (`activeagent`). `prompt` converts JSON Schema keywords to camel case
194
+ without renaming properties or definitions, keeping multiword attributes
195
+ and `has_many` associations consistent with `required` and `$ref` in
196
+ strict schemas. Literal data in `default`, `const`, `enum` and `examples`
197
+ is preserved too (#260).
198
+
199
+ Property names now reach the provider as declared, so a `first_name`
200
+ attribute is sent as `first_name` rather than `firstName`, and the model
201
+ answers with that key. `parsed_json` underscores keys either way, so code
202
+ reading it is unaffected; code reading the raw `message.content`, or
203
+ matching on request payloads, sees snake_case where it saw camelCase.
204
+
205
+ A delegation's `returns` schema follows suit: its `required` entries are
206
+ no longer camelized, so a multiword required key such as `max_points`
207
+ matches its property in strict mode instead of being sent as
208
+ `maxPoints`.
209
+ - **A judge with no `judge_model` is labelled with the model that
210
+ answered** (`actionagent`), not its provider ("judged by openrouter").
211
+ OpenRouter's default, `openrouter/auto`, routes each call, so a run judged
212
+ by more than one model is labelled "<provider> default" (#605).
9
213
 
10
214
  ## [1.9.1] - 2026-10-16
11
215
 
@@ -397,13 +397,38 @@ module ActiveAgent
397
397
 
398
398
  if response_format[:type].to_sym == :json_schema
399
399
  response_format = nest_flat_json_schema(response_format)
400
- response_format[:json_schema] = prompt_view_schema(response_format[:json_schema])
401
- response_format[:json_schema].deep_transform_keys! { _1.to_s.camelize(:lower) }
400
+ response_format[:json_schema] = normalize_json_schema(prompt_view_schema(response_format[:json_schema]))
402
401
  end
403
402
 
404
403
  response_format
405
404
  end
406
405
 
406
+ # Convert Ruby-style schema keywords without changing property names,
407
+ # definition names, references or literal data (default, const, enum,
408
+ # examples). Only schema-valued keywords are traversed. Named maps keep
409
+ # their keys, so required entries and $ref paths still address them.
410
+ #
411
+ # @api private
412
+ def normalize_json_schema(schema)
413
+ return schema.map { |item| normalize_json_schema(item) } if schema.is_a?(Array)
414
+ return schema unless schema.is_a?(Hash)
415
+
416
+ schema.to_h do |key, value|
417
+ keyword = key.to_s.camelize(:lower)
418
+ normalized = case keyword
419
+ when "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"
420
+ value.is_a?(Hash) ? value.transform_values { |definition| normalize_json_schema(definition) } : value
421
+ when "schema", "items", "prefixItems", "additionalItems", "contains", "additionalProperties",
422
+ "unevaluatedItems", "unevaluatedProperties", "propertyNames", "contentSchema",
423
+ "allOf", "anyOf", "oneOf", "not", "if", "then", "else"
424
+ normalize_json_schema(value)
425
+ else
426
+ value
427
+ end
428
+ [ keyword, normalized ]
429
+ end
430
+ end
431
+
407
432
  # Moves a json_schema given flat, with its name, schema and strict flag
408
433
  # beside +type+, under +json_schema+, so it is not mistaken for a
409
434
  # json_schema that names a view. Without a top-level schema, or with a
@@ -56,15 +56,25 @@ module ActiveAgent
56
56
  schema
57
57
  end
58
58
 
59
- # @param hash [Hash] JSON Schema object
59
+ # @param hash [Hash] JSON Schema object, or the +{ name:, schema:, strict: }+
60
+ # wrapper {ActiveAgent::SchemaGenerator} returns around one
60
61
  # @return [Schema]
61
62
  def from_hash(hash)
62
63
  hash = hash.deep_symbolize_keys
64
+ strict = nil
65
+ if !hash.key?(:properties) && hash[:schema].is_a?(Hash)
66
+ # SchemaGenerator's wrapper carries +strict: true+ only when the
67
+ # schema was built for strict mode; otherwise it is not strict.
68
+ strict = hash[:strict] == true
69
+ hash = hash[:schema]
70
+ end
63
71
 
64
72
  new.tap do |schema|
65
73
  (hash[:properties] || {}).each { |name, definition| schema.property(name, definition) }
66
74
  schema.required(*Array(hash[:required]))
67
75
  schema.additional_properties(hash.fetch(:additionalProperties, hash.fetch(:additional_properties, false)))
76
+ schema.definitions(hash[:$defs]) if hash[:$defs]
77
+ schema.strict(strict) unless strict.nil?
68
78
  end
69
79
  end
70
80
  end
@@ -73,6 +83,8 @@ module ActiveAgent
73
83
  @properties = {}
74
84
  @required = []
75
85
  @additional_properties = false
86
+ @definitions = {}
87
+ @strict = nil
76
88
  end
77
89
 
78
90
  # @return [Boolean] true when nothing has been declared
@@ -158,6 +170,29 @@ module ActiveAgent
158
170
  @additional_properties = value
159
171
  end
160
172
 
173
+ # Declares whether the schema was built for a provider's strict mode,
174
+ # as the +strict:+ of the wrapper {ActiveAgent::SchemaGenerator} returns
175
+ # says. Strict mode wants every property in +required+; a
176
+ # SchemaGenerator schema names only the attributes a presence
177
+ # validation covers unless it was built with +strict: true+, so a
178
+ # class is sent as it declared itself (see #to_response_format).
179
+ #
180
+ # @param value [Boolean]
181
+ # @return [void]
182
+ def strict(value = true)
183
+ @strict = value
184
+ end
185
+
186
+ # Declares the subschemas that properties reference with
187
+ # +"$ref": "#/$defs/name"+, such as the nested schemas
188
+ # {ActiveAgent::SchemaGenerator} emits for associations.
189
+ #
190
+ # @param definitions [Hash{Symbol => Hash}]
191
+ # @return [void]
192
+ def definitions(definitions)
193
+ @definitions = @definitions.merge(definitions.deep_symbolize_keys)
194
+ end
195
+
161
196
  # @return [Array<Symbol>] declared property names
162
197
  def keys
163
198
  @properties.keys
@@ -177,27 +212,30 @@ module ActiveAgent
177
212
  type: "object",
178
213
  properties: @properties.deep_dup,
179
214
  required: @required.map(&:to_s),
180
- additionalProperties: @additional_properties
181
- }
215
+ additionalProperties: @additional_properties,
216
+ "$defs": (@definitions.deep_dup if @definitions.any?)
217
+ }.compact
182
218
  end
183
219
  alias_method :to_h, :to_json_schema
184
220
 
185
221
  # JSON Schema for a +response_format+ payload.
186
222
  #
187
- # ActiveAgent camelizes response-format schema *keys* on the way to the
188
- # provider, but the +required+ array holds string *values* — so they are
189
- # camelized here to keep the emitted schema internally consistent.
190
- # {Assistant#parsed_json} underscores the keys again on the way back, so
191
- # agent code only ever sees snake_case.
223
+ # ActiveAgent sends property names to the provider as declared, so the
224
+ # +required+ array names them as declared too.
225
+ # {Assistant#parsed_json} underscores the keys of the answer, so agent
226
+ # code only ever sees snake_case.
192
227
  #
193
228
  # @param name [String] schema name reported to the provider
194
- # @param strict [Boolean]
229
+ # @param strict [Boolean, nil] nil follows the source: what a
230
+ # SchemaGenerator class declared (not strict unless built with
231
+ # +strict: true+, since its +required+ otherwise lists only the
232
+ # presence-validated attributes, which strict mode refuses), else true
195
233
  # @return [Hash]
196
- def to_response_format(name:, strict: true)
197
- schema = to_json_schema
198
- schema[:required] = schema[:required].map { |key| key.camelize(:lower) }
234
+ def to_response_format(name:, strict: nil)
235
+ strict = @strict if strict.nil?
236
+ strict = true if strict.nil?
199
237
 
200
- { name: name, schema: schema, strict: strict }
238
+ { name: name, schema: to_json_schema, strict: strict }
201
239
  end
202
240
 
203
241
  # Validates a parsed payload against the declared required properties.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Whether the gap between the models of a run is larger than the noise of
6
+ # its scenario sample: each model's pass rate with a confidence interval,
7
+ # and an interval for the difference between every pair of models.
8
+ #
9
+ # The scenario is the unit. A model's pass rate is the mean over its
10
+ # scenarios of the share of its results there that passed (1.0 or 0.0
11
+ # for one result per scenario), and the intervals come from resampling
12
+ # whole scenarios with replacement, +resamples+ times: the percentile
13
+ # bootstrap. A pair is compared on the scenarios both models ran, each
14
+ # resample drawing the same scenarios for both (a paired bootstrap of
15
+ # the per-scenario differences). A pair is decisive when its interval
16
+ # excludes zero.
17
+ #
18
+ # The resampling is seeded, so the same results always give the same
19
+ # intervals and a report renders the same page each time.
20
+ #
21
+ # comparison = Comparison.new(results: report.results, models: report.models)
22
+ # comparison.to_h["pairs"].first
23
+ # # => { "models" => ["gpt-5-mini", "qwen3:8b"], "scenarios" => 42, "difference" => 7.1,
24
+ # # "interval" => [0.0, 16.7], "decisive" => false }
25
+ # comparison.conclusion
26
+ # # => "The pairwise difference interval includes zero: the ranking is not decisive evidence of superiority."
27
+ class Comparison
28
+ RESAMPLES = 5_000
29
+ CONFIDENCE = 0.95
30
+ SEED = 0
31
+ # Fewer scenarios than this give no interval: resampling one scenario
32
+ # only ever draws that scenario.
33
+ MINIMUM_SCENARIOS = 2
34
+
35
+ attr_reader :resamples, :confidence, :seed
36
+
37
+ # @param results [Array<Result>]
38
+ # @param models [Array<ModelSpec>] in the run's order, which breaks ties in the ranking
39
+ # @param resamples [Integer]
40
+ # @param confidence [Float] 0..1
41
+ # @param seed [Integer]
42
+ def initialize(results:, models:, resamples: RESAMPLES, confidence: CONFIDENCE, seed: SEED)
43
+ @labels = models.map(&:label)
44
+ @resamples = resamples
45
+ @confidence = confidence
46
+ @seed = seed
47
+ @outcomes = @labels.to_h do |label|
48
+ by_scenario = results.select { |result| result.label == label }.group_by { |result| result.scenario.key }
49
+ [ label, by_scenario.transform_values { |cohort| cohort.count(&:passed?).fdiv(cohort.size) } ]
50
+ end
51
+ end
52
+
53
+ # Per model label, in the run's order: `{ "scenarios", "pass_rate",
54
+ # "interval" }`, the pass rate and its interval in percent. The interval
55
+ # is nil below MINIMUM_SCENARIOS, and the pass rate nil for none.
56
+ def models
57
+ @models ||= @labels.to_h do |label|
58
+ values = @outcomes[label].values
59
+ [ label, {
60
+ "scenarios" => values.size,
61
+ "pass_rate" => percent(mean(values)),
62
+ "interval" => interval(values)
63
+ } ]
64
+ end
65
+ end
66
+
67
+ # The labels by pass rate, best first; ties keep the run's order.
68
+ def ranking
69
+ @ranking ||= @labels.sort_by.with_index { |label, index| [ -(models[label]["pass_rate"] || -1), index ] }
70
+ end
71
+
72
+ # One entry per pair of models, the better ranked first: `{ "models" =>
73
+ # [a, b], "scenarios", "difference", "interval", "decisive" }`, where
74
+ # "difference" is a's pass rate minus b's over the scenarios both ran,
75
+ # in percentage points.
76
+ def pairs
77
+ @pairs ||= ranking.combination(2).map do |a, b|
78
+ shared = @outcomes[a].keys & @outcomes[b].keys
79
+ differences = shared.map { |key| @outcomes[a][key] - @outcomes[b][key] }
80
+ range = interval(differences)
81
+
82
+ {
83
+ "models" => [ a, b ],
84
+ "scenarios" => shared.size,
85
+ "difference" => percent(mean(differences)),
86
+ "interval" => range,
87
+ "decisive" => !range.nil? && (range.first.positive? || range.last.negative?)
88
+ }
89
+ end
90
+ end
91
+
92
+ # The honest read of the ranking, in a sentence: which pairs differ by
93
+ # more than the noise, or that none does. nil for a single model.
94
+ def conclusion
95
+ return nil if pairs.empty?
96
+
97
+ compared, too_few = pairs.partition { |pair| pair["interval"] }
98
+ decisive, inconclusive = compared.partition { |pair| pair["decisive"] }
99
+ sentences =
100
+ if compared.empty?
101
+ [ "The models share too few scenarios to compare." ]
102
+ elsif decisive.empty?
103
+ [ "#{all_include_zero(compared.size)}: the ranking is not decisive evidence of superiority." ]
104
+ else
105
+ decisive.map { |pair| decisive_sentence(pair) } + (inconclusive.any? ? [ others_include_zero(inconclusive.size) ] : [])
106
+ end
107
+ sentences << "#{plural(too_few.size, 'pair')} share#{'s' if too_few.one?} too few scenarios to compare." if compared.any? && too_few.any?
108
+ sentences.join(" ")
109
+ end
110
+
111
+ # How the intervals were computed, for a caption.
112
+ def method_summary
113
+ { "statistic" => "pass rate", "interval" => "percentile bootstrap", "unit" => "scenario",
114
+ "resamples" => @resamples, "confidence" => @confidence, "seed" => @seed }
115
+ end
116
+
117
+ def to_h
118
+ { "method" => method_summary, "models" => models, "ranking" => ranking, "pairs" => pairs, "conclusion" => conclusion }
119
+ end
120
+
121
+ private
122
+
123
+ # The bootstrap interval of the mean of +values+, in percent, or nil
124
+ # below MINIMUM_SCENARIOS.
125
+ def interval(values)
126
+ return nil if values.size < MINIMUM_SCENARIOS
127
+
128
+ random = Random.new(@seed)
129
+ size = values.size
130
+ means = Array.new(@resamples) do
131
+ sum = 0.0
132
+ size.times { sum += values[random.rand(size)] }
133
+ sum / size
134
+ end
135
+ # A rational tail, so 95% leaves exactly 2.5: in floating point it
136
+ # is 2.5000000000000022, whose nearest rank is one too high.
137
+ tail = (1 - @confidence.rationalize) / 2 * 100
138
+ [ percent(Statistics.percentile(means, tail)), percent(Statistics.percentile(means, 100 - tail)) ]
139
+ end
140
+
141
+ def mean(values)
142
+ values.sum.fdiv(values.size) if values.any?
143
+ end
144
+
145
+ def percent(fraction)
146
+ (fraction * 100).round(1) if fraction
147
+ end
148
+
149
+ def all_include_zero(count)
150
+ count == 1 ? "The pairwise difference interval includes zero" : "All #{count} pairwise difference intervals include zero"
151
+ end
152
+
153
+ def others_include_zero(count)
154
+ count == 1 ? "The other pairwise interval includes zero." : "The other #{count} pairwise intervals include zero."
155
+ end
156
+
157
+ def decisive_sentence(pair)
158
+ better, worse = pair["difference"].positive? ? pair["models"] : pair["models"].reverse
159
+ low, high = pair["interval"].map(&:abs).sort
160
+ "#{better} passes more scenarios than #{worse}, by #{pair['difference'].abs} points " \
161
+ "(#{(@confidence * 100).round}% CI #{low} to #{high})."
162
+ end
163
+
164
+ def plural(count, word)
165
+ "#{count} #{word}#{'s' unless count == 1}"
166
+ end
167
+ end
168
+ end
169
+ end
@@ -37,9 +37,12 @@ module ActiveAgent
37
37
  # @param release [Hash, nil] the release of the agent under evaluation,
38
38
  # `{ "digest", "revision", "label" }`, so the report names the code
39
39
  # it scored: in `to_h["release"]` and as a header chip.
40
+ # @param comparison [Hash, nil] a comparison already recorded for these
41
+ # results (Comparison#to_h). A report rebuilt from a persisted run
42
+ # renders the intervals that run recorded instead of resampling them.
40
43
  def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
41
44
  tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
42
- judge_usage: nil, release: nil)
45
+ judge_usage: nil, release: nil, comparison: nil)
43
46
  @results = results
44
47
  @models = models
45
48
  @judge = judge
@@ -53,6 +56,7 @@ module ActiveAgent
53
56
  @recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
54
57
  @run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
55
58
  @release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
59
+ @recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
56
60
  end
57
61
 
58
62
  # The release of the agent this report scored, `{ "digest",
@@ -64,7 +68,8 @@ module ActiveAgent
64
68
  end
65
69
 
66
70
  # Per model, keyed by label: scenario count, passes, errors, pass rate,
67
- # mean score, mean latency, tokens, cost and fault counts. "cost" sums
71
+ # mean score, latency (mean, and the P50 and P95 by nearest rank,
72
+ # Statistics.percentile), tokens, cost and fault counts. "cost" sums
68
73
  # the replays that carried a cost — "priced" of the "scenarios", of
69
74
  # which "reported" came from the caller and "estimated" from tokens ×
70
75
  # a model rate (Result#cost_source) — and is nil when none did.
@@ -90,6 +95,8 @@ module ActiveAgent
90
95
  "avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
91
96
  "avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
92
97
  "avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
98
+ "p50_duration_ms" => Statistics.percentile(durations, 50)&.round,
99
+ "p95_duration_ms" => Statistics.percentile(durations, 95)&.round,
93
100
  "input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
94
101
  "output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
95
102
  "cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
@@ -267,14 +274,27 @@ module ActiveAgent
267
274
  verdict&.dig("winner")
268
275
  end
269
276
 
270
- # Adds "judge_usage" and "release" only when there is one to add, so
271
- # a report built the way it always was serializes exactly as before.
277
+ # Whether the gap between the models is real (Comparison#to_h): each
278
+ # model's pass rate with its interval, every pair's difference with
279
+ # its interval and whether it is decisive, and the conclusion. The
280
+ # comparison recorded for these results when one was handed in. nil
281
+ # for a single model.
282
+ def comparison
283
+ return nil unless comparing?
284
+
285
+ @comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
286
+ end
287
+
288
+ # Adds "judge_usage" and "release" only when there is one to add, and
289
+ # "comparison" only when comparing models, so a single-model report
290
+ # built the way it always was serializes exactly as before.
272
291
  def to_h
273
292
  {
274
293
  "models" => summary_by_model,
275
294
  "criteria" => criterion_scores,
276
295
  "recommendations" => recommendations,
277
296
  "verdict" => verdict,
297
+ "comparison" => comparison,
278
298
  "judge" => @judge_label || @judge&.label,
279
299
  "judge_usage" => judge_usage,
280
300
  "release" => release,
@@ -298,6 +318,7 @@ module ActiveAgent
298
318
  lines.concat(total_lines)
299
319
  lines << "**Best model: #{winner}**" if winner
300
320
  lines << ""
321
+ lines.concat(comparison_lines)
301
322
  lines.concat(matrix_table)
302
323
  lines.concat(detail_lines)
303
324
  lines.concat(recommendation_lines)
@@ -525,22 +546,27 @@ module ActiveAgent
525
546
 
526
547
  # --- Markdown ----------------------------------------------------------
527
548
 
528
- # The per-model table. A Judge column joins it when a judge spent
529
- # anything, so the agent's cost and the evaluation's stay two numbers.
549
+ # The per-model table. Cost / 1,000 puts every model on one
550
+ # denominator however many of its scenarios ran. A Judge column joins
551
+ # it when a judge spent anything, so the agent's cost and the
552
+ # evaluation's stay two numbers.
530
553
  def summary_table
531
554
  judged = judge_usage.present?
532
- columns = [ "Model", "Passed", "Mean score", "Mean latency", "Tokens in/out", "Cost", ("Judge" if judged), "Faults" ].compact
555
+ columns = [ "Model", "Passed", "Mean score", "Mean latency", "Median / P95", "Tokens in/out", "Cost", "Cost / 1,000", ("Judge" if judged), "Faults" ].compact
533
556
  header = [ "| #{columns.join(' | ')} |", "|#{columns.map { '---' }.join('|')}|" ]
534
557
  rows = summary_by_model.map do |label, stats|
535
558
  faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
536
559
  latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
560
+ percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
537
561
  cells = [
538
562
  "`#{label}`",
539
563
  Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
540
564
  Format.score(stats["avg_score"]),
541
565
  latency,
566
+ percentiles,
542
567
  "#{stats['input_tokens']}/#{stats['output_tokens']}",
543
568
  Format.money(stats["cost"], estimated: estimated_cost?(stats)),
569
+ per_thousand_cell(stats),
544
570
  (judge_cell(stats) if judged),
545
571
  faults.presence || "—"
546
572
  ].compact
@@ -549,6 +575,14 @@ module ActiveAgent
549
575
  header + rows
550
576
  end
551
577
 
578
+ # "$3.6300": what 1,000 scenario runs cost at the model's mean cost
579
+ # per priced replay, marked "~" when that mean is estimated or covers
580
+ # only some of the replays. "—" when none was priced.
581
+ def per_thousand_cell(stats)
582
+ per = cost_per_priced(stats)
583
+ per ? Format.money(per * 1_000, estimated: estimated_cost?(stats)) : "—"
584
+ end
585
+
552
586
  # "~$0.0030 (3 calls)" for a model's judge spend, "—" when the judge
553
587
  # was not asked about its results.
554
588
  def judge_cell(stats)
@@ -572,6 +606,38 @@ module ActiveAgent
572
606
  lines << ""
573
607
  end
574
608
 
609
+ # Each model's pass rate with its interval, best first, then every
610
+ # pair's difference with its interval, the conclusion, and how the
611
+ # intervals were computed. Nothing for a single model.
612
+ def comparison_lines
613
+ return [] unless comparison
614
+
615
+ method = comparison["method"]
616
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
617
+ lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
618
+ comparison["ranking"].each do |label|
619
+ stats = comparison["models"][label]
620
+ lines << "| `#{label}` | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
621
+ end
622
+ lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
623
+ comparison["pairs"].each do |pair|
624
+ a, b = pair["models"]
625
+ difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
626
+ lines << "| `#{a}` vs `#{b}` | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
627
+ end
628
+ lines += [ "", comparison["conclusion"].to_s, "",
629
+ "_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
630
+ end
631
+
632
+ def rate_text(rate)
633
+ rate ? "#{rate}%" : "—"
634
+ end
635
+
636
+ # "73.8 to 95.2", or "—" when there were too few scenarios.
637
+ def interval_text(interval)
638
+ interval ? "#{interval.first} to #{interval.last}" : "—"
639
+ end
640
+
575
641
  # The scenario matrix, with a trailing Cost column: what the scenario
576
642
  # cost across every model, and in brackets what the judge spent on it.
577
643
  def matrix_table
@@ -151,6 +151,14 @@ module ActiveAgent
151
151
  "#{format("%.#{n >= 10_000 ? 1 : 2}f", n / 1_000).sub(/\.?0+\z/, '')}s"
152
152
  end
153
153
 
154
+ # "4.02s / 22.2s": a model's P50 and P95 latency; "—" when no replay
155
+ # was timed.
156
+ def fmt_percentiles(stats)
157
+ return "—" if stats["p50_duration_ms"].nil?
158
+
159
+ "#{fmt_ms(stats['p50_duration_ms'])} / #{fmt_ms(stats['p95_duration_ms'])}"
160
+ end
161
+
154
162
  # Money reads "~$0.0243" when any part of it was estimated from tokens
155
163
  # × a model rate rather than reported (Format.money); the footer's
156
164
  # legend explains the mark once.
@@ -314,6 +322,7 @@ module ActiveAgent
314
322
  <div class="panel">
315
323
  <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span></div>
316
324
  #{html_comparison_table if comparing?}
325
+ #{html_intervals}
317
326
  #{blocks.join}
318
327
  #{verdict_row}
319
328
  </div>
@@ -335,12 +344,49 @@ module ActiveAgent
335
344
 
336
345
  <<~TABLE
337
346
  <div class="compare"><table>
338
- <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
347
+ <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Median and 95th percentile latency, nearest rank">P50 / P95</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
339
348
  <tbody>#{rows.map { |label, stats| html_comparison_row(label, stats) }.join}</tbody>
340
349
  </table></div>
341
350
  TABLE
342
351
  end
343
352
 
353
+ # The ranked interval plot under the comparison table (Report#comparison):
354
+ # one row per model, best first, its pass rate a dot inside its interval
355
+ # on a fixed 0-100% axis, so a narrow gap is not magnified, with the
356
+ # figures written out beside it; then the conclusion and how the
357
+ # intervals were computed. Nothing for a single model. The rows and the
358
+ # axis share one grid (.iv-row is display: contents), so every track
359
+ # spans the same width and a tick sits under the dots it names.
360
+ def html_intervals
361
+ return "" unless comparison
362
+
363
+ method = comparison["method"]
364
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
365
+ rows = comparison["ranking"].map { |label| html_interval_row(label, comparison["models"][label] || {}) }
366
+ ticks = [ 0, 25, 50, 75, 100 ].map { |tick| %(<span class="iv-tick" style="left:#{tick}%">#{tick}%</span>) }
367
+
368
+ <<~PLOT
369
+ <div class="intervals">
370
+ <div class="micro sm">Pass rate · #{h(confidence)}</div>
371
+ #{rows.join}
372
+ <div class="iv-row iv-axis" aria-hidden="true"><span></span><span class="iv-track">#{ticks.join}</span><span></span></div>
373
+ <p class="iv-conclusion">#{h(comparison['conclusion'])}</p>
374
+ <p class="iv-caption">#{h(confidence)} from #{h(Format.delimited(method['resamples']))} resamples of whole scenarios</p>
375
+ </div>
376
+ PLOT
377
+ end
378
+
379
+ def html_interval_row(label, stats)
380
+ rate = stats["pass_rate"]
381
+ low, high = stats["interval"]
382
+ range = high ? %(<span class="iv-range" style="left:#{low}%;width:#{(high - low).round(1)}%"></span>) : ""
383
+ point = rate ? %(<span class="iv-point" style="left:#{rate}%"></span>) : ""
384
+ figures = "<b>#{rate ? "#{rate}%" : '—'}</b> #{high ? "#{low} to #{high}" : 'no interval'} · #{h(plural(stats['scenarios'].to_i, 'scenario'))}"
385
+
386
+ %(<div class="iv-row"><span class="iv-name">#{h(short_name(model_by_label(label)))}</span>) +
387
+ %(<span class="iv-track">#{range}#{point}</span><span class="iv-figures">#{figures}</span></div>\n)
388
+ end
389
+
344
390
  def html_comparison_row(label, stats)
345
391
  short, provider = split_label(model_by_label(label))
346
392
  total = stats["scenarios"].to_i
@@ -362,6 +408,7 @@ module ActiveAgent
362
408
  <td class="num ratio tone-#{tone_for(ratio)}">#{h(fmt_passes(stats['passed'], total))}</td>
363
409
  <td class="num">#{h(fmt_score(stats['avg_score']))}</td>
364
410
  <td class="num">#{h(fmt_ms(stats['avg_duration_ms']))}</td>
411
+ <td class="num">#{h(fmt_percentiles(stats))}</td>
365
412
  <td class="num"#{tokens_title}>#{tokens_cell}</td>
366
413
  <td class="num"#{cost_title_attr(results_for(label), estimated: estimated)}>#{cost_cell}</td>
367
414
  #{judge_cell}<td class="fault">#{typical_fault_text(label, stats)}</td>
@@ -417,7 +464,7 @@ module ActiveAgent
417
464
  <<~BLOCK
418
465
  <div class="model">
419
466
  <div class="line"><span class="name">#{h(short)}</span><span class="provider">#{h(provider)}</span>#{pick}<span class="pass"><span class="bar bar-#{tone}"><span style="width:#{(ratio * 100).round}%"></span></span><span class="ratio tone-#{tone}">#{h(fmt_passes(stats['passed'], total))}</span></span></div>
420
- <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
467
+ <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span>p50 / p95 <b>#{h(fmt_percentiles(stats))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
421
468
  <div class="faults">#{faults_html}</div>
422
469
  </div>
423
470
  BLOCK
@@ -830,6 +877,21 @@ module ActiveAgent
830
877
  .pick-model input { position: absolute; opacity: 0; width: 0; height: 0; }
831
878
  .pick-model:has(input:checked) { border-color: var(--color-accent-ui); background: var(--color-accent-ui-tint); color: var(--color-accent-ui); }
832
879
  .clean { font-family: var(--font-mono); font-size: 11px; color: var(--color-success-text); }
880
+ .intervals { padding: 10px 12px; border-top: 1px solid var(--color-border-light); display: grid; grid-template-columns: minmax(120px, 200px) minmax(160px, 1fr) minmax(190px, auto); align-items: center; gap: 6px 12px; }
881
+ .intervals > .micro, .iv-conclusion, .iv-caption { grid-column: 1 / -1; }
882
+ .iv-row { display: contents; }
883
+ .iv-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
884
+ .iv-track { position: relative; height: 14px; }
885
+ .iv-track::before { content: ""; position: absolute; left: 0; right: 0; top: 6px; height: 2px; background: var(--color-muted); }
886
+ .iv-range { position: absolute; top: 3px; height: 8px; box-sizing: border-box; border: 1px solid var(--color-info); border-radius: 999px; background: var(--color-info-soft); }
887
+ .iv-point { position: absolute; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--color-info); }
888
+ .iv-figures { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
889
+ .iv-figures b { color: var(--color-text-primary); }
890
+ .iv-axis .iv-track::before { display: none; }
891
+ .iv-tick { position: absolute; top: 0; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
892
+ .iv-conclusion { margin: 4px 0 0; font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
893
+ .iv-caption { margin: 0; font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
894
+ @media (max-width: 640px) { .intervals { grid-template-columns: minmax(0, 1fr); row-gap: 4px; } .iv-row + .iv-row .iv-name { margin-top: 6px; } .iv-axis { display: none; } }
833
895
  .verdict { padding: 10px 12px; border-top: 1px solid var(--color-border-light); font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
834
896
  .verdict .micro { margin-right: 8px; }
835
897
  .fixes { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
@@ -54,10 +54,14 @@ module ActiveAgent
54
54
  # @param release [Hash, nil] the release of the agent under evaluation,
55
55
  # `{ "digest", "revision", "label" }`, carried onto the Report so a
56
56
  # published run names the code it scored (see Report.new)
57
+ # @param refine_gate [#call, nil] asked before each recommendation call,
58
+ # after `judge_limit`; false skips it. A caller that scores each
59
+ # scenario in its own Runner (a background job per scenario) shares one
60
+ # run-wide limit this way, since `judge_limit` counts per instance.
57
61
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
58
62
  instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
59
63
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
60
- around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
64
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
61
65
  @scenarios = scenarios
62
66
  @models = models
63
67
  @replay = replay
@@ -75,6 +79,7 @@ module ActiveAgent
75
79
  @require_judge_scores = require_judge_scores
76
80
  @metadata = metadata
77
81
  @release = release
82
+ @refine_gate = refine_gate
78
83
  @judge_calls = 0
79
84
  @scorer = Scorer.new(criteria: criteria, judge: judge)
80
85
  end
@@ -187,6 +192,7 @@ module ActiveAgent
187
192
  def refine!(diagnosis, scenario, replay, result)
188
193
  return unless @judge && @refine_faults.include?(result.fault)
189
194
  return if @judge_calls >= @judge_limit
195
+ return if @refine_gate && !@refine_gate.call
190
196
 
191
197
  @judge_calls += 1
192
198
  refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The summary statistics a report computes over its results.
6
+ module Statistics
7
+ module_function
8
+
9
+ # Nearest-rank percentile: the value at rank ceil(pct / 100 × n) of the
10
+ # values sorted ascending, so the result is always one of the values. The
11
+ # dashboard's Metrics view uses the same rule (ActionAgent::MetricsReport),
12
+ # so a model's P95 reads the same in both. nil for no values.
13
+ #
14
+ # @param values [Array<Numeric>] in any order; nils are ignored
15
+ # @param pct [Numeric] 0..100
16
+ # @return [Numeric, nil]
17
+ def percentile(values, pct)
18
+ sorted = values.compact.sort
19
+ return nil if sorted.empty?
20
+
21
+ rank = (sorted.size * pct / 100.0).ceil
22
+ sorted[rank.clamp(1, sorted.size) - 1]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -19,6 +19,8 @@ require_relative "evals/catalog"
19
19
  require_relative "evals/model_spec"
20
20
  require_relative "evals/replay"
21
21
  require_relative "evals/format"
22
+ require_relative "evals/statistics"
23
+ require_relative "evals/comparison"
22
24
  require_relative "evals/scorer"
23
25
  require_relative "evals/diagnosis"
24
26
  require_relative "evals/judge"
@@ -43,7 +43,7 @@ module ActiveAgent
43
43
  def initialize(kwargs = {})
44
44
  super
45
45
 
46
- self.json_format_retry_count = kwargs[:max_retries] || ::Anthropic::Client::DEFAULT_MAX_RETRIES
46
+ self.json_format_retry_count = options.max_retries
47
47
  end
48
48
 
49
49
  # @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
@@ -222,6 +222,8 @@ module ActiveAgent
222
222
  # No-Op; Wait for Function call to be complete
223
223
  when :thinking_delta, :signature_delta
224
224
  # TODO: Add with thinking rendering support
225
+ when :citations_delta
226
+ # No-Op; the citations arrive with the full block at content_block_stop
225
227
  else
226
228
  raise "Unexpected delta type: #{api_delta.type}"
227
229
  end
@@ -143,6 +143,13 @@ module ActiveAgent
143
143
  # => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
144
144
  when :"tool_calls.function.arguments.done"
145
145
  # => => {type: :"tool_calls.function.arguments.done", index: 0, name: "get_current_weather", arguments: "{\"location\":\"Boston, MA\"}", parsed: nil}
146
+ when :"refusal.delta"
147
+ # No-Op; the refusal is merged into the message from the :chunk event
148
+ when :"refusal.done"
149
+ # A refused message ends here, with no content.done
150
+ self.stream_completion_pending = true
151
+ when :"logprobs.content.delta", :"logprobs.content.done", :"logprobs.refusal.delta", :"logprobs.refusal.done"
152
+ # No-Op; sent when the request asks for logprobs
146
153
  else
147
154
  fail "Unexpected Response Event Type: #{api_response_event.type}"
148
155
  end
@@ -15,6 +15,18 @@ module ActiveAgent
15
15
  class ResponsesProvider < Base
16
16
  include ToolChoiceClearing
17
17
 
18
+ # Output items of OpenAI's built-in tools (web search, file search,
19
+ # image generation, code interpreter, remote MCP).
20
+ BUILT_IN_TOOL_ITEM_TYPES = %i[
21
+ web_search_call file_search_call image_generation_call code_interpreter_call mcp_call mcp_list_tools
22
+ ].freeze
23
+
24
+ # Prefixes of the progress events those tools stream.
25
+ BUILT_IN_TOOL_EVENT_PREFIXES = %w[
26
+ response.web_search_call. response.file_search_call. response.image_generation_call.
27
+ response.code_interpreter_call response.mcp_call response.mcp_list_tools.
28
+ ].freeze
29
+
18
30
  # @return [Class]
19
31
  def self.options_klass
20
32
  Options
@@ -99,6 +111,7 @@ module ActiveAgent
99
111
  # - `:"response.content_part.done"` - content part completed
100
112
  # - `:"response.output_item.done"` - message, reasoning or function call completed
101
113
  # - `:"response.completed"` - response finished
114
+ # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
102
115
  #
103
116
  # @param api_response_event [Hash] streaming chunk with :type key
104
117
  # @return [void]
@@ -106,6 +119,12 @@ module ActiveAgent
106
119
  def process_stream_chunk(api_response_event)
107
120
  instrument("stream_chunk.active_agent", chunk_type: api_response_event.type)
108
121
 
122
+ # Built-in tools report their progress with their own events; the
123
+ # finished tool call arrives as an output item.
124
+ if BUILT_IN_TOOL_EVENT_PREFIXES.any? { api_response_event.type.to_s.start_with?(_1) }
125
+ return
126
+ end
127
+
109
128
  case api_response_event.type
110
129
  # Response Created
111
130
  when :"response.created", :"response.in_progress"
@@ -124,12 +143,20 @@ module ActiveAgent
124
143
  message[:content] += api_response_event.delta
125
144
  broadcast_stream_update(message, api_response_event.delta)
126
145
 
146
+ # -> -> -> Content Text Citation (e.g. from web search)
147
+ when :"response.output_text.annotation.added"
148
+ # No-Op: The text is complete at response.output_text.done
149
+
127
150
  # -> -> -> Content Text Completed [Full Text]
128
151
  when :"response.output_text.done"
129
152
  message = message_stack.find { _1[:id] == api_response_event.item_id }
130
153
  message[:content] = api_response_event.text
131
154
  broadcast_stream_update(message, nil) # Don't double send content
132
155
 
156
+ # -> -> -> Refusal (the response still ends with response.completed)
157
+ when :"response.refusal.delta", :"response.refusal.done"
158
+ # No-Op: The message has no refusal field to stream it into
159
+
133
160
  # -> -> -> Content Function Call Append
134
161
  when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
135
162
  # No-Op: Wait for FC to Land
@@ -147,8 +174,13 @@ module ActiveAgent
147
174
  when :"response.output_item.done"
148
175
  process_stream_output_item_done(api_response_event)
149
176
 
150
- # Response Completed
151
- when :"response.completed"
177
+ # Response Completed, or cut short (e.g. by max_output_tokens) with
178
+ # the output it has so far
179
+ when :"response.completed", :"response.incomplete"
180
+ # The streamed response carries no body for resolve_prompt to read
181
+ # usage from, so it is recorded from the final event.
182
+ record_stream_usage(api_response_event.response.usage)
183
+
152
184
  # Completion, tool loop included, runs from stream_finished! once
153
185
  # the stream has drained, so each turn's tool calls run once.
154
186
  self.stream_completion_pending = true
@@ -174,7 +206,7 @@ module ActiveAgent
174
206
  # PATCH: API returns an empty array instead of empty string due to a bug in their serialization
175
207
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
176
208
  message_stack << { content: "" }.merge(item_hash)
177
- when :function_call, :reasoning
209
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
178
210
  # No-Op: Wait for the item to land (-> response.output_item.done)
179
211
  else
180
212
  raise "Unexpected Item Type: #{api_response_event.item.type}"
@@ -194,7 +226,8 @@ module ActiveAgent
194
226
  case api_response_event.item.type
195
227
  when :message
196
228
  # No-Op: Message Up to Date
197
- when :function_call, :reasoning
229
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
230
+ # Kept like the non-streamed output, which holds every item
198
231
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
199
232
  message_stack << item_hash
200
233
  else
@@ -34,6 +34,21 @@ module ActiveAgent
34
34
  #
35
35
  # ContactForm.to_json_schema
36
36
  #
37
+ # @example ActiveModel with a nested array of objects
38
+ # class Person
39
+ # include ActiveModel::Model
40
+ # include ActiveModel::Attributes
41
+ # include ActiveAgent::SchemaGenerator
42
+ #
43
+ # attribute :name, :string
44
+ # has_many :addresses, class_name: "Address"
45
+ # end
46
+ #
47
+ # Person.to_json_schema
48
+ # # => { name: "person", schema: { type: "object", properties: {
49
+ # # name: {...}, addresses: { type: "array", items: { "$ref": "#/$defs/address" } } },
50
+ # # ..., "$defs": { address: { type: "object", ... } } } }
51
+ #
37
52
  # @see Builder
38
53
  module SchemaGenerator
39
54
  extend ActiveSupport::Concern
@@ -47,6 +62,13 @@ module ActiveAgent
47
62
  # Fallback for any class that includes this module
48
63
  extend ActiveModelClassMethods
49
64
  end
65
+
66
+ # The railtie includes this module into ActiveRecord::Base too, whose
67
+ # has_many must stay ActiveRecord's.
68
+ unless respond_to?(:has_many)
69
+ extend ActiveModelAssociations
70
+ class_attribute :schema_associations, instance_accessor: false, default: {}.freeze
71
+ end
50
72
  end
51
73
 
52
74
  # Class methods added to ActiveRecord models.
@@ -106,6 +128,40 @@ module ActiveAgent
106
128
  end
107
129
  end
108
130
 
131
+ # Nested schemas for ActiveModel classes, which have no associations of
132
+ # their own. Added only to a class without a +has_many+ already.
133
+ module ActiveModelAssociations
134
+ # Declares an array of objects described by another schema class.
135
+ #
136
+ # {ActiveModelClassMethods#to_json_schema} emits the attribute as an
137
+ # array whose items are a +$ref+ to that class's schema, which goes under
138
+ # the root +$defs+ keyed by the singular name in lower camel case
139
+ # (+addresses+ becomes +address+, +line_items+ becomes +lineItem+).
140
+ # The prompt preserves both property and definition names.
141
+ #
142
+ # Also declares the attribute, defaulting to an empty array, so the
143
+ # model accepts the data the schema describes.
144
+ #
145
+ # @param name [Symbol] the attribute holding the array
146
+ # @param class_name [String] full constant name of the item class, which
147
+ # generates JSON Schema too; resolved when the schema is generated
148
+ # @return [void]
149
+ # @raise [ArgumentError] when the class does not include
150
+ # +ActiveModel::Attributes+, which declares the attribute
151
+ #
152
+ # @example
153
+ # has_many :addresses, class_name: "Address"
154
+ def has_many(name, class_name:)
155
+ unless respond_to?(:attribute)
156
+ raise ArgumentError, "#{self.name || inspect}.has_many :#{name} needs ActiveModel::Attributes to declare the attribute; include it before calling has_many"
157
+ end
158
+
159
+ self.schema_associations = schema_associations.merge(name.to_sym => class_name).freeze
160
+
161
+ attribute name, default: -> { [] }
162
+ end
163
+ end
164
+
109
165
  # Internal builder class for constructing JSON Schemas from model classes.
110
166
  #
111
167
  # This class handles the actual schema generation logic, supporting both
@@ -122,24 +178,7 @@ module ActiveAgent
122
178
  #
123
179
  # @raise [ArgumentError] If model_class is not an ActiveRecord or ActiveModel class
124
180
  def self.json_schema_from_model(model_class, options = {})
125
- schema = {
126
- type: "object",
127
- properties: {},
128
- required: [],
129
- additional_properties: options.fetch(:additional_properties, false)
130
- }
131
-
132
- if defined?(ActiveRecord::Base) && model_class < ActiveRecord::Base
133
- schema = build_activerecord_schema(schema, model_class, options)
134
- elsif defined?(ActiveModel::Model) && model_class.include?(ActiveModel::Model)
135
- schema = build_activemodel_schema(schema, model_class, options)
136
- else
137
- raise ArgumentError, "Model must be an ActiveRecord or ActiveModel class"
138
- end
139
-
140
- # OpenAI strict mode requires all properties to be in the required array
141
- # So we add all properties to required if strict mode is enabled
142
- schema[:required] = schema[:properties].keys.sort if options[:strict]
181
+ schema = object_schema(model_class, options)
143
182
 
144
183
  {
145
184
  name: options[:name] || model_class.name.underscore,
@@ -151,6 +190,37 @@ module ActiveAgent
151
190
  class << self
152
191
  private
153
192
 
193
+ # Builds the object schema of a model class, without the name and
194
+ # strict flag {json_schema_from_model} wraps it in.
195
+ #
196
+ # @param model_class [Class] The ActiveRecord or ActiveModel class
197
+ # @param options [Hash] Schema generation options
198
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
199
+ #
200
+ # @return [Hash] The object schema
201
+ def object_schema(model_class, options, ancestors = [])
202
+ schema = {
203
+ type: "object",
204
+ properties: {},
205
+ required: [],
206
+ additional_properties: options.fetch(:additional_properties, false)
207
+ }
208
+
209
+ if defined?(ActiveRecord::Base) && model_class < ActiveRecord::Base
210
+ schema = build_activerecord_schema(schema, model_class, options)
211
+ elsif defined?(ActiveModel::Model) && model_class.include?(ActiveModel::Model)
212
+ schema = build_activemodel_schema(schema, model_class, options, ancestors)
213
+ else
214
+ raise ArgumentError, "Model must be an ActiveRecord or ActiveModel class"
215
+ end
216
+
217
+ # OpenAI strict mode requires all properties to be in the required array
218
+ # So we add all properties to required if strict mode is enabled
219
+ schema[:required] = schema[:properties].keys.sort if options[:strict]
220
+
221
+ schema
222
+ end
223
+
154
224
  # Builds a JSON Schema from an ActiveRecord model.
155
225
  #
156
226
  # Extracts column information, associations, and validations to build
@@ -187,14 +257,16 @@ module ActiveAgent
187
257
 
188
258
  # Builds a JSON Schema from an ActiveModel class.
189
259
  #
190
- # Extracts attribute types and validations to build a schema representation.
260
+ # Extracts attribute types, nested schemas and validations to build a
261
+ # schema representation.
191
262
  #
192
263
  # @param schema [Hash] The schema hash to populate
193
264
  # @param model_class [Class] The ActiveModel class
194
265
  # @param options [Hash] Schema generation options
266
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
195
267
  #
196
268
  # @return [void]
197
- def build_activemodel_schema(schema, model_class, options)
269
+ def build_activemodel_schema(schema, model_class, options, ancestors)
198
270
  if model_class.respond_to?(:attribute_types)
199
271
  model_class.attribute_types.each do |name, type|
200
272
  next if options[:exclude]&.include?(name.to_sym)
@@ -206,6 +278,10 @@ module ActiveAgent
206
278
  raise ArgumentError, "#{model_class.name} does not define any attributes. Use `attribute :name, :type` to define attributes."
207
279
  end
208
280
 
281
+ if model_class.respond_to?(:schema_associations)
282
+ schema = add_activemodel_associations_to_schema(schema, model_class, options, ancestors)
283
+ end
284
+
209
285
  if model_class.respond_to?(:validators)
210
286
  schema = add_validations_to_schema(schema, model_class, options)
211
287
  end
@@ -356,6 +432,51 @@ module ActiveAgent
356
432
  schema
357
433
  end
358
434
 
435
+ # Adds the arrays an ActiveModel class declares with
436
+ # {ActiveModelAssociations#has_many}, each item a +$ref+ to the nested
437
+ # class's schema under +$defs+.
438
+ #
439
+ # A +$ref+ resolves against the root of the schema, so the definitions
440
+ # a nested class collects move up into this schema's +$defs+.
441
+ #
442
+ # @param schema [Hash] The schema hash to populate
443
+ # @param model_class [Class] The ActiveModel class
444
+ # @param options [Hash] Schema generation options
445
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
446
+ #
447
+ # @return [Hash]
448
+ # @raise [ArgumentError] when a class nests itself, or two different
449
+ # schemas would share a definition name
450
+ def add_activemodel_associations_to_schema(schema, model_class, options, ancestors)
451
+ path = ancestors + [ model_class ]
452
+ definitions = {}
453
+
454
+ model_class.schema_associations.each do |name, class_name|
455
+ next if options[:exclude]&.include?(name)
456
+
457
+ nested_class = class_name.constantize
458
+ if path.include?(nested_class)
459
+ raise ArgumentError, "#{model_class.name}.has_many :#{name} is recursive (#{(path + [ nested_class ]).map(&:name).join(' > ')}); recursive schemas are not supported"
460
+ end
461
+
462
+ key = name.to_s.singularize.camelize(:lower).to_sym
463
+ nested = object_schema(nested_class, options.slice(:strict, :additional_properties), path)
464
+
465
+ [ [ key, nested ], *nested.delete(:$defs) ].each do |definition_key, definition|
466
+ if definitions.key?(definition_key) && definitions[definition_key] != definition
467
+ raise ArgumentError, "#{model_class.name} nests two different schemas under the definition #{definition_key.inspect}"
468
+ end
469
+
470
+ definitions[definition_key] = definition
471
+ end
472
+
473
+ schema[:properties][name] = { type: "array", items: { "$ref": "#/$defs/#{key}" } }
474
+ end
475
+
476
+ schema[:$defs] = definitions if definitions.any?
477
+ schema
478
+ end
479
+
359
480
  # Adds validation constraints to the schema.
360
481
  #
361
482
  # Translates ActiveModel validations (presence, length, numericality,
@@ -375,11 +496,14 @@ module ActiveAgent
375
496
  when ActiveModel::Validations::PresenceValidator
376
497
  schema[:required] << attribute.to_sym unless schema[:required].include?(attribute.to_sym)
377
498
  when ActiveModel::Validations::LengthValidator
499
+ # An array, such as a has_many, is limited by its item count.
500
+ min_key, max_key = schema[:properties][attribute.to_sym][:type] == "array" ? [ :min_items, :max_items ] : [ :min_length, :max_length ]
501
+
378
502
  if validator.options[:minimum]
379
- schema[:properties][attribute.to_sym][:min_length] = validator.options[:minimum]
503
+ schema[:properties][attribute.to_sym][min_key] = validator.options[:minimum]
380
504
  end
381
505
  if validator.options[:maximum]
382
- schema[:properties][attribute.to_sym][:max_length] = validator.options[:maximum]
506
+ schema[:properties][attribute.to_sym][max_key] = validator.options[:maximum]
383
507
  end
384
508
  when ActiveModel::Validations::NumericalityValidator
385
509
  if validator.options[:greater_than]
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.9.1"
2
+ VERSION = "1.9.2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -456,6 +456,7 @@ files:
456
456
  - lib/active_agent/deprecator.rb
457
457
  - lib/active_agent/evals.rb
458
458
  - lib/active_agent/evals/catalog.rb
459
+ - lib/active_agent/evals/comparison.rb
459
460
  - lib/active_agent/evals/correlation.rb
460
461
  - lib/active_agent/evals/design_tokens.rb
461
462
  - lib/active_agent/evals/diagnosis.rb
@@ -472,6 +473,7 @@ files:
472
473
  - lib/active_agent/evals/scenario.rb
473
474
  - lib/active_agent/evals/scenario_parser.rb
474
475
  - lib/active_agent/evals/scorer.rb
476
+ - lib/active_agent/evals/statistics.rb
475
477
  - lib/active_agent/evals/suite.rb
476
478
  - lib/active_agent/generation.rb
477
479
  - lib/active_agent/generation_job.rb