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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # What a suite implements to run one case: everything the model sees, and
6
+ # everything done to its answer before it is scored. An adapter never
7
+ # scores; that is the {Scorer}'s, and keeping them apart is what lets one
8
+ # suite compare prompts and another compare models with the same runner.
9
+ #
10
+ # @abstract Subclass and override {#call}.
11
+ class Adapter
12
+ # Called once per dataset before its cases run; load what the dataset
13
+ # shares (a schema, a flow definition) here, outside the threads.
14
+ #
15
+ # @param dataset [String]
16
+ # @return [void]
17
+ def prepare(_dataset) = nil
18
+
19
+ # Runs one case against one model.
20
+ #
21
+ # @param kase [Case]
22
+ # @param model [Model]
23
+ # @return [Reply] with `error:` set when the model failed to answer; raise for a transport failure
24
+ def call(kase, model:)
25
+ raise NotImplementedError, "#{self.class}#call(kase, model:) must return an Agent::Evals::Reply"
26
+ end
27
+
28
+ # What the scorer needs to know about a case beyond the case itself: a
29
+ # schema, field types, the prompt. Passed to {Scorer#call} as `context:`.
30
+ #
31
+ # @param kase [Case]
32
+ # @return [Hash]
33
+ def context(_kase) = {}
34
+
35
+ # Offline checks of a dataset's shared files. `agent-evals verify` prints them.
36
+ #
37
+ # @param dataset [String]
38
+ # @return [Array<String>] problems; empty when the dataset is sound
39
+ def verify(_dataset) = []
40
+
41
+ # Recorded with each Braintrust experiment, e.g. which prompt version ran.
42
+ #
43
+ # @return [Hash]
44
+ def metadata = {}
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # One eval case, as {Discovery} found it on disk: the text the model is
6
+ # given, the answer a perfect reply would produce, and where it came from.
7
+ #
8
+ # @!attribute [r] id
9
+ # @return [String] unique within its dataset, e.g. "clear/003"
10
+ # @!attribute [r] dataset
11
+ # @return [String] the folder under datasets/, e.g. "colors"
12
+ # @!attribute [r] variant
13
+ # @return [String] the folder under cases/, e.g. "clear" or "vague"
14
+ # @!attribute [r] input
15
+ # @return [String] input.txt, stripped
16
+ # @!attribute [r] expected
17
+ # @return [Object, nil] expected.json parsed; nil when missing or unparsable
18
+ # @!attribute [r] path
19
+ # @return [String] the case's directory
20
+ # @!attribute [r] metadata
21
+ # @return [Hash{Symbol => Object}] number, difficulty, and any `expected_error`
22
+ Case = Data.define(:id, :dataset, :variant, :input, :expected, :path, :metadata) do
23
+ # @return [Integer, nil] the NNN/ level as a number, when the case has one
24
+ def number = metadata[:number]
25
+
26
+ # @return [Integer] the number, else 0; what the tables sort and show difficulty by
27
+ def difficulty = metadata.fetch(:difficulty, number || 0)
28
+
29
+ # @return [String] "dataset/id", unique across the suite
30
+ def key = "#{dataset}/#{id}"
31
+
32
+ # @return [Boolean] whether expected.json was there and parsed
33
+ def expected? = !expected.nil?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ # The cases, without calling anything.
7
+ class Cases < Command
8
+ desc "List the eval cases in every dataset"
9
+
10
+ option :dataset, type: :array, aliases: ["-d"], default: [], desc: "Only these datasets, comma-separated"
11
+
12
+ def call(dataset: [], **)
13
+ scorer = configuration.scorer
14
+ names = dataset.flat_map { it.split(",") }
15
+ names = discovery.datasets if names.empty?
16
+ rows = names.flat_map { discovery.cases(dataset: it) }.map do |kase|
17
+ [kase.dataset, kase.id, kase.difficulty, scorer.summary(kase) || summary(kase), kase.input.split.size]
18
+ end
19
+ raise Error, "no cases found under #{discovery.root} with #{discovery.glob}" if rows.empty?
20
+
21
+ table(rows, header: %w[dataset case difficulty expected words])
22
+ refusals
23
+ end
24
+
25
+ private
26
+
27
+ # Names, on STDERR, every lineup entry the suite cannot ask, so the
28
+ # mismatch is visible before anyone reaches for `run`.
29
+ def refusals
30
+ config = configuration.lineup
31
+ _, refused = configuration.providers.admit(config.contenders, request_types: config.request_types)
32
+ refused.map(&:to_s).uniq.each { status_box :warning, it, stream: err }
33
+ end
34
+
35
+ # The generic cell when the scorer has nothing to say: how many fields
36
+ # the expected answer holds, or what kind of thing it is.
37
+ def summary(kase)
38
+ case kase.expected
39
+ when Hash then "#{kase.expected.size} fields"
40
+ when Array then "#{kase.expected.size} items"
41
+ when nil then "-"
42
+ else kase.expected.class.name.downcase
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "pastel"
5
+ require "tty-box"
6
+ require "tty-table"
7
+
8
+ module Agent
9
+ module Evals
10
+ module CLI
11
+ # What every command inherits: the ui helpers, and the flags they all share.
12
+ class Command < Dry::CLI::Command
13
+ include Dry::CLI::UI
14
+
15
+ # A cell that reads as a number: 42, 1,200, 97.1%, 3.5s, $0.0042.
16
+ NUMBER = /\A-?\$?\d[\d,]*(?:\.\d+)?[%s]?\z/
17
+
18
+ # Cells that stand in for a number without being one; they do not stop
19
+ # a column from right-aligning.
20
+ PLACEHOLDERS = ["", "-", "default"].freeze
21
+
22
+ # The width assumed when the output is not a terminal.
23
+ DEFAULT_WIDTH = 120
24
+
25
+ # The narrowest a wrapped column gets, however full the screen.
26
+ MIN_WRAP = 30
27
+
28
+ # Each status box's title and colors.
29
+ STATUS = {
30
+ info: { title: " Info ", fg: :bright_white, bg: :blue },
31
+ success: { title: " Success ", fg: :black, bg: :green },
32
+ warning: { title: " Warning ", fg: :black, bg: :yellow },
33
+ error: { title: " Error ", fg: :bright_white, bg: :red }
34
+ }.freeze
35
+
36
+ def self.inherited(subclass)
37
+ super
38
+ subclass.option :verbose, type: :boolean, default: false, aliases: ["-v"], desc: "Print the full backtrace on failure"
39
+ end
40
+
41
+ private
42
+
43
+ # @return [Configuration] the suite, which must be loaded
44
+ def configuration = Agent::Evals.configuration.require_suite!
45
+
46
+ # @return [Discovery]
47
+ def discovery = configuration.discovery
48
+
49
+ # ui.table, as wide as the terminal (and so as the info and warning
50
+ # boxes), with every all-numeric column right-aligned and a line between
51
+ # rows once any cell spans several. dry-cli-ui does none of that, so this
52
+ # renders through tty-table, as it does.
53
+ #
54
+ # The fill column takes whatever width the others leave. With wrap, its
55
+ # text is word-wrapped to fit; without, it is only ever widened, so an
56
+ # id is never broken and a table too wide for the screen stays so.
57
+ #
58
+ # @param rows [Array<Array<#to_s>>]
59
+ # @param header [Array<String>]
60
+ # @param highlight [Array<Integer>] rows drawn in bold yellow (when color is on)
61
+ # @param fill [Integer] the column that absorbs the spare width
62
+ # @param wrap [Boolean] word-wrap the fill column to fit
63
+ # @return [void]
64
+ def table(rows, header:, highlight: [], fill: 1, wrap: false)
65
+ return if rows.empty?
66
+
67
+ cells = rows.map { |row| row.map(&:to_s) }
68
+ widths = column_widths([header] + cells)
69
+ spare = terminal_width - (widths.sum - widths[fill]) - ((3 * widths.size) + 1)
70
+ if wrap
71
+ widths[fill] = [spare, MIN_WRAP].max
72
+ cells.each { it[fill] = wrap(it[fill], widths[fill]) }
73
+ else
74
+ widths[fill] = [spare, widths[fill]].max
75
+ end
76
+ alignments = header.each_index.map do |index|
77
+ column = cells.map { it[index] }.reject { PLACEHOLDERS.include?(it) }
78
+ !column.empty? && column.all? { NUMBER.match?(it) } ? :right : :left
79
+ end
80
+ multiline = cells.flatten.any? { it.include?("\n") }
81
+ cells = cells.each_with_index.map { |row, index| highlight.include?(index) ? row.map { pastel.bold.yellow(it) } : row }
82
+ rendered = TTY::Table.new(header: header.map { pastel.bold(it) }, rows: cells).render(
83
+ :unicode,
84
+ padding: [0, 1],
85
+ width: Dry::CLI::UI::Widgets::Table::UNLIMITED,
86
+ alignments: alignments,
87
+ multiline: multiline,
88
+ column_widths: widths
89
+ ) { |renderer| renderer.border.separator = :each_row if multiline }
90
+ out.puts rendered
91
+ end
92
+
93
+ # @return [Array<Integer>] each column's widest line
94
+ def column_widths(rows)
95
+ rows.transpose.map { |column| column.flat_map { it.to_s.split("\n") }.map(&:length).max.to_i }
96
+ end
97
+
98
+ # ui.info, ui.success, ui.warn and ui.error, painted on their color
99
+ # across the full width of the terminal. dry-cli-ui colors only a box's
100
+ # title, so this draws the box with tty-box, which it uses too. Without
101
+ # color it is a plain framed box.
102
+ #
103
+ # @param level [Symbol] a key of {STATUS}
104
+ # @param text [String]
105
+ # @param stream [IO] out by default; warnings and errors go to err
106
+ # @return [void]
107
+ def status_box(level, text, stream: out)
108
+ look = STATUS.fetch(level)
109
+ colored = color?(stream)
110
+ style = colored ? { fg: look[:fg], bg: look[:bg], border: { fg: look[:fg], bg: look[:bg] } } : {}
111
+ # tty-box drops a word wider than the box, such as a long path; cut it first
112
+ stream.puts TTY::Box.frame(wrap(text, terminal_width - 4),
113
+ width: terminal_width,
114
+ padding: [0, 1],
115
+ title: { top_left: look[:title] },
116
+ style: style,
117
+ enable_color: colored)
118
+ end
119
+
120
+ # A report written with -o is in color, as dry-cli-ui writes its own.
121
+ #
122
+ # @param stream [IO]
123
+ # @return [Boolean] whether the stream takes color
124
+ def color?(stream = out)
125
+ (stream.respond_to?(:color?) && stream.color?) || (stream.respond_to?(:tty?) && stream.tty? && !ENV.key?("NO_COLOR"))
126
+ end
127
+
128
+ def pastel = @pastel ||= Pastel.new(enabled: color?)
129
+
130
+ # @return [Integer] columns available on this terminal; {DEFAULT_WIDTH} when it is not one.
131
+ # While writing to a report, the screen's.
132
+ def terminal_width
133
+ screen = @screen || out
134
+ width = screen.respond_to?(:winsize) && screen.tty? ? screen.winsize[1] : 0
135
+ width.positive? ? width : DEFAULT_WIDTH
136
+ rescue SystemCallError
137
+ DEFAULT_WIDTH
138
+ end
139
+
140
+ # Word-wraps text to a width, keeping its own line breaks. A word
141
+ # longer than the width is cut.
142
+ #
143
+ # @param text [String]
144
+ # @param width [Integer]
145
+ # @return [String]
146
+ def wrap(text, width)
147
+ text.split("\n").flat_map { |line| line.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/).map(&:strip) }.join("\n")
148
+ end
149
+
150
+ # @param only [Array<String>, nil] case prefixes from --case
151
+ # @param dataset [String]
152
+ # @return [Array<Case>]
153
+ # @raise [Error] when nothing matches
154
+ def cases_for(only, dataset:)
155
+ list = discovery.cases(dataset: dataset, only: Array(only))
156
+ raise Error, "no cases match #{Array(only).join(", ")} in #{dataset}" if list.empty?
157
+
158
+ list
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module CLI
6
+ # Several models at one setting, side by side: one experiment each,
7
+ # and only the leaderboard.
8
+ class Compare < ModelCommand
9
+ desc "Run every case against several models and print a leaderboard"
10
+
11
+ option :concurrency, default: "0", desc: "Models running at once; 0 runs them all together"
12
+
13
+ example [
14
+ " # everything .agent-eval.config.yml lists",
15
+ "-m anthropic/claude-haiku-5.5,openai/gpt-4o-mini -t 0",
16
+ "-e low -c clear --concurrency 2",
17
+ "-n # show the matrix, spend nothing"
18
+ ]
19
+
20
+ def call(models:, dataset:, dry_run:, concurrency:, **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
+ report(execute(runs, concurrency: Integer(concurrency)), output: options[:output], open: options[:open])
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end