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,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# What a {Scorer} says about one reply. `value` is the case's overall
|
|
6
|
+
# score in 0..1; `metrics` holds one entry per {Metric} the scorer
|
|
7
|
+
# declares, nil where the case does not apply; `outcomes` and
|
|
8
|
+
# `diagnostics` are free-form, keyed by field, and only ever displayed.
|
|
9
|
+
Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
|
|
10
|
+
def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
|
|
11
|
+
raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
|
|
12
|
+
|
|
13
|
+
super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
|
|
17
|
+
#
|
|
18
|
+
# @param metrics [Array<Metric>]
|
|
19
|
+
# @param error [String]
|
|
20
|
+
# @return [Score]
|
|
21
|
+
def self.failed(metrics, error)
|
|
22
|
+
new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# What a suite implements to score one reply. Pure and deterministic: it
|
|
6
|
+
# reads the case, the reply and the adapter's context, and calls nothing.
|
|
7
|
+
#
|
|
8
|
+
# @abstract Subclass and override {#metrics} and {#call}.
|
|
9
|
+
class Scorer
|
|
10
|
+
# The metrics every {Score} this scorer returns carries. The CLI builds
|
|
11
|
+
# its columns, its glossary and its Braintrust scorers from this list,
|
|
12
|
+
# so a metric missing here is a metric nobody sees.
|
|
13
|
+
#
|
|
14
|
+
# @return [Array<Metric>]
|
|
15
|
+
def metrics
|
|
16
|
+
raise NotImplementedError, "#{self.class}#metrics must return an Array of Agent::Evals::Metric"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Metric] the one flagged primary, else the first
|
|
20
|
+
def primary = metrics.find(&:primary) || metrics.first
|
|
21
|
+
|
|
22
|
+
# @param kase [Case]
|
|
23
|
+
# @param reply [Reply] never one with `error` set; the runner scores those 0 itself
|
|
24
|
+
# @param context [Hash] what {Adapter#context} returned for the case
|
|
25
|
+
# @return [Score]
|
|
26
|
+
def call(kase, reply, context:)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an Agent::Evals::Score"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Offline checks of one case against what the scorer will demand of it,
|
|
31
|
+
# so a typo in expected.json is caught before it is billed as a model's mistake.
|
|
32
|
+
#
|
|
33
|
+
# @param kase [Case]
|
|
34
|
+
# @param context [Hash] what {Adapter#context} returned for the case
|
|
35
|
+
# @return [Array<String>] problems; empty when the case is sound
|
|
36
|
+
def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
|
|
37
|
+
|
|
38
|
+
# One short cell describing the case in `agent-evals cases`, e.g. "5/8 answered".
|
|
39
|
+
#
|
|
40
|
+
# @param kase [Case]
|
|
41
|
+
# @return [String, nil] nil for the generic fallback
|
|
42
|
+
def summary(_kase) = nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Testing
|
|
6
|
+
# The smallest honest adapter: sends the case's input as the user
|
|
7
|
+
# prompt, parses the reply as a JSON object, and reports a reply that
|
|
8
|
+
# is not one as the model's failure. The toy suite runs on it, and a
|
|
9
|
+
# new suite can start from it.
|
|
10
|
+
class EchoAdapter < Adapter
|
|
11
|
+
SYSTEM = "Answer with a single JSON object and nothing else."
|
|
12
|
+
FENCE = /\A```(?:json)?\s*|\s*```\z/
|
|
13
|
+
|
|
14
|
+
# @param kase [Case]
|
|
15
|
+
# @param model [Model]
|
|
16
|
+
# @return [Reply]
|
|
17
|
+
def call(kase, model:)
|
|
18
|
+
completion = model.complete(system: SYSTEM, user: kase.input, json: true)
|
|
19
|
+
output = JSON.parse(completion.text.strip.gsub(FENCE, ""))
|
|
20
|
+
return Reply.new(error: "reply is not a JSON object", raw: completion.text, usage: completion.usage) unless output.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
Reply.new(output: output, raw: completion.text, usage: completion.usage)
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
Reply.new(error: "reply is not JSON", raw: completion.text, usage: completion.usage)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Testing
|
|
6
|
+
# A {Model} on a {FakeProvider} of its own, for specs that run an
|
|
7
|
+
# adapter or an {Experiment} without the CLI.
|
|
8
|
+
class FakeModel < Model
|
|
9
|
+
# @param id [String]
|
|
10
|
+
# @param settings [Hash]
|
|
11
|
+
# @param provider_options [Hash] passed to {FakeProvider#initialize}
|
|
12
|
+
def initialize(id: "fake/echo", settings: { effort: nil, temperature: 0.2, max_tokens: 4096 }, **provider_options)
|
|
13
|
+
super(id: id, provider: FakeProvider.new(**provider_options), settings: settings)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Testing
|
|
6
|
+
# Serves every "fake/..." model without a network: a call answers with
|
|
7
|
+
# the expected JSON of the case whose input matches the user prompt, so
|
|
8
|
+
# a suite's own plumbing can be run end to end through the executable.
|
|
9
|
+
#
|
|
10
|
+
# `junk` names cases (by id or key) that get a non-JSON reply, and
|
|
11
|
+
# `wrong` maps a case to fields to answer wrongly. `answers`, a callable
|
|
12
|
+
# taking the user prompt, replaces the lookup altogether.
|
|
13
|
+
class FakeProvider < Providers::Base
|
|
14
|
+
PREFIX = "fake/"
|
|
15
|
+
USAGE = Usage.new(input_tokens: 100, output_tokens: 10, cost_usd: 0.001)
|
|
16
|
+
JUNK = "Sure! Here is my answer, in prose rather than JSON."
|
|
17
|
+
|
|
18
|
+
attr_reader :calls
|
|
19
|
+
|
|
20
|
+
# @param junk [Array<String>] case ids or keys to answer with prose
|
|
21
|
+
# @param wrong [Hash{String => Hash}] case id or key => fields to override
|
|
22
|
+
# @param answers [#call, nil] user prompt => reply text; replaces the lookup
|
|
23
|
+
# @param discovery [Discovery, nil] where to look cases up; the configured suite's when nil
|
|
24
|
+
def initialize(junk: [], wrong: {}, answers: nil, discovery: nil)
|
|
25
|
+
super()
|
|
26
|
+
@junk = junk
|
|
27
|
+
@wrong = wrong
|
|
28
|
+
@answers = answers
|
|
29
|
+
@discovery = discovery
|
|
30
|
+
@calls = []
|
|
31
|
+
@lock = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def name = "fake"
|
|
35
|
+
def request_type = :prompt
|
|
36
|
+
def prefix = PREFIX
|
|
37
|
+
def env_keys = []
|
|
38
|
+
|
|
39
|
+
# @return [nil] any id passes verification
|
|
40
|
+
def catalog = nil
|
|
41
|
+
|
|
42
|
+
# @param model [Model]
|
|
43
|
+
# @param system [String, nil]
|
|
44
|
+
# @param user [String]
|
|
45
|
+
# @param json [Boolean]
|
|
46
|
+
# @return [Completion]
|
|
47
|
+
def complete(model, system:, user:, json: true)
|
|
48
|
+
@lock.synchronize { @calls << { model: model.id, system: system, user: user, json: json } }
|
|
49
|
+
Completion.new(text: @answers ? @answers.call(user) : answer(user), usage: USAGE)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def answer(user)
|
|
55
|
+
kase = discovery.all.find { it.input == user }
|
|
56
|
+
return "{}" if kase.nil?
|
|
57
|
+
return JUNK if @junk.include?(kase.id) || @junk.include?(kase.key)
|
|
58
|
+
|
|
59
|
+
wrong = @wrong[kase.id] || @wrong[kase.key] || {}
|
|
60
|
+
JSON.generate(kase.expected.is_a?(Hash) ? kase.expected.merge(wrong) : kase.expected)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def discovery = @discovery || Agent::Evals.configuration.discovery
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Where a run's results go besides the terminal. The gem always owns the
|
|
6
|
+
# run loop; a tracker only receives callbacks: {#start} once per
|
|
7
|
+
# experiment, {#record} once per finished case, {#finish} once with the
|
|
8
|
+
# summary. The local JSON log ({Trackers::Local}) always runs; at most
|
|
9
|
+
# one remote tracker runs beside it, chosen by `--tracker`, the
|
|
10
|
+
# `tracker:` key, or the environment (see {Trackers::Registry#choose}).
|
|
11
|
+
#
|
|
12
|
+
# A tracker for another framework (W&B Weave, an OTLP backend) subclasses
|
|
13
|
+
# this in a consumer or a plugin gem and is registered with `c.tracker`.
|
|
14
|
+
#
|
|
15
|
+
# @abstract Override the three callbacks; declare {.env_key} and {.gems}.
|
|
16
|
+
class Tracker
|
|
17
|
+
class << self
|
|
18
|
+
# @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
|
|
19
|
+
def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
|
|
22
|
+
def env_key = nil
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] the optional gems this tracker requires when chosen
|
|
25
|
+
def gems = []
|
|
26
|
+
|
|
27
|
+
# @param env [Hash] the environment
|
|
28
|
+
# @return [Boolean] whether the environment names this tracker
|
|
29
|
+
def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
|
|
30
|
+
|
|
31
|
+
# Loads the gems and checks the key, with an error that names both, so
|
|
32
|
+
# a chosen tracker that cannot run says so before any model is called.
|
|
33
|
+
#
|
|
34
|
+
# @param env [Hash]
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @raise [MissingDependency, Error]
|
|
37
|
+
def requirements!(env = ENV)
|
|
38
|
+
gems.each do |gem_name|
|
|
39
|
+
Agent::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
|
|
40
|
+
end
|
|
41
|
+
return if env_key.nil? || detect?(env)
|
|
42
|
+
|
|
43
|
+
raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :context
|
|
48
|
+
|
|
49
|
+
# @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
|
|
50
|
+
def initialize(**context)
|
|
51
|
+
@context = context
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String]
|
|
55
|
+
def name = self.class.tracker_name
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] false turns every callback into a no-op
|
|
58
|
+
def enabled? = true
|
|
59
|
+
|
|
60
|
+
# @return [String] one line for `--verbose`: where this tracker sends things, and why
|
|
61
|
+
def describe = name
|
|
62
|
+
|
|
63
|
+
# @param experiment [Experiment]
|
|
64
|
+
# @return [void]
|
|
65
|
+
def start(_experiment) = nil
|
|
66
|
+
|
|
67
|
+
# @param result [RunResult]
|
|
68
|
+
# @return [void]
|
|
69
|
+
def record(_result) = nil
|
|
70
|
+
|
|
71
|
+
# @param experiment [Experiment]
|
|
72
|
+
# @param summary [Experiment::Summary]
|
|
73
|
+
# @return [Hash] `url:` to the experiment when one is known, else nil
|
|
74
|
+
def finish(_experiment, _summary) = { url: nil }
|
|
75
|
+
|
|
76
|
+
# @return [Hash] the tracker's own section of the config file
|
|
77
|
+
def options = context.fetch(:options, nil) || {}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# Records each case as a Braintrust eval row in an experiment of the
|
|
7
|
+
# suite's project, through the braintrust gem's own OpenTelemetry
|
|
8
|
+
# exporter, the way `Braintrust::Eval.run` would have, except that the
|
|
9
|
+
# loop stays ours. The gem is required only when this tracker is chosen.
|
|
10
|
+
class Braintrust < Tracker
|
|
11
|
+
def self.env_key = "BRAINTRUST_API_KEY"
|
|
12
|
+
def self.gems = ["braintrust"]
|
|
13
|
+
|
|
14
|
+
attr_reader :experiment_id, :permalink
|
|
15
|
+
|
|
16
|
+
def describe = "braintrust: project #{context[:project_id] || context[:project].inspect}"
|
|
17
|
+
|
|
18
|
+
# Logs in, finds or creates the project, creates the experiment.
|
|
19
|
+
#
|
|
20
|
+
# @param experiment [Experiment]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def start(experiment)
|
|
23
|
+
::Braintrust.init(blocking_login: true, auto_instrument: false)
|
|
24
|
+
@state = ::Braintrust.current_state
|
|
25
|
+
@state.login
|
|
26
|
+
project_id = context[:project_id] || ::Braintrust::API::Internal::Projects.new(@state).create(name: context[:project]).fetch("id")
|
|
27
|
+
@experiment_id = ::Braintrust::API::Internal::Experiments.new(@state).create(
|
|
28
|
+
name: experiment.name,
|
|
29
|
+
project_id: project_id,
|
|
30
|
+
ensure_new: true,
|
|
31
|
+
tags: [context[:suite], experiment.model.id, "dataset-#{experiment.dataset}"].compact,
|
|
32
|
+
metadata: metadata(experiment)
|
|
33
|
+
).fetch("id")
|
|
34
|
+
@permalink = @state.object_permalink(object_type: "experiment", object_id: experiment_id)
|
|
35
|
+
# Braintrust.init installs its exporter on the global provider, which
|
|
36
|
+
# is where the gem's own runner takes its tracer from too.
|
|
37
|
+
@tracer = OpenTelemetry.tracer_provider.tracer("agent-evals")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One root "eval" span per case, with the shape Braintrust's own
|
|
41
|
+
# runner emits: input, expected, output, scores and metadata as JSON
|
|
42
|
+
# attributes, parented to the experiment.
|
|
43
|
+
#
|
|
44
|
+
# @param result [RunResult]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def record(result)
|
|
47
|
+
span = @tracer.start_root_span("eval")
|
|
48
|
+
span.set_attribute("braintrust.parent", "experiment_id:#{experiment_id}")
|
|
49
|
+
json(span, "braintrust.span_attributes", { type: "eval", name: result.experiment.name })
|
|
50
|
+
json(span, "braintrust.input_json", { case: result.case.key, text: result.case.input })
|
|
51
|
+
json(span, "braintrust.expected_json", result.case.expected) unless result.case.expected.nil?
|
|
52
|
+
json(span, "braintrust.metadata", case_metadata(result))
|
|
53
|
+
span.set_attribute("braintrust.tags", ["variant-#{result.case.variant}", "dataset-#{result.case.dataset}"])
|
|
54
|
+
if result.failed?
|
|
55
|
+
span.status = OpenTelemetry::Trace::Status.error(result.error)
|
|
56
|
+
else
|
|
57
|
+
json(span, "braintrust.output_json", result.reply.error? ? { "_error" => result.reply.error } : result.reply.output)
|
|
58
|
+
json(span, "braintrust.scores", result.score.metrics.compact)
|
|
59
|
+
end
|
|
60
|
+
ensure
|
|
61
|
+
span&.finish
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Flushes the spans and hands back the experiment's page.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash] `url:` the experiment permalink
|
|
67
|
+
def finish(_experiment, _summary)
|
|
68
|
+
provider = OpenTelemetry.tracer_provider
|
|
69
|
+
provider.force_flush if provider.respond_to?(:force_flush)
|
|
70
|
+
{ url: permalink }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def json(span, key, value) = span.set_attribute(key, JSON.dump(value))
|
|
76
|
+
|
|
77
|
+
def metadata(experiment)
|
|
78
|
+
{ model: experiment.model.id, provider: experiment.model.provider.name, dataset: experiment.dataset,
|
|
79
|
+
**experiment.model.settings, **experiment.adapter.metadata }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def case_metadata(result)
|
|
83
|
+
base = { case: result.case.key, dataset: result.case.dataset, variant: result.case.variant, difficulty: result.case.difficulty }
|
|
84
|
+
return base.merge(error: result.error) if result.failed?
|
|
85
|
+
|
|
86
|
+
base.merge(diagnostics: result.score.diagnostics, outcomes: result.score.outcomes, usage: result.reply.usage.to_h)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Evals
|
|
7
|
+
module Trackers
|
|
8
|
+
# Always on: every experiment's results and summary land in one JSON
|
|
9
|
+
# file under `log/`, so a run is never lost to a closed terminal or a
|
|
10
|
+
# remote tracker that was never configured.
|
|
11
|
+
class Local < Tracker
|
|
12
|
+
attr_reader :dir, :path
|
|
13
|
+
|
|
14
|
+
# @param dir [String] where the files go; `log/agent-evals` at the repository root by default
|
|
15
|
+
# @param context [Hash] see {Tracker#initialize}
|
|
16
|
+
def initialize(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "agent-evals"), **context)
|
|
17
|
+
super(**context)
|
|
18
|
+
@dir = dir
|
|
19
|
+
@results = []
|
|
20
|
+
@lock = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def describe = "local: #{dir}"
|
|
24
|
+
|
|
25
|
+
def start(experiment)
|
|
26
|
+
@started_at = Time.now
|
|
27
|
+
@path = File.join(dir, "#{slug(experiment.name)}.#{@started_at.strftime("%Y-%m-%d.%H%M%S")}.json")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def record(result)
|
|
31
|
+
@lock.synchronize { @results << result }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash] `url:` nil, `path:` the file written
|
|
35
|
+
def finish(experiment, summary)
|
|
36
|
+
FileUtils.mkdir_p(dir)
|
|
37
|
+
File.write(path, JSON.pretty_generate(document(experiment, summary)))
|
|
38
|
+
{ url: nil, path: path }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def slug(text) = text.gsub(/[^A-Za-z0-9.-]+/, "_").squeeze("_").delete_prefix("_").delete_suffix("_")
|
|
44
|
+
|
|
45
|
+
def document(experiment, summary)
|
|
46
|
+
{
|
|
47
|
+
suite: context[:suite],
|
|
48
|
+
experiment: experiment.name,
|
|
49
|
+
model: experiment.model.id,
|
|
50
|
+
provider: experiment.model.provider.name,
|
|
51
|
+
settings: experiment.model.settings,
|
|
52
|
+
dataset: experiment.dataset,
|
|
53
|
+
started_at: @started_at.iso8601,
|
|
54
|
+
duration: summary.duration,
|
|
55
|
+
means: summary.means,
|
|
56
|
+
errors: summary.errors,
|
|
57
|
+
usage: summary.usage.to_h,
|
|
58
|
+
results: @lock.synchronize { @results.sort_by { experiment.cases.index(it.case) }.map { row(it) } }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def row(result)
|
|
63
|
+
{
|
|
64
|
+
case: result.case.key,
|
|
65
|
+
duration: result.duration,
|
|
66
|
+
error: result.error,
|
|
67
|
+
score: result.score&.value,
|
|
68
|
+
metrics: result.score&.metrics,
|
|
69
|
+
outcomes: result.score&.outcomes,
|
|
70
|
+
diagnostics: result.score&.diagnostics,
|
|
71
|
+
reply_error: result.reply&.error,
|
|
72
|
+
usage: result.reply&.usage&.to_h
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# Pydantic Logfire, which has no Ruby gem of its own: it is an OTLP
|
|
7
|
+
# backend for the standard OpenTelemetry Ruby SDK (pydantic.dev/logfire/ruby,
|
|
8
|
+
# read 2026-10-15). Traces only; Logfire's metrics and logs SDKs are
|
|
9
|
+
# pre-1.0. One span per experiment, one child span per case.
|
|
10
|
+
#
|
|
11
|
+
# The write token comes from `LOGFIRE_TOKEN`, our name for what their
|
|
12
|
+
# page passes as a raw `Authorization` header. When the standard
|
|
13
|
+
# `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_HEADERS` is
|
|
14
|
+
# already set, the SDK's own reading of it wins and {#describe} says so.
|
|
15
|
+
# The raw reply is not sent unless the config file opts in with
|
|
16
|
+
# `logfire: { include_replies: true }`.
|
|
17
|
+
class Logfire < Tracker
|
|
18
|
+
# Logfire's US region, per their docs on 2026-10-15; other regions
|
|
19
|
+
# exist, so LOGFIRE_BASE_URL overrides it.
|
|
20
|
+
DEFAULT_BASE_URL = "https://logfire-us.pydantic.dev"
|
|
21
|
+
TOKEN = "LOGFIRE_TOKEN"
|
|
22
|
+
BASE_URL = "LOGFIRE_BASE_URL"
|
|
23
|
+
OTEL_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
24
|
+
OTEL_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
|
|
25
|
+
|
|
26
|
+
# Bytes of the diagnostics JSON kept on a case span; the rest is cut,
|
|
27
|
+
# since an attribute is not the place for a whole transcript.
|
|
28
|
+
DIAGNOSTICS_LIMIT = 4096
|
|
29
|
+
|
|
30
|
+
def self.env_key = TOKEN
|
|
31
|
+
def self.gems = %w[opentelemetry-sdk opentelemetry-exporter-otlp]
|
|
32
|
+
|
|
33
|
+
attr_reader :endpoint, :source
|
|
34
|
+
|
|
35
|
+
# @param exporter [#export, nil] an exporter to use instead of OTLP, for tests
|
|
36
|
+
# @param env [Hash]
|
|
37
|
+
# @param context [Hash] see {Tracker#initialize}
|
|
38
|
+
def initialize(exporter: nil, env: ENV, **context)
|
|
39
|
+
super(**context)
|
|
40
|
+
@env = env
|
|
41
|
+
@exporter = exporter
|
|
42
|
+
@endpoint, @headers, @source = settings(env)
|
|
43
|
+
@cases = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def describe = "logfire: endpoint #{endpoint || "(SDK default)"}, credentials from #{source}"
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether raw replies ride along on the case spans
|
|
49
|
+
def include_replies? = options["include_replies"] == true || options[:include_replies] == true
|
|
50
|
+
|
|
51
|
+
def start(experiment)
|
|
52
|
+
@provider = provider
|
|
53
|
+
@tracer = @provider.tracer("agent-evals", Agent::Evals::VERSION)
|
|
54
|
+
@span = @tracer.start_span("experiment #{experiment.name}", attributes: experiment_attributes(experiment))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record(result)
|
|
58
|
+
now = Time.now
|
|
59
|
+
span = @tracer.start_span("case #{result.case.key}",
|
|
60
|
+
with_parent: OpenTelemetry::Trace.context_with_span(@span),
|
|
61
|
+
start_timestamp: now - result.duration,
|
|
62
|
+
attributes: case_attributes(result))
|
|
63
|
+
if result.failed?
|
|
64
|
+
span.record_exception(Error.new(result.error))
|
|
65
|
+
span.status = OpenTelemetry::Trace::Status.error(result.error)
|
|
66
|
+
end
|
|
67
|
+
span.finish(end_timestamp: now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Closes the experiment span with the summary and flushes. No URL:
|
|
71
|
+
# Logfire's trace pages are not something the token lets us build.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] `url:` nil
|
|
74
|
+
def finish(_experiment, summary)
|
|
75
|
+
summary.means.each { |metric, mean| @span.set_attribute("evals.mean.#{metric}", mean) unless mean.nil? }
|
|
76
|
+
@span.set_attribute("evals.errors", summary.errors.size)
|
|
77
|
+
@span.set_attribute("evals.duration_seconds", summary.duration)
|
|
78
|
+
@span.set_attribute("evals.usage.cost_usd", summary.usage.cost_usd)
|
|
79
|
+
@span.finish
|
|
80
|
+
@provider.force_flush
|
|
81
|
+
@provider.shutdown unless @exporter # an injected exporter is the caller's to read afterwards
|
|
82
|
+
{ url: nil }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Which credentials win: the standard OTEL_* variables when either is
|
|
88
|
+
# set, else our LOGFIRE_* pair. Returns the endpoint and headers to
|
|
89
|
+
# hand the exporter (nil leaves the SDK's own reading in place).
|
|
90
|
+
def settings(env)
|
|
91
|
+
if [OTEL_ENDPOINT, OTEL_HEADERS].any? { !env[it].to_s.empty? }
|
|
92
|
+
[env[OTEL_ENDPOINT], nil, "#{OTEL_ENDPOINT}/#{OTEL_HEADERS} (set in the environment; #{TOKEN} ignored)"]
|
|
93
|
+
else
|
|
94
|
+
base = env[BASE_URL].to_s.empty? ? DEFAULT_BASE_URL : env[BASE_URL]
|
|
95
|
+
["#{base.chomp("/")}/v1/traces", { "Authorization" => env[TOKEN].to_s }, "#{TOKEN}#{" and #{BASE_URL}" unless env[BASE_URL].to_s.empty?}"]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A tracer provider of our own, so the global one, which a consumer
|
|
100
|
+
# may have configured for its application, is left alone.
|
|
101
|
+
def provider
|
|
102
|
+
exporter = @exporter || OpenTelemetry::Exporter::OTLP::Exporter.new(**{ endpoint: endpoint, headers: @headers }.compact)
|
|
103
|
+
processor = if @exporter
|
|
104
|
+
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
|
|
105
|
+
else
|
|
106
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
|
|
107
|
+
end
|
|
108
|
+
resource = OpenTelemetry::SDK::Resources::Resource.create("service.name" => context[:suite] || "agent-evals")
|
|
109
|
+
OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource).tap { it.add_span_processor(processor) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def experiment_attributes(experiment)
|
|
113
|
+
model = experiment.model
|
|
114
|
+
{ "evals.suite" => context[:suite].to_s, "evals.project" => context[:project].to_s, "evals.experiment" => experiment.name,
|
|
115
|
+
"evals.model" => model.id, "evals.provider" => model.provider.name, "evals.dataset" => experiment.dataset,
|
|
116
|
+
"evals.effort" => model.effort.to_s, "evals.temperature" => model.temperature.to_f, "evals.max_tokens" => model.max_tokens.to_i,
|
|
117
|
+
"evals.cases" => experiment.cases.size }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def case_attributes(result)
|
|
121
|
+
kase = result.case
|
|
122
|
+
attributes = { "evals.case" => kase.id, "evals.dataset" => kase.dataset, "evals.variant" => kase.variant,
|
|
123
|
+
"evals.duration_seconds" => result.duration }
|
|
124
|
+
return attributes.merge("evals.error" => result.error) if result.failed?
|
|
125
|
+
|
|
126
|
+
score = result.score
|
|
127
|
+
attributes["evals.score"] = score.value
|
|
128
|
+
score.metrics.each { |metric, value| attributes["evals.metric.#{metric}"] = value unless value.nil? }
|
|
129
|
+
score.outcomes.group_by { |_, v| v.to_s }.each { |outcome, pairs| attributes["evals.outcome.#{outcome}"] = pairs.size }
|
|
130
|
+
attributes["evals.diagnostics"] = truncate(JSON.generate(score.diagnostics))
|
|
131
|
+
attributes["evals.usage.input_tokens"] = result.reply.usage.input_tokens
|
|
132
|
+
attributes["evals.usage.output_tokens"] = result.reply.usage.output_tokens
|
|
133
|
+
attributes["evals.usage.cost_usd"] = result.reply.usage.cost_usd
|
|
134
|
+
attributes["evals.reply_error"] = result.reply.error if result.reply.error?
|
|
135
|
+
attributes["evals.reply"] = truncate(result.reply.raw.to_s) if include_replies?
|
|
136
|
+
attributes
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def truncate(text) = text.bytesize > DIAGNOSTICS_LIMIT ? "#{text.byteslice(0, DIAGNOSTICS_LIMIT)}..." : text
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# The tracker classes a run may choose from, by name, and the choice
|
|
7
|
+
# itself: exactly one remote tracker, or none. A consumer or a plugin
|
|
8
|
+
# gem registers its own with `c.tracker`.
|
|
9
|
+
class Registry
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
# Spellings of "no remote tracker" on the command line and in the config.
|
|
13
|
+
NONE = %w[none local off].freeze
|
|
14
|
+
|
|
15
|
+
# @return [Registry] Braintrust and Logfire
|
|
16
|
+
def self.default = new([Braintrust, Logfire])
|
|
17
|
+
|
|
18
|
+
# @param classes [Array<Class<Tracker>>]
|
|
19
|
+
def initialize(classes = [])
|
|
20
|
+
@classes = classes.dup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param klass [Class<Tracker>] replaces one registered under the same name
|
|
24
|
+
# @return [Class<Tracker>]
|
|
25
|
+
def register(klass)
|
|
26
|
+
@classes.reject! { it.tracker_name == klass.tracker_name }
|
|
27
|
+
@classes << klass
|
|
28
|
+
klass
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&) = @classes.each(&)
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>]
|
|
34
|
+
def names = map(&:tracker_name)
|
|
35
|
+
|
|
36
|
+
# @param name [String, Symbol]
|
|
37
|
+
# @return [Class<Tracker>]
|
|
38
|
+
# @raise [Error] naming the known trackers
|
|
39
|
+
def named(name)
|
|
40
|
+
find { it.tracker_name == name.to_s } || raise(Error, "no tracker named #{name.to_s.inspect}; known: #{names.join(", ")}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The one remote tracker to run, in this precedence: `--tracker`,
|
|
44
|
+
# then the config's `tracker:`, then the environment, where a single
|
|
45
|
+
# tracker's key being set selects it and two or more is an ambiguity
|
|
46
|
+
# to be settled by naming one. `--local` is none.
|
|
47
|
+
#
|
|
48
|
+
# @param flag [String, nil] what `--tracker` was given
|
|
49
|
+
# @param config [String, nil] the config file's `tracker:`
|
|
50
|
+
# @param env [Hash]
|
|
51
|
+
# @param local [Boolean] `--local`
|
|
52
|
+
# @return [Array(Class<Tracker>, String), nil] the class and why it was chosen; nil for none
|
|
53
|
+
# @raise [Error] on an unknown name or an ambiguous environment
|
|
54
|
+
def choose(flag: nil, config: nil, env: ENV, local: false)
|
|
55
|
+
return nil if local
|
|
56
|
+
return choice(flag, "--tracker") if present?(flag)
|
|
57
|
+
return choice(config, "tracker: in the config file") if present?(config)
|
|
58
|
+
|
|
59
|
+
detected = @classes.select { it.detect?(env) }
|
|
60
|
+
case detected.size
|
|
61
|
+
when 0 then nil
|
|
62
|
+
when 1 then [detected.first, "#{detected.first.env_key} is set (pass --tracker or set tracker: to choose explicitly)"]
|
|
63
|
+
else
|
|
64
|
+
raise Error,
|
|
65
|
+
"more than one tracker is configured in the environment (#{detected.map(&:env_key).join(", ")}); " \
|
|
66
|
+
"choose one with --tracker #{detected.map(&:tracker_name).join("|")} or tracker: in the config file"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
73
|
+
|
|
74
|
+
def choice(name, from)
|
|
75
|
+
return nil if NONE.include?(name.to_s)
|
|
76
|
+
|
|
77
|
+
[named(name), from]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|