activeagent 1.9.6 → 1.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. data/lib/tasks/activeagent_tasks.rake +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f73d1c3c9a6dffc5bf68d07e59a21a88e40eef1ab92da228688e4f1e8148658
4
- data.tar.gz: caf1a01b8e53a7a6e9685e39162c38f88fed355fe9bef46f8850a1c4b313a09b
3
+ metadata.gz: a2e216123a2bdec63446cf6f6af685c0c61b285a318f5a98845e09b188838427
4
+ data.tar.gz: 434fbcc22e9a7ffa10434c93ba4b3d63306e6f0ae5502742eb2c83a31abb9338
5
5
  SHA512:
6
- metadata.gz: '02886d6a6c6d30366ae19b1a2877c3969a39b814896a942f67e4c6beca14fa53c5169dff447adcb956039e8c6358d2903969d9c254e8475c8c0af467b66770e6'
7
- data.tar.gz: 5cf3b2847e796674db3033a0745ab1e549a54ede83ddee48fb6f3edb8bd33d7256db17267a3ff42935aba39f1c07e0f2e8832bf9e7578216825176919271898d
6
+ metadata.gz: 442971fa937ce0d4f44c6add3d3e8841f48789da87de99363e8fcc0a891bcf4bc49461b914c5816ee2754a8c8b22ed92d42b0a26cff3423f3c5de8fbdd3e653c
7
+ data.tar.gz: 971714f2043a08e213f3a646706da229d77f289f2a98e7674169406ee1b5767235b98988a71113868dcd93d680da72dd9721859d49c30577f812ac902e84704f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,413 @@ 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]
9
+
10
+ ## [1.9.7] - 2026-11-04
11
+
12
+ Evaluations an application runs in its own test suite, and the dashboard
13
+ pieces around them: a suite declares the pass rate each group of questions
14
+ must reach (`pass`), and `assert_meets_pass_gates` or RSpec's
15
+ `meet_its_pass_gates` holds a run to it; scenarios replay a set number of
16
+ trials; the judge can skip an answer that already failed its expectations;
17
+ `Patterns` builds the regex for a count or a share a seeded scenario
18
+ expects; scenario keys select strictly; named criteria lists layer per
19
+ audience; `ActiveAgent::Evals::RubyLLM.verify!` checks every model's
20
+ credentials before a run; and the publisher builds itself from the
21
+ environment and returns links that open. Alongside them: Ollama Cloud,
22
+ scoring leases, catalog scenario extensions kept, evaluation catalogs
23
+ without Rails, ranked comparison tables, and users' own remote MCP servers.
24
+
25
+ Upgrading: three dashboard migrations, 030 `add_evaluation_scoring_leases`
26
+ (the scoring lease columns on `evaluation_scenario_results`), 031
27
+ `add_catalog_scenario_metadata` (the `metadata` column on
28
+ `catalog_scenarios`) and 032 `create_active_agent_mcp_server_configs` (the
29
+ table behind a user's own MCP servers): run `bin/rails generate
30
+ action_agent:install --skip` and `bin/rails db:migrate`. Until they run,
31
+ fanned-out runs keep scoring with the claim 1.9.3 uses, catalog imports
32
+ keep working but still drop scenario extensions (extensions discarded
33
+ before the upgrade need the original document imported again), and
34
+ the MCP Services page cannot list or save servers of your own.
35
+
36
+ The pricing tables changed too: a trace recorded under Ollama that priced
37
+ at the default rate before prices at Ollama Cloud's listed rate now, and a
38
+ finished evaluation run's cached figures are worked out again.
39
+
40
+ A suite's `pass` block is read and checked as the suite loads: a key other
41
+ than `threshold` and `min_pass_rate` (and, on a group, `min_pass_rate` and
42
+ `gated`) raises `ArgumentError` naming it, and in a catalog a product's or
43
+ set's `pass` raises `Catalog::InvalidDocument`. 1.9.6 ignored a suite's
44
+ `pass` and kept a product's as metadata, so a document that already carried
45
+ one under its own keys needs them renamed.
46
+
47
+ ### Added
48
+
49
+ - **Ollama Cloud** (`activeagent`). `generate_with :ollama, cloud: true,
50
+ model: "gpt-oss:120b"` sends requests to `https://ollama.com/v1` with the
51
+ key from `OLLAMA_API_KEY` (or `api_key:`), and a request to the cloud
52
+ without a key fails before it is sent, naming what to set. The host can
53
+ also be given as `host:` or through `OLLAMA_HOST`, read the way the
54
+ `ollama` command reads it: a bare origin is completed to its `/v1`
55
+ endpoint, and a scheme-less `host[:port]` is http on 11434. A local server
56
+ signed in to the cloud needs none of this — `model: "gpt-oss:120b-cloud"`
57
+ works as before — and the provider knows that spelling names the cloud's
58
+ model (`ActiveAgent::Providers::Ollama::Cloud`).
59
+ - **Ollama model lookup** (`activeagent`). `provider.models` lists the
60
+ models the configured host serves and `provider.model_info(name)`
61
+ describes one — its details, capabilities, context length, and whether
62
+ the cloud serves it — from the host's own API (`/api/tags`, `/api/show`),
63
+ local server or Ollama Cloud alike; a host with only the OpenAI-compatible
64
+ API is listed through `/v1/models` (`ActiveAgent::Providers::Ollama::Catalog`).
65
+ - **Ollama Cloud rates on traces and evaluations** (`actionagent`). A model
66
+ run under Ollama is priced from Ollama Cloud's listing by the name the
67
+ cloud uses: RubyLLM's registry carries it under its `ollama_cloud`
68
+ provider, and an install without RubyLLM has the same rows bundled
69
+ (`ModelPricing::OLLAMA_CLOUD_RATES`), each a `catalog` rate rather than a
70
+ guess at the family's. `gpt-oss:120b-cloud` through a local server and
71
+ `gpt-oss:120b` through the cloud price alike, and `deepseek-v4-flash`
72
+ under Ollama is Ollama's price, not DeepSeek's.
73
+ - **The model lookup says which host is the cloud** (`actionagent`).
74
+ `POST /api/provider_keys/test` and `GET /api/provider_models?provider=ollama`
75
+ carry `cloud` and, per listed model a price list names, `rates`
76
+ (`{ input, output, source }` in $ per million tokens); Settings → Provider
77
+ API Keys shows both with the connection test.
78
+
79
+ - **Atlas Cloud as an OpenAI-compatible provider** (`activeagent`).
80
+ `service: "AtlasCloud"` generates through Atlas Cloud's compatibility
81
+ endpoint, with the key from `ATLASCLOUD_API_KEY` and an overridable
82
+ `base_url`; chat only, no embeddings (docs/providers/atlas_cloud.md).
83
+ Contributed in #352.
84
+
85
+ - **Cheaper Inference as an OpenAI-compatible provider** (`activeagent`).
86
+ `service: "CheaperInference"` generates and embeds through
87
+ `https://api.cheaperinference.com/v1`, with the key from
88
+ `CHEAPER_INFERENCE_API_KEY`; a thin subclass of the OpenAI provider, like
89
+ Opper. Contributed in #629 and landed in #666.
90
+
91
+ - **Your own MCP servers** (`actionagent`). A dashboard user adds a remote
92
+ MCP server by URL on the MCP Services page (*Your MCP servers*), with
93
+ optional request headers, tests it (`tools/list`, recording the tools it
94
+ serves) and enables it on agents from the Tools tab, where it is listed
95
+ beside the catalog as `custom:<id>`. Agent runs and scenario evaluation runs
96
+ call its tools, and evaluation fix items attribute them to it. Each server
97
+ belongs to an owner like a provider key (`MCPServerConfig`, install
98
+ migration 032), and every lookup, including a run's, is scoped to that
99
+ owner. Header values are encrypted at rest, never returned to the browser
100
+ (names with masked values; a blank value on update keeps the stored one),
101
+ and scrubbed from recorded output. Writes and tests ask for
102
+ `:manage_credentials`. A URL that reaches a loopback, private or
103
+ link-local address is refused when saved and when called (`MCPHostGuard`),
104
+ unless the host sets `ActionAgent.allow_private_mcp_hosts`, e.g. for local
105
+ development. API: `/api/mcp_server_configs` with a `test` member action.
106
+
107
+ - **Evals in the test suite** (`activeagent`). An eval is an ordinary test
108
+ under `test/evals`, collected by `rails test` like any other, whose
109
+ `ActiveAgent::Evals::TestCase` skips it unless `EVALS` is on, so the
110
+ suite never calls a model by accident. `bin/rails test:evals` switches
111
+ evals on and runs them through the same runner (`TEST=path` narrows,
112
+ `TESTOPTS` passes through); `EVALS=1 bin/rails test ...` runs one file,
113
+ one line, or the whole suite with the evals in. The same switch gates an
114
+ RSpec suite (`filter_run_excluding eval: true unless
115
+ ActiveAgent::Evals::TestHelper.enabled?`, with `expect_with :minitest`
116
+ for the assertions). `require "active_agent/evals/test_helper"`
117
+ after `rails/test_help` gives `ActiveAgent::Evals::TestCase` and the
118
+ `TestHelper` module: `run_eval MyAgent.with(...).action` returns an
119
+ `ActiveAgent::Evals::Replay` — the answer, every tool call with its
120
+ arguments, result or error and timing, duration and tokens — and
121
+ `assert_answer_present`, `assert_output_contains` /
122
+ `refute_output_contains`, `assert_tool_called` (with an argument subset)
123
+ / `refute_tool_called` and `assert_tools_succeeded` read it, failing with
124
+ what the agent did. `assert_eval_passes` (and `score_eval`) scores the
125
+ replay as a dashboard run does and fails with the fault and the fix;
126
+ `run_eval_suite` replays a YAML suite across models and returns the
127
+ `Report`, which `assert_pass_rate` gates and `to_html` writes.
128
+ `ActiveAgent::Evals::Capture` is the assertion-free core: it runs a
129
+ generation into a `Replay`, capturing tool calls on the agent's
130
+ `tools_function` (where the telemetry tool spans are; tools served by a
131
+ client-side MCP server are recorded through the agent's `tool_observer`),
132
+ replays under another `model:` or `provider:` the way a comparison run
133
+ does, and takes a block for an agent run some other way. The framework's railtie now
134
+ loads the gem's rake tasks; the empty `activeagent_tasks.rake`
135
+ placeholder is gone.
136
+
137
+ - **A suite declares its pass gates** (`activeagent`). A suite's `pass`
138
+ block sets the score every answer must reach (`threshold`) and the share
139
+ of a model's answers that must pass (`min_pass_rate`), for the suite and
140
+ for any group with its own bar; `gated: false` keeps a group in the report
141
+ and out of `Report#passed?`. A catalog's products and sets take the
142
+ same block, and later documents layer it key by key. `Suite#pass_thresholds`
143
+ reads it as a `PassThresholds`, `Runner.new(pass_thresholds:)` takes it in
144
+ place of `threshold:` and hands it to the report, and `Report#gates` is
145
+ then one row per group × model with its pass rate against its bar, plus an
146
+ `All` row per model over its gated results; `Report#passed?` is every gate
147
+ met, and a report with no gate (a selection that matched nothing, or every
148
+ scenario dropped as `production_only`) does not pass;
149
+ `Report#failures(group:)` lists the results that did not pass. The
150
+ Markdown report adds a *Pass rate by group* table (`gates_markdown`;
151
+ `failures_markdown(group:)` lists the failures for a test message), the
152
+ HTML page tones each group's pass count by its gate, and `to_h` gains
153
+ `pass_thresholds`, `groups` and `gates`. A report built without pass
154
+ thresholds renders as it did. The command line's
155
+ `evals run --min-pass-rate` stays a gate on the whole run.
156
+ - **The pass-gate assertion, for Minitest and RSpec** (`activeagent`).
157
+ `TestHelper#assert_meets_pass_gates(report)` passes when every gate of
158
+ the report is met, each group held to the bar the suite declares for it
159
+ and ungated groups left out, where `assert_pass_rate` holds every model
160
+ to one rate the test names; it takes a `PassThresholds` to hold the
161
+ report to another bar, and returns the check. `run_eval_suite` hands a
162
+ suite's bars to the runner when it declares any (a `threshold` other
163
+ than 0.7, a `min_pass_rate` below 1, or any key in a group's `pass`), so
164
+ the report it returns carries them; a suite with no `pass` block, or
165
+ only a suite-level one that restates the defaults, runs under the
166
+ defaults and its report carries no bars, and a `pass_thresholds:` or
167
+ `threshold:` given to `run_eval_suite` wins over the suite's. In RSpec the
168
+ assertion works through `expect_with :minitest` like the rest of the
169
+ helper, and `require "active_agent/evals/rspec"`, with
170
+ `ActiveAgent::Evals::RSpec` included beside the helper, adds the first
171
+ eval matcher, `expect(report).to meet_its_pass_gates`; that file loads
172
+ nothing from rspec, and `require "active_agent/evals"` loads neither it
173
+ nor the helper. Both fail with one message, `GateCheck#message`: how many
174
+ of the gates were not met, each unmet gate as its sentence, then the
175
+ failures to read under a heading per group with an unmet gate (one
176
+ `Report#failure_markdown(result)` line each, the line `failures_markdown`
177
+ is built from) — and, for an `All` row that is short, the failures it
178
+ counts: under `Failures:` when no group row is short with it, else the
179
+ ones outside the groups already listed under `Other failures:`; an
180
+ ungated group's failures are never listed. `GateCheck.new(report)`
181
+ answers `met?`, `unmet` and `gates` for a script that reports on its own.
182
+ - **Pattern builders for counts and shares** (`activeagent`).
183
+ `ActiveAgent::Evals::Patterns.number(count)` and
184
+ `Patterns.share(count, total)` return a regex string for a scenario's
185
+ `contains` or `not_contains` entry: `number(10)` matches the figure on its
186
+ own and not the `10` in `2026-10-01`, `10/1`, `10:00`, `1–10`, `Oct 10`,
187
+ `10 October`, `10.5`, `110`, `10,000` or `10%`; `share(3, 4)` matches
188
+ `75%`, `75 %`, `75.0%` and `75.00%` and not `175%` or `7.5%`.
189
+ `Scorer.matches_pattern?` tries an entry as a substring and then as a
190
+ regex, so the string goes into a suite document or a scenario hash as it
191
+ is, and a scenario seeded with known data can expect the figure that data
192
+ implies. A fractional or negative count, a total of zero or a count above
193
+ the total raise `ArgumentError`.
194
+ - **Strict scenario selection by key** (`activeagent`). `Suite#scenarios`
195
+ and `Catalog#scenarios` take `strict: true` with `keys:`: every key must
196
+ name a scenario the suite or catalog holds, or the call raises
197
+ `Suite::NotFound` listing the missing keys in the order given, so a
198
+ misspelled key from an environment variable or a command line fails the
199
+ run, named in the error. A key the suite holds outside the chosen
200
+ `groups:` returns an empty selection. `Suite.select` is the same check
201
+ over any scenarios, with `known:` the wider set the keys must come from,
202
+ for a runner that takes keys across several suites. Without `strict:` the
203
+ selection narrows as before: the scenario a misspelled key meant is left
204
+ out without notice, and only when every key misses does a run held to
205
+ `Report#passed?` (or `assert_meets_pass_gates`) fail, without naming the
206
+ key.
207
+ - **Skip the judge for a replay that already failed its expectations**
208
+ (`activeagent`). `Runner.new(judge_failed_expectations: false)` asks the
209
+ judge about an answered replay only when every expectation its scenario
210
+ declared was met. A replay that called none of its expected tools, printed
211
+ a forbidden phrase, lacks expected content or had a tool call fail is a
212
+ failed result whatever a judge would say — `Diagnosis` assigns one of its
213
+ evidence faults (`tool_error`, `missing_capability`,
214
+ `expected_tool_not_called`, `ungrounded_answer`, `forbidden_content` or
215
+ `missing_content`) before it looks at quality — so it fails on whichever
216
+ of those it assigns first, with no `task_completion` score, its
217
+ `llm_judge` criteria unscored and no judge-written recommendation (a
218
+ refusal or invented figure the judge skipped loses the judge's suggested
219
+ tool too), and the calls saved stay out of the `judge_limit` budget.
220
+ `Scorer.new(judge_failed_expectations:)` and
221
+ `Scorer.expectations_met?(scores)` are the pieces underneath, and the
222
+ scores hash keeps its key order either way. The default (`true`) grades
223
+ every answered replay as it did; a blank answer is never graded, and a
224
+ blank or errored replay is never refined under the default
225
+ `refine_faults`, with or without the flag.
226
+ - **Replay each scenario several times** (`activeagent`, `actionagent`).
227
+ `Runner.new(trials: 3)` replays every scenario under every model that
228
+ many times, scenario by scenario and model by model, so a flaky scenario
229
+ gets a pass share instead of one coin flip. Scenario keys stay as they
230
+ are; `Result#trial` tells the repeats apart, reads 1 on a result built
231
+ without one, and lands in `to_h` as `"trial"`. A model's `"scenarios"`
232
+ count and pass rate cover every replay, with `"unique_scenarios"` beside
233
+ them, and the verdict then reads "Passed 5 of 6 replays of 2 scenarios";
234
+ a matrix cell shows one mark per trial with the mean score and the most
235
+ frequent fault; the Answers section lists every trial; a failure line
236
+ (`failure_markdown`, `failures_markdown`, the pass-gate message) names
237
+ the trial (`gpt-5-mini` · trial 2) when its scenario × model was
238
+ replayed more than once; the Markdown, JSON and HTML reports state the
239
+ trial count only when it is above 1, so a one-trial Markdown or HTML
240
+ report is exactly what it was, and its JSON differs only by `"trial"` on
241
+ each result and `"unique_scenarios"` on each model. `Comparison` pools a
242
+ scenario's replays into its pass share, so its intervals still resample
243
+ scenarios. The collector accepts the repeats of one scenario × model when
244
+ each carries its `trial`, stores every one, and rebuilds them into the
245
+ run's report; the dashboard's own runs replay once.
246
+ - **The evaluation publisher reads `run_id` from the report, returns an
247
+ absolute receipt `url` and builds itself from `ENV`** (`activeagent`).
248
+ `ActiveAgent::Evals::Publisher#call` and `#progress` take `run_id:` as
249
+ optional and read it from the report's `metadata.run_id`, where
250
+ `Correlation#with_run` records it, from a `Report` or its saved JSON hash;
251
+ an explicit `run_id:` wins, and a report with neither raises
252
+ `ArgumentError` before anything is sent. The receipt's `url` comes back
253
+ absolute: the path a collector sends
254
+ (`<mount>/evaluations/:evaluation_id/runs/:id` from the engine) is joined
255
+ onto the publisher's `dashboard_url`, by default the endpoint's origin
256
+ (`https://activeagents.ai` for the hosted collector on its API host), or
257
+ the HTTP(S) URL `Publisher.new(dashboard_url:)` is given, so the link opens
258
+ in a browser; an absolute `url` and a receipt without one are left as they
259
+ are. `Publisher.from_env` builds the publisher from
260
+ `ACTIVEAGENTS_EVALUATIONS_ENDPOINT` (the hosted default when blank),
261
+ `ACTIVEAGENTS_API_KEY` (required) and the new, optional
262
+ `ACTIVEAGENTS_DASHBOARD_URL`, for a dashboard people reach at another
263
+ address than the collector's:
264
+ `Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "orders")`.
265
+ - **Verify RubyLLM models and the judge before a run** (`activeagent`).
266
+ `ActiveAgent::Evals::RubyLLM.verify!(models, context:)` takes a run's
267
+ `ModelSpec`s (or one) and, for the first that cannot run, raises
268
+ `RubyLLM::ConfigurationError` saying why: a provider RubyLLM does not know
269
+ (the message lists the ones it does), one outside `providers:` (the names
270
+ a host has keys for), or one its context cannot build —
271
+ `claude-sonnet-5 runs on anthropic, which has no API key. Set
272
+ ANTHROPIC_API_KEY.`, naming the provider's `configuration_requirements`
273
+ the context leaves unset (`which is not configured. Set OLLAMA_API_BASE.`
274
+ when they are not keys), or carrying RubyLLM's own message when those are
275
+ all set and it refused for another reason, with RubyLLM's error as the
276
+ cause. It builds each chat with `assume_model_exists: true`, which is
277
+ where RubyLLM checks its keys, and sends nothing. `judge(..., verify: true)`
278
+ runs the same check on the judge's own model when the judge is built,
279
+ with the same `providers:` when it is given them, so a judge whose
280
+ provider has no key refuses before the first scenario is replayed rather
281
+ than at its first grade; the default stays lazy.
282
+ - **Named criteria lists on a suite** (`activeagent`). A suite document
283
+ carries a top-level `criteria` beside `groups`: a list of the
284
+ `{ key, type, config }` hashes `Runner.new(criteria:)` takes, which reads
285
+ as the one list named `default`, or a mapping of name → list, one per
286
+ audience the same questions are graded for. `Suite#criteria(*names)`
287
+ joins the named lists in the order given, a later list's criterion
288
+ replacing an earlier one with the same key, ready for the runner;
289
+ with no names it returns every list in document order, and a name no
290
+ document declares contributes nothing, so a deployment asks for `shared`
291
+ plus an audience that may have no list of its own. `Suite#criteria_names`
292
+ lists the names. Later documents layer each list by criterion key, as they
293
+ layer scenarios: a key already present is replaced in place, a new key
294
+ appended. A catalog product carries the same mapping, which `suite_for`
295
+ hands its suite and `to_yaml` writes back. A `criteria` value that is
296
+ neither a list nor a mapping of lists, or a criterion that is not a
297
+ mapping or has no key, is refused: `ArgumentError` from a suite,
298
+ `Catalog::InvalidDocument` from a catalog product (a set keeps a criterion
299
+ without a key; see Changed) and `ScenarioParser::ParseError` from a pasted
300
+ suite. Earlier releases ignored a suite document's top-level `criteria`;
301
+ a document that carries one in another shape is refused.
302
+
303
+ ### Changed
304
+
305
+ - **Every trace cost is priced under its provider** (`actionagent`). The
306
+ trace list, Metrics, the agent scorecard, the interactions list and an
307
+ evaluation result priced from its trace all hand `ModelPricing` the
308
+ provider the llm span recorded, so `gpt-5.5` through OpenRouter is
309
+ OpenRouter's rate, and a trace's `OpenAI::Chat` tag reads as `openai`
310
+ (`ModelPricing.provider_slug`). `TelemetryTrace.pluck_with_llm_model_and_provider`
311
+ plucks the provider beside the model for a caller that prices tokens.
312
+
313
+ - **Ranked comparison tables and value pills** (`actionagent`). Wherever the
314
+ dashboard compares rows, they now read through one pattern: a
315
+ `ComparisonTable` with two-line mono headers that sort on click and a
316
+ rank-tinted `ValuePill` in every numeric cell, banded by tercile in the
317
+ column's better direction or by fixed thresholds where the domain has them
318
+ (pass rates at 85 and 50), with missing values unranked and sorted last,
319
+ and a visually hidden rank word so colour is never the only signal. Applied
320
+ to a run's model comparison table and the Evaluations leaderboard, the
321
+ scenario matrix (the key leads each step with the prompt under it, one
322
+ score pill per model, cost and time pills per scenario, the tools level
323
+ stacking tool call, time, tokens and cost pills), each opened result's
324
+ score, time, tokens and cost strip, the Metrics overview's change pills and
325
+ the home page's Top Agents list. Under 600px a table stacks each row to
326
+ labelled pills.
327
+
328
+ - **A set's `criteria` layer across catalog documents** (`activeagent`). A
329
+ later document's criterion replaces the one with its key in place and a
330
+ new key is appended, as a product's named lists and a suite's scenarios
331
+ layer; a later document used to replace the set's whole list. A criterion
332
+ without a key is still accepted and appended as it is, once: a later
333
+ document that restates it adds nothing. A criterion's `key` is written as
334
+ a string, as scenario keys are, so a set criterion keyed by a number reads
335
+ back with a quoted key and the catalog's digest changes with it.
336
+
337
+ ### Fixed
338
+
339
+ - **The dashboard engine loads in a host without Action Cable**
340
+ (`actionagent`). The engine's class body camelizes its inflection
341
+ overrides with `Zeitwerk::Inflector` and did not require Zeitwerk itself.
342
+ Rails requires it only once the application sets up its autoloaders,
343
+ after `Bundler.require` has loaded the gem; until then the constant
344
+ exists only because Action Cable's engine loads it as a side effect. An
345
+ app generated with `--skip-action-cable`, or any API-only host, died at
346
+ boot with `uninitialized constant ActionAgent::Engine::Zeitwerk` (seen on
347
+ Rails 8.1.4; the require order is the same on every Rails since 7.0). The
348
+ engine requires Zeitwerk, a dependency of Active Support, before reading
349
+ it, and a test loads the engine in a fresh process right after `rails`,
350
+ with nothing in between.
351
+
352
+ - **An evaluation judge job delivered twice scores its result once**
353
+ (`actionagent`). The claim on a pending result was a conditional write on
354
+ its timestamp, which turned away a stale reader but not a duplicate
355
+ delivered after the claim: that one read the new timestamp and asked the
356
+ judge to score the result again. A claim now takes a 30-minute lease
357
+ (`scoring_token`, `scoring_expires_at`) that a fresh reader cannot take
358
+ while it is live, and a worker that finishes after its lease was
359
+ reclaimed cannot overwrite its successor's result. A delivery that finds
360
+ a live lease, such as the queue's redelivery after a worker died
361
+ mid-judge, enqueues the job again for when the lease expires, so the run
362
+ still completes. Without migration 030 the judge job keeps the timestamp
363
+ claim instead of failing on the missing columns.
364
+
365
+ - **Keep scenario catalog extension fields** (`activeagent`, `actionagent`).
366
+ Opaque scenario fields survive parsing, YAML/JSON export, database reload,
367
+ standard-field edits and storage restore. Their changes affect import
368
+ previews and catalog digests, including when only an extension changes.
369
+ Empty values and literal `metadata` keys are preserved; canonical format
370
+ fields cannot be replaced through the metadata container.
371
+
372
+ - **Bound trace console list limits** (`actionagent`). The console defaults
373
+ to 50 recent matching traces, clamps integer limits to 1–1000, and uses
374
+ the default for malformed values. Its documentation describes the bounded
375
+ recent list rather than claiming pagination.
376
+
377
+ - **Select trace time windows in dark mode** (`actionagent`). The dark
378
+ Traces header includes the shared time-window selector and zoom controls,
379
+ including when embedded on an agent page or when the window is empty.
380
+
381
+ - **Show tool usage in both dashboard themes** (`actionagent`). The Traces
382
+ page renders the same tool counts, durations, failures and empty state
383
+ in dark and light mode, using the shared telemetry colors.
384
+
385
+ - **Load evaluation catalogs without Rails** (`activeagent`). The standalone
386
+ `active_agent/evals` entry point loads the ActiveSupport array extension
387
+ used to construct, parse and load catalogs, without loading the framework.
388
+
389
+ - **Client-side MCP tool calls appear in an eval replay** (`activeagent`).
390
+ `Evals::Capture` recorded the tools that reach the agent's
391
+ `tools_function`, and a tool served by a client-side MCP server is
392
+ dispatched before it, so a replay of an agent with `mcps:` showed none of
393
+ those calls and `assert_tool_called` could not find them. The provider
394
+ now runs such a call inside the agent's `tool_observer`, which Capture
395
+ sets, so it is recorded once with its arguments, result preview and
396
+ timing. A result the server marks as an error, which the model receives
397
+ as a result and not an exception, is recorded as a failed call, so
398
+ `failed_tool_calls` and `assert_tools_succeeded` see it. A server the
399
+ provider runs itself (Anthropic's or OpenAI's hosted MCP) executes on the
400
+ provider's side and is still not recorded.
401
+
402
+ - **An agent run is executed by one job** (`actionagent`). The execution
403
+ job read a run's status, then wrote `running`, as two steps, and only
404
+ turned away a finished or paused run. A job delivered twice, or two
405
+ workers that both loaded the run as pending, each started a generation
406
+ for it, and a duplicate that arrived while the run was running started
407
+ another. The job now moves the run from pending to running in one
408
+ conditional update, as `CodeSessionJob` claims a session, and starts no
409
+ generation unless it was the one to do so. The database decides at the
410
+ write, so this holds on SQLite under Rails 7.2 too, where a row-lock read
411
+ followed by a write is refused once another worker has committed. Nothing
412
+ is held over the generation, so a cancel does not wait for one. A run
413
+ whose worker died after the claim stays running and is not run again.
414
+
8
415
  ## [1.9.6] - 2026-10-29
9
416
 
10
417
  The dashboard on a phone, and the small finished pieces: the sidebar as a
@@ -1386,6 +1793,35 @@ finished must handle. Session Replay is now **Sessions** in the sidebar.
1386
1793
  do for `generate_later`. `InputRequest#arguments` holds the arguments of the
1387
1794
  paused call.
1388
1795
 
1796
+ - **`ActiveAgent::Evals::RubyLLM`** — the RubyLLM side of an evaluation,
1797
+ which hosts driving RubyLLM conversations had been writing for themselves.
1798
+ `require "active_agent/evals/ruby_llm"` (it requires `ruby_llm`;
1799
+ `require "active_agent/evals"` alone still does not) gives two helpers, for
1800
+ RubyLLM 1.16 and later and for 2.x:
1801
+ - `RubyLLM.judge(label:, model:, provider:, context:, correlation:)`, a
1802
+ `Judge` that answers from `context.chat(...)` and, with a `Correlation`,
1803
+ traces each call under the kind it serves. `temperature:` and a
1804
+ `configure:` hook set the chat up; any other keyword must be one
1805
+ `RubyLLM::Chat.new` takes, and anything else raises when the judge is
1806
+ built rather than failing every call. `on_usage:` reports each call's
1807
+ model, tokens and cost.
1808
+ - `RubyLLM.replay(messages)`, a `Replay` from a conversation's messages.
1809
+ `acts_as_chat` records of either RubyLLM generation are read through
1810
+ their own `to_llm`, so a 2.x table, or one between 2.x's upgrade and its
1811
+ cleanup, replays the same as `RubyLLM::Message` values. Tool calls keep
1812
+ the order the model made them in, each paired with the result that
1813
+ answers it; one is errored when that result reports an error at its top
1814
+ level — JSON whose `"error"` is a non-empty string, object or array, or
1815
+ `true`; an MCP result with `"isError": true`; or the inspected
1816
+ `{ error: "..." }` RubyLLM 1.x stores for a tool's error Hash with a
1817
+ String error — with the error (JSON-encoded, at most 1,200 bytes) as its
1818
+ detail. `input_tokens` counts the whole prompt, cache reads and writes
1819
+ included. Cost is RubyLLM's own price, left nil when any message is
1820
+ unpriced. The answer is the last reply after the last user message; a
1821
+ conversation that stopped at a tool call or on the user's own message
1822
+ has no answer and says so in its `error` (on RubyLLM 1.x, pass `answer:`
1823
+ for a tool that ends the turn with `halt`).
1824
+
1389
1825
  ### Changed
1390
1826
 
1391
1827
  - **Confirm destructive actions in the dashboard's own dialog**
@@ -2100,34 +2536,6 @@ output in the shape ruby_llm reads. No migrations.
2100
2536
  `json_object`, which ruby_llm has no mode for, and a `json_schema`
2101
2537
  without a schema now raise `ArgumentError` instead of sending a request
2102
2538
  the API rejects (#501).
2103
- - **`ActiveAgent::Evals::RubyLLM`** — the RubyLLM side of an evaluation,
2104
- which hosts driving RubyLLM conversations had been writing for themselves.
2105
- `require "active_agent/evals/ruby_llm"` (it requires `ruby_llm`;
2106
- `require "active_agent/evals"` alone still does not) gives two helpers, for
2107
- RubyLLM 1.16 and later and for 2.x:
2108
- - `RubyLLM.judge(label:, model:, provider:, context:, correlation:)`, a
2109
- `Judge` that answers from `context.chat(...)` and, with a `Correlation`,
2110
- traces each call under the kind it serves. `temperature:` and a
2111
- `configure:` hook set the chat up; any other keyword must be one
2112
- `RubyLLM::Chat.new` takes, and anything else raises when the judge is
2113
- built rather than failing every call. `on_usage:` reports each call's
2114
- model, tokens and cost.
2115
- - `RubyLLM.replay(messages)`, a `Replay` from a conversation's messages.
2116
- `acts_as_chat` records of either RubyLLM generation are read through
2117
- their own `to_llm`, so a 2.x table, or one between 2.x's upgrade and its
2118
- cleanup, replays the same as `RubyLLM::Message` values. Tool calls keep
2119
- the order the model made them in, each paired with the result that
2120
- answers it; one is errored when that result reports an error at its top
2121
- level — JSON whose `"error"` is a non-empty string, object or array, or
2122
- `true`; an MCP result with `"isError": true`; or the inspected
2123
- `{ error: "..." }` RubyLLM 1.x stores for a tool's error Hash with a
2124
- String error — with the error (JSON-encoded, at most 1,200 bytes) as its
2125
- detail. `input_tokens` counts the whole prompt, cache reads and writes
2126
- included. Cost is RubyLLM's own price, left nil when any message is
2127
- unpriced. The answer is the last reply after the last user message; a
2128
- conversation that stopped at a tool call or on the user's own message
2129
- has no answer and says so in its `error` (on RubyLLM 1.x, pass `answer:`
2130
- for a tool that ends the turn with `halt`).
2131
2539
  - **`ActionAgent::ProviderKey.credentials_for(owner)` and
2132
2540
  `.apply_to(config, owner:)`** (`actionagent`) hand an owner's API keys to
2133
2541
  code outside the engine — `{ "openai" => "sk-..." }`, or written through
data/README.md CHANGED
@@ -48,6 +48,9 @@ bundle add openai
48
48
  # Anthropic
49
49
  bundle add anthropic
50
50
 
51
+ # Atlas Cloud (uses OpenAI-compatible API)
52
+ bundle add openai
53
+
51
54
  # Ollama (uses OpenAI-compatible API)
52
55
  bundle add openai
53
56
 
@@ -60,6 +63,9 @@ bundle add openai
60
63
  # Opper (uses OpenAI-compatible API)
61
64
  bundle add openai
62
65
 
66
+ # Cheaper Inference (uses OpenAI-compatible API)
67
+ bundle add openai
68
+
63
69
  # DeepSeek (uses OpenAI-compatible API)
64
70
  bundle add openai
65
71
 
@@ -336,6 +336,7 @@ module ActiveAgent
336
336
  exception_handler:,
337
337
  stream_broadcaster:,
338
338
  tools_function:,
339
+ tool_observer:,
339
340
  action_name: generation_action_name,
340
341
  input_request_resume: @_input_request_resume,
341
342
  announce_input_requests: !input_requests_silenced?
@@ -138,11 +138,12 @@ module ActiveAgent
138
138
 
139
139
  # Posts a saved report to the collector. FILE is a version-1 envelope
140
140
  # (what `evals report --format json` writes) or a bare Report#to_h
141
- # hash, whose identities then come from the options.
141
+ # hash, whose identities then come from the options; the publisher
142
+ # reads the run_id from the report's metadata when neither names one.
142
143
  def evals_import(argv)
143
144
  identities = {}
144
145
  argv = parse(argv, "evals import FILE [--run-id ID --source NAME --agent NAME --suite NAME]") do |p|
145
- p.on("--run-id ID", "The report's run_id (kept across retries)") { |v| identities["run_id"] = v }
146
+ p.on("--run-id ID", "The report's run_id, kept across retries; by default the one its metadata records") { |v| identities["run_id"] = v }
146
147
  p.on("--source NAME", "The application that ran it") { |v| identities["source"] = v }
147
148
  p.on("--agent NAME", "The agent's name") { |v| identities["agent_name"] = v }
148
149
  p.on("--suite NAME", "The suite's name") { |v| identities["suite"] = v }
@@ -156,7 +157,6 @@ module ActiveAgent
156
157
  raise Error.new("#{path} is a version #{envelope["version"].inspect} envelope; this CLI sends version 1", exit_code: EXIT_USAGE)
157
158
  end
158
159
  envelope = envelope.merge(identities)
159
- envelope["run_id"] ||= envelope["report"].is_a?(Hash) ? envelope["report"].dig("metadata", "run_id") : nil
160
160
 
161
161
  receipt = client.publisher.call(report: envelope["report"], run_id: envelope["run_id"], source: envelope["source"],
162
162
  agent_name: envelope["agent_name"], suite: envelope["suite"])
@@ -26,5 +26,14 @@ module ActiveAgent
26
26
  raise_without_input_request_secrets(error)
27
27
  end
28
28
  end
29
+
30
+ # A callback the provider runs the calls it serves from an MCP server
31
+ # around, which never reach {#tools_function}. It receives the tool name,
32
+ # its arguments and a block that makes the call, and returns the block's
33
+ # result. Nil unless a host that records a generation's tool calls (see
34
+ # Evals::Capture) sets it on the instance.
35
+ #
36
+ # @return [Proc, nil]
37
+ def tool_observer = nil
29
38
  end
30
39
  end