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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/providers/_base_provider.rb +17 -1
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -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
|