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
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
#
|
|
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 << "| #{[
|
|
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 << "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|