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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +635 -0
  5. data/exe/agent-evals +9 -0
  6. data/lib/agent/evals/adapter.rb +47 -0
  7. data/lib/agent/evals/case.rb +36 -0
  8. data/lib/agent/evals/cli/cases.rb +48 -0
  9. data/lib/agent/evals/cli/command.rb +163 -0
  10. data/lib/agent/evals/cli/compare.rb +33 -0
  11. data/lib/agent/evals/cli/model_command.rb +393 -0
  12. data/lib/agent/evals/cli/progress_bars.rb +90 -0
  13. data/lib/agent/evals/cli/run.rb +61 -0
  14. data/lib/agent/evals/cli/verify.rb +24 -0
  15. data/lib/agent/evals/cli/version.rb +15 -0
  16. data/lib/agent/evals/cli.rb +40 -0
  17. data/lib/agent/evals/completion.rb +11 -0
  18. data/lib/agent/evals/config.rb +146 -0
  19. data/lib/agent/evals/configuration.rb +172 -0
  20. data/lib/agent/evals/contender.rb +31 -0
  21. data/lib/agent/evals/discovery.rb +95 -0
  22. data/lib/agent/evals/experiment.rb +133 -0
  23. data/lib/agent/evals/helper.rb +98 -0
  24. data/lib/agent/evals/launcher.rb +37 -0
  25. data/lib/agent/evals/metric.rb +26 -0
  26. data/lib/agent/evals/model.rb +55 -0
  27. data/lib/agent/evals/providers/base.rb +93 -0
  28. data/lib/agent/evals/providers/catalog.rb +32 -0
  29. data/lib/agent/evals/providers/open_router.rb +64 -0
  30. data/lib/agent/evals/providers/registry.rb +112 -0
  31. data/lib/agent/evals/providers/type_safe.rb +53 -0
  32. data/lib/agent/evals/reply.rb +18 -0
  33. data/lib/agent/evals/run_result.rb +14 -0
  34. data/lib/agent/evals/runtime.rb +74 -0
  35. data/lib/agent/evals/schema_validator.rb +84 -0
  36. data/lib/agent/evals/score.rb +26 -0
  37. data/lib/agent/evals/scorer.rb +45 -0
  38. data/lib/agent/evals/testing/echo_adapter.rb +29 -0
  39. data/lib/agent/evals/testing/fake_model.rb +18 -0
  40. data/lib/agent/evals/testing/fake_provider.rb +67 -0
  41. data/lib/agent/evals/tracker.rb +80 -0
  42. data/lib/agent/evals/trackers/braintrust.rb +91 -0
  43. data/lib/agent/evals/trackers/local.rb +78 -0
  44. data/lib/agent/evals/trackers/logfire.rb +143 -0
  45. data/lib/agent/evals/trackers/registry.rb +82 -0
  46. data/lib/agent/evals/usage.rb +33 -0
  47. data/lib/agent/evals/verify.rb +49 -0
  48. data/lib/agent/evals/version.rb +7 -0
  49. data/lib/agent/evals/warnings.rb +89 -0
  50. data/lib/agent/evals.rb +82 -0
  51. metadata +257 -0
data/README.md ADDED
@@ -0,0 +1,635 @@
1
+ # agent-evals
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/agent-evals.svg)](https://rubygems.org/gems/agent-evals)
4
+ [![CircleCI](https://dl.circleci.com/status-badge/img/gh/kigster/agent-evals/tree/main.svg?style=shield)](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!