agent-evals 0.1.1
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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +635 -0
- data/exe/agent-evals +9 -0
- data/lib/agent/evals/adapter.rb +47 -0
- data/lib/agent/evals/case.rb +36 -0
- data/lib/agent/evals/cli/cases.rb +48 -0
- data/lib/agent/evals/cli/command.rb +163 -0
- data/lib/agent/evals/cli/compare.rb +33 -0
- data/lib/agent/evals/cli/model_command.rb +393 -0
- data/lib/agent/evals/cli/progress_bars.rb +90 -0
- data/lib/agent/evals/cli/run.rb +61 -0
- data/lib/agent/evals/cli/verify.rb +24 -0
- data/lib/agent/evals/cli/version.rb +15 -0
- data/lib/agent/evals/cli.rb +40 -0
- data/lib/agent/evals/completion.rb +11 -0
- data/lib/agent/evals/config.rb +146 -0
- data/lib/agent/evals/configuration.rb +172 -0
- data/lib/agent/evals/contender.rb +31 -0
- data/lib/agent/evals/discovery.rb +95 -0
- data/lib/agent/evals/experiment.rb +133 -0
- data/lib/agent/evals/helper.rb +98 -0
- data/lib/agent/evals/launcher.rb +37 -0
- data/lib/agent/evals/metric.rb +26 -0
- data/lib/agent/evals/model.rb +55 -0
- data/lib/agent/evals/providers/base.rb +93 -0
- data/lib/agent/evals/providers/catalog.rb +32 -0
- data/lib/agent/evals/providers/open_router.rb +64 -0
- data/lib/agent/evals/providers/registry.rb +112 -0
- data/lib/agent/evals/providers/type_safe.rb +53 -0
- data/lib/agent/evals/reply.rb +18 -0
- data/lib/agent/evals/run_result.rb +14 -0
- data/lib/agent/evals/runtime.rb +74 -0
- data/lib/agent/evals/schema_validator.rb +84 -0
- data/lib/agent/evals/score.rb +26 -0
- data/lib/agent/evals/scorer.rb +45 -0
- data/lib/agent/evals/testing/echo_adapter.rb +29 -0
- data/lib/agent/evals/testing/fake_model.rb +18 -0
- data/lib/agent/evals/testing/fake_provider.rb +67 -0
- data/lib/agent/evals/tracker.rb +80 -0
- data/lib/agent/evals/trackers/braintrust.rb +91 -0
- data/lib/agent/evals/trackers/local.rb +78 -0
- data/lib/agent/evals/trackers/logfire.rb +143 -0
- data/lib/agent/evals/trackers/registry.rb +82 -0
- data/lib/agent/evals/usage.rb +33 -0
- data/lib/agent/evals/verify.rb +49 -0
- data/lib/agent/evals/version.rb +7 -0
- data/lib/agent/evals/warnings.rb +89 -0
- data/lib/agent/evals.rb +82 -0
- metadata +257 -0
data/README.md
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# agent-evals
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/agent-evals)
|
|
4
|
+
[](https://dl.circleci.com/status-badge/redirect/gh/kigster/agent-evals/tree/main)
|
|
5
|
+
|
|
6
|
+
> [!IMPORTANT]
|
|
7
|
+
> **Which model answers your cases best, and what does it cost?** `agent-evals` runs every model in your lineup over every case in your folder, in parallel, and prints a leaderboard with scores, tokens and dollars.
|
|
8
|
+
>
|
|
9
|
+
> You write two small classes: an **adapter** that asks the model, and a **scorer** that grades the answer.
|
|
10
|
+
>
|
|
11
|
+
> The gem does the rest:
|
|
12
|
+
>
|
|
13
|
+
> - case discovery,
|
|
14
|
+
> - the model lineup,
|
|
15
|
+
> - providers,
|
|
16
|
+
> - threads,
|
|
17
|
+
> - progress bars,
|
|
18
|
+
> - the leaderboard,
|
|
19
|
+
> - and sending results to Braintrust or Logfire if you so desire.
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
Every LLM project grows its own eval script, and every one of them rebuilds the same runner, model list, thread pool and results table. This gem is that script, written once.
|
|
24
|
+
|
|
25
|
+
- **Scores reflect production.** Your adapter calls your real prompt builder and your real parser. The gem never touches what the model sees.
|
|
26
|
+
- **Compare settings, not just models.** `effort: [low, high]` and `temperature: [0.0, 0.7]` expand into one contender per combination.
|
|
27
|
+
- **Know the cheap winner.** "Best value" is the cheapest experiment within two points of the top score.
|
|
28
|
+
- **Failures are counted honestly.** A wrong answer scores 0. A network error is an error, not a 0. A model your suite cannot ask is left out by name, not scored.
|
|
29
|
+
- **No lock-in.** A local JSON log is always written. Braintrust, Logfire or your own tracker sits beside it.
|
|
30
|
+
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
The bundled toy suite, run against two fake models (no network):
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
$ agent-evals compare --local
|
|
37
|
+
┌─────────┬─────────────┬─────────┬────────────────┬─────────────┬───────────┬────────┬─────────┐
|
|
38
|
+
│ dataset │ model │ effort │ field_accuracy │ exact_match │ restraint │ errors │ cost │
|
|
39
|
+
├─────────┼─────────────┼─────────┼────────────────┼─────────────┼───────────┼────────┼─────────┤
|
|
40
|
+
│ animals │ fake/echo │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
41
|
+
│ animals │ fake/echo │ low │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
42
|
+
│ animals │ fake/parrot │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
43
|
+
│ colors │ fake/echo │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
44
|
+
│ colors │ fake/echo │ low │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
45
|
+
│ colors │ fake/parrot │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
|
|
46
|
+
└─────────┴─────────────┴─────────┴────────────────┴─────────────┴───────────┴────────┴─────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Abridged: the real table also has `temp`, `time`, `tok_in` and `tok_out` columns. It's followed by a "best value" line per dataset, such as `best value [animals] (fake/echo effort=default): 100.0% at $0.0040`, then a glossary of every column and an overall leaderboard. There is one column per metric your scorer declares.
|
|
50
|
+
|
|
51
|
+
## Run the examples
|
|
52
|
+
|
|
53
|
+
The repository ships two example suites. Both use fake models, so they need no API keys and no network. They live under `spec/fixtures/` and are **not** part of the installed gem, so start from a clone:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/kigster/agent-evals.git
|
|
57
|
+
cd agent-evals
|
|
58
|
+
bundle install
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### The short way: `just`
|
|
62
|
+
|
|
63
|
+
From the repository root, with [`just`](https://github.com/casey/just) installed (`brew install just`)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Example 1: cases, verify, then compare --local
|
|
67
|
+
just toy
|
|
68
|
+
|
|
69
|
+
# Example 2: the same, for the config-only suite
|
|
70
|
+
just config-only
|
|
71
|
+
|
|
72
|
+
# Both
|
|
73
|
+
just examples
|
|
74
|
+
|
|
75
|
+
# Any other command, run inside the suite's folder
|
|
76
|
+
just toy compare -n
|
|
77
|
+
just toy run -m fake/echo -d animals --local
|
|
78
|
+
just config-only cases
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The recipes `cd` into the suite's folder for you and, for the config-only suite, set `RUBYLIB` too. The rest of this section is what they do by hand.
|
|
82
|
+
|
|
83
|
+
### Where to run from
|
|
84
|
+
|
|
85
|
+
`agent-evals` finds its suite by searching **upward from the current directory** for `evals/eval_helper.rb` or `evals/.agent-eval.config.yml`. The repository root has neither, so running there fails:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
$ bundle exec exe/agent-evals cases
|
|
89
|
+
error: no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Either `cd` into a suite's folder (the parent of its `evals/`), or point at the helper with `--helper`.
|
|
93
|
+
|
|
94
|
+
### 1. The toy suite (`eval_helper.rb` style)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd spec/fixtures/toy_suite
|
|
98
|
+
|
|
99
|
+
# List the 8 cases (2 datasets × 2 variants × 2 cases); calls nothing.
|
|
100
|
+
bundle exec agent-evals cases
|
|
101
|
+
|
|
102
|
+
# Offline checks of inputs, expected.json, the adapter and the scorer.
|
|
103
|
+
bundle exec agent-evals verify
|
|
104
|
+
|
|
105
|
+
# Show the experiment matrix the lineup expands into; calls nothing.
|
|
106
|
+
bundle exec agent-evals compare -n
|
|
107
|
+
|
|
108
|
+
# One model, one dataset, with the per-case breakdown.
|
|
109
|
+
bundle exec agent-evals run -m fake/echo -d animals --local
|
|
110
|
+
|
|
111
|
+
# Every model in the lineup, leaderboard only.
|
|
112
|
+
bundle exec agent-evals compare --local
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The same commands work from the repository root with `--helper`:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bundle exec exe/agent-evals compare --local --helper spec/fixtures/toy_suite/evals/eval_helper.rb
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
What to read in it:
|
|
122
|
+
|
|
123
|
+
| File (under `spec/fixtures/toy_suite/`) | What it shows |
|
|
124
|
+
| --------------------------------------- | ------------------------------------------------------------ |
|
|
125
|
+
| `evals/eval_helper.rb` | `Agent::Evals.configure`: suite, cases glob, adapter, scorer |
|
|
126
|
+
| `lib/toy_suite/evals.rb` | the adapter and scorer classes |
|
|
127
|
+
| `evals/.agent-eval.config.yml` | the lineup, including a `direct:` entry the suite refuses |
|
|
128
|
+
| `evals/datasets/` | the case layout on disk |
|
|
129
|
+
|
|
130
|
+
The warning `typesafe/jev-latest refused: needs typesafe; this suite produces prompt` is intentional: it shows a contender being left out rather than scored 0.
|
|
131
|
+
|
|
132
|
+
### 2. The config-only suite (no `eval_helper.rb`)
|
|
133
|
+
|
|
134
|
+
The same suite, with the adapter and scorer named in `.agent-eval.config.yml` instead of Ruby. Its `require: toy_suite/evals` must be loadable, which in a real project means a gem in your bundle. Here the code lives in the toy suite's `lib/`, so add it to the load path with `RUBYLIB`:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
cd spec/fixtures/config_only_suite
|
|
138
|
+
RUBYLIB=../toy_suite/lib bundle exec agent-evals cases
|
|
139
|
+
RUBYLIB=../toy_suite/lib bundle exec agent-evals compare --local
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Without `RUBYLIB` the run stops with `require: toy_suite/evals could not be loaded ... is its gem in the bundle?`.
|
|
143
|
+
|
|
144
|
+
### Against real models
|
|
145
|
+
|
|
146
|
+
Use the toy suite as a template:
|
|
147
|
+
|
|
148
|
+
1. Copy its `evals/` folder into your project.
|
|
149
|
+
1. Replace the adapter and scorer with your own (see [Writing a suite](#writing-a-suite)).
|
|
150
|
+
1. List real models under `vendors:` in `.agent-eval.config.yml` and drop the `fake:` entries.
|
|
151
|
+
1. Add `gem "ruby_llm"` to your Gemfile and set `OPENROUTER_API_KEY`, in your shell or the suite's `.env` (see [Environment](#environment)).
|
|
152
|
+
|
|
153
|
+
## Install
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# Gemfile
|
|
157
|
+
gem "agent-evals"
|
|
158
|
+
|
|
159
|
+
# for OpenRouter, the default provider
|
|
160
|
+
gem "ruby_llm"
|
|
161
|
+
|
|
162
|
+
# only for the braintrust tracker
|
|
163
|
+
gem "braintrust"
|
|
164
|
+
|
|
165
|
+
# only for the logfire tracker
|
|
166
|
+
gem "opentelemetry-sdk"
|
|
167
|
+
gem "opentelemetry-exporter-otlp"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Requires Ruby 3.4 or later. `ruby_llm`, `braintrust` and the OpenTelemetry gems are optional: they load only when a command needs them, and a missing one is named in the error.
|
|
171
|
+
|
|
172
|
+
## Writing a suite
|
|
173
|
+
|
|
174
|
+
The boundary runs one way:
|
|
175
|
+
|
|
176
|
+
- The **adapter** runs one case against a model and returns a `Reply`. It owns everything the model sees and everything done to its answer. It never scores.
|
|
177
|
+
- The **scorer** turns a case and a reply into a `Score`. It is pure and deterministic. It never calls anything.
|
|
178
|
+
|
|
179
|
+
Nothing in `lib/` knows what is being evaluated.
|
|
180
|
+
|
|
181
|
+
The executable looks for `evals/eval_helper.rb` upward from the current directory (or `--helper PATH`, or `AGENT_EVALS_HELPER`), loads it, and dispatches. The helper calls `Agent::Evals.configure`:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# evals/eval_helper.rb
|
|
185
|
+
require "bundler/setup"
|
|
186
|
+
require "agent/evals"
|
|
187
|
+
require "my_gem"
|
|
188
|
+
|
|
189
|
+
class MyAdapter < Agent::Evals::Adapter
|
|
190
|
+
# Once per dataset, outside the threads.
|
|
191
|
+
def prepare(dataset) = @schema = MyGem.schema_for(dataset)
|
|
192
|
+
|
|
193
|
+
def call(kase, model:)
|
|
194
|
+
completion = model.complete(system: MyGem.system_prompt(@schema), user: kase.input, json: true)
|
|
195
|
+
# The production parser, so scores reflect production.
|
|
196
|
+
output = MyGem.parse(completion.text)
|
|
197
|
+
Agent::Evals::Reply.new(output: output, raw: completion.text, usage: completion.usage)
|
|
198
|
+
rescue MyGem::NotJSON => e
|
|
199
|
+
# The model's failure: scored 0.
|
|
200
|
+
Agent::Evals::Reply.new(error: e.message, raw: completion&.text)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# What the scorer needs beyond the case.
|
|
204
|
+
def context(kase) = { schema: @schema }
|
|
205
|
+
|
|
206
|
+
# Offline, for `agent-evals verify`.
|
|
207
|
+
def verify(dataset) = MyGem.schema_problems(dataset)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
class MyScorer < Agent::Evals::Scorer
|
|
211
|
+
def metrics
|
|
212
|
+
[
|
|
213
|
+
Agent::Evals::Metric.new(
|
|
214
|
+
name: "field_accuracy",
|
|
215
|
+
description: "Mean per-field score.",
|
|
216
|
+
primary: true
|
|
217
|
+
),
|
|
218
|
+
Agent::Evals::Metric.new(
|
|
219
|
+
name: "exact_match",
|
|
220
|
+
description: "1 when every field is right."
|
|
221
|
+
),
|
|
222
|
+
Agent::Evals::Metric.new(
|
|
223
|
+
name: "restraint",
|
|
224
|
+
description: "Of the fields the text never answers, the share left null."
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def call(kase, reply, context:)
|
|
230
|
+
scores = MyGem.field_scores(context[:schema], kase.expected, reply.output)
|
|
231
|
+
mean = scores.values.sum / scores.size
|
|
232
|
+
misses = scores.reject { |_, v| v == 1.0 }.keys
|
|
233
|
+
|
|
234
|
+
Agent::Evals::Score.new(
|
|
235
|
+
value: mean,
|
|
236
|
+
metrics: {
|
|
237
|
+
"field_accuracy" => mean,
|
|
238
|
+
"exact_match" => scores.values.all?(1.0) ? 1.0 : 0.0,
|
|
239
|
+
# nil: this case does not apply; it is skipped, not zeroed.
|
|
240
|
+
"restraint" => nil
|
|
241
|
+
},
|
|
242
|
+
diagnostics: misses.to_h { |f| [f, { want: kase.expected[f], got: reply.output[f] }] }
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def verify(kase, context:)
|
|
247
|
+
Agent::Evals::SchemaValidator.new(context[:schema]).problems(kase.expected)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def summary(kase) = "#{kase.expected.count { |_, v| !v.nil? }}/#{kase.expected.size} answered"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
Agent::Evals.configure do |c|
|
|
254
|
+
c.suite "extract", root: __dir__, env_files: ["../.env"]
|
|
255
|
+
c.cases "datasets/*/cases/**/input.txt"
|
|
256
|
+
c.adapter MyAdapter.new
|
|
257
|
+
c.scorer MyScorer.new
|
|
258
|
+
# After the env files load, before any model is called.
|
|
259
|
+
c.before_run { MyGem.reset_configuration! }
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A complete, runnable suite lives in [`spec/fixtures/toy_suite/evals/`](spec/fixtures/toy_suite/evals/): two datasets, two variants, the gem's own `Testing::EchoAdapter` and a fake provider. It runs without a network.
|
|
264
|
+
|
|
265
|
+
### A config-only suite
|
|
266
|
+
|
|
267
|
+
When the adapter and scorer live in your own gem, you need no `eval_helper.rb`. Three optional keys in `.agent-eval.config.yml` name them:
|
|
268
|
+
|
|
269
|
+
```yaml
|
|
270
|
+
# evals/.agent-eval.config.yml
|
|
271
|
+
require: my_gem/evals # a require path, or a list; loaded through the bundle's load path
|
|
272
|
+
adapter: MyGem::Evals::Adapter # constant names, instantiated with no arguments
|
|
273
|
+
scorer: MyGem::Evals::Scorer
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The executable finds `evals/.agent-eval.config.yml` upward from the working directory, roots the suite at `evals/`, requires what `require:` names and instantiates the two constants. `evals/` holds only the config file and `datasets/`:
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
lib/my_gem/evals/adapter.rb # the Ruby, in the gem
|
|
280
|
+
lib/my_gem/evals/scorer.rb
|
|
281
|
+
evals/.agent-eval.config.yml # the lineup, plus require/adapter/scorer
|
|
282
|
+
evals/datasets/...
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
If `evals/eval_helper.rb` also exists, it loads first and may register either object in code; the config keys fill whatever it left unset. The required file may itself call `Agent::Evals.configure`, for instance to register a provider.
|
|
286
|
+
|
|
287
|
+
Errors name what is wrong: no adapter or scorer from either source names the file and the keys; a failing `require:` or an unknown constant names the key and its value instead of a bare `LoadError` or `NameError`. [`spec/fixtures/config_only_suite/`](spec/fixtures/config_only_suite/) is the toy suite in this form.
|
|
288
|
+
|
|
289
|
+
### Cases on disk
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
evals/
|
|
293
|
+
eval_helper.rb
|
|
294
|
+
.agent-eval.config.yml # the lineup and the request types
|
|
295
|
+
datasets/<dataset>/cases/<variant>/[NNN/]input.txt
|
|
296
|
+
datasets/<dataset>/cases/<variant>/[NNN/]expected.json
|
|
297
|
+
datasets/<dataset>/cases/<variant>/[NNN/]metadata.json # optional; merged into Case#metadata
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
- The folder after `datasets/` is the **dataset**.
|
|
301
|
+
- The folder holding `input.txt` is the **variant**, unless it is a number: then it is the case number and its parent is the variant.
|
|
302
|
+
- A case id is `variant/NNN`. `-c clear` selects a variant, `-c clear/003` one case.
|
|
303
|
+
|
|
304
|
+
Need another layout? Change the glob passed to `c.cases`.
|
|
305
|
+
|
|
306
|
+
### The lineup: `.agent-eval.config.yml`
|
|
307
|
+
|
|
308
|
+
The file at the suite root names the models to compare, their settings, and the kinds of request the suite sends:
|
|
309
|
+
|
|
310
|
+
```yaml
|
|
311
|
+
# evals/.agent-eval.config.yml
|
|
312
|
+
project: my-evals # Braintrust project name (BRAINTRUST_PROJECT_ID wins when set)
|
|
313
|
+
request_types: [prompt] # required: what the suite's adapter sends
|
|
314
|
+
|
|
315
|
+
defaults:
|
|
316
|
+
effort: default # default sends none and leaves the model's own setting
|
|
317
|
+
temperature: 0.1
|
|
318
|
+
max_tokens: 8192 # covers the reasoning tokens a high effort spends
|
|
319
|
+
parallelism: 4 # cases in flight per experiment; the default for -p
|
|
320
|
+
|
|
321
|
+
vendors: # the OpenRouter list; every entry takes a prompt
|
|
322
|
+
anthropic:
|
|
323
|
+
- model: claude-fable-5
|
|
324
|
+
- model: claude-opus-5
|
|
325
|
+
- model: claude-sonnet-5.5
|
|
326
|
+
- model: claude-haiku-5.5
|
|
327
|
+
effort: [default, low, high] # a list expands into one contender per value
|
|
328
|
+
openai:
|
|
329
|
+
- model: gpt-4.1-mini
|
|
330
|
+
- model: gpt-4o-mini
|
|
331
|
+
- model: gpt-6.1-sol
|
|
332
|
+
google:
|
|
333
|
+
- model: gemini-2.5-flash
|
|
334
|
+
- model: gemini-4-argon
|
|
335
|
+
meta-llama:
|
|
336
|
+
- model: llama-3.3-70b-instruct
|
|
337
|
+
typesafe:
|
|
338
|
+
- model: jev-router # Jev on OpenRouter, a chat model
|
|
339
|
+
moonshotai:
|
|
340
|
+
- model: kimi-k3
|
|
341
|
+
|
|
342
|
+
direct: # a vendor's own API, one request type each
|
|
343
|
+
typesafe: # request type "typesafe": noul, choice, score
|
|
344
|
+
- model: jev-latest
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**`vendors:`** Each entry becomes `<vendor>/<model>`, the OpenRouter id. `effort` and `temperature` take one value or a list, and lists multiply: `effort: [low, high]` with `temperature: [0.0, 0.7]` runs four experiments for that model. Anything left out falls back to `defaults`. Flags (`-e`, `-t`, `--max-tokens`, `-p`) beat both.
|
|
348
|
+
|
|
349
|
+
**`direct:`** Each key names a provider the gem knows (today only `typesafe`). Entries take the same `{model:, effort:, temperature:, max_tokens:}` format, expand the same way, and render as `typesafe/jev-latest`. The contender carries its provider, so a direct `typesafe/jev-latest` and the OpenRouter chat model `typesafe/jev-router` never get confused. An unknown key is a configuration error naming the file and the known providers.
|
|
350
|
+
|
|
351
|
+
#### Request types
|
|
352
|
+
|
|
353
|
+
`request_types` is required. It says what the suite's adapter sends. The types belong to the providers and show in `agent-evals --help`; a suite only names them:
|
|
354
|
+
|
|
355
|
+
| Type | Provider | Meaning |
|
|
356
|
+
| ---------- | ---------------------------------------- | ---------------------------------------------------- |
|
|
357
|
+
| `prompt` | OpenRouter (everything under `vendors:`) | a chat completion: system and user text in, text out |
|
|
358
|
+
| `typesafe` | `direct: typesafe:` | typed questions: noul, choice, score |
|
|
359
|
+
|
|
360
|
+
A contender runs only when its provider's type is in the suite's list. Otherwise it is **refused**:
|
|
361
|
+
|
|
362
|
+
- named in the run's warnings: `typesafe/jev-latest refused: needs typesafe; this suite produces prompt`
|
|
363
|
+
- left out of the matrix and the leaderboard
|
|
364
|
+
- never scored 0: a model the suite cannot ask did not answer wrongly
|
|
365
|
+
|
|
366
|
+
`cases` prints refusals too, so a mismatch shows before anything runs. A missing, empty or unknown `request_types` is a configuration error naming the file.
|
|
367
|
+
|
|
368
|
+
## The command line
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
agent-evals cases # list the cases, no calls
|
|
372
|
+
agent-evals verify # offline checks: inputs, expected.json, adapter, scorer
|
|
373
|
+
agent-evals run -m openai/gpt-4.1-mini # one model, every dataset, per-case breakdown
|
|
374
|
+
agent-evals run -m anthropic/claude-haiku-5.5 -e low -d vague -c vague/00
|
|
375
|
+
agent-evals run --local -o # no remote tracker; the breakdowns go to log/
|
|
376
|
+
agent-evals compare # everything the lineup lists, leaderboard only
|
|
377
|
+
agent-evals compare -n # show that matrix, call nothing
|
|
378
|
+
agent-evals completion zsh # shell completion (bash too)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
| Command | Does | Calls models |
|
|
382
|
+
| ------------ | ---------------------------------------------------- | ------------ |
|
|
383
|
+
| `cases` | Lists cases and refused lineup entries | no |
|
|
384
|
+
| `verify` | Checks inputs, `expected.json`, adapter and scorer | no |
|
|
385
|
+
| `run` | Runs experiments; per-case breakdown and leaderboard | yes |
|
|
386
|
+
| `compare` | Runs experiments; leaderboard only | yes |
|
|
387
|
+
| `version` | Prints the gem version (`-V`) | no |
|
|
388
|
+
| `completion` | Prints a bash or zsh completion script | no |
|
|
389
|
+
|
|
390
|
+
- `cases` prints one row per case: dataset, id, difficulty, the scorer's `summary`, word count.
|
|
391
|
+
- `verify` reports empty inputs and missing or unparsable `expected.json`, then runs `adapter.verify(dataset)` and `scorer.verify`.
|
|
392
|
+
- `run` and `compare` run one experiment per model and dataset. `compare --concurrency N` limits how many models run at once.
|
|
393
|
+
|
|
394
|
+
Flags shared by `run` and `compare`: `-m/--models`, `-d/--dataset` (every dataset when omitted), `-c/--case`, `-e/--effort`, `-t/--temperature`, `--max-tokens`, `-p/--parallelism`, `--config FILE`, `--project NAME`, `--tracker NAME`, `--local`, `--no-open`, `-n/--dry-run`, `-o/--output [FILE]`. `-l` and `-L` are reserved for logging and not yet implemented.
|
|
395
|
+
|
|
396
|
+
**Reading the output.** The leaderboard has one column per metric the scorer declares, ranked by the `primary` one, with a glossary built from each metric's `description`. STDOUT carries the tables. Progress bars and warnings go to STDERR, and anything a library prints while the bars redraw is saved to `log/<command>.warnings.<stamp>.log` instead of garbling the screen.
|
|
397
|
+
|
|
398
|
+
## Trackers
|
|
399
|
+
|
|
400
|
+
The gem always owns the run loop. A **tracker** decides where results go besides the terminal. The local JSON log always runs; at most **one remote tracker** runs beside it.
|
|
401
|
+
|
|
402
|
+
| Tracker | Gems | Key |
|
|
403
|
+
| ------------ | -------------------------------------------------- | -------------------- |
|
|
404
|
+
| local | none | none |
|
|
405
|
+
| `braintrust` | `braintrust` | `BRAINTRUST_API_KEY` |
|
|
406
|
+
| `logfire` | `opentelemetry-sdk`, `opentelemetry-exporter-otlp` | `LOGFIRE_TOKEN` |
|
|
407
|
+
|
|
408
|
+
Choose one with `--tracker NAME`, or let its key select it (see selection order below). Braintrust also takes `BRAINTRUST_PROJECT_ID` or `--project`.
|
|
409
|
+
|
|
410
|
+
What each one sends:
|
|
411
|
+
|
|
412
|
+
- **local** writes `log/agent-evals/<experiment>.<stamp>.json`: settings, means, errors, usage, and one row per case (score, metrics, outcomes, diagnostics, error, usage).
|
|
413
|
+
- **braintrust** creates one experiment per run in the project, with one eval row per case: input, expected, output (or `_error`), the scorer's metrics as scores, and diagnostics, outcomes and usage as metadata. The experiment page opens when the run finishes, unless `--no-open`.
|
|
414
|
+
- **logfire** sends OTLP traces to `https://logfire-us.pydantic.dev/v1/traces` (`LOGFIRE_BASE_URL` for another region): one span per experiment (suite, project, model, provider, settings, case count, means) with one child span per case (id, dataset, variant, score, each metric, outcome counts, duration, tokens and cost, diagnostics as JSON cut at 4 KB, exceptions recorded). Raw replies are not sent unless the config has `logfire: { include_replies: true }`. There is no page to open.
|
|
415
|
+
|
|
416
|
+
**Selection order:** `--tracker NAME`, then `tracker:` in `.agent-eval.config.yml`, then the environment, where exactly one tracker's key being set selects it.
|
|
417
|
+
|
|
418
|
+
- Two keys set and nothing chosen is an error asking for `--tracker` or `tracker:`.
|
|
419
|
+
- `--local` (or `--tracker none`) means no remote tracker.
|
|
420
|
+
- A chosen tracker whose gem or key is missing fails before any model is called, naming both.
|
|
421
|
+
- `-v` prints which tracker was chosen and why.
|
|
422
|
+
|
|
423
|
+
Logfire has no Ruby gem of its own. It is an OTLP backend for the standard OpenTelemetry Ruby SDK, so the tracker builds its own tracer provider and exporter and leaves the global one alone. When `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_HEADERS` is already set, those win over `LOGFIRE_TOKEN`, and `-v` says so. Traces only: Logfire's metrics and logs SDKs are pre-1.0.
|
|
424
|
+
|
|
425
|
+
### Writing a tracker
|
|
426
|
+
|
|
427
|
+
A tracker is a class with three callbacks. Register it with `c.tracker` in the helper (or in a plugin gem loaded through `require:`), then choose it by name:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
class Weave < Agent::Evals::Tracker
|
|
431
|
+
# Selects it when set and nothing else is chosen.
|
|
432
|
+
def self.env_key = "WANDB_API_KEY"
|
|
433
|
+
|
|
434
|
+
# Lazily required; named in the error when missing.
|
|
435
|
+
def self.gems = ["wandb"]
|
|
436
|
+
|
|
437
|
+
# Once, before the first case.
|
|
438
|
+
def start(experiment)
|
|
439
|
+
@run = Wandb.init(
|
|
440
|
+
project: context[:project],
|
|
441
|
+
name: experiment.name,
|
|
442
|
+
config: { model: experiment.model.id, **experiment.model.settings }
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Once per finished case, in order, under a lock.
|
|
447
|
+
def record(result)
|
|
448
|
+
@run.log(
|
|
449
|
+
"case" => result.case.key,
|
|
450
|
+
"score" => result.score&.value,
|
|
451
|
+
**(result.score&.metrics || {}),
|
|
452
|
+
"error" => result.error,
|
|
453
|
+
"cost_usd" => result.reply&.usage&.cost_usd
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Once, with the summary.
|
|
458
|
+
def finish(experiment, summary)
|
|
459
|
+
@run.summary.update(summary.means)
|
|
460
|
+
@run.finish
|
|
461
|
+
# Or { url: nil } when there is no page to open.
|
|
462
|
+
{ url: @run.url }
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
Agent::Evals.configure { |c| c.tracker Weave }
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`context` holds `suite:`, `project:`, `project_id:` and `options:` (the tracker's own section of the config file, e.g. `weave: { entity: "team" }`). Return `false` from `enabled?` to turn every callback into a no-op. To target another OTLP backend, subclass `Trackers::Logfire` with different credentials.
|
|
470
|
+
|
|
471
|
+
## Environment
|
|
472
|
+
|
|
473
|
+
| Variable | Used for |
|
|
474
|
+
| ----------------------------- | --------------------------------------------------------- |
|
|
475
|
+
| `OPENROUTER_API_KEY` | required for any `vendors:` model (OpenRouter) |
|
|
476
|
+
| `TYPESAFE_API_KEY` | required for a `direct: typesafe:` model |
|
|
477
|
+
| `BRAINTRUST_API_KEY` | the braintrust tracker; selects it when nothing else does |
|
|
478
|
+
| `BRAINTRUST_PROJECT_ID` | Braintrust project UUID; beats the lineup's `project` |
|
|
479
|
+
| `LOGFIRE_TOKEN` | the logfire tracker (a write token); selects it likewise |
|
|
480
|
+
| `LOGFIRE_BASE_URL` | Logfire region; default `https://logfire-us.pydantic.dev` |
|
|
481
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | when set, logfire uses it instead of `LOGFIRE_TOKEN` |
|
|
482
|
+
| `OTEL_EXPORTER_OTLP_HEADERS` | likewise |
|
|
483
|
+
| `AGENT_EVALS_HELPER` | a helper to load instead of searching upward |
|
|
484
|
+
|
|
485
|
+
The suite's `env_files` load first, without overriding what is already exported. A missing key fails the run before any model is called, naming the variable.
|
|
486
|
+
|
|
487
|
+
## Writing a provider
|
|
488
|
+
|
|
489
|
+
A provider wraps one API: the request type it answers, the keys it needs, the models it lists, and `complete`.
|
|
490
|
+
|
|
491
|
+
- A provider that serves `vendors:` ids declares which ones with `prefix`.
|
|
492
|
+
- A `direct?` provider is reached only through a `direct:` key matching its `name`.
|
|
493
|
+
- Register it with `c.provider`. The last registered is asked first for a `vendors:` id; OpenRouter, which claims every id, is always asked last.
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
class Ollama < Agent::Evals::Providers::Base
|
|
497
|
+
HOST = "http://localhost:11434"
|
|
498
|
+
|
|
499
|
+
def name = "ollama"
|
|
500
|
+
|
|
501
|
+
# Admitted by a suite declaring prompt.
|
|
502
|
+
def request_type = :prompt
|
|
503
|
+
|
|
504
|
+
# Shown in --help; a suite never spells it.
|
|
505
|
+
def request_type_description = "a chat completion"
|
|
506
|
+
|
|
507
|
+
# Serves "ollama/<model>".
|
|
508
|
+
def prefix = "ollama/"
|
|
509
|
+
|
|
510
|
+
# Local, no key.
|
|
511
|
+
def env_keys = []
|
|
512
|
+
|
|
513
|
+
# Takes no reasoning effort.
|
|
514
|
+
def efforts = []
|
|
515
|
+
|
|
516
|
+
def catalog
|
|
517
|
+
tags = JSON.parse(Net::HTTP.get(URI("#{HOST}/api/tags")))
|
|
518
|
+
tags.fetch("models").map { "ollama/#{it["name"]}" }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def complete(model, system:, user:, json: true)
|
|
522
|
+
body = {
|
|
523
|
+
model: model.name,
|
|
524
|
+
system: system,
|
|
525
|
+
prompt: user,
|
|
526
|
+
format: (json ? "json" : nil),
|
|
527
|
+
stream: false
|
|
528
|
+
}.compact
|
|
529
|
+
response = JSON.parse(Net::HTTP.post(URI("#{HOST}/api/generate"), JSON.generate(body)).body)
|
|
530
|
+
|
|
531
|
+
Agent::Evals::Completion.new(
|
|
532
|
+
text: response.fetch("response"),
|
|
533
|
+
usage: {
|
|
534
|
+
input_tokens: response["prompt_eval_count"],
|
|
535
|
+
output_tokens: response["eval_count"],
|
|
536
|
+
cost_usd: 0.0
|
|
537
|
+
},
|
|
538
|
+
raw: response
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
Agent::Evals.configure { |c| c.provider Ollama.new }
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
`model.effort`, `model.temperature` and `model.max_tokens` carry the contender's settings; nil means "send nothing". A transport failure should raise: the run counts it as an error, not as a wrong answer.
|
|
547
|
+
|
|
548
|
+
`Providers::TypeSafe` is a direct provider (`direct: typesafe:`, request type `typesafe`). It ships with TypeSafe's model list, key and pricing, which is enough for `verify` and the cost column. Its `complete` is not implemented yet and raises "no typesafe consumer yet": the typed request shape will be defined by the first suite that declares `request_types: [typesafe]`.
|
|
549
|
+
|
|
550
|
+
## Public API
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
module Agent::Evals
|
|
554
|
+
Case = Data.define(:id, :dataset, :variant, :input, :expected, :path, :metadata)
|
|
555
|
+
Usage = Data.define(:input_tokens, :output_tokens, :cost_usd)
|
|
556
|
+
Completion = Data.define(:text, :usage, :raw)
|
|
557
|
+
# error: the model failed, scored 0. A raise: transport failure.
|
|
558
|
+
Reply = Data.define(:output, :raw, :usage, :error, :metadata)
|
|
559
|
+
# aggregate: :mean, skipping nil.
|
|
560
|
+
Metric = Data.define(:name, :description, :aggregate, :primary)
|
|
561
|
+
# value: 0..1; metrics: { name => Float | nil }.
|
|
562
|
+
Score = Data.define(:value, :metrics, :outcomes, :diagnostics)
|
|
563
|
+
RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error)
|
|
564
|
+
# provider: nil for vendors:, a name for direct:.
|
|
565
|
+
Contender = Data.define(:model, :effort, :temperature, :max_tokens, :provider)
|
|
566
|
+
|
|
567
|
+
class Model
|
|
568
|
+
attr_reader :id, :provider, :settings
|
|
569
|
+
def complete(system: nil, user:, json: true) = Completion
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
class Adapter
|
|
573
|
+
def prepare(dataset)
|
|
574
|
+
def call(kase, model:)
|
|
575
|
+
def context(kase)
|
|
576
|
+
def verify(dataset)
|
|
577
|
+
def metadata
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
class Scorer
|
|
581
|
+
def metrics
|
|
582
|
+
def call(kase, reply, context:)
|
|
583
|
+
def verify(kase, context:)
|
|
584
|
+
def summary(kase)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Schema: { name => { type:, enum:, nullable: } }.
|
|
588
|
+
# Types: boolean, integer, number, string, enum, multi_enum.
|
|
589
|
+
class SchemaValidator
|
|
590
|
+
|
|
591
|
+
# One model over one dataset, on the gem's own thread pool; trackers watch.
|
|
592
|
+
class Experiment
|
|
593
|
+
|
|
594
|
+
# start(experiment), record(run_result), finish(experiment, summary) -> { url: }
|
|
595
|
+
class Tracker
|
|
596
|
+
|
|
597
|
+
# Local (always), Braintrust, Logfire (OTLP), Registry (choose).
|
|
598
|
+
module Trackers
|
|
599
|
+
|
|
600
|
+
# Base (request_type, direct?), Registry (for, direct, admit), OpenRouter, TypeSafe, Catalog.
|
|
601
|
+
module Providers
|
|
602
|
+
|
|
603
|
+
# EchoAdapter, FakeProvider, FakeModel.
|
|
604
|
+
module Testing
|
|
605
|
+
|
|
606
|
+
# Config#request_types is required; Config::REQUEST_TYPES = %w[prompt typesafe].
|
|
607
|
+
class Discovery, Config, Configuration, Runtime, Verify, Warnings, Launcher
|
|
608
|
+
end
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
## Development
|
|
612
|
+
|
|
613
|
+
```bash
|
|
614
|
+
# Install the bundle
|
|
615
|
+
just install
|
|
616
|
+
|
|
617
|
+
# RuboCop, RSpec (coverage >= 95%), both example suites; no network
|
|
618
|
+
just ci
|
|
619
|
+
|
|
620
|
+
# Both example suites through the executable: cases, verify, compare --local
|
|
621
|
+
just examples
|
|
622
|
+
|
|
623
|
+
# Build the .gem into pkg/; publish pushes it (RubyGems OTP from 1Password)
|
|
624
|
+
just build
|
|
625
|
+
just publish
|
|
626
|
+
|
|
627
|
+
# Install the pre-commit hooks
|
|
628
|
+
lefthook install
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Specs build real directories under `Dir.mktmpdir` and never reach the network. `RubyLLM`, the Braintrust API classes and `Net::HTTP` are stubbed, and the trackers are asserted through OpenTelemetry's in-memory span exporter.
|
|
632
|
+
|
|
633
|
+
## License
|
|
634
|
+
|
|
635
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/exe/agent-evals
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# No Bundler here: a Gemfile in the user's directory must not decide which
|
|
5
|
+
# gems load. The suite's own eval_helper.rb may `require "bundler/setup"`.
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
7
|
+
require "agent/evals"
|
|
8
|
+
|
|
9
|
+
Agent::Evals::Launcher.new(ARGV).execute!
|