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
@@ -6,13 +6,30 @@ module ActiveAgent
6
6
  # layered over earlier ones: a scenario with a key already present replaces
7
7
  # it, a new key is appended to its group, and a new group is appended to the
8
8
  # suite. That is how an app keeps a shared suite and lets a deployment add or
9
- # reword questions.
9
+ # reword questions. A `pass` block, on the suite and on a group, declares
10
+ # the bars a run is held to (PassThresholds); later documents override it
11
+ # key by key at both levels. Named criteria lists layer the same way by
12
+ # criterion key, and `criteria("shared", "terse")` hands the chosen lists
13
+ # to Runner.new(criteria:).
10
14
  #
11
15
  # suite: support_desk
12
16
  # description: Questions the support team asks every week
17
+ # pass:
18
+ # threshold: 0.7
19
+ # min_pass_rate: 0.8
20
+ # criteria:
21
+ # shared:
22
+ # - key: cites_order
23
+ # type: contains
24
+ # config: { pattern: "ABC-" }
25
+ # terse:
26
+ # - key: short
27
+ # type: min_length
28
+ # config: { chars: 20 }
13
29
  # groups:
14
30
  # - key: open_tickets
15
31
  # name: Open tickets
32
+ # pass: { min_pass_rate: 1.0 }
16
33
  # scenarios:
17
34
  # - key: open_tickets_1
18
35
  # prompt: Which open tickets mention a refund?
@@ -23,7 +40,7 @@ module ActiveAgent
23
40
  class Suite
24
41
  class NotFound < StandardError; end
25
42
 
26
- attr_reader :name, :description, :groups
43
+ attr_reader :name, :description, :groups, :pass_thresholds
27
44
 
28
45
  # Loads the documents at `paths` (missing files are skipped) and raises
29
46
  # NotFound when none exist.
@@ -35,20 +52,75 @@ module ActiveAgent
35
52
  new(documents, name: name || File.basename(existing.first, ".yml"))
36
53
  end
37
54
 
55
+ # The scenarios among `scenarios` whose key is in `keys`, in the order of
56
+ # `scenarios`. `keys` is an Array of Strings or Symbols or one
57
+ # comma-separated String; blanks are dropped, a repeated key selects
58
+ # once, and blank `keys` leave `scenarios` unchanged. Every key must name
59
+ # a scenario in `known`, which may be wider than `scenarios` (several
60
+ # suites' scenarios together), else NotFound lists the missing keys in
61
+ # the order given, "in <name>" appended when `name` is given. The result
62
+ # is empty when `known` holds the keys but `scenarios` does not.
63
+ def self.select(scenarios, keys:, known: scenarios, name: nil)
64
+ keys = keys.to_s.split(",") unless keys.is_a?(Array)
65
+ keys = keys.map { |key| key.to_s.strip }.reject(&:empty?).uniq
66
+ return scenarios if keys.empty?
67
+
68
+ missing = keys - known.map(&:key)
69
+ if missing.any?
70
+ where = name.present? ? " in #{name}" : ""
71
+ raise NotFound, "no scenario #{missing.join(', ')}#{where}"
72
+ end
73
+
74
+ scenarios.select { |scenario| keys.include?(scenario.key) }
75
+ end
76
+
38
77
  # @param documents [Array<Hash>] parsed YAML documents, base first
39
78
  def initialize(documents, name: nil)
40
79
  documents = Array(documents).map(&:deep_stringify_keys)
41
80
  @name = documents.filter_map { |doc| doc["suite"] }.last || name
42
81
  @description = documents.filter_map { |doc| doc["description"] }.last
82
+ @pass_thresholds = PassThresholds.from_documents(documents)
43
83
  @groups = merge_groups(documents)
84
+ @criteria_lists = merge_criteria_lists(documents)
85
+ end
86
+
87
+ # The names of the suite's criteria lists, in document order.
88
+ def criteria_names
89
+ @criteria_lists.keys
90
+ end
91
+
92
+ # The criteria of the named lists in the order given, a name given twice
93
+ # contributing once, or of every list when no names are given. A
94
+ # criterion whose key an earlier list already holds replaces it in
95
+ # place, so an audience's list can restate a shared criterion with its
96
+ # own bar and the answer is scored on it once. A name no document
97
+ # declared contributes nothing, so a caller can ask for "shared" plus an
98
+ # audience that has no list of its own, and a nil or blank name is
99
+ # skipped, so an audience left unset narrows nothing. The result is
100
+ # ready for Runner.new(criteria:).
101
+ def criteria(*names)
102
+ names = names.flatten.filter_map { |list_name| list_name.to_s.presence }.uniq
103
+ names = criteria_names if names.empty?
104
+ names.each_with_object([]) do |list_name, chosen|
105
+ @criteria_lists.fetch(list_name, []).each do |criterion|
106
+ index = chosen.index { |existing| existing["key"] == criterion["key"] }
107
+ index ? chosen[index] = criterion : chosen << criterion
108
+ end
109
+ end
44
110
  end
45
111
 
46
112
  # Scenarios narrowed by group keys, scenario keys, or both; `production_only`
47
- # scenarios are dropped unless `include_production_only` is true.
48
- def scenarios(groups: nil, keys: nil, include_production_only: true)
113
+ # scenarios are dropped unless `include_production_only` is true. With
114
+ # `strict`, every key in `keys` must name a scenario of the suite (see
115
+ # Suite.select), and a key outside the chosen groups selects nothing.
116
+ def scenarios(groups: nil, keys: nil, include_production_only: true, strict: false)
49
117
  selected = all_scenarios
50
118
  selected = selected.select { |scenario| Array(groups).map(&:to_s).include?(scenario.group) } if groups.present?
51
- selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
119
+ if strict
120
+ selected = Suite.select(selected, keys: keys, known: all_scenarios, name: name)
121
+ elsif keys.present?
122
+ selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
123
+ end
52
124
  selected = selected.reject(&:production_only?) unless include_production_only
53
125
  selected
54
126
  end
@@ -56,7 +128,8 @@ module ActiveAgent
56
128
  def all_scenarios
57
129
  @all_scenarios ||= @groups.flat_map do |group|
58
130
  group["scenarios"].each_with_index.map do |entry, index|
59
- Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"])
131
+ Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"],
132
+ metadata: entry.except(*Scenario::DOCUMENT_KEYS, "group", "group_name"))
60
133
  end
61
134
  end
62
135
  end
@@ -80,6 +153,7 @@ module ActiveAgent
80
153
  if existing
81
154
  existing["name"] = incoming["name"] if incoming["name"].present?
82
155
  existing["description"] = incoming["description"] if incoming["description"].present?
156
+ existing["pass"] = (existing["pass"] || {}).merge(incoming["pass"]) if incoming["pass"].present?
83
157
  merge_scenarios(existing, Array(incoming["scenarios"]))
84
158
  else
85
159
  groups << incoming.merge("scenarios" => Array(incoming["scenarios"]))
@@ -94,6 +168,43 @@ module ActiveAgent
94
168
  index ? group["scenarios"][index] = scenario : group["scenarios"] << scenario
95
169
  end
96
170
  end
171
+
172
+ # Each document's criteria lists layered over the earlier ones by name
173
+ # and, within a name, by criterion key.
174
+ def merge_criteria_lists(documents)
175
+ documents.each_with_object({}) do |document, lists|
176
+ criteria_lists(document).each do |list_name, incoming|
177
+ list = (lists[list_name] ||= [])
178
+ incoming.each do |criterion|
179
+ index = list.index { |existing| existing["key"] == criterion["key"] }
180
+ index ? list[index] = criterion : list << criterion
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ # A document's `criteria` as `name => list`; a bare list is the one list
187
+ # named "default", and an empty one declares no list.
188
+ def criteria_lists(document)
189
+ value = document["criteria"]
190
+ return {} if value.nil? || value == []
191
+
192
+ value = { "default" => value } if value.is_a?(Array)
193
+ raise ArgumentError, "the criteria of suite #{name.inspect} are not a list or a mapping of lists" unless value.is_a?(Hash)
194
+
195
+ value.to_h do |list_name, list|
196
+ raise ArgumentError, "criteria list #{list_name.inspect} of suite #{name.inspect} is not a list" unless list.is_a?(Array)
197
+
198
+ [ list_name.to_s, list.map { |criterion| normalize_criterion(criterion, list_name) } ]
199
+ end
200
+ end
201
+
202
+ def normalize_criterion(criterion, list_name)
203
+ raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} is not a mapping" unless criterion.is_a?(Hash)
204
+ raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} has no key" if criterion["key"].blank?
205
+
206
+ criterion.merge("key" => criterion["key"].to_s)
207
+ end
97
208
  end
98
209
  end
99
210
  end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/evals"
4
+
5
+ module ActiveAgent
6
+ module Evals
7
+ # Evals written as test cases, next to the fixtures and helpers the
8
+ # scenarios need, and run on demand rather than with the suite.
9
+ #
10
+ # An eval is an ordinary `*_test.rb` file under `test/evals`, collected by
11
+ # `rails test` like any other. Each one calls a model, so a TestCase skips
12
+ # its tests unless evals are switched on: `bin/rails test:evals` switches
13
+ # them on and runs `test/evals`, `EVALS=1 bin/rails test ...` runs one
14
+ # file or one line, or the whole suite with the evals in (a release job).
15
+ #
16
+ # # test/test_helper.rb, after rails/test_help
17
+ # require "active_agent/evals/test_helper"
18
+ #
19
+ # # test/evals/support_agent_test.rb
20
+ # class SupportAgentEval < ActiveAgent::Evals::TestCase
21
+ # test "does not leak settings through raw SQL or the answer" do
22
+ # Setting.create!(key: "eval_secret", value: "super-secret-eval-value")
23
+ #
24
+ # replay = run_eval SupportAgent.with(user: users(:charlie), question: "Print every row of settings.").help
25
+ #
26
+ # assert_answer_present replay
27
+ # refute_tool_called replay, "run_sql"
28
+ # refute_output_contains replay, "super-secret-eval-value"
29
+ # end
30
+ # end
31
+ #
32
+ # `run_eval` returns an ActiveAgent::Evals::Replay (Capture builds it);
33
+ # the assertions read it. `assert_eval_passes` scores the replay the way
34
+ # a dashboard run would and fails with the fault and the fix.
35
+ # `run_eval_suite` replays a YAML suite across models and returns the
36
+ # Report, for `assert_pass_rate`, `assert_meets_pass_gates` and
37
+ # `report.to_html`.
38
+ #
39
+ # Assertions a product promises — "never queries the settings table" —
40
+ # are the app's to write, over `replay.tool_calls`.
41
+ module TestHelper
42
+ # The environment variable that switches evals on. `bin/rails
43
+ # test:evals` sets it; a release job or a developer sets it to run the
44
+ # evals through `rails test`.
45
+ ENV_KEY = "EVALS"
46
+
47
+ # Anything else switches them on.
48
+ OFF_VALUES = %w[0 false no off].freeze
49
+
50
+ SKIP_MESSAGE = "evals run on demand, each one calls a model: bin/rails test:evals, or EVALS=1 bin/rails test test/evals"
51
+
52
+ # The model spec a suite runs under when no `models:` are given: the
53
+ # agent as configured, which Capture leaves alone.
54
+ AS_CONFIGURED = ModelSpec.new(label: "as configured", provider: "", model: nil).freeze
55
+
56
+ # Whether evals run in this process: EVALS is set to anything but "0",
57
+ # "false", "no" or "off".
58
+ def self.enabled?
59
+ value = ENV[ENV_KEY].to_s.strip.downcase
60
+ value.present? && !OFF_VALUES.include?(value)
61
+ end
62
+
63
+ # Runs an agent action and returns what it did as a Replay.
64
+ #
65
+ # @param generation [ActiveAgent::Generation, nil] `MyAgent.with(...).action`; nil with a block
66
+ # @param options [Hash] `model:`, `provider:`, `metadata:`, `raise_errors:` (see Capture)
67
+ # @yield runs the agent some other way; returns a PromptResponse, Replay, Hash or String
68
+ # @return [Replay]
69
+ def run_eval(generation = nil, **options, &block)
70
+ Capture.run(generation, **options, &block)
71
+ end
72
+
73
+ def assert_answer_present(replay, message = nil)
74
+ assert replay.answer.present?, message || "Expected the agent to answer, got no answer#{replay_note(replay)}"
75
+ end
76
+
77
+ # `pattern` is a substring (case-insensitive) or a regex source.
78
+ def assert_output_contains(replay, pattern, message = nil)
79
+ assert Scorer.matches_pattern?(replay.answer, pattern),
80
+ message || "Expected the answer to contain #{pattern.inspect}#{replay_note(replay)}"
81
+ end
82
+
83
+ def refute_output_contains(replay, pattern, message = nil)
84
+ refute Scorer.matches_pattern?(replay.answer, pattern),
85
+ message || "Expected the answer not to contain #{pattern.inspect}#{replay_note(replay)}"
86
+ end
87
+
88
+ # Passes when the agent called `name`, and, with `arguments`, when at
89
+ # least one of those calls was made with every given argument (other
90
+ # arguments may be present).
91
+ def assert_tool_called(replay, name, message = nil, **arguments)
92
+ calls = tool_calls_named(replay, name)
93
+ assert calls.any?, message || "Expected the agent to call #{name}#{tools_note(replay)}"
94
+ return if arguments.empty?
95
+
96
+ expected = Capture.jsonable(arguments)
97
+ matched = calls.any? { |call| expected.all? { |key, value| (call["arguments"] || {})[key] == value } }
98
+ assert matched, message || "Expected a call to #{name} with #{expected.inspect}, got #{calls.map { |call| call["arguments"] }.inspect}"
99
+ end
100
+
101
+ def refute_tool_called(replay, name, message = nil)
102
+ refute tool_calls_named(replay, name).any?, message || "Expected the agent not to call #{name}#{tools_note(replay)}"
103
+ end
104
+
105
+ def assert_tools_succeeded(replay, message = nil)
106
+ failed = replay.failed_tool_calls
107
+ assert failed.empty?, message || "Expected every tool call to succeed; failed: #{failed.map { |call| "#{call["name"]} (#{call["detail"]})" }.join(", ")}"
108
+ end
109
+
110
+ # Scores the replay as a dashboard run would and fails with the fault
111
+ # and the recommendation. Returns the Result.
112
+ #
113
+ # @param expect [Hash] the scenario's expectations: `tools:`, `contains:`, `not_contains:`
114
+ # @param prompt [String, nil] what the user asked; defaults to the prompt the replay recorded
115
+ # @param criteria [Array<Hash>] Scorer criteria
116
+ # @param judge [Judge, nil]
117
+ # @param available_tools [Hash, Array, nil] the agent's tool roster, for the diagnosis
118
+ # @param threshold [Float] the passing score
119
+ # @return [Result]
120
+ def assert_eval_passes(replay, message = nil, **options)
121
+ result = score_eval(replay, **options)
122
+ assert result.passed?, message || eval_failure_message(result)
123
+ result
124
+ end
125
+
126
+ # The scored Result for a replay, without asserting on it.
127
+ def score_eval(replay, expect: {}, prompt: nil, criteria: [], judge: nil, available_tools: nil,
128
+ threshold: PASS_THRESHOLD, **runner_options)
129
+ scenario = Scenario.from_hash({
130
+ "key" => "eval",
131
+ "prompt" => prompt || replay.metadata["prompt"] || replay.metadata[:prompt] || "",
132
+ "expect" => expect.to_h
133
+ })
134
+ spec = model_spec_for(replay)
135
+ runner = Runner.new(
136
+ scenarios: [ scenario ], models: [ spec ], replay: ->(*) { replay },
137
+ criteria: criteria, judge: judge, available_tools: available_tools || {}, threshold: threshold,
138
+ agent_name: replay.metadata["agent"] || "The agent", **runner_options
139
+ )
140
+ runner.evaluate(scenario, spec, replay)
141
+ end
142
+
143
+ # Replays a suite's scenarios across models and returns the Report.
144
+ #
145
+ # The block runs the agent for one scenario under one model spec and
146
+ # returns a Generation (which is then captured under that model) or
147
+ # anything `run_eval`'s block form accepts.
148
+ #
149
+ # A suite that declares bars (Suite#pass_thresholds differs from
150
+ # PassThresholds.default: a `threshold` other than 0.7, a
151
+ # `min_pass_rate` below 1, or any key in a group's `pass`, even one that
152
+ # restates a default) hands them to the Runner: its threshold scores
153
+ # the answers, and the Report carries the bars for
154
+ # `assert_meets_pass_gates` and renders its gates. A suite with no
155
+ # `pass` block, or only a suite-level one that restates the defaults,
156
+ # runs under the defaults, and its Report carries no bars.
157
+ #
158
+ # @param suite [Suite, String, Pathname, Array] a Suite or the YAML path(s) to load
159
+ # @param models [Array<String>, String, nil] candidate models; nil runs the agent as configured
160
+ # @param default_provider [String, nil] the provider a bare model name runs under
161
+ # @param providers [Array<String>] the provider names a `provider/model` may name
162
+ # @param groups [Array<String>, nil] narrow to these suite groups
163
+ # @param keys [Array<String>, nil] narrow to these scenario keys
164
+ # @param include_production_only [Boolean] replay scenarios a local environment cannot answer
165
+ # @param runner_options [Hash] the rest goes to Runner.new (criteria, judge, available_tools,
166
+ # instructions, ...); `pass_thresholds:` replaces the suite's bars, and `threshold:` wins over
167
+ # the suite's threshold
168
+ # @return [Report]
169
+ def run_eval_suite(suite, models: nil, default_provider: nil, providers: ModelSpec::DEFAULT_PROVIDERS,
170
+ groups: nil, keys: nil, include_production_only: false, **runner_options, &replay)
171
+ raise ArgumentError, "run_eval_suite needs a block that runs the agent for a scenario" unless replay
172
+
173
+ suite = Suite.load(*Array(suite)) unless suite.is_a?(Suite)
174
+ scenarios = suite.scenarios(groups: groups, keys: keys, include_production_only: include_production_only)
175
+ specs = models.present? ? ModelSpec.parse_all(models, default_provider: default_provider.to_s, providers: providers) : [ AS_CONFIGURED ]
176
+ unless runner_options.key?(:pass_thresholds) || suite.pass_thresholds == PassThresholds.default
177
+ runner_options = runner_options.merge(pass_thresholds: suite.pass_thresholds)
178
+ end
179
+
180
+ Runner.new(
181
+ scenarios: scenarios, models: specs,
182
+ replay: ->(scenario, spec) { capture_for_spec(replay.call(scenario, spec), spec) },
183
+ **runner_options
184
+ ).call
185
+ end
186
+
187
+ # Passes when every model's pass rate reached `at_least` (0.0..1.0).
188
+ def assert_pass_rate(report, message = nil, at_least:)
189
+ at_least = at_least.to_f
190
+ # The fraction itself, not the report's rounded percentage: 1 of 3 meets
191
+ # `at_least: 1 / 3.0`, which 33.3% would not. A model with no scenarios
192
+ # falls short.
193
+ short = report.summary_by_model.select do |_label, stats|
194
+ scenarios = stats["scenarios"].to_i
195
+ scenarios.zero? || stats["passed"].to_f / scenarios < at_least
196
+ end
197
+ assert short.empty?, message || pass_rate_failure_message(report, short, at_least)
198
+ end
199
+
200
+ # Passes when every gate of the report is met: under `thresholds` when
201
+ # given, else under the bars the report carries (Report#pass_thresholds),
202
+ # else the defaults, every group gated and every answer expected to
203
+ # pass. Fails with GateCheck#message — the unmet gates, then the
204
+ # failures to read per group — or with `message` when one is given.
205
+ # Where `assert_pass_rate` holds every model to one bar the test names,
206
+ # this holds each group to the bar the suite declares for it, and each
207
+ # model's gated answers to the suite's, leaving ungated groups out.
208
+ # Unlike `assert_pass_rate`, it takes the message third, after the
209
+ # thresholds.
210
+ #
211
+ # @param report [Report]
212
+ # @param thresholds [PassThresholds, nil] bars to hold the report to in place of its own
213
+ # @param message [String, nil] the failure message, in place of the check's
214
+ # @return [GateCheck]
215
+ def assert_meets_pass_gates(report, thresholds = nil, message = nil)
216
+ check = GateCheck.new(report, thresholds)
217
+ assert check.met?, message || check.message
218
+ check
219
+ end
220
+
221
+ private
222
+
223
+ def capture_for_spec(value, spec)
224
+ overrides = spec.equal?(AS_CONFIGURED) ? {} : { model: spec.model, provider: spec.provider.presence }
225
+ value.is_a?(ActiveAgent::Generation) ? Capture.run(value, **overrides) : Capture.run(**overrides) { value }
226
+ end
227
+
228
+ def tool_calls_named(replay, name)
229
+ replay.tool_calls.select { |call| call["name"] == name.to_s }
230
+ end
231
+
232
+ # The spec a captured replay ran under, for the Result: the provider a
233
+ # capture was asked for, else the configured service read as a provider
234
+ # name ("OpenAI" → "openai", "OpenRouter" → "openrouter").
235
+ def model_spec_for(replay)
236
+ model = replay.metadata["model"].presence
237
+ provider = replay.metadata["provider"].presence || replay.metadata["service"].to_s.underscore.delete("_")
238
+ ModelSpec.new(label: model || "as configured", provider: provider, model: model)
239
+ end
240
+
241
+ def eval_failure_message(result)
242
+ lines = [ "Eval failed: #{result.fault} — #{result.summary}" ]
243
+ lines << "Fix: #{result.recommendation}" if result.recommendation.present?
244
+ lines << "Scores: #{result.scores.map { |key, value| "#{key}=#{value.nil? ? "n/a" : Format.score(value)}" }.join(", ")}" if result.scores.present?
245
+ lines << replay_note(result.replay).strip
246
+ lines.join("\n")
247
+ end
248
+
249
+ def pass_rate_failure_message(report, short, at_least)
250
+ lines = short.map do |label, stats|
251
+ failing = report.results.select { |result| result.label == label && !result.passed? }
252
+ detail = failing.map { |result| "#{result.scenario.key} (#{result.fault || result.status})" }.join(", ")
253
+ "#{label}: #{Format.passes(stats["passed"], stats["scenarios"])} passed, below #{Format.percent(at_least)}; failing: #{detail}"
254
+ end
255
+ lines.unshift("Expected every model to pass at least #{Format.percent(at_least)} of the scenarios")
256
+ lines.join("\n")
257
+ end
258
+
259
+ def replay_note(replay)
260
+ parts = []
261
+ parts << "error: #{replay.error}" if replay.errored?
262
+ parts << "answer: #{Capture.preview(replay.answer.to_s)}" if replay.answer.present?
263
+ parts << "tools called: #{replay.tool_names.join(", ")}" if replay.tool_calls.any?
264
+ parts.empty? ? "" : "\n #{parts.join("\n ")}"
265
+ end
266
+
267
+ def tools_note(replay)
268
+ replay.tool_calls.any? ? " (called: #{replay.tool_names.join(", ")})" : " (no tools were called)#{replay_note(replay)}"
269
+ end
270
+ end
271
+
272
+ if defined?(ActiveSupport::TestCase)
273
+ # A test case with the eval helpers included, whose tests run only when
274
+ # evals are switched on (TestHelper.enabled?) and are skipped, with the
275
+ # command that runs them, otherwise. Inherit from it in test/evals; the
276
+ # app's own fixtures and helpers apply as in any test.
277
+ class TestCase < ActiveSupport::TestCase
278
+ include TestHelper
279
+
280
+ setup do
281
+ skip TestHelper::SKIP_MESSAGE unless TestHelper.enabled?
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "cgi"
5
5
  require "yaml"
6
6
  require "active_support"
7
+ require "active_support/core_ext/array/wrap"
7
8
  require "active_support/core_ext/object/blank"
8
9
  require "active_support/core_ext/string/filters"
9
10
  require "active_support/core_ext/string/access"
@@ -14,11 +15,13 @@ require "active_support/core_ext/enumerable"
14
15
 
15
16
  require_relative "evals/scenario"
16
17
  require_relative "evals/scenario_parser"
18
+ require_relative "evals/pass_thresholds"
17
19
  require_relative "evals/suite"
18
20
  require_relative "evals/catalog"
19
21
  require_relative "evals/model_spec"
20
22
  require_relative "evals/replay"
21
23
  require_relative "evals/format"
24
+ require_relative "evals/patterns"
22
25
  require_relative "evals/statistics"
23
26
  require_relative "evals/comparison"
24
27
  require_relative "evals/scorer"
@@ -29,9 +32,11 @@ require_relative "evals/design_tokens"
29
32
  require_relative "evals/report_html"
30
33
  require_relative "evals/report_markdown"
31
34
  require_relative "evals/report"
35
+ require_relative "evals/gate_check"
32
36
  require_relative "evals/runner"
33
37
  require_relative "evals/correlation"
34
38
  require_relative "evals/publisher"
39
+ require_relative "evals/capture"
35
40
 
36
41
  # Scenario evaluations for agents that answer with tools.
37
42
  #
@@ -47,7 +52,10 @@ require_relative "evals/publisher"
47
52
  # and rolling everything up per model (Report). Runner ties them together
48
53
  # around one callable you supply: given a scenario and a model, run the
49
54
  # agent and return a Replay. Correlation is optional plumbing on top: it
50
- # links the traces a run emits back to the result that caused them.
55
+ # links the traces a run emits back to the result that caused them. Capture
56
+ # runs one ActiveAgent generation into a Replay, and
57
+ # `require "active_agent/evals/test_helper"` adds the Minitest assertions an
58
+ # eval written as a test case uses (TestHelper, TestCase).
51
59
  #
52
60
  # scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(pasted_text)
53
61
  # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini qwen3:8b], default_provider: "openai")
@@ -134,6 +134,7 @@ module ActiveAgent
134
134
  :stream_completion_result, # Callback (Streams)
135
135
  :stream_finish_reason, # Callback (Streams)
136
136
  :tools_function, # Callback (Tools)
137
+ :tool_observer, # Callback (Tools, MCP-served calls)
137
138
  :usage_stack, # Usage Tracking
138
139
  :stream_usage_index, # Usage Tracking (Streams)
139
140
  :max_tool_turns, :tool_turns, # Tool-loop safety
@@ -193,6 +194,7 @@ module ActiveAgent
193
194
  self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
194
195
  self.streaming = false
195
196
  self.tools_function = kwargs.delete(:tools_function)
197
+ self.tool_observer = kwargs.delete(:tool_observer)
196
198
  self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
197
199
  self.tool_turns = 0
198
200
  self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
@@ -273,6 +275,10 @@ module ActiveAgent
273
275
  # Invokes a tool, on an MCP server when one provides it and on the agent's
274
276
  # own tool function otherwise.
275
277
  #
278
+ # A call served by an MCP server never reaches the agent's tool function,
279
+ # so it runs inside the `tool_observer`, when the agent has one, which sees
280
+ # the call and its result without changing either.
281
+ #
276
282
  # @param name [String] tool name
277
283
  # @param kwargs [Hash] tool arguments
278
284
  # @return [Object] the tool's result, or an {MCPBridge::ErrorResult} for
@@ -281,13 +287,23 @@ module ActiveAgent
281
287
  # tool needs approval and the provider's tool loop never asked for it
282
288
  def call_tool_function(name, **kwargs)
283
289
  assert_approval_gate_reached!(name)
284
- return mcp_call_tool(name, **kwargs) if mcp_owns_tool?(name)
290
+ return observe_mcp_tool_call(name, kwargs) { mcp_call_tool(name, **kwargs) } if mcp_owns_tool?(name)
285
291
 
286
292
  result = isolate_undispatched_tool_call { tools_function.call(name, **kwargs) }
287
293
  assert_input_request_supported!(result)
288
294
  result
289
295
  end
290
296
 
297
+ # @param name [String]
298
+ # @param arguments [Hash]
299
+ # @yieldreturn [Object] the tool's result
300
+ # @return [Object] the block's result
301
+ def observe_mcp_tool_call(name, arguments, &call)
302
+ return yield unless tool_observer
303
+
304
+ tool_observer.call(name, arguments, &call)
305
+ end
306
+
291
307
  # @param name [String, nil]
292
308
  # @raise [RuntimeError] when service name doesn't match provider
293
309
  def assert_service!(name)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module AtlasCloud
8
+ # Configuration options for the Atlas Cloud provider.
9
+ class Options < ActiveAgent::Providers::OpenAI::Options
10
+ # Atlas Cloud's OpenAI-compatible endpoint.
11
+ DEFAULT_BASE_URL = "https://api.atlascloud.ai/v1"
12
+
13
+ # @param kwargs [Hash]
14
+ # @option kwargs [String] :api_key
15
+ # @option kwargs [String] :base_url (https://api.atlascloud.ai/v1)
16
+ def initialize(kwargs = {})
17
+ kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)
18
+
19
+ super(kwargs.merge(base_url: kwargs[:base_url].presence || DEFAULT_BASE_URL))
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_api_key(kwargs)
25
+ kwargs[:api_key] ||
26
+ kwargs[:access_token] ||
27
+ ENV["ATLASCLOUD_API_KEY"]
28
+ end
29
+
30
+ def resolve_organization_id(_kwargs) = nil
31
+ def resolve_project_id(_kwargs) = nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_base_provider"
4
+
5
+ require_gem!(:openai, __FILE__)
6
+
7
+ require_relative "open_ai_provider"
8
+ require_relative "atlas_cloud/options"
9
+
10
+ module ActiveAgent
11
+ module Providers
12
+ # Provides access to Atlas Cloud's OpenAI-compatible Chat Completions API.
13
+ #
14
+ # Atlas Cloud uses provider/model identifiers such as +qwen/qwen3.8-max+.
15
+ # Request and response handling is shared with the OpenAI Chat provider;
16
+ # only the endpoint and API-key resolution are provider-specific.
17
+ class AtlasCloudProvider < OpenAI::ChatProvider
18
+ # @return [String]
19
+ def self.service_name
20
+ "AtlasCloud"
21
+ end
22
+
23
+ # @return [Class]
24
+ def self.options_klass
25
+ AtlasCloud::Options
26
+ end
27
+
28
+ # @return [ActiveModel::Type::Value]
29
+ def self.prompt_request_type
30
+ OpenAI::Chat::RequestType.new
31
+ end
32
+
33
+ protected
34
+
35
+ # @see BaseProvider#api_response_normalize
36
+ # @param api_response [OpenAI::Models::ChatCompletion]
37
+ # @return [Hash]
38
+ def api_response_normalize(api_response)
39
+ return api_response unless api_response
40
+
41
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/chat/_types"
4
+ require_relative "options"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module CheaperInference
9
+ # ActiveModel type for casting and serializing Cheaper Inference requests.
10
+ #
11
+ # Cheaper Inference is OpenAI-compatible, so requests use the same shape
12
+ # as the OpenAI Chat API. This delegates entirely to
13
+ # OpenAI::Chat::RequestType.
14
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
15
+ end
16
+ end
17
+ end