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
|
@@ -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
|