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 +4 -4
- data/CHANGELOG.md +205 -1
- data/lib/active_agent/base.rb +27 -2
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/report.rb +73 -7
- data/lib/active_agent/evals/report_html.rb +64 -2
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +3 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +7 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +37 -4
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b52709687c94c3d6c989fc36bba7826b931a6d0aa6e193b1bd4482f916d945f
|
|
4
|
+
data.tar.gz: 1801338ff01afc21837db5382aee49eb4cde1f002b322b479cf2256c283e8668
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -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
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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:
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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,
|
|
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
|
-
#
|
|
271
|
-
#
|
|
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.
|
|
529
|
-
#
|
|
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
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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][
|
|
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][
|
|
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]
|
data/lib/active_agent/version.rb
CHANGED
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.
|
|
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-
|
|
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
|