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