activeagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -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/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,589 @@ 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
+ ## [1.9.8] - 2026-11-12
9
+
10
+ The dashboard holds a run to its catalog's pass bars: a product's and a
11
+ set's `pass` blocks and a product's named criteria lists survive import, a
12
+ run records the bars it started under and shows each group's gate beside
13
+ its pass rate, and `evals run` exits 4 when a gated group misses, so CI
14
+ fails on the group that slipped rather than on the whole-run average. A
15
+ catalog that declares a `threshold` now scores at it instead of the fixed
16
+ 0.7. Around that: the empty agent list shows the ways an agent arrives,
17
+ telemetry first; the RubyLLM provider works with ruby_llm 2.2 and the
18
+ Anthropic provider with the `anthropic` gem 2.x; Runcrate joins the
19
+ OpenAI-compatible providers; and smaller fixes to Anthropic usage counts,
20
+ Requesty embeddings, `top_k` for thinking-first models, late cancels,
21
+ `tool_observer` under Capture and Responses streaming errors.
22
+
23
+ Upgrading: two dashboard migrations, 034 `add_catalog_product_criteria`
24
+ (the `criteria` column on scenario products) and 035
25
+ `add_catalog_pass_bars` (the `pass` column on scenario products and sets):
26
+ run `bin/rails generate action_agent:install --skip` and `bin/rails
27
+ db:migrate`. Until they run, catalog imports keep working but still drop
28
+ named criteria lists and pass bars, so dashboard runs stay ungated; a
29
+ catalog imported before the upgrade needs importing again to gain them.
30
+ 033 is left free for a later release. A finished run that carries gates
31
+ now fails `evals run --wait` with exit 4 when any gated group missed its
32
+ bar; a run without gates is reported as before. ruby_llm 2.2.0 (2026-11-10)
33
+ breaks the RubyLLM provider in 1.9.7 and earlier (every tool call reads as
34
+ `nil`); upgrade, or hold `ruby_llm < 2.2` until you do.
35
+
36
+ ### Added
37
+
38
+ - **The dashboard shows a run's per-group pass gates** (`actionagent`). A
39
+ published report's `pass_thresholds` are kept with the run, so the
40
+ rebuilt report, the export and the run page gate the stored results as
41
+ the application's report did, and a run started from the dashboard on a
42
+ catalog set is held to the set's and product's `pass` bars, recorded as
43
+ the run starts so a later catalog change does not regrade it. The run
44
+ page adds a **Pass rate by group** table, and the run's badge reads
45
+ `gate missed` when a gate was not met even if the whole-run pass rate
46
+ looks fine. The run's JSON adds `gates` and `gates_met`; a run without
47
+ bars reads as before (#678).
48
+ - **An empty agent list shows how agents arrive** (`actionagent`). With no
49
+ agents, the Agents page offered only Browse Templates and Create From
50
+ Scratch. It now shows three ways in, telemetry first:
51
+ - **Connect your app** asks what runs the agents. For ActiveAgent it shows
52
+ the `config/active_agent.yml` telemetry block. For RubyLLM it shows the
53
+ `activeagents-telemetry-ruby_llm` Gemfile line and the initializer that
54
+ subscribes it. Both point at this dashboard's endpoint and read the key
55
+ from `ACTIVEAGENTS_API_KEY`. The first trace registers the agent, and
56
+ **Check for agents** reloads the list.
57
+ - **Link a GitHub repository** opens New project.
58
+ - **Build one here** keeps the template library and the builder.
59
+ - **Runcrate as an OpenAI-compatible provider** (`activeagent`).
60
+ `service: "Runcrate"` generates and embeds through
61
+ `https://api.runcrate.ai/v1` (open-weight models such as
62
+ `deepseek-ai/DeepSeek-V4.1-Flash` and `Qwen/Qwen3-Embedding-8B`), with the
63
+ key from `RUNCRATE_API_KEY`; a thin subclass of the OpenAI provider, like
64
+ Cheaper Inference.
65
+
66
+ ### Fixed
67
+
68
+ - **The RubyLLM provider works with ruby_llm 2.2** (`activeagent`). RubyLLM
69
+ 2.2.0 (2026-11-10) changed three things the provider leaned on. A
70
+ message's `tool_calls` became a `ToolCall::Collection` that yields the
71
+ calls rather than ID-and-call pairs, so every tool call read as `nil`; a
72
+ streamed delta now carries the provider's call ID in `provider_call_id`
73
+ and leaves `id` empty, so every fragment joined the latest call and
74
+ arguments doubled up; and a chat asks each tool for its `namespace`,
75
+ `qualified_name` and `deferred?`, which ActiveAgent's tool proxy did not
76
+ answer. The provider now iterates either shape, keys streamed fragments
77
+ by the provider's ID, and the proxy answers the three. ruby_llm 1.16
78
+ through 2.1 behave as before. The evals conversation check also learned
79
+ 2.2's `acts_as_chat` modules, columns and reworded unavailable-tool error.
80
+ - **`evals run` fails CI on a missed group gate** (`activeagent`). A run
81
+ whose critical group slipped to 50% passed `evals run --min-pass-rate`
82
+ whenever the other groups lifted the whole-run pass rate over the bar.
83
+ A finished run that the dashboard holds to per-group pass gates now fails
84
+ (exit 4) when any gated group missed its bar, with `--wait` alone or with
85
+ `--min-pass-rate`, which stays the whole-run bar; each missed gate prints
86
+ as one line, and `--json` adds `gates`. A run without gates is gated as
87
+ before (#677).
88
+ - **Responses streaming failures retain their error details** (`activeagent`).
89
+ `error` and `response.failed` events raise a provider error with the API
90
+ code and message instead of an unexpected chunk type error.
91
+ - **The dashboard keeps a catalog's pass bars** (`actionagent`). A
92
+ product's and a set's `pass` blocks (`min_pass_rate`, `threshold`,
93
+ `gated`) parsed in `ActiveAgent::Evals::Catalog`, but neither
94
+ `ScenarioProduct` nor `ScenarioSet` kept them: an import dropped the
95
+ bars, the export left them out, the stored catalog's `pass_thresholds`
96
+ fell back to the defaults (every group gated at 100%), and the import
97
+ preview reported the product and its sets as `update` on every re-import
98
+ of an unchanged file. The bars are now stored and exported as imported,
99
+ and both import modes take the document's. Existing installs add the
100
+ columns with `bin/rails generate action_agent:install --skip` and
101
+ `bin/rails db:migrate` (`add_catalog_pass_bars`); imports keep working,
102
+ without the bars, until it runs. Bars dropped by an earlier version come
103
+ back when the original document is imported again.
104
+ - **The dashboard keeps a catalog product's named criteria lists**
105
+ (`actionagent`). A product's `criteria: { shared: [...], strict: [...] }`
106
+ parsed and round-tripped in `ActiveAgent::Evals::Catalog`, but
107
+ `ScenarioProduct` had nowhere to keep it: an import dropped the lists, the
108
+ export left them out, the stored digest never matched the records'
109
+ export, and the import preview reported the product as `update` on every
110
+ re-import of an unchanged file. The lists are now stored on the product
111
+ and exported as imported, so the documents and digests match and an
112
+ unchanged file previews as unchanged. Both import modes take the
113
+ document's lists, as they take a set's `criteria`. A set still runs on
114
+ its own `criteria`; which product lists a run uses is left for later.
115
+ Existing installs add the column with
116
+ `bin/rails generate action_agent:install --skip` and `bin/rails db:migrate`
117
+ (`add_catalog_product_criteria`); imports keep working, without the lists,
118
+ until it runs. Lists dropped by an earlier version come back when the
119
+ original document is imported again (#687).
120
+ - **A self-hosted dashboard shows its own trace endpoint** (`actionagent`).
121
+ The Organization page's Telemetry card showed the platform's `/v1/traces`.
122
+ A self-hosted engine takes traces at `<mount>/api/traces`, and the card
123
+ now shows that. Multi-tenant installs are unchanged.
124
+ - **The agent list's filter arrows sit inside the select** (`actionagent`).
125
+ The browser's own arrow ignores padding and sat against the right border.
126
+ The three filters now draw an inset chevron (`aa-select`).
127
+ - **The Anthropic provider loads the `anthropic` gem 2.x** (`activeagent`).
128
+ The provider pinned its client gem to `~> 1.12`, so a bundle holding
129
+ anthropic 2.0 or later raised `LoadError` the moment the provider loaded,
130
+ although the provider builds its requests as hashes and the 2.0 SDK
131
+ (role-union `MessageParam`, one-value enums as constants, Text Completions
132
+ removed) breaks none of them. The provider and the gemspec's development
133
+ dependency now accept anthropic 1.12 through 2.x, and CI runs the provider
134
+ tests against 2.3 beside the 1.12, 1.14 and 1.55 legs. Two 2.0 changes do
135
+ reach the code: Text Completions went away with
136
+ `Anthropic::Resources::Completions`, which the Bedrock bearer client now
137
+ sets up only when it exists, and the client sends its key as
138
+ `Authorization: Bearer` instead of `x-api-key`. The 2.0 SDK also dropped
139
+ the `?beta=true` query string from its beta Messages requests, so the test
140
+ suite matches recorded cassettes with or without it.
141
+ - **A streamed Anthropic response reports its token usage**
142
+ (`activeagent`). With `stream: true`, `response.usage` was nil, so a
143
+ streamed generation counted 0 tokens and cost nothing. The provider now
144
+ records the usage `message_start` reports and the cumulative counts
145
+ `message_delta` carries, as the OpenAI providers do from their chunks.
146
+ - **Anthropic's `total_tokens` counts cached tokens** (`activeagent`).
147
+ Anthropic's `input_tokens` leaves out cache reads and writes, which it
148
+ reports separately, so `total_tokens` undercounted any prompt that used
149
+ the cache. It is now input + output + cache read + cache creation. The
150
+ other counts are unchanged, and so is cost: pricing still reads
151
+ `input_tokens` and `output_tokens` only.
152
+ - **Added usage keeps its duration** (`activeagent`). `Usage#+` dropped
153
+ `duration_ms`, so the usage of a tool-calling loop (Ollama reports a
154
+ duration) came back without one. It is now summed like the other
155
+ optional counts.
156
+ - **Requesty embeddings work** (`activeagent`). The Requesty provider
157
+ declared an embedding request but never sent one, so `embed` raised
158
+ `NotImplementedError`. It now calls Requesty's OpenAI-compatible
159
+ `/v1/embeddings`.
160
+ - **`top_k` is stripped for thinking-first models** (`activeagent`).
161
+ `ModelCapabilities` removed `temperature` and `top_p` for the models that
162
+ reject sampling parameters, but not `top_k`, which they reject too, so an
163
+ agent configured with it still got a 400.
164
+ - **A late cancel leaves a finished run as it finished** (`actionagent`).
165
+ `AgentRun#cancel!` checked the status of its own copy of the run and
166
+ then wrote `cancelled` unconditionally. A cancel request that loaded the
167
+ run while it was running, and wrote after the worker had recorded its
168
+ result, turned a complete run into a cancelled one that still carried
169
+ its output, and replaced a failed run's error with "Cancelled by user".
170
+ The cancel now moves the run to `cancelled` in one conditional update,
171
+ as the execution job claims a pending run, and only from pending,
172
+ running or awaiting input. A run that finished first keeps its status,
173
+ output, error and completion time; `cancel!` returns false and reloads
174
+ the copy, so the API answers with the status the run ended in. A cancel
175
+ is broadcast once, where the callback and the method each sent it
176
+ before. Cancelling still does not stop a provider call already in
177
+ flight; it keeps that call's result from being recorded.
178
+ - **Capture keeps an agent's own `tool_observer`** (`activeagent`). Since
179
+ 1.9.7, `Evals::Capture` records client-side MCP calls through the
180
+ agent's `tool_observer`, and it replaced an observer the agent defined
181
+ itself, so that observer stopped running whenever the agent was
182
+ captured. Capture now runs the agent's observer around the call it
183
+ records: the server is still called once, both see the same result or
184
+ exception, and a call the observer withholds is not recorded as made.
185
+
186
+ ## [1.9.7] - 2026-11-04
187
+
188
+ Evaluations an application runs in its own test suite, and the dashboard
189
+ pieces around them: a suite declares the pass rate each group of questions
190
+ must reach (`pass`), and `assert_meets_pass_gates` or RSpec's
191
+ `meet_its_pass_gates` holds a run to it; scenarios replay a set number of
192
+ trials; the judge can skip an answer that already failed its expectations;
193
+ `Patterns` builds the regex for a count or a share a seeded scenario
194
+ expects; scenario keys select strictly; named criteria lists layer per
195
+ audience; `ActiveAgent::Evals::RubyLLM.verify!` checks every model's
196
+ credentials before a run; and the publisher builds itself from the
197
+ environment and returns links that open. Alongside them: Ollama Cloud,
198
+ scoring leases, catalog scenario extensions kept, evaluation catalogs
199
+ without Rails, ranked comparison tables, and users' own remote MCP servers.
200
+
201
+ Upgrading: three dashboard migrations, 030 `add_evaluation_scoring_leases`
202
+ (the scoring lease columns on `evaluation_scenario_results`), 031
203
+ `add_catalog_scenario_metadata` (the `metadata` column on
204
+ `catalog_scenarios`) and 032 `create_active_agent_mcp_server_configs` (the
205
+ table behind a user's own MCP servers): run `bin/rails generate
206
+ action_agent:install --skip` and `bin/rails db:migrate`. Until they run,
207
+ fanned-out runs keep scoring with the claim 1.9.3 uses, catalog imports
208
+ keep working but still drop scenario extensions (extensions discarded
209
+ before the upgrade need the original document imported again), and
210
+ the MCP Services page cannot list or save servers of your own.
211
+
212
+ The pricing tables changed too: a trace recorded under Ollama that priced
213
+ at the default rate before prices at Ollama Cloud's listed rate now, and a
214
+ finished evaluation run's cached figures are worked out again.
215
+
216
+ A suite's `pass` block is read and checked as the suite loads: a key other
217
+ than `threshold` and `min_pass_rate` (and, on a group, `min_pass_rate` and
218
+ `gated`) raises `ArgumentError` naming it, and in a catalog a product's or
219
+ set's `pass` raises `Catalog::InvalidDocument`. 1.9.6 ignored a suite's
220
+ `pass` and kept a product's as metadata, so a document that already carried
221
+ one under its own keys needs them renamed.
222
+
223
+ ### Added
224
+
225
+ - **Ollama Cloud** (`activeagent`). `generate_with :ollama, cloud: true,
226
+ model: "gpt-oss:120b"` sends requests to `https://ollama.com/v1` with the
227
+ key from `OLLAMA_API_KEY` (or `api_key:`), and a request to the cloud
228
+ without a key fails before it is sent, naming what to set. The host can
229
+ also be given as `host:` or through `OLLAMA_HOST`, read the way the
230
+ `ollama` command reads it: a bare origin is completed to its `/v1`
231
+ endpoint, and a scheme-less `host[:port]` is http on 11434. A local server
232
+ signed in to the cloud needs none of this — `model: "gpt-oss:120b-cloud"`
233
+ works as before — and the provider knows that spelling names the cloud's
234
+ model (`ActiveAgent::Providers::Ollama::Cloud`).
235
+ - **Ollama model lookup** (`activeagent`). `provider.models` lists the
236
+ models the configured host serves and `provider.model_info(name)`
237
+ describes one — its details, capabilities, context length, and whether
238
+ the cloud serves it — from the host's own API (`/api/tags`, `/api/show`),
239
+ local server or Ollama Cloud alike; a host with only the OpenAI-compatible
240
+ API is listed through `/v1/models` (`ActiveAgent::Providers::Ollama::Catalog`).
241
+ - **Ollama Cloud rates on traces and evaluations** (`actionagent`). A model
242
+ run under Ollama is priced from Ollama Cloud's listing by the name the
243
+ cloud uses: RubyLLM's registry carries it under its `ollama_cloud`
244
+ provider, and an install without RubyLLM has the same rows bundled
245
+ (`ModelPricing::OLLAMA_CLOUD_RATES`), each a `catalog` rate rather than a
246
+ guess at the family's. `gpt-oss:120b-cloud` through a local server and
247
+ `gpt-oss:120b` through the cloud price alike, and `deepseek-v4-flash`
248
+ under Ollama is Ollama's price, not DeepSeek's.
249
+ - **The model lookup says which host is the cloud** (`actionagent`).
250
+ `POST /api/provider_keys/test` and `GET /api/provider_models?provider=ollama`
251
+ carry `cloud` and, per listed model a price list names, `rates`
252
+ (`{ input, output, source }` in $ per million tokens); Settings → Provider
253
+ API Keys shows both with the connection test.
254
+
255
+ - **Atlas Cloud as an OpenAI-compatible provider** (`activeagent`).
256
+ `service: "AtlasCloud"` generates through Atlas Cloud's compatibility
257
+ endpoint, with the key from `ATLASCLOUD_API_KEY` and an overridable
258
+ `base_url`; chat only, no embeddings (docs/providers/atlas_cloud.md).
259
+ Contributed in #352.
260
+
261
+ - **Cheaper Inference as an OpenAI-compatible provider** (`activeagent`).
262
+ `service: "CheaperInference"` generates and embeds through
263
+ `https://api.cheaperinference.com/v1`, with the key from
264
+ `CHEAPER_INFERENCE_API_KEY`; a thin subclass of the OpenAI provider, like
265
+ Opper. Contributed in #629 and landed in #666.
266
+
267
+ - **Your own MCP servers** (`actionagent`). A dashboard user adds a remote
268
+ MCP server by URL on the MCP Services page (*Your MCP servers*), with
269
+ optional request headers, tests it (`tools/list`, recording the tools it
270
+ serves) and enables it on agents from the Tools tab, where it is listed
271
+ beside the catalog as `custom:<id>`. Agent runs and scenario evaluation runs
272
+ call its tools, and evaluation fix items attribute them to it. Each server
273
+ belongs to an owner like a provider key (`MCPServerConfig`, install
274
+ migration 032), and every lookup, including a run's, is scoped to that
275
+ owner. Header values are encrypted at rest, never returned to the browser
276
+ (names with masked values; a blank value on update keeps the stored one),
277
+ and scrubbed from recorded output. Writes and tests ask for
278
+ `:manage_credentials`. A URL that reaches a loopback, private or
279
+ link-local address is refused when saved and when called (`MCPHostGuard`),
280
+ unless the host sets `ActionAgent.allow_private_mcp_hosts`, e.g. for local
281
+ development. API: `/api/mcp_server_configs` with a `test` member action.
282
+
283
+ - **Evals in the test suite** (`activeagent`). An eval is an ordinary test
284
+ under `test/evals`, collected by `rails test` like any other, whose
285
+ `ActiveAgent::Evals::TestCase` skips it unless `EVALS` is on, so the
286
+ suite never calls a model by accident. `bin/rails test:evals` switches
287
+ evals on and runs them through the same runner (`TEST=path` narrows,
288
+ `TESTOPTS` passes through); `EVALS=1 bin/rails test ...` runs one file,
289
+ one line, or the whole suite with the evals in. The same switch gates an
290
+ RSpec suite (`filter_run_excluding eval: true unless
291
+ ActiveAgent::Evals::TestHelper.enabled?`, with `expect_with :minitest`
292
+ for the assertions). `require "active_agent/evals/test_helper"`
293
+ after `rails/test_help` gives `ActiveAgent::Evals::TestCase` and the
294
+ `TestHelper` module: `run_eval MyAgent.with(...).action` returns an
295
+ `ActiveAgent::Evals::Replay` — the answer, every tool call with its
296
+ arguments, result or error and timing, duration and tokens — and
297
+ `assert_answer_present`, `assert_output_contains` /
298
+ `refute_output_contains`, `assert_tool_called` (with an argument subset)
299
+ / `refute_tool_called` and `assert_tools_succeeded` read it, failing with
300
+ what the agent did. `assert_eval_passes` (and `score_eval`) scores the
301
+ replay as a dashboard run does and fails with the fault and the fix;
302
+ `run_eval_suite` replays a YAML suite across models and returns the
303
+ `Report`, which `assert_pass_rate` gates and `to_html` writes.
304
+ `ActiveAgent::Evals::Capture` is the assertion-free core: it runs a
305
+ generation into a `Replay`, capturing tool calls on the agent's
306
+ `tools_function` (where the telemetry tool spans are; tools served by a
307
+ client-side MCP server are recorded through the agent's `tool_observer`),
308
+ replays under another `model:` or `provider:` the way a comparison run
309
+ does, and takes a block for an agent run some other way. The framework's railtie now
310
+ loads the gem's rake tasks; the empty `activeagent_tasks.rake`
311
+ placeholder is gone.
312
+
313
+ - **A suite declares its pass gates** (`activeagent`). A suite's `pass`
314
+ block sets the score every answer must reach (`threshold`) and the share
315
+ of a model's answers that must pass (`min_pass_rate`), for the suite and
316
+ for any group with its own bar; `gated: false` keeps a group in the report
317
+ and out of `Report#passed?`. A catalog's products and sets take the
318
+ same block, and later documents layer it key by key. `Suite#pass_thresholds`
319
+ reads it as a `PassThresholds`, `Runner.new(pass_thresholds:)` takes it in
320
+ place of `threshold:` and hands it to the report, and `Report#gates` is
321
+ then one row per group × model with its pass rate against its bar, plus an
322
+ `All` row per model over its gated results; `Report#passed?` is every gate
323
+ met, and a report with no gate (a selection that matched nothing, or every
324
+ scenario dropped as `production_only`) does not pass;
325
+ `Report#failures(group:)` lists the results that did not pass. The
326
+ Markdown report adds a *Pass rate by group* table (`gates_markdown`;
327
+ `failures_markdown(group:)` lists the failures for a test message), the
328
+ HTML page tones each group's pass count by its gate, and `to_h` gains
329
+ `pass_thresholds`, `groups` and `gates`. A report built without pass
330
+ thresholds renders as it did. The command line's
331
+ `evals run --min-pass-rate` stays a gate on the whole run.
332
+ - **The pass-gate assertion, for Minitest and RSpec** (`activeagent`).
333
+ `TestHelper#assert_meets_pass_gates(report)` passes when every gate of
334
+ the report is met, each group held to the bar the suite declares for it
335
+ and ungated groups left out, where `assert_pass_rate` holds every model
336
+ to one rate the test names; it takes a `PassThresholds` to hold the
337
+ report to another bar, and returns the check. `run_eval_suite` hands a
338
+ suite's bars to the runner when it declares any (a `threshold` other
339
+ than 0.7, a `min_pass_rate` below 1, or any key in a group's `pass`), so
340
+ the report it returns carries them; a suite with no `pass` block, or
341
+ only a suite-level one that restates the defaults, runs under the
342
+ defaults and its report carries no bars, and a `pass_thresholds:` or
343
+ `threshold:` given to `run_eval_suite` wins over the suite's. In RSpec the
344
+ assertion works through `expect_with :minitest` like the rest of the
345
+ helper, and `require "active_agent/evals/rspec"`, with
346
+ `ActiveAgent::Evals::RSpec` included beside the helper, adds the first
347
+ eval matcher, `expect(report).to meet_its_pass_gates`; that file loads
348
+ nothing from rspec, and `require "active_agent/evals"` loads neither it
349
+ nor the helper. Both fail with one message, `GateCheck#message`: how many
350
+ of the gates were not met, each unmet gate as its sentence, then the
351
+ failures to read under a heading per group with an unmet gate (one
352
+ `Report#failure_markdown(result)` line each, the line `failures_markdown`
353
+ is built from) — and, for an `All` row that is short, the failures it
354
+ counts: under `Failures:` when no group row is short with it, else the
355
+ ones outside the groups already listed under `Other failures:`; an
356
+ ungated group's failures are never listed. `GateCheck.new(report)`
357
+ answers `met?`, `unmet` and `gates` for a script that reports on its own.
358
+ - **Pattern builders for counts and shares** (`activeagent`).
359
+ `ActiveAgent::Evals::Patterns.number(count)` and
360
+ `Patterns.share(count, total)` return a regex string for a scenario's
361
+ `contains` or `not_contains` entry: `number(10)` matches the figure on its
362
+ own and not the `10` in `2026-10-01`, `10/1`, `10:00`, `1–10`, `Oct 10`,
363
+ `10 October`, `10.5`, `110`, `10,000` or `10%`; `share(3, 4)` matches
364
+ `75%`, `75 %`, `75.0%` and `75.00%` and not `175%` or `7.5%`.
365
+ `Scorer.matches_pattern?` tries an entry as a substring and then as a
366
+ regex, so the string goes into a suite document or a scenario hash as it
367
+ is, and a scenario seeded with known data can expect the figure that data
368
+ implies. A fractional or negative count, a total of zero or a count above
369
+ the total raise `ArgumentError`.
370
+ - **Strict scenario selection by key** (`activeagent`). `Suite#scenarios`
371
+ and `Catalog#scenarios` take `strict: true` with `keys:`: every key must
372
+ name a scenario the suite or catalog holds, or the call raises
373
+ `Suite::NotFound` listing the missing keys in the order given, so a
374
+ misspelled key from an environment variable or a command line fails the
375
+ run, named in the error. A key the suite holds outside the chosen
376
+ `groups:` returns an empty selection. `Suite.select` is the same check
377
+ over any scenarios, with `known:` the wider set the keys must come from,
378
+ for a runner that takes keys across several suites. Without `strict:` the
379
+ selection narrows as before: the scenario a misspelled key meant is left
380
+ out without notice, and only when every key misses does a run held to
381
+ `Report#passed?` (or `assert_meets_pass_gates`) fail, without naming the
382
+ key.
383
+ - **Skip the judge for a replay that already failed its expectations**
384
+ (`activeagent`). `Runner.new(judge_failed_expectations: false)` asks the
385
+ judge about an answered replay only when every expectation its scenario
386
+ declared was met. A replay that called none of its expected tools, printed
387
+ a forbidden phrase, lacks expected content or had a tool call fail is a
388
+ failed result whatever a judge would say — `Diagnosis` assigns one of its
389
+ evidence faults (`tool_error`, `missing_capability`,
390
+ `expected_tool_not_called`, `ungrounded_answer`, `forbidden_content` or
391
+ `missing_content`) before it looks at quality — so it fails on whichever
392
+ of those it assigns first, with no `task_completion` score, its
393
+ `llm_judge` criteria unscored and no judge-written recommendation (a
394
+ refusal or invented figure the judge skipped loses the judge's suggested
395
+ tool too), and the calls saved stay out of the `judge_limit` budget.
396
+ `Scorer.new(judge_failed_expectations:)` and
397
+ `Scorer.expectations_met?(scores)` are the pieces underneath, and the
398
+ scores hash keeps its key order either way. The default (`true`) grades
399
+ every answered replay as it did; a blank answer is never graded, and a
400
+ blank or errored replay is never refined under the default
401
+ `refine_faults`, with or without the flag.
402
+ - **Replay each scenario several times** (`activeagent`, `actionagent`).
403
+ `Runner.new(trials: 3)` replays every scenario under every model that
404
+ many times, scenario by scenario and model by model, so a flaky scenario
405
+ gets a pass share instead of one coin flip. Scenario keys stay as they
406
+ are; `Result#trial` tells the repeats apart, reads 1 on a result built
407
+ without one, and lands in `to_h` as `"trial"`. A model's `"scenarios"`
408
+ count and pass rate cover every replay, with `"unique_scenarios"` beside
409
+ them, and the verdict then reads "Passed 5 of 6 replays of 2 scenarios";
410
+ a matrix cell shows one mark per trial with the mean score and the most
411
+ frequent fault; the Answers section lists every trial; a failure line
412
+ (`failure_markdown`, `failures_markdown`, the pass-gate message) names
413
+ the trial (`gpt-5-mini` · trial 2) when its scenario × model was
414
+ replayed more than once; the Markdown, JSON and HTML reports state the
415
+ trial count only when it is above 1, so a one-trial Markdown or HTML
416
+ report is exactly what it was, and its JSON differs only by `"trial"` on
417
+ each result and `"unique_scenarios"` on each model. `Comparison` pools a
418
+ scenario's replays into its pass share, so its intervals still resample
419
+ scenarios. The collector accepts the repeats of one scenario × model when
420
+ each carries its `trial`, stores every one, and rebuilds them into the
421
+ run's report; the dashboard's own runs replay once.
422
+ - **The evaluation publisher reads `run_id` from the report, returns an
423
+ absolute receipt `url` and builds itself from `ENV`** (`activeagent`).
424
+ `ActiveAgent::Evals::Publisher#call` and `#progress` take `run_id:` as
425
+ optional and read it from the report's `metadata.run_id`, where
426
+ `Correlation#with_run` records it, from a `Report` or its saved JSON hash;
427
+ an explicit `run_id:` wins, and a report with neither raises
428
+ `ArgumentError` before anything is sent. The receipt's `url` comes back
429
+ absolute: the path a collector sends
430
+ (`<mount>/evaluations/:evaluation_id/runs/:id` from the engine) is joined
431
+ onto the publisher's `dashboard_url`, by default the endpoint's origin
432
+ (`https://activeagents.ai` for the hosted collector on its API host), or
433
+ the HTTP(S) URL `Publisher.new(dashboard_url:)` is given, so the link opens
434
+ in a browser; an absolute `url` and a receipt without one are left as they
435
+ are. `Publisher.from_env` builds the publisher from
436
+ `ACTIVEAGENTS_EVALUATIONS_ENDPOINT` (the hosted default when blank),
437
+ `ACTIVEAGENTS_API_KEY` (required) and the new, optional
438
+ `ACTIVEAGENTS_DASHBOARD_URL`, for a dashboard people reach at another
439
+ address than the collector's:
440
+ `Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "orders")`.
441
+ - **Verify RubyLLM models and the judge before a run** (`activeagent`).
442
+ `ActiveAgent::Evals::RubyLLM.verify!(models, context:)` takes a run's
443
+ `ModelSpec`s (or one) and, for the first that cannot run, raises
444
+ `RubyLLM::ConfigurationError` saying why: a provider RubyLLM does not know
445
+ (the message lists the ones it does), one outside `providers:` (the names
446
+ a host has keys for), or one its context cannot build —
447
+ `claude-sonnet-5 runs on anthropic, which has no API key. Set
448
+ ANTHROPIC_API_KEY.`, naming the provider's `configuration_requirements`
449
+ the context leaves unset (`which is not configured. Set OLLAMA_API_BASE.`
450
+ when they are not keys), or carrying RubyLLM's own message when those are
451
+ all set and it refused for another reason, with RubyLLM's error as the
452
+ cause. It builds each chat with `assume_model_exists: true`, which is
453
+ where RubyLLM checks its keys, and sends nothing. `judge(..., verify: true)`
454
+ runs the same check on the judge's own model when the judge is built,
455
+ with the same `providers:` when it is given them, so a judge whose
456
+ provider has no key refuses before the first scenario is replayed rather
457
+ than at its first grade; the default stays lazy.
458
+ - **Named criteria lists on a suite** (`activeagent`). A suite document
459
+ carries a top-level `criteria` beside `groups`: a list of the
460
+ `{ key, type, config }` hashes `Runner.new(criteria:)` takes, which reads
461
+ as the one list named `default`, or a mapping of name → list, one per
462
+ audience the same questions are graded for. `Suite#criteria(*names)`
463
+ joins the named lists in the order given, a later list's criterion
464
+ replacing an earlier one with the same key, ready for the runner;
465
+ with no names it returns every list in document order, and a name no
466
+ document declares contributes nothing, so a deployment asks for `shared`
467
+ plus an audience that may have no list of its own. `Suite#criteria_names`
468
+ lists the names. Later documents layer each list by criterion key, as they
469
+ layer scenarios: a key already present is replaced in place, a new key
470
+ appended. A catalog product carries the same mapping, which `suite_for`
471
+ hands its suite and `to_yaml` writes back. A `criteria` value that is
472
+ neither a list nor a mapping of lists, or a criterion that is not a
473
+ mapping or has no key, is refused: `ArgumentError` from a suite,
474
+ `Catalog::InvalidDocument` from a catalog product (a set keeps a criterion
475
+ without a key; see Changed) and `ScenarioParser::ParseError` from a pasted
476
+ suite. Earlier releases ignored a suite document's top-level `criteria`;
477
+ a document that carries one in another shape is refused.
478
+
479
+ ### Changed
480
+
481
+ - **Every trace cost is priced under its provider** (`actionagent`). The
482
+ trace list, Metrics, the agent scorecard, the interactions list and an
483
+ evaluation result priced from its trace all hand `ModelPricing` the
484
+ provider the llm span recorded, so `gpt-5.5` through OpenRouter is
485
+ OpenRouter's rate, and a trace's `OpenAI::Chat` tag reads as `openai`
486
+ (`ModelPricing.provider_slug`). `TelemetryTrace.pluck_with_llm_model_and_provider`
487
+ plucks the provider beside the model for a caller that prices tokens.
488
+
489
+ - **Ranked comparison tables and value pills** (`actionagent`). Wherever the
490
+ dashboard compares rows, they now read through one pattern: a
491
+ `ComparisonTable` with two-line mono headers that sort on click and a
492
+ rank-tinted `ValuePill` in every numeric cell, banded by tercile in the
493
+ column's better direction or by fixed thresholds where the domain has them
494
+ (pass rates at 85 and 50), with missing values unranked and sorted last,
495
+ and a visually hidden rank word so colour is never the only signal. Applied
496
+ to a run's model comparison table and the Evaluations leaderboard, the
497
+ scenario matrix (the key leads each step with the prompt under it, one
498
+ score pill per model, cost and time pills per scenario, the tools level
499
+ stacking tool call, time, tokens and cost pills), each opened result's
500
+ score, time, tokens and cost strip, the Metrics overview's change pills and
501
+ the home page's Top Agents list. Under 600px a table stacks each row to
502
+ labelled pills.
503
+
504
+ - **A set's `criteria` layer across catalog documents** (`activeagent`). A
505
+ later document's criterion replaces the one with its key in place and a
506
+ new key is appended, as a product's named lists and a suite's scenarios
507
+ layer; a later document used to replace the set's whole list. A criterion
508
+ without a key is still accepted and appended as it is, once: a later
509
+ document that restates it adds nothing. A criterion's `key` is written as
510
+ a string, as scenario keys are, so a set criterion keyed by a number reads
511
+ back with a quoted key and the catalog's digest changes with it.
512
+
513
+ ### Fixed
514
+
515
+ - **The dashboard engine loads in a host without Action Cable**
516
+ (`actionagent`). The engine's class body camelizes its inflection
517
+ overrides with `Zeitwerk::Inflector` and did not require Zeitwerk itself.
518
+ Rails requires it only once the application sets up its autoloaders,
519
+ after `Bundler.require` has loaded the gem; until then the constant
520
+ exists only because Action Cable's engine loads it as a side effect. An
521
+ app generated with `--skip-action-cable`, or any API-only host, died at
522
+ boot with `uninitialized constant ActionAgent::Engine::Zeitwerk` (seen on
523
+ Rails 8.1.4; the require order is the same on every Rails since 7.0). The
524
+ engine requires Zeitwerk, a dependency of Active Support, before reading
525
+ it, and a test loads the engine in a fresh process right after `rails`,
526
+ with nothing in between.
527
+
528
+ - **An evaluation judge job delivered twice scores its result once**
529
+ (`actionagent`). The claim on a pending result was a conditional write on
530
+ its timestamp, which turned away a stale reader but not a duplicate
531
+ delivered after the claim: that one read the new timestamp and asked the
532
+ judge to score the result again. A claim now takes a 30-minute lease
533
+ (`scoring_token`, `scoring_expires_at`) that a fresh reader cannot take
534
+ while it is live, and a worker that finishes after its lease was
535
+ reclaimed cannot overwrite its successor's result. A delivery that finds
536
+ a live lease, such as the queue's redelivery after a worker died
537
+ mid-judge, enqueues the job again for when the lease expires, so the run
538
+ still completes. Without migration 030 the judge job keeps the timestamp
539
+ claim instead of failing on the missing columns.
540
+
541
+ - **Keep scenario catalog extension fields** (`activeagent`, `actionagent`).
542
+ Opaque scenario fields survive parsing, YAML/JSON export, database reload,
543
+ standard-field edits and storage restore. Their changes affect import
544
+ previews and catalog digests, including when only an extension changes.
545
+ Empty values and literal `metadata` keys are preserved; canonical format
546
+ fields cannot be replaced through the metadata container.
547
+
548
+ - **Bound trace console list limits** (`actionagent`). The console defaults
549
+ to 50 recent matching traces, clamps integer limits to 1–1000, and uses
550
+ the default for malformed values. Its documentation describes the bounded
551
+ recent list rather than claiming pagination.
552
+
553
+ - **Select trace time windows in dark mode** (`actionagent`). The dark
554
+ Traces header includes the shared time-window selector and zoom controls,
555
+ including when embedded on an agent page or when the window is empty.
556
+
557
+ - **Show tool usage in both dashboard themes** (`actionagent`). The Traces
558
+ page renders the same tool counts, durations, failures and empty state
559
+ in dark and light mode, using the shared telemetry colors.
560
+
561
+ - **Load evaluation catalogs without Rails** (`activeagent`). The standalone
562
+ `active_agent/evals` entry point loads the ActiveSupport array extension
563
+ used to construct, parse and load catalogs, without loading the framework.
564
+
565
+ - **Client-side MCP tool calls appear in an eval replay** (`activeagent`).
566
+ `Evals::Capture` recorded the tools that reach the agent's
567
+ `tools_function`, and a tool served by a client-side MCP server is
568
+ dispatched before it, so a replay of an agent with `mcps:` showed none of
569
+ those calls and `assert_tool_called` could not find them. The provider
570
+ now runs such a call inside the agent's `tool_observer`, which Capture
571
+ sets, so it is recorded once with its arguments, result preview and
572
+ timing. A result the server marks as an error, which the model receives
573
+ as a result and not an exception, is recorded as a failed call, so
574
+ `failed_tool_calls` and `assert_tools_succeeded` see it. A server the
575
+ provider runs itself (Anthropic's or OpenAI's hosted MCP) executes on the
576
+ provider's side and is still not recorded.
577
+
578
+ - **An agent run is executed by one job** (`actionagent`). The execution
579
+ job read a run's status, then wrote `running`, as two steps, and only
580
+ turned away a finished or paused run. A job delivered twice, or two
581
+ workers that both loaded the run as pending, each started a generation
582
+ for it, and a duplicate that arrived while the run was running started
583
+ another. The job now moves the run from pending to running in one
584
+ conditional update, as `CodeSessionJob` claims a session, and starts no
585
+ generation unless it was the one to do so. The database decides at the
586
+ write, so this holds on SQLite under Rails 7.2 too, where a row-lock read
587
+ followed by a write is refused once another worker has committed. Nothing
588
+ is held over the generation, so a cancel does not wait for one. A run
589
+ whose worker died after the claim stays running and is not run again.
590
+
8
591
  ## [1.9.6] - 2026-10-29
9
592
 
10
593
  The dashboard on a phone, and the small finished pieces: the sidebar as a
@@ -1386,6 +1969,35 @@ finished must handle. Session Replay is now **Sessions** in the sidebar.
1386
1969
  do for `generate_later`. `InputRequest#arguments` holds the arguments of the
1387
1970
  paused call.
1388
1971
 
1972
+ - **`ActiveAgent::Evals::RubyLLM`** — the RubyLLM side of an evaluation,
1973
+ which hosts driving RubyLLM conversations had been writing for themselves.
1974
+ `require "active_agent/evals/ruby_llm"` (it requires `ruby_llm`;
1975
+ `require "active_agent/evals"` alone still does not) gives two helpers, for
1976
+ RubyLLM 1.16 and later and for 2.x:
1977
+ - `RubyLLM.judge(label:, model:, provider:, context:, correlation:)`, a
1978
+ `Judge` that answers from `context.chat(...)` and, with a `Correlation`,
1979
+ traces each call under the kind it serves. `temperature:` and a
1980
+ `configure:` hook set the chat up; any other keyword must be one
1981
+ `RubyLLM::Chat.new` takes, and anything else raises when the judge is
1982
+ built rather than failing every call. `on_usage:` reports each call's
1983
+ model, tokens and cost.
1984
+ - `RubyLLM.replay(messages)`, a `Replay` from a conversation's messages.
1985
+ `acts_as_chat` records of either RubyLLM generation are read through
1986
+ their own `to_llm`, so a 2.x table, or one between 2.x's upgrade and its
1987
+ cleanup, replays the same as `RubyLLM::Message` values. Tool calls keep
1988
+ the order the model made them in, each paired with the result that
1989
+ answers it; one is errored when that result reports an error at its top
1990
+ level — JSON whose `"error"` is a non-empty string, object or array, or
1991
+ `true`; an MCP result with `"isError": true`; or the inspected
1992
+ `{ error: "..." }` RubyLLM 1.x stores for a tool's error Hash with a
1993
+ String error — with the error (JSON-encoded, at most 1,200 bytes) as its
1994
+ detail. `input_tokens` counts the whole prompt, cache reads and writes
1995
+ included. Cost is RubyLLM's own price, left nil when any message is
1996
+ unpriced. The answer is the last reply after the last user message; a
1997
+ conversation that stopped at a tool call or on the user's own message
1998
+ has no answer and says so in its `error` (on RubyLLM 1.x, pass `answer:`
1999
+ for a tool that ends the turn with `halt`).
2000
+
1389
2001
  ### Changed
1390
2002
 
1391
2003
  - **Confirm destructive actions in the dashboard's own dialog**
@@ -2100,34 +2712,6 @@ output in the shape ruby_llm reads. No migrations.
2100
2712
  `json_object`, which ruby_llm has no mode for, and a `json_schema`
2101
2713
  without a schema now raise `ArgumentError` instead of sending a request
2102
2714
  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
2715
  - **`ActionAgent::ProviderKey.credentials_for(owner)` and
2132
2716
  `.apply_to(config, owner:)`** (`actionagent`) hand an owner's API keys to
2133
2717
  code outside the engine — `{ "openai" => "sk-..." }`, or written through