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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # Starts the CLI. The executable and the specs both build one, each with
6
+ # its own argv and streams, and it exits only through kernel.exit.
7
+ class Launcher
8
+ def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = Kernel)
9
+ @argv = argv
10
+ @stdin = stdin
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @kernel = kernel
14
+ end
15
+
16
+ def execute!
17
+ @kernel.exit(run)
18
+ end
19
+
20
+ private
21
+
22
+ # dry-cli exits by itself after help or a usage error; its status is kept.
23
+ def run
24
+ argv, helper = Helper.extract(@argv)
25
+ Helper.load!(Helper.locate(helper))
26
+ Dry::CLI.new(CLI).call(arguments: argv, out: @stdout, err: @stderr)
27
+ 0
28
+ rescue SystemExit => e
29
+ e.status
30
+ rescue StandardError => e
31
+ @stderr.puts "error: #{e.message}"
32
+ @stderr.puts(e.backtrace) if @argv.intersect?(%w[-v --verbose])
33
+ 1
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # One number a {Scorer} reports per case. `aggregate` is how the run
6
+ # combines them (only :mean today), skipping nil, so a metric a case does
7
+ # not apply to never drags its mean down with a free 0 or up with a free 1.
8
+ # The `primary` metric ranks the leaderboard and picks the best value.
9
+ class Metric < Data.define(:name, :description, :aggregate, :primary)
10
+ AGGREGATES = %i[mean].freeze
11
+
12
+ def initialize(name:, description: "", aggregate: :mean, primary: false)
13
+ raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
14
+
15
+ super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
16
+ end
17
+
18
+ # @param values [Array<Float, nil>] one per case
19
+ # @return [Float, nil] nil when no case reported the metric
20
+ def combine(values)
21
+ present = values.compact
22
+ present.sum.fdiv(present.size) unless present.empty?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # One model at one setting, reached through its provider. Adapters call
6
+ # {#complete}; the settings an experiment is named by are read here.
7
+ class Model
8
+ # The settings every provider understands; a provider may accept more.
9
+ SETTINGS = %i[effort temperature max_tokens].freeze
10
+
11
+ attr_reader :id, :provider, :settings
12
+
13
+ # @param id [String] the model id as the lineup names it, e.g. "anthropic/claude-haiku-5.5"
14
+ # @param provider [Providers::Base] what answers {#complete}
15
+ # @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens; nil leaves the model's default
16
+ def initialize(id:, provider:, settings: {})
17
+ @id = id
18
+ @provider = provider
19
+ @settings = settings.transform_keys(&:to_sym).freeze
20
+ end
21
+
22
+ # @return [String, nil] reasoning effort; nil sends none
23
+ def effort = settings[:effort]
24
+
25
+ # @return [Float, nil] sampling temperature; nil sends none
26
+ def temperature = settings[:temperature]
27
+
28
+ # @return [Integer, nil] output token cap
29
+ def max_tokens = settings[:max_tokens]
30
+
31
+ # @return [String] the id without the provider's prefix, as the provider's API knows it
32
+ def name = provider.model_name(id)
33
+
34
+ # @return [String] "provider:id", unique where two providers render the same id
35
+ def key = "#{provider.name}:#{id}"
36
+
37
+ # One call. Transport failures raise; they are the run's errors, not the model's.
38
+ #
39
+ # @param system [String, nil]
40
+ # @param user [String]
41
+ # @param json [Boolean] ask the provider for a JSON object reply where it supports that
42
+ # @return [Completion]
43
+ def complete(user:, system: nil, json: true)
44
+ provider.complete(self, system: system, user: user, json: json)
45
+ end
46
+
47
+ # @return [String] e.g. "effort=low t=0.2"
48
+ def label
49
+ "effort=#{effort || "default"} t=#{temperature.nil? ? "default" : temperature}"
50
+ end
51
+
52
+ def to_s = "#{id} #{label}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module Providers
6
+ # What answers a {Model}'s calls: one API, the ids it serves, the keys it
7
+ # needs and the models it lists. A suite with its own transport
8
+ # subclasses this and registers it through `c.provider`.
9
+ #
10
+ # @abstract Override {#serves?}, {#env_keys}, {#catalog} and {#complete}.
11
+ class Base
12
+ # The reasoning efforts this provider's API accepts.
13
+ EFFORTS = %w[none minimal low medium high xhigh max].freeze
14
+
15
+ # The kind of request this provider answers; see {Config::REQUEST_TYPES}.
16
+ # A suite admits a provider only when its config lists this type.
17
+ #
18
+ # @return [Symbol]
19
+ def request_type
20
+ raise NotImplementedError, "#{self.class}#request_type must be one of #{Config::REQUEST_TYPES.join(", ")}"
21
+ end
22
+
23
+ # One line on what the request type is, for the README and `--help`;
24
+ # a suite's config never says it, the provider owns it.
25
+ #
26
+ # @return [String]
27
+ def request_type_description = ""
28
+
29
+ # A direct provider serves a vendor's own API and is reached only
30
+ # through a `direct:` entry that names it; it never claims an id from
31
+ # `vendors:`, however the id is spelled.
32
+ #
33
+ # @return [Boolean]
34
+ def direct? = false
35
+
36
+ # @return [String] short name, for messages and the leaderboard
37
+ def name = self.class.name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
38
+
39
+ # @param model_id [String] as the lineup names it
40
+ # @return [Boolean] whether this provider serves the model
41
+ def serves?(model_id) = model_id.start_with?(prefix)
42
+
43
+ # @return [String] the id prefix this provider claims; "" claims everything
44
+ def prefix = ""
45
+
46
+ # @param model_id [String]
47
+ # @return [String] the id as the provider's API knows it
48
+ def model_name(model_id) = model_id.delete_prefix(prefix)
49
+
50
+ # @return [Array<String>] environment variables a run through this provider needs
51
+ def env_keys = []
52
+
53
+ # @return [Array<String>] the efforts the `-e` flag accepts for this provider's models
54
+ def efforts = EFFORTS
55
+
56
+ # Lists the model ids the provider serves, from the network, so a
57
+ # misspelled id aborts the run before any experiment starts instead
58
+ # of failing every case and showing up as a row of errors.
59
+ #
60
+ # @return [Array<String>, nil] ids as the lineup names them; nil when the provider cannot list them
61
+ def catalog = nil
62
+
63
+ # Prepares the client once per process, after the env files loaded.
64
+ #
65
+ # @option options [String] :app_name what the client tells the API the caller is
66
+ # @return [void]
67
+ def setup!(**) = nil
68
+
69
+ # @param model [Model]
70
+ # @param system [String, nil]
71
+ # @param user [String]
72
+ # @param json [Boolean]
73
+ # @return [Completion]
74
+ def complete(model, system:, user:, json: true)
75
+ raise NotImplementedError, "#{self.class}#complete must return an Agent::Evals::Completion"
76
+ end
77
+
78
+ # @param contender [Contender]
79
+ # @return [Model]
80
+ def model_for(contender) = Model.new(id: contender.model, provider: self, settings: contender.settings)
81
+
82
+ # Every id in `model_ids` the provider does not list.
83
+ #
84
+ # @param model_ids [Array<String>]
85
+ # @return [Array<String>]
86
+ def unknown(model_ids)
87
+ listed = catalog
88
+ listed.nil? ? [] : model_ids - listed
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Agent
6
+ module Evals
7
+ module Providers
8
+ # One GET of a provider's model list, parsed. Every failure becomes an
9
+ # {Error} that names the URL, so an offline run says so instead of
10
+ # printing a socket error from inside a provider.
11
+ module Catalog
12
+ module_function
13
+
14
+ # @param url [String]
15
+ # @param headers [Hash{String => String}]
16
+ # @return [Hash] the parsed JSON body
17
+ # @raise [Error] when the list cannot be read
18
+ def fetch(url, headers = {})
19
+ uri = URI(url)
20
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 20) do |http|
21
+ http.request(Net::HTTP::Get.new(uri, headers))
22
+ end
23
+ raise Error, "could not list models at #{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
24
+
25
+ JSON.parse(response.body)
26
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, JSON::ParserError, KeyError => e
27
+ raise Error, "could not list models at #{url}: #{e.message}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module Providers
6
+ # Every model OpenRouter serves, through RubyLLM, because Braintrust
7
+ # traces RubyLLM calls (tokens, latency, cost) and not raw HTTP. Claims
8
+ # every id no other provider does, so it is always asked last.
9
+ class OpenRouter < Base
10
+ URL = "https://openrouter.ai/api/v1/models"
11
+ KEY = "OPENROUTER_API_KEY"
12
+
13
+ # OpenRouter's reasoning.effort values.
14
+ EFFORTS = %w[none minimal low medium high xhigh max].freeze
15
+
16
+ def name = "openrouter"
17
+ def request_type = :prompt
18
+ def request_type_description = "a chat completion: system and user text in, text out"
19
+ def env_keys = [KEY]
20
+ def efforts = EFFORTS
21
+
22
+ # @return [Array<String>] every id OpenRouter lists
23
+ def catalog = Catalog.fetch(URL).fetch("data").map { it.fetch("id") }
24
+
25
+ # Points RubyLLM at OpenRouter. Requires it here, not at load, so a
26
+ # suite that never reaches OpenRouter never needs the gem.
27
+ #
28
+ # @param app_name [String]
29
+ # @return [void]
30
+ def setup!(app_name:)
31
+ Agent::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
32
+ RubyLLM.configure do |config|
33
+ config.openrouter_api_key = ENV.fetch(KEY, nil)
34
+ config.openrouter_app_name = app_name
35
+ end
36
+ end
37
+
38
+ # @param model [Model]
39
+ # @param system [String, nil]
40
+ # @param user [String]
41
+ # @param json [Boolean] asks for `response_format: json_object`
42
+ # @return [Completion] text, plus the tokens and the cost OpenRouter billed
43
+ def complete(model, system:, user:, json: true)
44
+ Agent::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
45
+ chat = RubyLLM.chat(model: model.name, provider: :openrouter, assume_model_exists: true)
46
+ chat.with_instructions(system) if system
47
+ chat.with_temperature(model.temperature) unless model.temperature.nil?
48
+ chat.with_max_output_tokens(model.max_tokens) if model.max_tokens
49
+ chat.with_thinking(effort: model.effort.to_sym) if model.effort
50
+ chat.with_provider_options(response_format: { type: "json_object" }) if json
51
+
52
+ message = chat.ask(user)
53
+ Completion.new(
54
+ text: message.content.to_s,
55
+ usage: Usage.new(input_tokens: message.tokens.input.to_i,
56
+ output_tokens: message.tokens.output.to_i,
57
+ cost_usd: message.tokens.reported_cost.to_f),
58
+ raw: message
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module Providers
6
+ # The providers in use. A `vendors:` contender goes to the first
7
+ # provider that serves its id, asked last-registered first, so a
8
+ # suite's own transport beats the defaults and OpenRouter, which claims
9
+ # everything, is always last. A `direct:` contender goes to the
10
+ # provider it names, and a direct provider is never asked about an id.
11
+ class Registry
12
+ include Enumerable
13
+
14
+ # @return [Registry] TypeSafe, then OpenRouter for everything else
15
+ def self.default = new([OpenRouter.new, TypeSafe.new])
16
+
17
+ # @param providers [Array<Base>] the last one is asked first
18
+ def initialize(providers = [])
19
+ @providers = providers.dup
20
+ end
21
+
22
+ # Registering a provider under a name already taken replaces it, so a
23
+ # helper and a required file that both register the same provider
24
+ # leave one, not two that would be asked twice.
25
+ #
26
+ # @param provider [Base]
27
+ # @return [Base] the provider
28
+ def register(provider)
29
+ @providers.reject! { it.name == provider.name }
30
+ @providers << provider
31
+ provider
32
+ end
33
+
34
+ def each(&) = @providers.reverse.each(&)
35
+
36
+ # @param target [Contender, String] a contender, or a `vendors:` model id
37
+ # @return [Base]
38
+ # @raise [Error] when no provider serves it
39
+ def for(target)
40
+ return direct(target.provider) if target.is_a?(Contender) && target.direct?
41
+
42
+ model_id = target.is_a?(Contender) ? target.model : target
43
+ find { !it.direct? && it.serves?(model_id) } ||
44
+ raise(Error, "no provider serves #{model_id.inspect}; have #{reject(&:direct?).map(&:name).join(", ")}")
45
+ end
46
+
47
+ # @param name [Symbol, String] a key under `direct:`
48
+ # @return [Base]
49
+ # @raise [Error] when no direct provider has the name
50
+ def direct(name)
51
+ find { it.direct? && it.name == name.to_s } ||
52
+ raise(Error, "no direct provider #{name.to_s.inspect}; have #{direct_names.join(", ")}")
53
+ end
54
+
55
+ # @return [Array<String>] the names `direct:` may use
56
+ def direct_names = select(&:direct?).map(&:name)
57
+
58
+ # @param contenders [Array<Contender>]
59
+ # @return [Array<Base>] each provider the contenders use, once
60
+ def used_by(contenders) = contenders.map { self.for(it) }.uniq
61
+
62
+ # @param contenders [Array<Contender>]
63
+ # @return [Array<String>] the API keys the contenders' providers need
64
+ def keys_for(contenders) = used_by(contenders).flat_map(&:env_keys).uniq
65
+
66
+ # @param contender [Contender]
67
+ # @return [Model]
68
+ def model_for(contender) = self.for(contender).model_for(contender)
69
+
70
+ # One refused contender: who, and why.
71
+ Refusal = Data.define(:contender, :reason) do
72
+ def to_s = "#{contender.model} refused: #{reason}"
73
+ end
74
+
75
+ # Splits the contenders into those whose provider produces a request
76
+ # type the suite declares and those it does not. A refused contender
77
+ # is named, never run, and never scored 0: a model the suite cannot
78
+ # even ask is not a model that answered wrongly.
79
+ #
80
+ # @param contenders [Array<Contender>]
81
+ # @param request_types [Array<Symbol>] what the suite's config declares
82
+ # @return [Array(Array<Contender>, Array<Refusal>)] admitted, refused
83
+ def admit(contenders, request_types:)
84
+ admitted, refused = contenders.partition { request_types.include?(self.for(it).request_type) }
85
+ refusals = refused.map do |contender|
86
+ needs = self.for(contender).request_type
87
+ Refusal.new(contender: contender, reason: "needs #{needs}; this suite produces #{request_types.join(", ")}")
88
+ end
89
+ [admitted, refusals]
90
+ end
91
+
92
+ # @return [Array<String>] every effort any provider accepts, for the flag's help
93
+ def efforts = flat_map(&:efforts).uniq
94
+
95
+ # Asks each provider in use for its catalog, and names every model it
96
+ # does not list.
97
+ #
98
+ # @param contenders [Array<Contender>]
99
+ # @return [void]
100
+ # @raise [Error] naming every unknown model
101
+ def verify!(contenders)
102
+ unknown = contenders.uniq(&:key).group_by { self.for(it) }.flat_map { |provider, list| provider.unknown(list.map(&:model)) }
103
+ return if unknown.empty?
104
+
105
+ raise Error,
106
+ "unknown model#{"s" if unknown.size > 1} #{unknown.join(", ")}: not listed by its provider; " \
107
+ "check the id in .agent-eval.config.yml or -m"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ module Providers
6
+ # TypeSafe's own API, which serves Jev as typed questions (noul, choice,
7
+ # score) rather than chat: request type `typesafe`, reached only through
8
+ # a `direct: typesafe:` entry, whose ids read "typesafe/<name>". Jev on
9
+ # OpenRouter, `typesafe/jev-router` under `vendors:`, is a chat model
10
+ # and goes through {OpenRouter}; the contender carries which is meant.
11
+ # The gem knows this API's model list, key and pricing, enough for
12
+ # `verify` and the cost column; the request shape waits for the first
13
+ # suite that declares `request_types: [typesafe]`.
14
+ class TypeSafe < Base
15
+ PREFIX = "typesafe/"
16
+ URL = "https://api.typesafe.ai/v1/models"
17
+ KEY = "TYPESAFE_API_KEY"
18
+
19
+ # TypeSafe bills input tokens only: $0.042 per million for jev-latest
20
+ # and jev-preview (docs.typesafe.ai/models, read 2026-10-14). Output is free.
21
+ USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
22
+
23
+ def name = "typesafe"
24
+ def request_type = :typesafe
25
+ def request_type_description = "typed questions: noul, choice, score"
26
+ def direct? = true
27
+ def prefix = PREFIX
28
+ def env_keys = [KEY]
29
+
30
+ # Jev takes no reasoning effort.
31
+ def efforts = []
32
+
33
+ # @return [Array<String>] the models, as "typesafe/<name>"
34
+ def catalog
35
+ Catalog.fetch(URL, "Authorization" => "Bearer #{ENV.fetch(KEY, nil)}").fetch("models").map { "#{PREFIX}#{it.fetch("name")}" }
36
+ end
37
+
38
+ # @param input_tokens [Integer]
39
+ # @return [Usage] with the cost at TypeSafe's input rate
40
+ def usage(input_tokens:, output_tokens: 0)
41
+ Usage.new(input_tokens: input_tokens, output_tokens: output_tokens, cost_usd: input_tokens * USD_PER_INPUT_TOKEN)
42
+ end
43
+
44
+ # @raise [Error] always: there is no typesafe consumer yet
45
+ def complete(model, system:, user:, json: true) # rubocop:disable Lint/UnusedMethodArgument
46
+ raise Error,
47
+ "#{model.id}: no typesafe consumer yet; the typed request shape is defined by the first suite " \
48
+ "that declares request_types: [typesafe]"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # What an {Adapter} returns for one case. `error` is the model's failure
6
+ # (it did not answer the question: no JSON, a refusal) and scores 0; a
7
+ # transport failure is not the model's and is raised instead, so it is
8
+ # counted as an error of the run rather than a wrong answer.
9
+ Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
10
+ def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
11
+ super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
12
+ end
13
+
14
+ # @return [Boolean] whether the model failed to answer
15
+ def error? = !error.nil?
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # One case, run once: what came back and what it scored. `reply` is nil
6
+ # when the adapter raised, and `error` then holds the message.
7
+ RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
8
+ def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
9
+
10
+ # @return [Boolean] whether the adapter raised rather than answered
11
+ def failed? = !error.nil?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv"
4
+
5
+ module Agent
6
+ module Evals
7
+ # One-time process setup: secrets from the suite's env files, a check that
8
+ # every key and gem the run needs is there, the suite's own hooks, and
9
+ # the providers' clients.
10
+ class Runtime
11
+ attr_reader :env_files, :hooks, :app_name
12
+
13
+ # @param env_files [Array<String>] absolute paths; those that exist load
14
+ # @param hooks [Array<Proc>] the suite's `before_run` blocks
15
+ # @param app_name [String] what a provider tells its API the caller is
16
+ def initialize(env_files: [], hooks: [], app_name: "agent-evals")
17
+ @env_files = env_files
18
+ @hooks = hooks
19
+ @app_name = app_name
20
+ end
21
+
22
+ # Loads the env files that exist, without overriding what is already
23
+ # set, so an exported variable beats the file.
24
+ #
25
+ # @return [void]
26
+ def load_env
27
+ files = env_files.select { File.exist?(it) }
28
+ Dotenv.load(*files) unless files.empty?
29
+ end
30
+
31
+ # Fails before anything is called when a key the run needs is neither
32
+ # exported nor in an env file.
33
+ #
34
+ # @param names [Array<String>]
35
+ # @return [void]
36
+ # @raise [Error] naming every missing variable
37
+ def require_env!(names)
38
+ missing = names.uniq.select { ENV[it].to_s.strip.empty? }
39
+ return if missing.empty?
40
+
41
+ found = env_files.select { File.exist?(it) }
42
+ where = if found.empty?
43
+ env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
44
+ else
45
+ "checked the environment, then #{found.join(", ")}"
46
+ end
47
+ raise Error,
48
+ "missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
49
+ "an exported blank value wins over the file"
50
+ end
51
+
52
+ # @param providers [Array<Providers::Base>] the providers the run's models use
53
+ # @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
54
+ # @return [void]
55
+ # @raise [Error] when a needed variable or gem is missing
56
+ def setup!(providers: [], tracker: nil)
57
+ load_env
58
+ require_env!(providers.flat_map(&:env_keys))
59
+ tracker&.requirements!(ENV)
60
+ hooks.each(&:call) # after load_env: a hook usually re-reads ENV
61
+ providers.each { it.setup!(app_name: app_name) }
62
+ end
63
+
64
+ # Opens a URL in the desktop browser.
65
+ #
66
+ # @param url [String]
67
+ # @return [Boolean] whether the opener ran
68
+ def browse(url)
69
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
70
+ system(opener, url, out: File::NULL, err: File::NULL) || false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Evals
5
+ # Checks a Hash of answers against a field map, for scorers and verifiers
6
+ # whose expected values are typed and closed. Nothing here is specific to
7
+ # what the fields mean.
8
+ #
9
+ # The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
10
+ # {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
11
+ # nullable field accepts nil (the text never answered it).
12
+ class SchemaValidator
13
+ TYPES = %i[boolean integer number string enum multi_enum].freeze
14
+
15
+ attr_reader :fields
16
+
17
+ # @param fields [Hash{String, Symbol => Hash}]
18
+ # @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
19
+ def initialize(fields)
20
+ @fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
21
+ end
22
+
23
+ # @return [Array<String>] the field names
24
+ def names = fields.keys
25
+
26
+ # @param values [Hash] String or Symbol keys
27
+ # @return [Array<String>] every problem found; empty when the values fit the map
28
+ def problems(values)
29
+ return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
30
+
31
+ actual = values.transform_keys(&:to_s)
32
+ missing = names - actual.keys
33
+ extra = actual.keys - names
34
+ problems = []
35
+ problems << "missing #{missing.join(", ")}" if missing.any?
36
+ problems << "unknown fields #{extra.join(", ")}" if extra.any?
37
+ fields.each do |name, spec|
38
+ next unless actual.key?(name)
39
+
40
+ problem = value_problem(spec, actual[name])
41
+ problems << "#{name}: #{problem}" if problem
42
+ end
43
+ problems
44
+ end
45
+
46
+ # @param values [Hash]
47
+ # @return [Boolean]
48
+ def valid?(values) = problems(values).empty?
49
+
50
+ # @param spec [Hash] one field's normalized spec
51
+ # @param value [Object]
52
+ # @return [String, nil] what is wrong with the value, or nil
53
+ def value_problem(spec, value)
54
+ return (spec[:nullable] ? nil : "is null") if value.nil?
55
+
56
+ case spec[:type]
57
+ when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
58
+ when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
59
+ when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
60
+ when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
61
+ when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
62
+ when :multi_enum then multi_enum_problem(spec, value)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def multi_enum_problem(spec, value)
69
+ return "#{value.inspect} is not an array" unless value.is_a?(Array)
70
+
71
+ stray = value - spec[:enum]
72
+ "#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
73
+ end
74
+
75
+ def normalize(name, spec)
76
+ spec = spec.transform_keys(&:to_sym)
77
+ type = spec.fetch(:type).to_sym
78
+ raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
79
+
80
+ { type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
81
+ end
82
+ end
83
+ end
84
+ end