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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -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/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -215,7 +215,13 @@ module ActiveAgent
215
215
  # --- page ------------------------------------------------------------
216
216
 
217
217
  def html_title
218
- "Evaluation — #{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}"
218
+ with_trials("Evaluation — #{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}")
219
+ end
220
+
221
+ # +text+ with "3 trials per scenario" beside it when each scenario was
222
+ # replayed more than once; +text+ alone otherwise.
223
+ def with_trials(text)
224
+ trials > 1 ? "#{text} · #{trials} trials per scenario" : text
219
225
  end
220
226
 
221
227
  def html_styles
@@ -282,7 +288,7 @@ module ActiveAgent
282
288
  passed = @results.count(&:passed?)
283
289
  ratio = total.positive? ? passed.to_f / total : 0.0
284
290
  tiles = [
285
- html_tile("Scenario runs", total, "#{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}"),
291
+ html_tile("Scenario runs", total, with_trials("#{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}")),
286
292
  html_tile("Pass rate", Format.percent(total.positive? ? ratio : nil), "#{passed}/#{total} passed", tone: tone_for(ratio)),
287
293
  html_tile("Open faults", total - passed, plural(fix_items.size, "fix item")),
288
294
  html_cost_tile,
@@ -496,29 +502,49 @@ module ActiveAgent
496
502
 
497
503
  <<~MATRIX
498
504
  <section class="section">
499
- <div class="section-head"><span class="micro">Scenarios</span><span class="meta">#{h(meta)}</span></div>
505
+ <div class="section-head"><span class="micro">Scenarios</span><span class="meta">#{h(with_trials(meta))}</span></div>
500
506
  <div class="matrix"><div class="inner">#{rows.join}</div></div>
501
507
  </section>
502
508
  MATRIX
503
509
  end
504
510
 
511
+ # A group's row: each model's passes over every replay of the row's
512
+ # scenarios, toned against the group's bar when the suite declared one —
513
+ # the row's own pass rate at or above it green, below it red, an ungated
514
+ # group plain, with the bar as the tooltip — and otherwise green when
515
+ # every replay passed and red when none did. A row with a group name but
516
+ # no group key has no bar to read.
505
517
  def html_group_row(cohorts)
506
- name = group_name(cohorts.first.first.scenario)
518
+ scenario = cohorts.first.first.scenario
519
+ name = group_name(scenario)
520
+ gated = pass_thresholds && scenario.group.present?
521
+ gates_by_label = gated ? gates.select { |gate| gate.group == scenario.group }.index_by(&:label) : {}
507
522
  passes = @models.map do |spec|
508
- results = cohorts.filter_map { |cohort| cohort.find { |result| result.label == spec.label } }
523
+ results = cohorts.flat_map { |cohort| cohort.select { |result| result.label == spec.label } }
524
+ next %(<span class="group-pass">—</span>) if results.empty?
525
+
509
526
  passed = results.count(&:passed?)
510
- tone =
511
- if results.empty? then ""
512
- elsif passed == results.size then " text-success"
513
- elsif passed.zero? then " text-error"
514
- else ""
515
- end
516
- %(<span class="group-pass#{tone}">#{results.empty? ? '—' : "#{h(fmt_passes(passed, results.size))} passed"}</span>)
527
+ tone, title = html_group_tone(passed, results.size, gates_by_label[spec.label])
528
+ %(<span class="group-pass#{tone}"#{title}>#{h(fmt_passes(passed, results.size))} passed</span>)
517
529
  end
518
530
  %(<div class="mx group"><span class="group-name">#{h(name)}</span>) +
519
531
  %(<span class="count">#{h(plural(cohorts.size, 'scenario'))}</span>#{passes.join}#{html_group_cost(cohorts)}</div>)
520
532
  end
521
533
 
534
+ # [tone class, title attribute] for a group-pass span: the row's own
535
+ # counts against its gate's bar when there is one, so a group split
536
+ # across the run tones each row by what that row shows; else from the
537
+ # counts alone, with no title.
538
+ def html_group_tone(passed, total, gate)
539
+ if gate
540
+ tone = gate.gated ? (passed.to_f / total >= gate.min_pass_rate ? " text-success" : " text-error") : ""
541
+ [ tone, %( title="#{h(gate.gated ? "pass rate ≥ #{Format.percent(gate.min_pass_rate)}" : 'not gated')}") ]
542
+ elsif passed == total then [ " text-success", "" ]
543
+ elsif passed.zero? then [ " text-error", "" ]
544
+ else [ "", "" ]
545
+ end
546
+ end
547
+
522
548
  # The group's subtotal — its scenarios' costs summed, judge apart — in
523
549
  # the trailing column, so the grid stays aligned under a group row.
524
550
  def html_group_cost(cohorts)
@@ -542,8 +568,11 @@ module ActiveAgent
542
568
  scenario = cohort.first.scenario
543
569
  expects = scenario.expected_tools.map { |tool| %(<span class="expect">#{h(tool)}</span>) }.join
544
570
  cells = @models.map do |spec|
545
- result = cohort.find { |candidate| candidate.label == spec.label }
546
- result ? html_result_cell(result) : %(<div class="cell"><div class="top"><span class="muted">—</span></div></div>)
571
+ mine = cohort.select { |candidate| candidate.label == spec.label }.sort_by(&:trial)
572
+ if mine.empty? then %(<div class="cell"><div class="top"><span class="muted">—</span></div></div>)
573
+ elsif mine.one? then html_result_cell(mine.first)
574
+ else html_trials_cell(mine)
575
+ end
547
576
  end
548
577
  costs = scenario_costs[scenario.key] || {}
549
578
  total_cell = if costs["cost"].nil? && costs["judge_cost"].nil?
@@ -564,6 +593,26 @@ module ActiveAgent
564
593
  %(<div class="calls">#{html_calls(result, empty: 'no tools called')}</div>#{html_cell_cost(result)}</div>)
565
594
  end
566
595
 
596
+ # The cell of a scenario replayed more than once under one model: how
597
+ # many replays passed, one line per trial (its number, the glyph, the
598
+ # score and the fault), and what the replays cost together.
599
+ def html_trials_cell(results)
600
+ passed = results.count(&:passed?)
601
+ tone =
602
+ if passed == results.size then " tone-success"
603
+ elsif passed.zero? then " tone-error"
604
+ else ""
605
+ end
606
+ lines = results.map do |result|
607
+ glyph_tone = result.passed? ? "success" : "error"
608
+ fault = result.fault ? %(<span class="f">#{h(fault_name(result.fault))}</span>) : ""
609
+ %(<div class="top trial"><span class="muted">#{result.trial}</span><span class="g tone-#{glyph_tone}">#{result.passed? ? '[+]' : '[!]'}</span>) +
610
+ %(<span class="s tone-#{glyph_tone}">#{h(fmt_score(result.score))}</span>#{fault}</div>)
611
+ end
612
+ %(<div class="cell"><div class="top"><span class="s#{tone}">#{h(fmt_passes(passed, results.size))} passed</span></div>) +
613
+ %(#{lines.join}#{html_trials_cost(results)}</div>)
614
+ end
615
+
567
616
  # "~$0.0243 · judge ~$0.0015" under a cell: what this answer cost and
568
617
  # what judging it cost. A result with no cost at all shows no line.
569
618
  def html_cell_cost(result)
@@ -573,6 +622,25 @@ module ActiveAgent
573
622
  %(<div class="cost-line"#{cost_title_attr([ result ], estimated: result.estimated_cost?)}>#{h(text)}</div>)
574
623
  end
575
624
 
625
+ # The cost line under a trials cell: the replays' costs summed, the
626
+ # judge's apart. The sum reads as an estimate when any replay's cost
627
+ # is one, or when a replay went unpriced (a lower bound), and shows its
628
+ # working in a tooltip only when a replay was priced from tokens.
629
+ # Nothing when no replay carried a cost.
630
+ def html_trials_cost(results)
631
+ costs = results.filter_map { |result| result.replay.cost }
632
+ judge_costs = results.filter_map { |result| result.judge_usage&.dig("cost") }
633
+ return "" if costs.empty? && judge_costs.empty?
634
+
635
+ priced_from_tokens = results.any?(&:estimated_cost?)
636
+ estimated = priced_from_tokens || (costs.any? && costs.size < results.size)
637
+ parts = [ fmt_cost(costs.any? ? costs.sum : nil, estimated: estimated) ]
638
+ if judge_costs.any?
639
+ parts << "judge #{fmt_cost(judge_costs.sum, estimated: results.any? { |result| estimated_usage?(result.judge_usage) })}"
640
+ end
641
+ %(<div class="cost-line"#{cost_title_attr(results, estimated: priced_from_tokens)}>#{h(parts.join(' · '))}</div>)
642
+ end
643
+
576
644
  def result_cost_text(result)
577
645
  judge_cost = result.judge_usage&.dig("cost")
578
646
  return nil if result.replay.cost.nil? && judge_cost.nil?
@@ -619,7 +687,10 @@ module ActiveAgent
619
687
  scenario = cohort.first.scenario
620
688
  expects = scenario.expected_tools.any? ? %(<span class="exp">expects <b>#{h(scenario.expected_tools.join(' or '))}</b></span>) : ""
621
689
  labels = @models.map(&:label)
622
- cards = cohort.sort_by { |result| labels.index(result.label) || labels.size }.map { |result| html_result_card(result) }
690
+ # A model that replayed the scenario more than once numbers its cards.
691
+ repeated = cohort.map(&:label).tally.select { |_label, count| count > 1 }
692
+ cards = cohort.sort_by { |result| [ labels.index(result.label) || labels.size, result.trial ] }
693
+ .map { |result| html_result_card(result, numbered: repeated.key?(result.label)) }
623
694
  md_id = "md-#{anchor(scenario)}"
624
695
 
625
696
  <<~BLOCK
@@ -632,8 +703,11 @@ module ActiveAgent
632
703
  BLOCK
633
704
  end
634
705
 
635
- def html_result_card(result)
706
+ # +numbered+ names the card's trial, for a model with several cards on
707
+ # one scenario.
708
+ def html_result_card(result, numbered: false)
636
709
  short, = split_label(result.spec)
710
+ trial = numbered ? %(<span class="micro sm">trial #{result.trial}</span>) : ""
637
711
  status =
638
712
  if result.passed? then %(<span class="badge success">passed · #{h(fmt_score(result.score))}</span>)
639
713
  elsif result.errored? then %(<span class="badge error">errored</span>)
@@ -650,7 +724,7 @@ module ActiveAgent
650
724
 
651
725
  <<~CARD
652
726
  <div class="result">
653
- <div class="head"><span class="name">#{h(short)}</span>#{status}<span class="meta">#{h(result_meta(result))}</span></div>
727
+ <div class="head"><span class="name">#{h(short)}</span>#{trial}#{status}<span class="meta">#{h(result_meta(result))}</span></div>
654
728
  <div class="body"><div class="tools-line"><span class="micro sm">Tools</span>#{html_calls(result, empty: 'none called')}</div>#{fault}#{error}#{answer}</div>
655
729
  </div>
656
730
  CARD
@@ -11,8 +11,9 @@ module ActiveAgent
11
11
  #
12
12
  # Each piece is public on its own, for the pieces people paste one at a
13
13
  # time: #fix_item_markdown, #fix_items_markdown (optionally for one
14
- # model), #verdict_markdown and #scenario_markdown. The whole report is
15
- # built from them, so a piece copied alone reads as it does in the report.
14
+ # model), #verdict_markdown, #scenario_markdown, #gates_markdown,
15
+ # #failures_markdown and #failure_markdown. The whole report is built
16
+ # from them, so a piece copied alone reads as it does in the report.
16
17
  #
17
18
  # Table cells escape `|` and fold line breaks; text set inside the HTML
18
19
  # of a `<details>` block escapes `<`, `>` and `&`.
@@ -25,9 +26,11 @@ module ActiveAgent
25
26
  lines = [ "# #{markdown_title}", "" ]
26
27
  lines << judged_by_line
27
28
  lines << ""
29
+ lines << trials_line << "" if trials > 1
28
30
  lines.concat(summary_table)
29
31
  lines << ""
30
32
  lines.concat(total_lines)
33
+ lines.concat(gates_markdown).push("") if pass_thresholds && gates.any?
31
34
  lines.concat(verdict_lines)
32
35
  lines.concat(comparison_lines)
33
36
  lines.concat(matrix_table)
@@ -84,6 +87,47 @@ module ActiveAgent
84
87
  lines.join("\n")
85
88
  end
86
89
 
90
+ # Pass rate by group: one row per Report#gates row, in that order —
91
+ # the group, the model, the passes, the pass rate with its mark (✅
92
+ # met, ❌ not met, — not gated), the bar and the group's mean score
93
+ # for that model (the whole report's for an "All" row).
94
+ def gates_markdown(thresholds = pass_thresholds || PassThresholds.default)
95
+ lines = [ "## Pass rate by group", "", "| Group | Model | Passed | Pass rate | Bar | Mean score |", "|---|---|---|---|---|---|" ]
96
+ gates(thresholds).each do |gate|
97
+ stats = gate.group ? summary_by_group.dig(gate.group, "models", gate.label) : summary_by_model[gate.label]
98
+ mark = gate.gated ? (gate.met? ? "✅" : "❌") : "—"
99
+ cells = [ cell(gate.name), cell("`#{gate.label}`"), "#{gate.passed} of #{gate.total}", "#{Format.percent(gate.pass_rate)} #{mark}",
100
+ gate.gated ? Format.percent(gate.min_pass_rate) : "not gated", Format.score(stats&.dig("avg_score")) ]
101
+ lines << "| #{cells.join(' | ')} |"
102
+ end
103
+ lines
104
+ end
105
+
106
+ # Every result that did not pass (Report#failures), one
107
+ # #failure_markdown line each, narrowed to one group when given.
108
+ def failures_markdown(group: nil)
109
+ failed = failures(group: group)
110
+ return "_Nothing failed._" if failed.empty?
111
+
112
+ failed.map { |result| failure_markdown(result) }.join("\n")
113
+ end
114
+
115
+ # One result that did not pass, as a line naming its scenario and
116
+ # model with its fault and summary — without a diagnosis, the replay's
117
+ # error for an errored result and the status alone otherwise. When the
118
+ # report holds more than one replay of that scenario × model, the line
119
+ # names the trial as #scenario_markdown does ("`gpt-5-mini` · trial 2"),
120
+ # so each line points at one answer.
121
+ def failure_markdown(result)
122
+ detail =
123
+ if result.fault then [ result.fault.tr("_", " "), result.summary.presence ].compact.join(" — ")
124
+ elsif result.errored? then [ "errored", result.replay.error.presence ].compact.join(" — ")
125
+ else result.status
126
+ end
127
+ trial = " · trial #{result.trial}" if replay_counts[[ result.scenario.key, result.label ]].to_i > 1
128
+ "- `#{result.scenario.key}` on `#{result.label}`#{trial}: #{detail}"
129
+ end
130
+
87
131
  # The verdict — the best model, why, and who picked it — then the
88
132
  # per-model table and, comparing models, the comparison with its
89
133
  # intervals.
@@ -108,7 +152,10 @@ module ActiveAgent
108
152
 
109
153
  scenario = cohort.first.scenario
110
154
  labels = @models.map(&:label)
111
- cohort = cohort.sort_by { |result| labels.index(result.label) || labels.size }
155
+ cohort = cohort.sort_by { |result| [ labels.index(result.label) || labels.size, result.trial ] }
156
+ # A model that replayed the scenario more than once names each trial.
157
+ repeated = cohort.map(&:label).tally.select { |_label, count| count > 1 }
158
+ name = ->(result) { repeated.key?(result.label) ? "`#{result.label}` · trial #{result.trial}" : "`#{result.label}`" }
112
159
  body = []
113
160
  body << "**Prompt**" << "" << quoted(scenario.prompt).join("\n") << ""
114
161
  expectations = expectation_lines(scenario)
@@ -116,12 +163,12 @@ module ActiveAgent
116
163
  body << "| Model | Status | Score | Tools | Fault | Cost |" << "|---|---|---|---|---|---|"
117
164
  cohort.each do |result|
118
165
  tools = result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(", ")
119
- body << "| #{[ "`#{result.label}`", status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
166
+ body << "| #{[ name.call(result), status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
120
167
  result.fault&.tr('_', ' ') || '—', (result.replay.cost ? answer_cost(result) : '—') ].map { |text| cell(text) }.join(' | ')} |"
121
168
  end
122
169
  body << ""
123
170
  cohort.each do |result|
124
- body << "**`#{result.label}`** — #{status_text(result)}"
171
+ body << "**#{name.call(result)}** — #{status_text(result)}"
125
172
  body << ""
126
173
  body << "Fault: #{result.fault.tr('_', ' ')} — #{[ result.summary, result.recommendation ].compact.join(' ')}" << "" if result.fault
127
174
  body << "Error: #{result.replay.error}" << "" if result.replay.error
@@ -132,7 +179,8 @@ module ActiveAgent
132
179
  text = body.join("\n").rstrip
133
180
  return "### Scenario `#{scenario.key}`\n\n#{text}" unless details
134
181
 
135
- marks = cohort.map { |result| "#{status_mark(result)} #{result.label}" }.join(" · ")
182
+ # A model that replayed the scenario shows one mark per trial, run together.
183
+ marks = cohort.group_by(&:label).map { |label, replays| "#{replays.map { |result| status_mark(result) }.join} #{label}" }.join(" · ")
136
184
  summary = "<code>#{html_text(scenario.key)}</code> #{html_text(scenario.prompt.truncate(80))} — #{html_text(marks)}"
137
185
  "<details>\n<summary>#{summary}</summary>\n\n#{text}\n\n</details>"
138
186
  end
@@ -148,11 +196,20 @@ module ActiveAgent
148
196
  @results.map { |result| result.scenario.key }.uniq
149
197
  end
150
198
 
199
+ # How many results the report holds per scenario key × model label.
200
+ def replay_counts
201
+ @replay_counts ||= @results.map { |result| [ result.scenario.key, result.label ] }.tally
202
+ end
203
+
151
204
  def judged_by_line
152
205
  label = @judge_label || @judge&.label
153
206
  label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone."
154
207
  end
155
208
 
209
+ def trials_line
210
+ "Each scenario was replayed #{trials} times; a model's pass rate counts every replay."
211
+ end
212
+
156
213
  # "**Best model: gpt-5-mini**" with the verdict's rationale under it
157
214
  # and, when a judge (not the pass-rate ranking) wrote it, who did.
158
215
  def verdict_lines
@@ -7,12 +7,19 @@ module ActiveAgent
7
7
  #
8
8
  # `status` is "errored" when the run raised, "failed" when a fault was
9
9
  # assigned, and "passed" otherwise. `diagnosis` is the Diagnosis::Result
10
- # hash, with a `"judge"` sub-hash when a Judge refined it.
11
- Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, keyword_init: true) do
10
+ # hash, with a `"judge"` sub-hash when a Judge refined it. `trial` numbers
11
+ # the replays of one scenario under one model from 1 when the Runner
12
+ # replays each more than once (Runner.new trials:).
13
+ Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, :trial, keyword_init: true) do
12
14
  def passed?
13
15
  status == "passed"
14
16
  end
15
17
 
18
+ # A Result built without a trial is the first.
19
+ def trial
20
+ self[:trial] || 1
21
+ end
22
+
16
23
  def failed?
17
24
  status == "failed"
18
25
  end
@@ -88,6 +95,7 @@ module ActiveAgent
88
95
  "label" => label,
89
96
  "provider" => provider,
90
97
  "model" => model,
98
+ "trial" => trial,
91
99
  "status" => status,
92
100
  "score" => score,
93
101
  "scores" => scores,
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by an explicit `require "active_agent/evals/rspec"` — never by
4
+ # `require "active_agent/evals"` — so a host on another test framework never
5
+ # sees it. It needs nothing from rspec: the matcher speaks RSpec's matcher
6
+ # protocol on its own and loads with rspec absent.
7
+ require_relative "../evals"
8
+
9
+ module ActiveAgent
10
+ module Evals
11
+ # `expect(report).to meet_its_pass_gates`: an RSpec matcher over
12
+ # GateCheck, failing with its message, as TestHelper's
13
+ # `assert_meets_pass_gates` does. Include it beside TestHelper where the
14
+ # evals are set up, tagged and run only when EVALS is on:
15
+ #
16
+ # # spec/rails_helper.rb
17
+ # require "active_agent/evals/test_helper"
18
+ # require "active_agent/evals/rspec"
19
+ #
20
+ # RSpec.configure do |config|
21
+ # config.expect_with :rspec, :minitest
22
+ # config.define_derived_metadata(file_path: %r{/spec/evals/}) { |metadata| metadata[:eval] = true }
23
+ # config.include ActiveAgent::Evals::TestHelper, eval: true
24
+ # config.include ActiveAgent::Evals::RSpec, eval: true
25
+ # config.filter_run_excluding eval: true unless ActiveAgent::Evals::TestHelper.enabled?
26
+ # end
27
+ #
28
+ # # spec/evals/support_desk_spec.rb
29
+ # RSpec.describe "the support desk suite" do
30
+ # it "clears its pass gates" do
31
+ # report = run_eval_suite("config/evals/support_desk.yml") { |scenario, _spec| SupportAgent.with(question: scenario.prompt).help }
32
+ #
33
+ # expect(report).to meet_its_pass_gates
34
+ # end
35
+ # end
36
+ module RSpec
37
+ # The matcher: every gate of the report met under `thresholds`, or
38
+ # without them under the report's own bars, else the defaults.
39
+ def meet_its_pass_gates(thresholds = nil)
40
+ MeetItsPassGates.new(thresholds)
41
+ end
42
+
43
+ # The matcher object, speaking RSpec's matcher protocol (`matches?`,
44
+ # the failure messages and `description`) without rspec loaded.
45
+ class MeetItsPassGates
46
+ def initialize(thresholds = nil)
47
+ @thresholds = thresholds
48
+ end
49
+
50
+ # Checks `report` and keeps the check for the failure messages.
51
+ def matches?(report)
52
+ @check = GateCheck.new(report, @thresholds)
53
+ @check.met?
54
+ end
55
+
56
+ # GateCheck#message: the unmet gates and the failures to read.
57
+ def failure_message
58
+ @check.message
59
+ end
60
+
61
+ # For `expect(report).not_to meet_its_pass_gates`: every gate, each
62
+ # met, as Gate#to_s.
63
+ def failure_message_when_negated
64
+ [ "expected the report not to meet its pass gates, but every gate was met:", *@check.gates.map(&:to_s) ].join("\n")
65
+ end
66
+
67
+ # The generated example description: "meet its pass gates", or
68
+ # "meet the given pass gates" when thresholds were passed.
69
+ def description
70
+ @thresholds ? "meet the given pass gates" : "meet its pass gates"
71
+ end
72
+
73
+ # RSpec shows no diff: the failure message already lists the unmet
74
+ # gates.
75
+ def diffable?
76
+ false
77
+ end
78
+
79
+ # The matcher takes a report, not a block, so RSpec refuses
80
+ # `expect { ... }.to meet_its_pass_gates`.
81
+ def supports_block_expectations?
82
+ false
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -55,7 +55,13 @@ module ActiveAgent
55
55
  # `chat_options` go to `chat` itself, so they must be keywords
56
56
  # `RubyLLM::Chat.new` takes (`protocol:` on RubyLLM 2.x); anything else
57
57
  # raises ArgumentError here rather than failing every judge call
58
- # later. Configure the chat through `temperature:` or `configure:`:
58
+ # later. With `verify: true`, a missing key fails here too: the judge's
59
+ # label, model and provider go through `verify!` with `context` and
60
+ # `providers`, so a judge whose provider has no key, or is not one of
61
+ # the providers a host has keys for, refuses when it is built rather
62
+ # than at its first grade, after the agent has already been replayed;
63
+ # the default stays lazy. Configure the chat through `temperature:` or
64
+ # `configure:`:
59
65
  #
60
66
  # ActiveAgent::Evals::RubyLLM.judge(label: "judge", model: "gpt-5-mini", provider: :openai,
61
67
  # temperature: 0,
@@ -74,12 +80,23 @@ module ActiveAgent
74
80
  # `{ "kind", "model", "input_tokens", "output_tokens", "cost" }`, for a
75
81
  # host that meters its judge's spend; an error it raises is logged,
76
82
  # not propagated, so bookkeeping never costs a grade
83
+ # @param verify [Boolean] run `verify!` on the judge's own model with
84
+ # `context` when the judge is built, so a provider with no key
85
+ # refuses here rather than at the first grade
86
+ # @param providers [Array<String, Symbol>, nil] with `verify: true`, the
87
+ # providers a host has keys for, handed to `verify!` as its
88
+ # `providers:`; given without `verify: true` it raises ArgumentError
77
89
  # @param chat_options [Hash] other `chat` keywords, such as `protocol:`
78
90
  # @return [Judge]
79
- # @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take
91
+ # @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take,
92
+ # or `providers:` is given without `verify: true`
93
+ # @raise [::RubyLLM::ConfigurationError] with `verify: true`, when the judge's provider is not
94
+ # among `providers` or `context` cannot build the judge's chat
80
95
  def judge(label:, model:, provider:, context: ::RubyLLM, correlation: nil, assume_model_exists: true,
81
- temperature: nil, configure: nil, on_usage: nil, **chat_options)
96
+ temperature: nil, configure: nil, on_usage: nil, verify: false, providers: nil, **chat_options)
82
97
  check_chat_options!(chat_options)
98
+ raise ArgumentError, "providers: is checked only with verify: true" if providers && !verify
99
+ verify!(ModelSpec.new(label: label, provider: provider, model: model), context: context, providers: providers) if verify
83
100
 
84
101
  Judge.new(label: label) do |instructions:, prompt:, kind:|
85
102
  complete = lambda do
@@ -96,6 +113,65 @@ module ActiveAgent
96
113
  end
97
114
  end
98
115
 
116
+ # Checks, before a run spends anything, that each model can be built
117
+ # on `context`: its provider is one RubyLLM registered, is one of
118
+ # `providers` when those are given, and is configured, which
119
+ # `context.chat(model:, provider:, assume_model_exists: true)` settles
120
+ # while it instantiates the provider. Nothing is sent over the
121
+ # network: RubyLLM resolves the model locally. The specs are checked
122
+ # in order, and the first that cannot run raises, naming what to
123
+ # change: the provider's configuration requirements that `context`
124
+ # leaves unset, as environment variable names, or RubyLLM's own
125
+ # message when those are all set and it refused for another reason,
126
+ # such as a credential it checks beyond them or a deployment it
127
+ # cannot resolve:
128
+ #
129
+ # context = RubyLLM.context { |config| config.openai_api_key = ENV["OPENAI_API_KEY"] }
130
+ # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini claude-sonnet-5], default_provider: "openai")
131
+ #
132
+ # ActiveAgent::Evals::RubyLLM.verify!(models, context: context)
133
+ # # raises RubyLLM::ConfigurationError: claude-sonnet-5 runs on anthropic, which has no API key. Set ANTHROPIC_API_KEY.
134
+ #
135
+ # @param models [Array<ModelSpec>, ModelSpec] the run's models, or anything
136
+ # answering `label`, `provider` and `model`; a single spec is wrapped
137
+ # @param context [#chat] where `chat` is called; defaults to `::RubyLLM`.
138
+ # When it also answers `config`, as `::RubyLLM` and a `RubyLLM.context`
139
+ # do, a refusal names only the requirements that configuration leaves
140
+ # unset
141
+ # @param providers [Array<String, Symbol>, String, Symbol, nil] the
142
+ # providers the host has credentials for; a model on any other provider
143
+ # is refused before its chat is built
144
+ # @return [Array<ModelSpec>] the given models
145
+ # @raise [::RubyLLM::ConfigurationError] for the first model whose provider
146
+ # RubyLLM does not know, is outside `providers`, or cannot be built on
147
+ # `context`; in the last case RubyLLM's own error is the cause, and the
148
+ # message names what to set or carries RubyLLM's own
149
+ def verify!(models, context: ::RubyLLM, providers: nil)
150
+ specs = models.is_a?(Array) ? models : [ models ]
151
+ configured = Array(providers).map(&:to_s) if providers
152
+
153
+ specs.each do |spec|
154
+ provider = spec.provider.to_s
155
+ unless ::RubyLLM::Provider.providers.key?(provider.to_sym)
156
+ raise ::RubyLLM::ConfigurationError,
157
+ "#{spec.label} runs on #{provider}, which is not a RubyLLM provider; " \
158
+ "RubyLLM knows #{::RubyLLM::Provider.providers.keys.map(&:to_s).sort.join(', ')}"
159
+ end
160
+ if configured && !configured.include?(provider)
161
+ configured_for = configured.empty? ? "no provider has credentials configured" : "credentials are configured for #{configured.join(', ')}"
162
+ raise ::RubyLLM::ConfigurationError, "#{spec.label} runs on #{provider}; #{configured_for}"
163
+ end
164
+
165
+ begin
166
+ context.chat(model: spec.model, provider: provider.to_sym, assume_model_exists: true)
167
+ rescue ::RubyLLM::ConfigurationError => e
168
+ raise ::RubyLLM::ConfigurationError, refusal(spec, provider, context, e)
169
+ end
170
+ end
171
+
172
+ specs
173
+ end
174
+
99
175
  # Builds a Replay from the ordered messages of a RubyLLM conversation:
100
176
  # `acts_as_chat` message records of either RubyLLM generation (read
101
177
  # through their own `to_llm`), `RubyLLM::Message` values, or anything
@@ -167,6 +243,32 @@ module ActiveAgent
167
243
  "Pass temperature: to judge, or configure the chat with configure: ->(chat) { chat.with_... }."
168
244
  end
169
245
 
246
+ # The message for a model whose provider refused to be built: the
247
+ # requirement names `context` leaves unset, as environment variable
248
+ # names, called an API key only when each of them is one; RubyLLM's
249
+ # own message when nothing is left to set.
250
+ def refusal(spec, provider, context, error)
251
+ names = unset_requirements(provider, context).map { |name| name.to_s.upcase }
252
+ return "#{spec.label} runs on #{provider}: #{error.message.strip}" if names.empty?
253
+
254
+ state = names.all? { |name| name.end_with?("_API_KEY") } ? "has no API key" : "is not configured"
255
+ "#{spec.label} runs on #{provider}, which #{state}. Set #{names.join(' and ')}."
256
+ end
257
+
258
+ # The provider's configuration requirements that `context` leaves
259
+ # unset: all of them when the context exposes no configuration, and
260
+ # none on a release whose providers do not list them.
261
+ def unset_requirements(provider, context)
262
+ provider_class = ::RubyLLM::Provider.providers[provider.to_sym]
263
+ return [] unless provider_class.respond_to?(:configuration_requirements)
264
+
265
+ requirements = provider_class.configuration_requirements
266
+ return requirements unless context.respond_to?(:config)
267
+
268
+ config = context.config
269
+ requirements.reject { |name| config.respond_to?(name) && config.public_send(name) }
270
+ end
271
+
170
272
  def report_usage(callback, kind, model, response)
171
273
  return unless callback
172
274