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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/providers/_base_provider.rb +17 -1
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- data/lib/tasks/activeagent_tasks.rake +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2e216123a2bdec63446cf6f6af685c0c61b285a318f5a98845e09b188838427
|
|
4
|
+
data.tar.gz: 434fbcc22e9a7ffa10434c93ba4b3d63306e6f0ae5502742eb2c83a31abb9338
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -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
|
|
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
|