activeagent 1.9.6 → 1.9.8
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 +612 -28
- data/README.md +9 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +50 -7
- data/lib/active_agent/concerns/tooling.rb +11 -0
- data/lib/active_agent/evals/capture.rb +286 -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/model_capabilities.rb +1 -1
- data/lib/active_agent/providers/_base_provider.rb +18 -2
- data/lib/active_agent/providers/anthropic_provider.rb +10 -0
- 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/bedrock/bearer_client.rb +2 -1
- 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/common/usage.rb +6 -1
- 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/providers/open_ai/responses_provider.rb +7 -0
- data/lib/active_agent/providers/requesty_provider.rb +8 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
- data/lib/active_agent/providers/runcrate/_types.rb +17 -0
- data/lib/active_agent/providers/runcrate/options.rb +39 -0
- data/lib/active_agent/providers/runcrate_provider.rb +72 -0
- 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 +27 -5
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -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")
|
|
@@ -17,7 +17,7 @@ module ActiveAgent
|
|
|
17
17
|
# @example Disable sanitization entirely
|
|
18
18
|
# ActiveAgent::ModelCapabilities.enabled = false
|
|
19
19
|
module ModelCapabilities
|
|
20
|
-
SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
|
|
20
|
+
SAMPLING_PARAMS = [ :temperature, :top_p, :top_k ].freeze
|
|
21
21
|
|
|
22
22
|
# Model families that reject sampling parameters with a 400:
|
|
23
23
|
# - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
|
|
@@ -10,7 +10,7 @@ require_relative "concerns/tool_choice_clearing"
|
|
|
10
10
|
|
|
11
11
|
# @private
|
|
12
12
|
GEM_LOADERS = {
|
|
13
|
-
anthropic: [ "anthropic", "
|
|
13
|
+
anthropic: [ "anthropic", [ ">= 1.12", "< 3" ], "anthropic" ],
|
|
14
14
|
openai: [ "openai", "~> 0.34", "openai" ],
|
|
15
15
|
# Keep a tested floor: 1.2 lacks the provider APIs this adapter uses.
|
|
16
16
|
ruby_llm: [ "ruby_llm", [ ">= 1.16", "< 3" ], "ruby_llm" ]
|
|
@@ -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)
|
|
@@ -197,6 +197,7 @@ module ActiveAgent
|
|
|
197
197
|
when :message_start
|
|
198
198
|
api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
|
|
199
199
|
self.message_stack.push(api_message)
|
|
200
|
+
record_stream_usage(api_message[:usage])
|
|
200
201
|
broadcast_stream_update(message_stack.last)
|
|
201
202
|
|
|
202
203
|
# -> Content Block Create
|
|
@@ -239,6 +240,15 @@ module ActiveAgent
|
|
|
239
240
|
self.message_stack.last.merge!(delta)
|
|
240
241
|
self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
|
|
241
242
|
|
|
243
|
+
# message_delta carries the cumulative output tokens (and, on newer
|
|
244
|
+
# API versions, refreshed input counts); layer them over the usage
|
|
245
|
+
# message_start reported so the turn keeps a single running total.
|
|
246
|
+
delta_usage = Anthropic::Transforms.gem_to_hash(api_response_chunk.usage)&.compact
|
|
247
|
+
if delta_usage.present?
|
|
248
|
+
message_stack.last[:usage] = (message_stack.last[:usage] || {}).merge(delta_usage)
|
|
249
|
+
record_stream_usage(message_stack.last[:usage])
|
|
250
|
+
end
|
|
251
|
+
|
|
242
252
|
# Message Completed [Full Message]
|
|
243
253
|
when :message_stop
|
|
244
254
|
api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
|
|
@@ -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
|
|
@@ -51,7 +51,8 @@ module ActiveAgent
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
@messages = ::Anthropic::Resources::Messages.new(client: self)
|
|
54
|
-
|
|
54
|
+
# The anthropic gem 2.0 removed Text Completions along with this resource.
|
|
55
|
+
@completions = ::Anthropic::Resources::Completions.new(client: self) if defined?(::Anthropic::Resources::Completions)
|
|
55
56
|
@beta = ::Anthropic::Resources::Beta.new(client: self)
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../open_ai/options"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Providers
|
|
7
|
+
module CheaperInference
|
|
8
|
+
# Configuration options for the Cheaper Inference provider.
|
|
9
|
+
#
|
|
10
|
+
# Extends OpenAI::Options, overriding the base URL to point at Cheaper
|
|
11
|
+
# Inference's OpenAI-compatible gateway and resolving the API key from
|
|
12
|
+
# CHEAPER_INFERENCE_API_KEY. Cheaper Inference does not use organization
|
|
13
|
+
# or project identifiers.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic configuration
|
|
16
|
+
# options = Options.new(api_key: ENV["CHEAPER_INFERENCE_API_KEY"])
|
|
17
|
+
#
|
|
18
|
+
# @see https://cheaperinference.com/docs
|
|
19
|
+
# @see https://cheaperinference.com/signup Cheaper Inference API Keys
|
|
20
|
+
class Options < ActiveAgent::Providers::OpenAI::Options
|
|
21
|
+
# @!attribute base_url
|
|
22
|
+
# @return [String] API endpoint (default: "https://api.cheaperinference.com/v1")
|
|
23
|
+
attribute :base_url, :string, as: "https://api.cheaperinference.com/v1"
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def resolve_api_key(kwargs)
|
|
28
|
+
kwargs[:api_key] ||
|
|
29
|
+
kwargs[:access_token] ||
|
|
30
|
+
ENV["CHEAPER_INFERENCE_API_KEY"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Not used as part of Cheaper Inference
|
|
34
|
+
def resolve_organization_id(kwargs) = nil
|
|
35
|
+
def resolve_project_id(kwargs) = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require_relative "_base_provider"
|
|
2
|
+
|
|
3
|
+
require_gem!(:openai, __FILE__)
|
|
4
|
+
|
|
5
|
+
require_relative "open_ai_provider"
|
|
6
|
+
require_relative "cheaper_inference/_types"
|
|
7
|
+
|
|
8
|
+
module ActiveAgent
|
|
9
|
+
module Providers
|
|
10
|
+
# Provides access to Cheaper Inference's OpenAI-compatible LLM gateway.
|
|
11
|
+
#
|
|
12
|
+
# Extends the OpenAI provider to work with Cheaper Inference's
|
|
13
|
+
# OpenAI-compatible API, enabling access to models from many providers
|
|
14
|
+
# through a single interface. Model ids are bare names (e.g.
|
|
15
|
+
# +gpt-5.4-mini+).
|
|
16
|
+
#
|
|
17
|
+
# Cheaper Inference is a plain OpenAI-compatible gateway: requests,
|
|
18
|
+
# responses and transforms are identical to the OpenAI Chat API, so this
|
|
19
|
+
# provider reuses OpenAI::Chat::RequestType and OpenAI::Chat::Transforms
|
|
20
|
+
# directly. The only Cheaper Inference-specific configuration is the base
|
|
21
|
+
# URL and API key, which live in CheaperInference::Options.
|
|
22
|
+
#
|
|
23
|
+
# @example Configuration in active_agent.yml
|
|
24
|
+
# cheaper_inference:
|
|
25
|
+
# service: "CheaperInference"
|
|
26
|
+
# api_key: <%= ENV["CHEAPER_INFERENCE_API_KEY"] %>
|
|
27
|
+
# model: "gpt-5.4-mini"
|
|
28
|
+
#
|
|
29
|
+
# @see OpenAI::ChatProvider
|
|
30
|
+
# @see https://cheaperinference.com/docs
|
|
31
|
+
class CheaperInferenceProvider < OpenAI::ChatProvider
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.service_name
|
|
34
|
+
"CheaperInference"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Class]
|
|
38
|
+
def self.options_klass
|
|
39
|
+
CheaperInference::Options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [ActiveModel::Type::Value]
|
|
43
|
+
def self.prompt_request_type
|
|
44
|
+
OpenAI::Chat::RequestType.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [ActiveModel::Type::Value]
|
|
48
|
+
def self.embed_request_type
|
|
49
|
+
OpenAI::Embedding::RequestType.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
# Executes an embedding request via Cheaper Inference's OpenAI-compatible API.
|
|
55
|
+
#
|
|
56
|
+
# @param parameters [Hash]
|
|
57
|
+
# @return [Hash] symbolized API response
|
|
58
|
+
def api_embed_execute(parameters)
|
|
59
|
+
client.embeddings.create(**parameters).as_json.deep_symbolize_keys
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @see BaseProvider#api_response_normalize
|
|
63
|
+
# @param api_response [OpenAI::Models::ChatCompletion]
|
|
64
|
+
# @return [Hash] normalized response hash
|
|
65
|
+
def api_response_normalize(api_response)
|
|
66
|
+
return api_response unless api_response
|
|
67
|
+
|
|
68
|
+
OpenAI::Chat::Transforms.gem_to_hash(api_response)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -148,7 +148,8 @@ module ActiveAgent
|
|
|
148
148
|
cached_tokens: sum_optional(self.cached_tokens, other.cached_tokens),
|
|
149
149
|
cache_creation_tokens: sum_optional(self.cache_creation_tokens, other.cache_creation_tokens),
|
|
150
150
|
reasoning_tokens: sum_optional(self.reasoning_tokens, other.reasoning_tokens),
|
|
151
|
-
audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens)
|
|
151
|
+
audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens),
|
|
152
|
+
duration_ms: sum_optional(self.duration_ms, other.duration_ms)
|
|
152
153
|
)
|
|
153
154
|
end
|
|
154
155
|
|
|
@@ -263,6 +264,10 @@ module ActiveAgent
|
|
|
263
264
|
**usage.slice(:input_tokens, :output_tokens, :service_tier),
|
|
264
265
|
input_tokens: usage[:input_tokens] || 0,
|
|
265
266
|
output_tokens: usage[:output_tokens] || 0,
|
|
267
|
+
# Anthropic's input_tokens excludes cache reads and writes, which are
|
|
268
|
+
# reported separately, so they are added to get every token processed.
|
|
269
|
+
total_tokens: (usage[:input_tokens] || 0) + (usage[:output_tokens] || 0) +
|
|
270
|
+
(usage[:cache_read_input_tokens] || 0) + (usage[:cache_creation_input_tokens] || 0),
|
|
266
271
|
cached_tokens: usage[:cache_read_input_tokens],
|
|
267
272
|
cache_creation_tokens: usage[:cache_creation_input_tokens],
|
|
268
273
|
provider_details: usage.slice(:cache_creation, :server_tool_use).compact
|