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
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Agent
7
+ module Evals
8
+ module CLI
9
+ # The options shared by the commands that call a model. Settings resolve
10
+ # flag first, then the config entry, then .agent-eval.config.yml's defaults.
11
+ #
12
+ # Every column that is not a metric is the gem's; the metric columns,
13
+ # their glossary and the ranking come from the suite's {Scorer#metrics}.
14
+ class ModelCommand < Command
15
+ # How close to the best primary metric a model must come to compete on
16
+ # price: 0.02 is two percentage points.
17
+ VALUE_MARGIN = 0.02
18
+
19
+ # What the gem's own leaderboard columns mean, printed under it with the metrics'.
20
+ GLOSSARY = {
21
+ "effort" => "Reasoning effort sent to the model. \"default\" sends none, and the model decides; " \
22
+ "a provider that takes none shows \"default\" too.",
23
+ "temp" => "Sampling temperature sent to the model. \"default\" sends none.",
24
+ "errors" => "Cases whose call failed outright (an API or network error), so they have no scores at all. " \
25
+ "A reply the model got wrong is not an error; it scores 0.",
26
+ "time" => "Wall-clock time for the experiment. Experiments run side by side and share the network, " \
27
+ "so compare times within one run only.",
28
+ "tok_in" => "Input tokens sent, summed over the cases, as the provider counted them.",
29
+ "tok_out" => "Output tokens received, summed over the cases; reasoning tokens are included where the " \
30
+ "provider bills them as output.",
31
+ "cost" => "US dollars, as the provider reported them, summed over the cases.",
32
+ "score" => "The mean of each case's overall score, as the scorer computed it. The last table ranks " \
33
+ "every experiment by it."
34
+ }.freeze
35
+
36
+ def self.inherited(subclass)
37
+ super
38
+ subclass.extend(Dry::CLI::UI::Flags)
39
+ subclass.flags(:dry_run, :output)
40
+ subclass.option :models,
41
+ type: :array,
42
+ aliases: ["-m"],
43
+ default: [],
44
+ desc: "Model ids, comma-separated, instead of the config file's vendors"
45
+ subclass.option :dataset,
46
+ type: :array,
47
+ aliases: ["-d"],
48
+ default: [],
49
+ desc: "Datasets, comma-separated; every dataset when omitted. Each model runs on each"
50
+ subclass.option :config, desc: "The lineup file (default: the suite's .agent-eval..agent-eval.config.yml)"
51
+ subclass.option :effort, aliases: ["-e"], desc: "Reasoning effort: default, or one the provider accepts (overrides the config file)"
52
+ subclass.option :temperature, aliases: ["-t"], desc: "Sampling temperature, or 'default' to send none (overrides the config file)"
53
+ subclass.option :max_tokens, desc: "Output token cap, reasoning included (overrides the config file)"
54
+ subclass.option :case,
55
+ type: :array,
56
+ aliases: ["-c"],
57
+ default: [],
58
+ desc: "Only these cases, by id prefix: -c clear or -c clear/003,vague/001"
59
+ subclass.option :parallelism, aliases: ["-p"], desc: "Cases in flight at once, per model (default: the config's defaults.parallelism)"
60
+ subclass.option :project,
61
+ desc: "Braintrust project name; default is the BRAINTRUST_PROJECT_ID project, else the config file's project"
62
+ subclass.option :open,
63
+ type: :boolean,
64
+ default: true,
65
+ desc: "Open the best experiment in the browser when done (no effect with --local)"
66
+ subclass.option :local,
67
+ type: :boolean,
68
+ default: false,
69
+ desc: "No remote tracker: results go to log/ only (still calls the models)"
70
+ subclass.option :tracker,
71
+ desc: "The remote tracker: braintrust, logfire, none, or one the suite registers " \
72
+ "(default: the config's tracker:, else the one whose key is in the environment)"
73
+ end
74
+
75
+ private
76
+
77
+ # @return [Config] the lineup: --config, else the suite's
78
+ def lineup(options) = options[:config] ? Config.new(File.expand_path(options[:config])) : configuration.lineup
79
+
80
+ # @return [Providers::Registry]
81
+ def providers = configuration.providers
82
+
83
+ # @return [Scorer]
84
+ def scorer = configuration.scorer
85
+
86
+ # @return [Array<Metric>]
87
+ def metrics = scorer.metrics
88
+
89
+ # @return [Hash{String => Object}] the setting flags that were given
90
+ def overrides(options)
91
+ { "effort" => options[:effort], "temperature" => options[:temperature], "max_tokens" => options[:max_tokens] }
92
+ end
93
+
94
+ def experiment(contender, config, options, dataset:)
95
+ Experiment.new(
96
+ model: providers.model_for(contender),
97
+ cases: cases_for(options[:case], dataset: dataset),
98
+ adapter: configuration.adapter,
99
+ scorer: scorer,
100
+ parallelism: Integer(options[:parallelism] || config.defaults["parallelism"]),
101
+ trackers: trackers_for(config, options)
102
+ )
103
+ end
104
+
105
+ # The local JSON log, always, then the one remote tracker if any,
106
+ # each experiment getting instances of its own.
107
+ #
108
+ # @return [Array<Tracker>]
109
+ def trackers_for(config, options)
110
+ context = { suite: configuration.name,
111
+ project: options[:project] || config.project || configuration.project || configuration.name,
112
+ project_id: options[:project] ? nil : ENV["BRAINTRUST_PROJECT_ID"].to_s[/\S+/] }
113
+ local = Trackers::Local.new(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "agent-evals"), **context)
114
+ klass = remote_tracker(config, options)
115
+ remote = klass&.new(**context, options: config.tracker_options(klass.tracker_name))
116
+ [local, remote].compact
117
+ end
118
+
119
+ # The remote tracker class this run uses, chosen once: --tracker,
120
+ # then tracker: in the config, then the environment. --local is none.
121
+ #
122
+ # @return [Class<Tracker>, nil]
123
+ def remote_tracker(config, options)
124
+ return @remote_tracker if defined?(@remote_tracker)
125
+
126
+ chosen = configuration.trackers.choose(flag: options[:tracker], config: config.tracker, env: ENV, local: options[:local])
127
+ @remote_tracker = chosen&.first
128
+ err.puts "tracker: #{chosen ? "#{chosen.first.tracker_name}, because #{chosen.last}" : "none"}" if options[:verbose]
129
+ @remote_tracker
130
+ end
131
+
132
+ # The contenders a command runs: the models named on the command line,
133
+ # or every model .agent-eval.config.yml lists.
134
+ #
135
+ # @param models [Array<String>] as given to -m, possibly comma-separated
136
+ # @return [Array<Contender>]
137
+ # @raise [Error] when neither names any model
138
+ def contenders(config, models, options)
139
+ models = models.flat_map { it.split(",") }
140
+ found = models.empty? ? config.contenders(overrides(options)) : config.contenders_for(models, overrides(options))
141
+ raise Error, "no models: list some under vendors in #{config.path}, or pass -m" if found.empty?
142
+
143
+ admit(found, config)
144
+ end
145
+
146
+ # Keeps the contenders whose provider produces what the suite declares
147
+ # in `request_types`, and records each refusal in {#warnings} by name,
148
+ # so a refused model is reported rather than run, failed and billed.
149
+ #
150
+ # @return [Array<Contender>] the admitted contenders
151
+ # @raise [Error] when none is admitted, naming every refusal
152
+ def admit(found, config)
153
+ found.each { providers.for(it) } # an unserved id fails here, before anything is spent
154
+ admitted, refusals = providers.admit(found, request_types: config.request_types)
155
+ refusals.each { warnings.record(it.to_s) }
156
+ raise Error, "no admitted models: #{refusals.join("; ")}" if admitted.empty?
157
+
158
+ admitted
159
+ end
160
+
161
+ # Env, the tracker's gems and key, hooks, provider clients, then the catalogs.
162
+ def setup!(contenders, options)
163
+ configuration.runtime.setup!(providers: providers.used_by(contenders), tracker: remote_tracker(lineup(options), options))
164
+ providers.verify!(contenders)
165
+ end
166
+
167
+ # What -n prints: every experiment that would run, and where the
168
+ # refusals went, if there were any.
169
+ def matrix(datasets, contenders)
170
+ rows = datasets.product(contenders).map { |name, c| [name, c.model, c.effort, c.temperature, c.max_tokens] }
171
+ table(rows, header: %w[dataset model effort temp max_tokens])
172
+ save_warnings
173
+ end
174
+
175
+ # Runs the experiments side by side, a progress bar each. What would
176
+ # print as a warning meanwhile, failed cases included, goes to
177
+ # {#warnings} instead.
178
+ #
179
+ # @param runs [Array<Experiment>]
180
+ # @param concurrency [Integer] experiments at once; 0 runs them all together
181
+ # @return [Array<Experiment::Summary>]
182
+ def execute(runs, concurrency: 0)
183
+ summaries = Array.new(runs.size)
184
+ warnings.capture { bars(runs, summaries, concurrency) }
185
+ summaries.compact.each { |s| s.errors.each { warnings.record("[#{s.name}] #{it}") } }
186
+ summaries.compact
187
+ end
188
+
189
+ # @return [Warnings] what this command has held back from the screen
190
+ def warnings = @warnings ||= Warnings.new
191
+
192
+ def bars(runs, summaries, concurrency)
193
+ ProgressBars.for(ui).run("Running #{runs.size} experiment#{"s" unless runs.size == 1}",
194
+ concurrent: concurrency.zero? || concurrency) do |multi|
195
+ runs.each_with_index do |run, index|
196
+ multi.progress(run.name, total: run.cases.size) do |bar|
197
+ summaries[index] = run.run(on_case: -> { bar.advance })
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ # @param names [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
204
+ # @return [Array<String>]
205
+ # @raise [Error] on an unknown dataset, before anything is spent
206
+ def datasets(names)
207
+ names = names.flat_map { it.split(",") }.uniq
208
+ return discovery.datasets.tap { raise Error, "no datasets under #{discovery.root}" if it.empty? } if names.empty?
209
+
210
+ names.each { discovery.cases(dataset: it) }
211
+ end
212
+
213
+ # Prints what the block writes, then the leaderboard, its glossary and
214
+ # the scores; then says where the warnings went, if any, and opens the
215
+ # best-scoring experiment.
216
+ #
217
+ # With -o, the block's output goes to the file alone, and the rest to
218
+ # both, each drawn for its own stream. The file is in color and ends
219
+ # with the time it was closed, as dry-cli-ui's own reports do.
220
+ #
221
+ # @param summaries [Array<Experiment::Summary>]
222
+ # @param output [String, nil] what -o was given: nil, "-", "" for the default file, or a path
223
+ # @param open [Boolean]
224
+ # @return [void]
225
+ def report(summaries, output:, open:, &details)
226
+ path = invocation.report_path(output)
227
+ if path
228
+ File.open(path, "w") do |file|
229
+ report = Dry::CLI::UI::Report.new(file)
230
+ writing_to(report) do
231
+ details&.call
232
+ leaderboard(summaries)
233
+ end
234
+ report.puts "Closed at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}"
235
+ end
236
+ else
237
+ details&.call
238
+ end
239
+ leaderboard(summaries)
240
+ status_box(:info, "Report written to #{relative(path)}", stream: err) if path
241
+ save_warnings
242
+ url = summaries.max_by { it.score || -1 }&.permalink
243
+ status_box(:warning, "could not open #{url}", stream: err) if open && url && !configuration.runtime.browse(url)
244
+ end
245
+
246
+ # Saves {#warnings}, if there are any, to
247
+ # log/<command>.warnings.<YYYY-MM-DD>.<HHMMSS>.log at the repository
248
+ # root, stamped with the time the process started, and says how many.
249
+ #
250
+ # @return [void]
251
+ def save_warnings
252
+ return if warnings.none?
253
+
254
+ stamp = Dry::CLI::UI.started_at.strftime("%Y-%m-%d.%H%M%S")
255
+ path = File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "#{invocation.action}.warnings.#{stamp}.log")
256
+ FileUtils.mkdir_p(File.dirname(path))
257
+ warnings.save(path)
258
+ count = warnings.count
259
+ status_box(:warning, "#{count} #{count == 1 ? "Warning was" : "Warnings were"} recorded in #{relative(path)}", stream: err)
260
+ end
261
+
262
+ # @return [Dry::CLI::UI::Invocation] how the command was run, which names its files
263
+ def invocation = ui.send(:invocation)
264
+
265
+ def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
266
+
267
+ # Points the tables and boxes at another stream while the block runs.
268
+ # They keep the screen's width.
269
+ #
270
+ # @param io [IO, nil] nil to leave them on the screen
271
+ def writing_to(io)
272
+ return yield if io.nil?
273
+
274
+ screen = out
275
+ @screen = screen
276
+ @out = io
277
+ @pastel = nil
278
+ yield
279
+ ensure
280
+ if io
281
+ @out = screen
282
+ @screen = nil
283
+ @pastel = nil
284
+ end
285
+ end
286
+
287
+ def percent(value) = value.nil? ? "-" : format("%.1f%%", value * 100)
288
+
289
+ def dollars(value) = format("$%.4f", value.to_f)
290
+
291
+ def primary = scorer.primary
292
+
293
+ # The best value: the cheapest entry within {VALUE_MARGIN} of the best
294
+ # primary metric. Entries with errors, or no score, do not compete. A
295
+ # plain score-per-dollar ratio would crown whatever is nearly free,
296
+ # however wrong it is.
297
+ #
298
+ # @param entries [Array<Array(Float, Float, Integer)>] primary metric, cost, errors
299
+ # @return [Integer, nil] the winner's index
300
+ def best_value(entries)
301
+ eligible = entries.each_index.select { entries[it][0] && entries[it][2].zero? }
302
+ return nil if eligible.empty?
303
+
304
+ floor = eligible.map { entries[it][0] }.max - VALUE_MARGIN
305
+ eligible.select { entries[it][0] >= floor - 1e-9 }.min_by { entries[it][1] }
306
+ end
307
+
308
+ def value_line(label, value, cost)
309
+ out.puts "best value#{label}: #{format("%.1f%%", value * 100)} at #{dollars(cost)}, " \
310
+ "the cheapest within #{(VALUE_MARGIN * 100).round} points of the best #{primary.name}"
311
+ end
312
+
313
+ def contender_label(summary) = "#{summary.model.id} effort=#{summary.model.effort || "default"}"
314
+
315
+ def temperature_cell(summary) = summary.model.temperature.nil? ? "default" : summary.model.temperature
316
+
317
+ # Tokens, cost and the mean primary metric per contender, over every
318
+ # dataset it ran.
319
+ def totals(summaries)
320
+ groups = summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values.map do |group|
321
+ values = group.filter_map { it.mean(primary) }
322
+ { first: group.first, errors: group.sum { it.errors.size },
323
+ value: values.size == group.size ? values.sum / values.size : nil,
324
+ usage: group.map(&:usage).reduce(Usage.zero, :+) }
325
+ end
326
+ groups.sort_by! { -(it[:value] || -1) }
327
+ rows = groups.map do |g|
328
+ s = g[:first]
329
+ [s.model.id, s.model.effort || "default", temperature_cell(s), percent(g[:value]),
330
+ g[:errors], g[:usage].input_tokens, g[:usage].output_tokens, dollars(g[:usage].cost_usd)]
331
+ end
332
+ best = best_value(groups.map { [it[:value], it[:usage].cost_usd, it[:errors]] })
333
+ table(rows,
334
+ header: ["model", "effort", "temp", primary.name, "errors", "tok_in", "tok_out", "cost"],
335
+ highlight: [best].compact,
336
+ fill: 0)
337
+ return unless best
338
+
339
+ g = groups[best]
340
+ value_line(" overall (#{contender_label(g[:first])})", g[:value], g[:usage].cost_usd)
341
+ end
342
+
343
+ # Every experiment by its mean score, best first, with its cost and its
344
+ # Braintrust page. URLs are never wrapped, so they stay clickable.
345
+ def scores(summaries)
346
+ rows = summaries.sort_by { -(it.score || -1) }.map do |s|
347
+ [s.model.id, s.model.effort || "default", s.dataset, percent(s.score), dollars(s.usage.cost_usd), s.permalink || "-"]
348
+ end
349
+ table(rows, header: %w[model effort dataset score cost url], fill: 0)
350
+ end
351
+
352
+ # The best value in each dataset's block of the leaderboard.
353
+ #
354
+ # @param ranked [Array<Experiment::Summary>]
355
+ # @return [Array<Integer>] indices into ranked
356
+ def best_per_dataset(ranked)
357
+ ranked.each_index.group_by { ranked[it].dataset }.values.filter_map do |indices|
358
+ winner = best_value(indices.map { [ranked[it].mean(primary), ranked[it].usage.cost_usd, ranked[it].errors.size] })
359
+ winner && indices[winner]
360
+ end
361
+ end
362
+
363
+ # One row per experiment, best primary metric first, with the best
364
+ # value per dataset; the glossary; the totals, over several datasets;
365
+ # and every experiment by score.
366
+ def leaderboard(summaries)
367
+ ranked = summaries.sort_by { [it.dataset, -(it.mean(primary) || -1)] }
368
+ rows = ranked.map do |s|
369
+ [s.dataset, s.model.id, s.model.effort || "default", temperature_cell(s),
370
+ *metrics.map { percent(s.mean(it)) },
371
+ s.errors.size, format("%.1fs", s.duration), s.usage.input_tokens, s.usage.output_tokens, dollars(s.usage.cost_usd)]
372
+ end
373
+ best = best_per_dataset(ranked)
374
+ table(rows,
375
+ header: ["dataset", "model", "effort", "temp", *metrics.map(&:name), "errors", "time", "tok_in", "tok_out", "cost"],
376
+ highlight: best)
377
+ best.each do |index|
378
+ s = ranked[index]
379
+ value_line(" [#{s.dataset}] (#{contender_label(s)})", s.mean(primary), s.usage.cost_usd)
380
+ end
381
+ table(glossary, header: %w[Term Description], fill: 1, wrap: true)
382
+ totals(summaries) if summaries.map(&:dataset).uniq.size > 1
383
+ scores(summaries)
384
+ end
385
+
386
+ # @return [Array<Array(String, String)>] the metrics' descriptions, then the gem's columns
387
+ def glossary
388
+ metrics.map { [it.name, it.description.to_s.empty? ? "(no description)" : it.description] } + GLOSSARY.to_a
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ # dry-cli-ui's multi_progress, drawn differently:
7
+ #
8
+ # - a flat list, the jobs indented under the headline, with no tree;
9
+ # - the headline's bar, which counts every case, in yellow;
10
+ # - a finished job keeps its bar, full, with the time it took where the
11
+ # ETA was, instead of turning into a one-line summary;
12
+ # - labels padded to at least {LABEL_WIDTH} columns;
13
+ # - each frame overwrites the last line by line, inside a synchronized
14
+ # update, rather than clearing the screen below and drawing it again,
15
+ # which flickers with many rows.
16
+ class ProgressBars < Dry::CLI::UI::Widgets::MultiProgress
17
+ # The fewest columns a label is padded to.
18
+ LABEL_WIDTH = 40
19
+
20
+ # What each job's row starts with.
21
+ INDENT = " "
22
+
23
+ # The headline's bar color.
24
+ HEADLINE_COLOR = :yellow
25
+
26
+ # Rows that have ended and drew a bar while they ran.
27
+ ENDED = %i[done failed].freeze
28
+
29
+ # Tells the terminal to show a frame only once all of it has arrived.
30
+ # Terminals that do not know these ignore them.
31
+ SYNC_START = "\e[?2026h"
32
+ SYNC_END = "\e[?2026l"
33
+
34
+ # @param console [Dry::CLI::UI::Console] the command's ui, whose stream, clock and settings it draws with
35
+ def self.for(console) = new(console.send(:err), clock: console.send(:clock), config: console.send(:config))
36
+
37
+ private
38
+
39
+ def label_width = [LABEL_WIDTH, title.length, *jobs.map { it.label.length }].max + INDENT.length
40
+
41
+ def rows
42
+ width = label_width
43
+ [headline(width), *visible.map { "#{INDENT}#{row(it, width - INDENT.length)}" }]
44
+ end
45
+
46
+ def redraw
47
+ drawn = rows
48
+ cursor = terminal.cursor
49
+ up = @drawn.positive? ? cursor.up(@drawn) : ""
50
+ lines = drawn.map { "\r#{it}#{cursor.clear_line_after}\n" }.join
51
+ below = drawn.size < @drawn ? cursor.clear_screen_down : ""
52
+ terminal.print("#{SYNC_START}#{up}#{lines}#{below}#{SYNC_END}")
53
+ @drawn = drawn.size
54
+ end
55
+
56
+ def headline(width)
57
+ return super unless live?
58
+
59
+ finished = state != :running
60
+ bar = full_meter(current, total, started, HEADLINE_COLOR, counts, (@seconds if finished))
61
+ "#{glyph(state)} #{title.ljust(width)} #{bar}#{finished ? noted(headline_note) : stopping}"
62
+ end
63
+
64
+ def row(job, width)
65
+ return super unless ENDED.include?(job.state) && !spinner?(job) && live?
66
+
67
+ handle = job.handle
68
+ bar = full_meter(handle.current, handle.total, job.started, handle.color, handle.counts, job.seconds.to_f)
69
+ "#{glyph(job.state)} #{job.label.ljust(width)} #{bar}#{noted(note(job))}"
70
+ end
71
+
72
+ # {#meter}, with the time taken in place of the ETA once seconds are known.
73
+ def full_meter(done, all, since, color, tally, seconds)
74
+ return meter(done, all, since, color, tally) if seconds.nil?
75
+
76
+ took = Dry::CLI::UI::Duration.format(seconds)
77
+ Dry::CLI::UI::Widgets::Progress.meter(terminal.pastel,
78
+ config,
79
+ done: done,
80
+ all: all,
81
+ columns: bar_columns,
82
+ eta: took,
83
+ color: color,
84
+ counts: tally,
85
+ count_width: count_width).sub("ETA #{took}", "in #{took}")
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ # One experiment per model and dataset, each with a per-case breakdown.
7
+ # Without -m, every model .agent-eval.config.yml lists. {Compare} runs the same
8
+ # experiments and prints only the leaderboard.
9
+ class Run < ModelCommand
10
+ desc "Run every case against a model, or every model in .agent-eval.config.yml, with a per-case breakdown"
11
+
12
+ example [
13
+ "-m anthropic/claude-haiku-5.5",
14
+ "-m openai/gpt-4.1-mini -t 0 -c clear/00 # one variant's first cases",
15
+ "-m google/gemini-2.5-flash -e high --local # score without Braintrust",
16
+ "-d colors,animals -o # both datasets; the breakdowns go to log/",
17
+ "-n # show what would run, call nothing"
18
+ ]
19
+
20
+ def call(models:, dataset:, dry_run:, **options)
21
+ config = lineup(options)
22
+ contenders = contenders(config, models, options)
23
+ datasets = datasets(dataset)
24
+ return matrix(datasets, contenders) if dry_run
25
+
26
+ setup!(contenders, options)
27
+ runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
28
+ summaries = execute(runs)
29
+ report(summaries, output: options[:output], open: options[:open]) do
30
+ summaries.each { breakdown(it, titled: summaries.size > 1) }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # One row per case: its score, and whatever the scorer diagnosed.
37
+ def breakdown(summary, titled: false)
38
+ status_box :info, summary.name if titled
39
+ rows = summary.results.map do |result|
40
+ [result.case.id, result.case.difficulty, result.score ? percent(result.score.value) : "-", diagnosis(result)]
41
+ end
42
+ table(rows, header: %w[case difficulty score diagnostics], fill: 3, wrap: true)
43
+ end
44
+
45
+ # The scorer's diagnostics, one per line, rendered without knowing
46
+ # their shape: "field: value" for a Hash, each item for an Array.
47
+ def diagnosis(result)
48
+ return result.error if result.failed?
49
+
50
+ result.score.diagnostics.map do |field, detail|
51
+ case detail
52
+ when Hash then "#{field}: #{detail.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
53
+ when Array then "#{field}: #{detail.join(", ")}"
54
+ else "#{field}: #{detail}"
55
+ end
56
+ end.join("\n")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ # Offline checks that every case agrees with what its suite demands of it.
7
+ class Verify < Command
8
+ desc "Check every case's input and expected answer, through the adapter and the scorer (no API calls)"
9
+
10
+ def call(**)
11
+ all = discovery.all
12
+ failures = Agent::Evals::Verify.new(configuration).problems
13
+ if failures.empty?
14
+ status_box :success, "#{all.size} cases verified across #{discovery.datasets.join(", ")}"
15
+ return
16
+ end
17
+
18
+ failures.each { |name, problems| problems.each { status_box :error, "#{name}: #{it}", stream: err } }
19
+ raise Error, "#{failures.size} case(s) failed verification"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ class Version < Command
7
+ desc "Print the version"
8
+
9
+ def call(**)
10
+ out.puts Agent::Evals::VERSION
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # Every command the executable answers to. The help's title and
6
+ # description come from the suite's configuration, which the executable
7
+ # loads before this module is first referenced.
8
+ module CLI
9
+ extend Dry::CLI::Registry
10
+
11
+ PROGRAM = "agent-evals"
12
+
13
+ Dry::CLI::Help.configure do
14
+ configuration = Agent::Evals.configuration
15
+ title configuration.name == PROGRAM ? PROGRAM : "#{PROGRAM} (#{configuration.name})"
16
+ types = configuration.providers.uniq(&:request_type).map { "#{it.request_type} (#{it.request_type_description})" }
17
+ description "#{configuration.description} Request types: #{types.join("; ")}."
18
+
19
+ columns = IO.console&.winsize&.last || 80
20
+ width columns.clamp(60, 120)
21
+ exit_code_without_arguments 0
22
+
23
+ styles do
24
+ heading :bold, :cyan, case: :UPPERCASE
25
+ usage :yellow
26
+ example :yellow
27
+ option :green
28
+ example_comment :bright_black
29
+ end
30
+ end
31
+
32
+ register "run", Run
33
+ register "compare", Compare
34
+ register "cases", Cases
35
+ register "verify", Verify
36
+ register "version", Version, aliases: ["-V", "--version"]
37
+ register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # What a {Model} returns from one call: the text, what it cost, and the
6
+ # provider's raw response for an adapter that needs more than the text.
7
+ Completion = Data.define(:text, :usage, :raw) do
8
+ def initialize(text:, usage: Usage.zero, raw: nil) = super(text: text.to_s, usage: Usage.from(usage), raw: raw)
9
+ end
10
+ end
11
+ end