activeagent 1.9.6 → 1.9.7

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by an explicit `require "active_agent/evals/rspec"` — never by
4
+ # `require "active_agent/evals"` — so a host on another test framework never
5
+ # sees it. It needs nothing from rspec: the matcher speaks RSpec's matcher
6
+ # protocol on its own and loads with rspec absent.
7
+ require_relative "../evals"
8
+
9
+ module ActiveAgent
10
+ module Evals
11
+ # `expect(report).to meet_its_pass_gates`: an RSpec matcher over
12
+ # GateCheck, failing with its message, as TestHelper's
13
+ # `assert_meets_pass_gates` does. Include it beside TestHelper where the
14
+ # evals are set up, tagged and run only when EVALS is on:
15
+ #
16
+ # # spec/rails_helper.rb
17
+ # require "active_agent/evals/test_helper"
18
+ # require "active_agent/evals/rspec"
19
+ #
20
+ # RSpec.configure do |config|
21
+ # config.expect_with :rspec, :minitest
22
+ # config.define_derived_metadata(file_path: %r{/spec/evals/}) { |metadata| metadata[:eval] = true }
23
+ # config.include ActiveAgent::Evals::TestHelper, eval: true
24
+ # config.include ActiveAgent::Evals::RSpec, eval: true
25
+ # config.filter_run_excluding eval: true unless ActiveAgent::Evals::TestHelper.enabled?
26
+ # end
27
+ #
28
+ # # spec/evals/support_desk_spec.rb
29
+ # RSpec.describe "the support desk suite" do
30
+ # it "clears its pass gates" do
31
+ # report = run_eval_suite("config/evals/support_desk.yml") { |scenario, _spec| SupportAgent.with(question: scenario.prompt).help }
32
+ #
33
+ # expect(report).to meet_its_pass_gates
34
+ # end
35
+ # end
36
+ module RSpec
37
+ # The matcher: every gate of the report met under `thresholds`, or
38
+ # without them under the report's own bars, else the defaults.
39
+ def meet_its_pass_gates(thresholds = nil)
40
+ MeetItsPassGates.new(thresholds)
41
+ end
42
+
43
+ # The matcher object, speaking RSpec's matcher protocol (`matches?`,
44
+ # the failure messages and `description`) without rspec loaded.
45
+ class MeetItsPassGates
46
+ def initialize(thresholds = nil)
47
+ @thresholds = thresholds
48
+ end
49
+
50
+ # Checks `report` and keeps the check for the failure messages.
51
+ def matches?(report)
52
+ @check = GateCheck.new(report, @thresholds)
53
+ @check.met?
54
+ end
55
+
56
+ # GateCheck#message: the unmet gates and the failures to read.
57
+ def failure_message
58
+ @check.message
59
+ end
60
+
61
+ # For `expect(report).not_to meet_its_pass_gates`: every gate, each
62
+ # met, as Gate#to_s.
63
+ def failure_message_when_negated
64
+ [ "expected the report not to meet its pass gates, but every gate was met:", *@check.gates.map(&:to_s) ].join("\n")
65
+ end
66
+
67
+ # The generated example description: "meet its pass gates", or
68
+ # "meet the given pass gates" when thresholds were passed.
69
+ def description
70
+ @thresholds ? "meet the given pass gates" : "meet its pass gates"
71
+ end
72
+
73
+ # RSpec shows no diff: the failure message already lists the unmet
74
+ # gates.
75
+ def diffable?
76
+ false
77
+ end
78
+
79
+ # The matcher takes a report, not a block, so RSpec refuses
80
+ # `expect { ... }.to meet_its_pass_gates`.
81
+ def supports_block_expectations?
82
+ false
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -55,7 +55,13 @@ module ActiveAgent
55
55
  # `chat_options` go to `chat` itself, so they must be keywords
56
56
  # `RubyLLM::Chat.new` takes (`protocol:` on RubyLLM 2.x); anything else
57
57
  # raises ArgumentError here rather than failing every judge call
58
- # later. Configure the chat through `temperature:` or `configure:`:
58
+ # later. With `verify: true`, a missing key fails here too: the judge's
59
+ # label, model and provider go through `verify!` with `context` and
60
+ # `providers`, so a judge whose provider has no key, or is not one of
61
+ # the providers a host has keys for, refuses when it is built rather
62
+ # than at its first grade, after the agent has already been replayed;
63
+ # the default stays lazy. Configure the chat through `temperature:` or
64
+ # `configure:`:
59
65
  #
60
66
  # ActiveAgent::Evals::RubyLLM.judge(label: "judge", model: "gpt-5-mini", provider: :openai,
61
67
  # temperature: 0,
@@ -74,12 +80,23 @@ module ActiveAgent
74
80
  # `{ "kind", "model", "input_tokens", "output_tokens", "cost" }`, for a
75
81
  # host that meters its judge's spend; an error it raises is logged,
76
82
  # not propagated, so bookkeeping never costs a grade
83
+ # @param verify [Boolean] run `verify!` on the judge's own model with
84
+ # `context` when the judge is built, so a provider with no key
85
+ # refuses here rather than at the first grade
86
+ # @param providers [Array<String, Symbol>, nil] with `verify: true`, the
87
+ # providers a host has keys for, handed to `verify!` as its
88
+ # `providers:`; given without `verify: true` it raises ArgumentError
77
89
  # @param chat_options [Hash] other `chat` keywords, such as `protocol:`
78
90
  # @return [Judge]
79
- # @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take
91
+ # @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take,
92
+ # or `providers:` is given without `verify: true`
93
+ # @raise [::RubyLLM::ConfigurationError] with `verify: true`, when the judge's provider is not
94
+ # among `providers` or `context` cannot build the judge's chat
80
95
  def judge(label:, model:, provider:, context: ::RubyLLM, correlation: nil, assume_model_exists: true,
81
- temperature: nil, configure: nil, on_usage: nil, **chat_options)
96
+ temperature: nil, configure: nil, on_usage: nil, verify: false, providers: nil, **chat_options)
82
97
  check_chat_options!(chat_options)
98
+ raise ArgumentError, "providers: is checked only with verify: true" if providers && !verify
99
+ verify!(ModelSpec.new(label: label, provider: provider, model: model), context: context, providers: providers) if verify
83
100
 
84
101
  Judge.new(label: label) do |instructions:, prompt:, kind:|
85
102
  complete = lambda do
@@ -96,6 +113,65 @@ module ActiveAgent
96
113
  end
97
114
  end
98
115
 
116
+ # Checks, before a run spends anything, that each model can be built
117
+ # on `context`: its provider is one RubyLLM registered, is one of
118
+ # `providers` when those are given, and is configured, which
119
+ # `context.chat(model:, provider:, assume_model_exists: true)` settles
120
+ # while it instantiates the provider. Nothing is sent over the
121
+ # network: RubyLLM resolves the model locally. The specs are checked
122
+ # in order, and the first that cannot run raises, naming what to
123
+ # change: the provider's configuration requirements that `context`
124
+ # leaves unset, as environment variable names, or RubyLLM's own
125
+ # message when those are all set and it refused for another reason,
126
+ # such as a credential it checks beyond them or a deployment it
127
+ # cannot resolve:
128
+ #
129
+ # context = RubyLLM.context { |config| config.openai_api_key = ENV["OPENAI_API_KEY"] }
130
+ # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini claude-sonnet-5], default_provider: "openai")
131
+ #
132
+ # ActiveAgent::Evals::RubyLLM.verify!(models, context: context)
133
+ # # raises RubyLLM::ConfigurationError: claude-sonnet-5 runs on anthropic, which has no API key. Set ANTHROPIC_API_KEY.
134
+ #
135
+ # @param models [Array<ModelSpec>, ModelSpec] the run's models, or anything
136
+ # answering `label`, `provider` and `model`; a single spec is wrapped
137
+ # @param context [#chat] where `chat` is called; defaults to `::RubyLLM`.
138
+ # When it also answers `config`, as `::RubyLLM` and a `RubyLLM.context`
139
+ # do, a refusal names only the requirements that configuration leaves
140
+ # unset
141
+ # @param providers [Array<String, Symbol>, String, Symbol, nil] the
142
+ # providers the host has credentials for; a model on any other provider
143
+ # is refused before its chat is built
144
+ # @return [Array<ModelSpec>] the given models
145
+ # @raise [::RubyLLM::ConfigurationError] for the first model whose provider
146
+ # RubyLLM does not know, is outside `providers`, or cannot be built on
147
+ # `context`; in the last case RubyLLM's own error is the cause, and the
148
+ # message names what to set or carries RubyLLM's own
149
+ def verify!(models, context: ::RubyLLM, providers: nil)
150
+ specs = models.is_a?(Array) ? models : [ models ]
151
+ configured = Array(providers).map(&:to_s) if providers
152
+
153
+ specs.each do |spec|
154
+ provider = spec.provider.to_s
155
+ unless ::RubyLLM::Provider.providers.key?(provider.to_sym)
156
+ raise ::RubyLLM::ConfigurationError,
157
+ "#{spec.label} runs on #{provider}, which is not a RubyLLM provider; " \
158
+ "RubyLLM knows #{::RubyLLM::Provider.providers.keys.map(&:to_s).sort.join(', ')}"
159
+ end
160
+ if configured && !configured.include?(provider)
161
+ configured_for = configured.empty? ? "no provider has credentials configured" : "credentials are configured for #{configured.join(', ')}"
162
+ raise ::RubyLLM::ConfigurationError, "#{spec.label} runs on #{provider}; #{configured_for}"
163
+ end
164
+
165
+ begin
166
+ context.chat(model: spec.model, provider: provider.to_sym, assume_model_exists: true)
167
+ rescue ::RubyLLM::ConfigurationError => e
168
+ raise ::RubyLLM::ConfigurationError, refusal(spec, provider, context, e)
169
+ end
170
+ end
171
+
172
+ specs
173
+ end
174
+
99
175
  # Builds a Replay from the ordered messages of a RubyLLM conversation:
100
176
  # `acts_as_chat` message records of either RubyLLM generation (read
101
177
  # through their own `to_llm`), `RubyLLM::Message` values, or anything
@@ -167,6 +243,32 @@ module ActiveAgent
167
243
  "Pass temperature: to judge, or configure the chat with configure: ->(chat) { chat.with_... }."
168
244
  end
169
245
 
246
+ # The message for a model whose provider refused to be built: the
247
+ # requirement names `context` leaves unset, as environment variable
248
+ # names, called an API key only when each of them is one; RubyLLM's
249
+ # own message when nothing is left to set.
250
+ def refusal(spec, provider, context, error)
251
+ names = unset_requirements(provider, context).map { |name| name.to_s.upcase }
252
+ return "#{spec.label} runs on #{provider}: #{error.message.strip}" if names.empty?
253
+
254
+ state = names.all? { |name| name.end_with?("_API_KEY") } ? "has no API key" : "is not configured"
255
+ "#{spec.label} runs on #{provider}, which #{state}. Set #{names.join(' and ')}."
256
+ end
257
+
258
+ # The provider's configuration requirements that `context` leaves
259
+ # unset: all of them when the context exposes no configuration, and
260
+ # none on a release whose providers do not list them.
261
+ def unset_requirements(provider, context)
262
+ provider_class = ::RubyLLM::Provider.providers[provider.to_sym]
263
+ return [] unless provider_class.respond_to?(:configuration_requirements)
264
+
265
+ requirements = provider_class.configuration_requirements
266
+ return requirements unless context.respond_to?(:config)
267
+
268
+ config = context.config
269
+ requirements.reject { |name| config.respond_to?(name) && config.public_send(name) }
270
+ end
271
+
170
272
  def report_usage(callback, kind, model, response)
171
273
  return unless callback
172
274
 
@@ -30,8 +30,9 @@ module ActiveAgent
30
30
  # tool to add, or how to change the instructions.
31
31
  DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called ungrounded_answer low_quality missing_content].freeze
32
32
  DEFAULT_JUDGE_LIMIT = 25
33
+ DEFAULT_TRIALS = 1
33
34
 
34
- attr_reader :scenarios, :models, :criteria, :judge, :threshold
35
+ attr_reader :scenarios, :models, :criteria, :judge, :threshold, :pass_thresholds, :judge_failed_expectations, :trials
35
36
 
36
37
  # @param scenarios [Array<Scenario>]
37
38
  # @param models [Array<ModelSpec>]
@@ -43,6 +44,12 @@ module ActiveAgent
43
44
  # @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
44
45
  # @param instructions [String, nil] the agent's instructions, for the judge
45
46
  # @param agent_name [String] how recommendations refer to the agent
47
+ # @param threshold [Float, nil] the score an answer must reach to pass;
48
+ # an explicit value wins over the `pass_thresholds` bar, and without
49
+ # either it is PASS_THRESHOLD
50
+ # @param pass_thresholds [PassThresholds, nil] the bars the suite
51
+ # declared (Suite#pass_thresholds): its threshold applies when none
52
+ # is given here, and the Report gates each group by it (Report#gates)
46
53
  # @param on_result [#call, nil] called with each Result as it lands
47
54
  # @param around_evaluation [#call, nil] called with (scenario, spec) and a
48
55
  # block that returns the Result. Establishes context for replay, scoring
@@ -58,20 +65,38 @@ module ActiveAgent
58
65
  # after `judge_limit`; false skips it. A caller that scores each
59
66
  # scenario in its own Runner (a background job per scenario) shares one
60
67
  # run-wide limit this way, since `judge_limit` counts per instance.
68
+ # @param judge_failed_expectations [Boolean] whether the judge grades a
69
+ # replay that missed one of its scenario's expectations (an expected
70
+ # tool not called, forbidden or missing content, a failed tool call).
71
+ # false skips its task grade, its llm_judge criteria and its judged
72
+ # recommendation, since it fails whatever the judge says, and the
73
+ # calls saved do not count against `judge_limit`. The default grades
74
+ # every answered replay.
75
+ # @param trials [Integer] how many times each scenario is replayed under
76
+ # each model, 1 or more. Every replay is a Result of its own under the
77
+ # scenario's key, told apart by Result#trial, so a flaky scenario
78
+ # reads as a pass share rather than one pass or fail.
61
79
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
62
- instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
80
+ instructions: nil, agent_name: "The agent", threshold: nil, pass_thresholds: nil,
63
81
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
64
- around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
82
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil,
83
+ judge_failed_expectations: true, trials: DEFAULT_TRIALS)
84
+ unless trials.is_a?(Integer) && trials >= 1
85
+ raise ArgumentError, "trials must be an Integer of 1 or more, got #{trials.inspect}"
86
+ end
87
+
65
88
  @scenarios = scenarios
66
89
  @models = models
67
90
  @replay = replay
91
+ @trials = trials
68
92
  @criteria = criteria
69
93
  @judge = judge
70
94
  @judge_task = judge_task
71
95
  @available_tools = normalize_tools(available_tools)
72
96
  @instructions = instructions
73
97
  @agent_name = agent_name
74
- @threshold = threshold
98
+ @threshold = threshold || pass_thresholds&.threshold || PASS_THRESHOLD
99
+ @pass_thresholds = pass_thresholds
75
100
  @refine_faults = refine_faults
76
101
  @judge_limit = judge_limit
77
102
  @on_result = on_result
@@ -80,27 +105,33 @@ module ActiveAgent
80
105
  @metadata = metadata
81
106
  @release = release
82
107
  @refine_gate = refine_gate
108
+ @judge_failed_expectations = judge_failed_expectations
83
109
  @judge_calls = 0
84
- @scorer = Scorer.new(criteria: criteria, judge: judge)
110
+ @scorer = Scorer.new(criteria: criteria, judge: judge, judge_failed_expectations: judge_failed_expectations)
85
111
  end
86
112
 
113
+ # Replays every scenario under every model `trials` times: scenario by
114
+ # scenario, model by model, trial 1 to `trials`.
87
115
  def call
88
116
  results = @scenarios.flat_map do |scenario|
89
- @models.map do |spec|
90
- evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
117
+ @models.flat_map do |spec|
118
+ (1..@trials).map do |trial|
119
+ evaluate_with_context(scenario, spec, trial: trial).tap { |result| @on_result&.call(result) }
120
+ end
91
121
  end
92
122
  end
93
123
 
94
124
  Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
95
- threshold: @threshold, metadata: @metadata, release: @release)
125
+ threshold: @threshold, metadata: @metadata, release: @release, pass_thresholds: @pass_thresholds)
96
126
  end
97
127
 
98
128
  # Scores and diagnoses one replay. Public so a caller that has already run
99
129
  # the agent (a background job per scenario, say) can score without the loop.
100
- def evaluate(scenario, spec, replay = nil)
130
+ # `trial` numbers the Result when the caller replays a scenario more than once.
131
+ def evaluate(scenario, spec, replay = nil, trial: 1)
101
132
  replay ||= run_replay(scenario, spec)
102
133
  scores = @scorer.score(scenario, replay)
103
- if judge_task? && replay.answer.present?
134
+ if judge_task? && replay.answer.present? && judge_answer?(scores)
104
135
  scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
105
136
  end
106
137
  score = Scorer.mean(scores)
@@ -110,7 +141,7 @@ module ActiveAgent
110
141
  judge_keys: llm_judge_keys)
111
142
  diagnosis ||= unavailable_judge_diagnosis(scores)
112
143
  diagnosis_hash = diagnosis&.to_h
113
- refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
144
+ refine!(diagnosis_hash, scenario, replay, diagnosis, scores) if diagnosis_hash
114
145
 
115
146
  Result.new(
116
147
  scenario: scenario,
@@ -119,16 +150,17 @@ module ActiveAgent
119
150
  scores: scores,
120
151
  score: score,
121
152
  status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
122
- diagnosis: diagnosis_hash
153
+ diagnosis: diagnosis_hash,
154
+ trial: trial
123
155
  )
124
156
  end
125
157
 
126
158
  private
127
159
 
128
- def evaluate_with_context(scenario, spec)
129
- return evaluate(scenario, spec) unless @around_evaluation
160
+ def evaluate_with_context(scenario, spec, trial:)
161
+ return evaluate(scenario, spec, trial: trial) unless @around_evaluation
130
162
 
131
- result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
163
+ result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec, trial: trial) }
132
164
  # A wrapper written the natural way — do something, yield, do something
133
165
  # after — returns that last value rather than the Result. Left alone it
134
166
  # reaches on_result and the Report, and fails somewhere far from the
@@ -144,6 +176,16 @@ module ActiveAgent
144
176
  @judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
145
177
  end
146
178
 
179
+ # Whether the judge is asked about a replay with these scores: always by
180
+ # default, or only when it met every expectation of its scenario. A
181
+ # replay that did not already carries one of the faults Diagnosis
182
+ # assigns ahead of low_quality (tool_error, missing_capability,
183
+ # expected_tool_not_called, ungrounded_answer, forbidden_content or
184
+ # missing_content), so its status is failed whatever the judge would say.
185
+ def judge_answer?(scores)
186
+ @judge_failed_expectations || Scorer.expectations_met?(scores)
187
+ end
188
+
147
189
  # The keys in `scores` a judge graded, so a low grade is not averaged
148
190
  # away against rule checks.
149
191
  def llm_judge_keys
@@ -189,8 +231,9 @@ module ActiveAgent
189
231
  end
190
232
  end
191
233
 
192
- def refine!(diagnosis, scenario, replay, result)
234
+ def refine!(diagnosis, scenario, replay, result, scores)
193
235
  return unless @judge && @refine_faults.include?(result.fault)
236
+ return unless judge_answer?(scores)
194
237
  return if @judge_calls >= @judge_limit
195
238
  return if @refine_gate && !@refine_gate.call
196
239
 
@@ -24,16 +24,21 @@ module ActiveAgent
24
24
  # @return [Array<String>] labels a catalog files the scenario under ("billing")
25
25
  # @!attribute params
26
26
  # @return [Hash] free-form parameters a replay may read; kept as written
27
+ # @!attribute metadata
28
+ # @return [Hash, nil] opaque extension fields from a catalog document
27
29
  Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
28
- :forbidden_patterns, :notes, :production_only, :position, :tags, :params,
30
+ :forbidden_patterns, :notes, :production_only, :position, :tags, :params, :metadata,
29
31
  keyword_init: true) do
30
32
  # Builds a scenario from a hash — ScenarioParser output, a suite entry, or
31
33
  # a persisted record's attributes. Expectations are read from an
32
34
  # `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
33
35
  # or from those keys at the top level.
34
- def self.from_hash(attributes, group: nil, group_name: nil)
36
+ # Pass +metadata+ when extension fields have been separated from a
37
+ # document; its literal `metadata` key may itself be an extension.
38
+ def self.from_hash(attributes, group: nil, group_name: nil, metadata: nil)
35
39
  attrs = attributes.to_h.deep_stringify_keys
36
40
  expect = (attrs["expectations"] || attrs["expect"] || {}).to_h.stringify_keys
41
+ metadata = attrs["metadata"] if metadata.nil? && attrs["metadata"].is_a?(Hash)
37
42
 
38
43
  new(
39
44
  key: attrs["key"].to_s,
@@ -47,7 +52,8 @@ module ActiveAgent
47
52
  production_only: attrs["production_only"] == true,
48
53
  position: attrs["position"],
49
54
  tags: list(attrs["tags"]),
50
- params: (attrs["params"] || {}).to_h.deep_stringify_keys
55
+ params: (attrs["params"] || {}).to_h.deep_stringify_keys,
56
+ metadata: (metadata || {}).to_h.deep_stringify_keys
51
57
  )
52
58
  end
53
59
 
@@ -68,8 +74,10 @@ module ActiveAgent
68
74
  end
69
75
 
70
76
  def to_h
71
- super.compact.reject { |field, value| %i[tags params].include?(field) && value.blank? }
77
+ super.compact.reject { |field, value| %i[tags params metadata].include?(field) && value.blank? }
72
78
  end
73
79
  end
80
+
81
+ Scenario::DOCUMENT_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
74
82
  end
75
83
  end
@@ -138,12 +138,20 @@ module ActiveAgent
138
138
  end
139
139
  end
140
140
 
141
- Suite.new([ document ]).all_scenarios.map do |item|
141
+ suite_from(document).all_scenarios.map do |item|
142
142
  scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
143
143
  notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
144
144
  end
145
145
  end
146
146
 
147
+ # The document as a Suite; a `criteria` block the Suite refuses is a
148
+ # ParseError, as every other fault in a pasted document is.
149
+ def suite_from(document)
150
+ Suite.new([ document ])
151
+ rescue ArgumentError => e
152
+ raise ParseError, e.message
153
+ end
154
+
147
155
  def scenario_from_hash(entry)
148
156
  entry = entry.stringify_keys
149
157
  prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
@@ -4,7 +4,8 @@ module ActiveAgent
4
4
  module Evals
5
5
  # Scores one Replay against an evaluation's criteria and the scenario's
6
6
  # expectations. Returns `criterion key => 0.0..1.0`, with nil for a
7
- # criterion that could not be scored (an llm_judge criterion with no judge).
7
+ # criterion that could not be scored (an llm_judge criterion with no judge,
8
+ # or one the Scorer did not ask because the expectations failed).
8
9
  #
9
10
  # Criteria are `{ "key", "type", "config" }` hashes:
10
11
  #
@@ -22,41 +23,37 @@ module ActiveAgent
22
23
  class Scorer
23
24
  RULE_CRITERION_TYPES = %w[response_present min_length max_latency_ms token_budget contains not_contains].freeze
24
25
  CRITERION_TYPES = (RULE_CRITERION_TYPES + %w[llm_judge]).freeze
26
+ # The keys `score` adds from the scenario's own expectations.
27
+ EXPECTATION_KEYS = %w[expected_tools expected_content forbidden_content tools_succeeded].freeze
25
28
 
26
- attr_reader :criteria, :judge
29
+ attr_reader :criteria, :judge, :judge_failed_expectations
27
30
 
28
- def initialize(criteria: [], judge: nil)
31
+ # @param criteria [Array<Hash>] the criteria every replay is scored on
32
+ # @param judge [Judge, nil] scores the llm_judge criteria
33
+ # @param judge_failed_expectations [Boolean] whether an llm_judge criterion
34
+ # is asked of a replay that missed one of its scenario's expectations;
35
+ # false leaves it nil, since the replay fails on the expectation anyway
36
+ def initialize(criteria: [], judge: nil, judge_failed_expectations: true)
29
37
  @criteria = Array(criteria).map { |criterion| criterion.to_h.deep_stringify_keys }
30
38
  @judge = judge
39
+ @judge_failed_expectations = judge_failed_expectations
31
40
  end
32
41
 
33
42
  def score(scenario, replay)
34
43
  scores = {}
35
44
  answer = replay.answer.to_s
45
+ expectations = expectation_scores(scenario, replay)
46
+ ask_judge = answer.present? && (@judge_failed_expectations || self.class.expectations_met?(expectations))
36
47
 
37
48
  @criteria.each do |criterion|
38
- scores[criterion["key"]] = answer.present? ? score_criterion(criterion, scenario, replay) : 0.0
49
+ scores[criterion["key"]] =
50
+ if answer.blank? then 0.0
51
+ elsif criterion["type"] == "llm_judge" && !ask_judge then nil
52
+ else score_criterion(criterion, scenario, replay)
53
+ end
39
54
  end
40
55
 
41
- if scenario.expected_tools.any?
42
- scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
43
- end
44
- if scenario.expected_patterns.any?
45
- hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
46
- scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
47
- end
48
- if scenario.forbidden_patterns.any?
49
- hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
50
- scores["forbidden_content"] = hit ? 0.0 : 1.0
51
- end
52
- # A tool that ran without erroring is evidence only when it is one the
53
- # scenario expected: a wrong tool that succeeded used to outscore
54
- # calling nothing at all.
55
- if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
56
- scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
57
- end
58
-
59
- scores
56
+ scores.merge(expectations)
60
57
  end
61
58
 
62
59
  # The mean of the scored criteria, or nil when nothing could be scored.
@@ -67,6 +64,12 @@ module ActiveAgent
67
64
  (scored.sum / scored.size).round(3)
68
65
  end
69
66
 
67
+ # Whether every expectation score in `scores` is a full 1.0. A scores
68
+ # hash with no expectation keys is met.
69
+ def self.expectations_met?(scores)
70
+ scores.slice(*EXPECTATION_KEYS).values.all? { |value| value.to_f >= 1.0 }
71
+ end
72
+
70
73
  # How long one pattern may take to match one answer. Patterns are
71
74
  # whatever the scenario's author typed, and a pathological one must not
72
75
  # stall the evaluation.
@@ -90,6 +93,33 @@ module ActiveAgent
90
93
 
91
94
  private
92
95
 
96
+ # The scores the scenario's own expectations add, in the order they appear
97
+ # after the criteria.
98
+ def expectation_scores(scenario, replay)
99
+ scores = {}
100
+ answer = replay.answer.to_s
101
+
102
+ if scenario.expected_tools.any?
103
+ scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
104
+ end
105
+ if scenario.expected_patterns.any?
106
+ hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
107
+ scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
108
+ end
109
+ if scenario.forbidden_patterns.any?
110
+ hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
111
+ scores["forbidden_content"] = hit ? 0.0 : 1.0
112
+ end
113
+ # A tool that ran without erroring is evidence only when it is one the
114
+ # scenario expected: a wrong tool that succeeded used to outscore
115
+ # calling nothing at all.
116
+ if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
117
+ scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
118
+ end
119
+
120
+ scores
121
+ end
122
+
93
123
  def score_criterion(criterion, scenario, replay)
94
124
  config = criterion["config"] || {}
95
125
  answer = replay.answer.to_s