activeagent 1.9.0 → 1.9.2
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 +219 -1
- data/lib/active_agent/base.rb +27 -2
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/report.rb +73 -7
- data/lib/active_agent/evals/report_html.rb +64 -2
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +3 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +7 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +37 -4
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +4 -2
|
@@ -37,9 +37,12 @@ module ActiveAgent
|
|
|
37
37
|
# @param release [Hash, nil] the release of the agent under evaluation,
|
|
38
38
|
# `{ "digest", "revision", "label" }`, so the report names the code
|
|
39
39
|
# it scored: in `to_h["release"]` and as a header chip.
|
|
40
|
+
# @param comparison [Hash, nil] a comparison already recorded for these
|
|
41
|
+
# results (Comparison#to_h). A report rebuilt from a persisted run
|
|
42
|
+
# renders the intervals that run recorded instead of resampling them.
|
|
40
43
|
def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
|
|
41
44
|
tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
|
|
42
|
-
judge_usage: nil, release: nil)
|
|
45
|
+
judge_usage: nil, release: nil, comparison: nil)
|
|
43
46
|
@results = results
|
|
44
47
|
@models = models
|
|
45
48
|
@judge = judge
|
|
@@ -53,6 +56,7 @@ module ActiveAgent
|
|
|
53
56
|
@recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
|
|
54
57
|
@run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
|
|
55
58
|
@release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
|
|
59
|
+
@recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
# The release of the agent this report scored, `{ "digest",
|
|
@@ -64,7 +68,8 @@ module ActiveAgent
|
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
# Per model, keyed by label: scenario count, passes, errors, pass rate,
|
|
67
|
-
# mean score,
|
|
71
|
+
# mean score, latency (mean, and the P50 and P95 by nearest rank,
|
|
72
|
+
# Statistics.percentile), tokens, cost and fault counts. "cost" sums
|
|
68
73
|
# the replays that carried a cost — "priced" of the "scenarios", of
|
|
69
74
|
# which "reported" came from the caller and "estimated" from tokens ×
|
|
70
75
|
# a model rate (Result#cost_source) — and is nil when none did.
|
|
@@ -90,6 +95,8 @@ module ActiveAgent
|
|
|
90
95
|
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
91
96
|
"avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
|
|
92
97
|
"avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
|
|
98
|
+
"p50_duration_ms" => Statistics.percentile(durations, 50)&.round,
|
|
99
|
+
"p95_duration_ms" => Statistics.percentile(durations, 95)&.round,
|
|
93
100
|
"input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
|
|
94
101
|
"output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
|
|
95
102
|
"cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
|
|
@@ -267,14 +274,27 @@ module ActiveAgent
|
|
|
267
274
|
verdict&.dig("winner")
|
|
268
275
|
end
|
|
269
276
|
|
|
270
|
-
#
|
|
271
|
-
#
|
|
277
|
+
# Whether the gap between the models is real (Comparison#to_h): each
|
|
278
|
+
# model's pass rate with its interval, every pair's difference with
|
|
279
|
+
# its interval and whether it is decisive, and the conclusion. The
|
|
280
|
+
# comparison recorded for these results when one was handed in. nil
|
|
281
|
+
# for a single model.
|
|
282
|
+
def comparison
|
|
283
|
+
return nil unless comparing?
|
|
284
|
+
|
|
285
|
+
@comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Adds "judge_usage" and "release" only when there is one to add, and
|
|
289
|
+
# "comparison" only when comparing models, so a single-model report
|
|
290
|
+
# built the way it always was serializes exactly as before.
|
|
272
291
|
def to_h
|
|
273
292
|
{
|
|
274
293
|
"models" => summary_by_model,
|
|
275
294
|
"criteria" => criterion_scores,
|
|
276
295
|
"recommendations" => recommendations,
|
|
277
296
|
"verdict" => verdict,
|
|
297
|
+
"comparison" => comparison,
|
|
278
298
|
"judge" => @judge_label || @judge&.label,
|
|
279
299
|
"judge_usage" => judge_usage,
|
|
280
300
|
"release" => release,
|
|
@@ -298,6 +318,7 @@ module ActiveAgent
|
|
|
298
318
|
lines.concat(total_lines)
|
|
299
319
|
lines << "**Best model: #{winner}**" if winner
|
|
300
320
|
lines << ""
|
|
321
|
+
lines.concat(comparison_lines)
|
|
301
322
|
lines.concat(matrix_table)
|
|
302
323
|
lines.concat(detail_lines)
|
|
303
324
|
lines.concat(recommendation_lines)
|
|
@@ -525,22 +546,27 @@ module ActiveAgent
|
|
|
525
546
|
|
|
526
547
|
# --- Markdown ----------------------------------------------------------
|
|
527
548
|
|
|
528
|
-
# The per-model table.
|
|
529
|
-
#
|
|
549
|
+
# The per-model table. Cost / 1,000 puts every model on one
|
|
550
|
+
# denominator however many of its scenarios ran. A Judge column joins
|
|
551
|
+
# it when a judge spent anything, so the agent's cost and the
|
|
552
|
+
# evaluation's stay two numbers.
|
|
530
553
|
def summary_table
|
|
531
554
|
judged = judge_usage.present?
|
|
532
|
-
columns = [ "Model", "Passed", "Mean score", "Mean latency", "Tokens in/out", "Cost", ("Judge" if judged), "Faults" ].compact
|
|
555
|
+
columns = [ "Model", "Passed", "Mean score", "Mean latency", "Median / P95", "Tokens in/out", "Cost", "Cost / 1,000", ("Judge" if judged), "Faults" ].compact
|
|
533
556
|
header = [ "| #{columns.join(' | ')} |", "|#{columns.map { '---' }.join('|')}|" ]
|
|
534
557
|
rows = summary_by_model.map do |label, stats|
|
|
535
558
|
faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
|
|
536
559
|
latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
|
|
560
|
+
percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
|
|
537
561
|
cells = [
|
|
538
562
|
"`#{label}`",
|
|
539
563
|
Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
|
|
540
564
|
Format.score(stats["avg_score"]),
|
|
541
565
|
latency,
|
|
566
|
+
percentiles,
|
|
542
567
|
"#{stats['input_tokens']}/#{stats['output_tokens']}",
|
|
543
568
|
Format.money(stats["cost"], estimated: estimated_cost?(stats)),
|
|
569
|
+
per_thousand_cell(stats),
|
|
544
570
|
(judge_cell(stats) if judged),
|
|
545
571
|
faults.presence || "—"
|
|
546
572
|
].compact
|
|
@@ -549,6 +575,14 @@ module ActiveAgent
|
|
|
549
575
|
header + rows
|
|
550
576
|
end
|
|
551
577
|
|
|
578
|
+
# "$3.6300": what 1,000 scenario runs cost at the model's mean cost
|
|
579
|
+
# per priced replay, marked "~" when that mean is estimated or covers
|
|
580
|
+
# only some of the replays. "—" when none was priced.
|
|
581
|
+
def per_thousand_cell(stats)
|
|
582
|
+
per = cost_per_priced(stats)
|
|
583
|
+
per ? Format.money(per * 1_000, estimated: estimated_cost?(stats)) : "—"
|
|
584
|
+
end
|
|
585
|
+
|
|
552
586
|
# "~$0.0030 (3 calls)" for a model's judge spend, "—" when the judge
|
|
553
587
|
# was not asked about its results.
|
|
554
588
|
def judge_cell(stats)
|
|
@@ -572,6 +606,38 @@ module ActiveAgent
|
|
|
572
606
|
lines << ""
|
|
573
607
|
end
|
|
574
608
|
|
|
609
|
+
# Each model's pass rate with its interval, best first, then every
|
|
610
|
+
# pair's difference with its interval, the conclusion, and how the
|
|
611
|
+
# intervals were computed. Nothing for a single model.
|
|
612
|
+
def comparison_lines
|
|
613
|
+
return [] unless comparison
|
|
614
|
+
|
|
615
|
+
method = comparison["method"]
|
|
616
|
+
confidence = "#{(method['confidence'].to_f * 100).round}% CI"
|
|
617
|
+
lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
|
|
618
|
+
comparison["ranking"].each do |label|
|
|
619
|
+
stats = comparison["models"][label]
|
|
620
|
+
lines << "| `#{label}` | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
|
|
621
|
+
end
|
|
622
|
+
lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
|
|
623
|
+
comparison["pairs"].each do |pair|
|
|
624
|
+
a, b = pair["models"]
|
|
625
|
+
difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
|
|
626
|
+
lines << "| `#{a}` vs `#{b}` | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
|
|
627
|
+
end
|
|
628
|
+
lines += [ "", comparison["conclusion"].to_s, "",
|
|
629
|
+
"_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def rate_text(rate)
|
|
633
|
+
rate ? "#{rate}%" : "—"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# "73.8 to 95.2", or "—" when there were too few scenarios.
|
|
637
|
+
def interval_text(interval)
|
|
638
|
+
interval ? "#{interval.first} to #{interval.last}" : "—"
|
|
639
|
+
end
|
|
640
|
+
|
|
575
641
|
# The scenario matrix, with a trailing Cost column: what the scenario
|
|
576
642
|
# cost across every model, and in brackets what the judge spent on it.
|
|
577
643
|
def matrix_table
|
|
@@ -151,6 +151,14 @@ module ActiveAgent
|
|
|
151
151
|
"#{format("%.#{n >= 10_000 ? 1 : 2}f", n / 1_000).sub(/\.?0+\z/, '')}s"
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# "4.02s / 22.2s": a model's P50 and P95 latency; "—" when no replay
|
|
155
|
+
# was timed.
|
|
156
|
+
def fmt_percentiles(stats)
|
|
157
|
+
return "—" if stats["p50_duration_ms"].nil?
|
|
158
|
+
|
|
159
|
+
"#{fmt_ms(stats['p50_duration_ms'])} / #{fmt_ms(stats['p95_duration_ms'])}"
|
|
160
|
+
end
|
|
161
|
+
|
|
154
162
|
# Money reads "~$0.0243" when any part of it was estimated from tokens
|
|
155
163
|
# × a model rate rather than reported (Format.money); the footer's
|
|
156
164
|
# legend explains the mark once.
|
|
@@ -314,6 +322,7 @@ module ActiveAgent
|
|
|
314
322
|
<div class="panel">
|
|
315
323
|
<div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span></div>
|
|
316
324
|
#{html_comparison_table if comparing?}
|
|
325
|
+
#{html_intervals}
|
|
317
326
|
#{blocks.join}
|
|
318
327
|
#{verdict_row}
|
|
319
328
|
</div>
|
|
@@ -335,12 +344,49 @@ module ActiveAgent
|
|
|
335
344
|
|
|
336
345
|
<<~TABLE
|
|
337
346
|
<div class="compare"><table>
|
|
338
|
-
<thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
|
|
347
|
+
<thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Median and 95th percentile latency, nearest rank">P50 / P95</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
|
|
339
348
|
<tbody>#{rows.map { |label, stats| html_comparison_row(label, stats) }.join}</tbody>
|
|
340
349
|
</table></div>
|
|
341
350
|
TABLE
|
|
342
351
|
end
|
|
343
352
|
|
|
353
|
+
# The ranked interval plot under the comparison table (Report#comparison):
|
|
354
|
+
# one row per model, best first, its pass rate a dot inside its interval
|
|
355
|
+
# on a fixed 0-100% axis, so a narrow gap is not magnified, with the
|
|
356
|
+
# figures written out beside it; then the conclusion and how the
|
|
357
|
+
# intervals were computed. Nothing for a single model. The rows and the
|
|
358
|
+
# axis share one grid (.iv-row is display: contents), so every track
|
|
359
|
+
# spans the same width and a tick sits under the dots it names.
|
|
360
|
+
def html_intervals
|
|
361
|
+
return "" unless comparison
|
|
362
|
+
|
|
363
|
+
method = comparison["method"]
|
|
364
|
+
confidence = "#{(method['confidence'].to_f * 100).round}% CI"
|
|
365
|
+
rows = comparison["ranking"].map { |label| html_interval_row(label, comparison["models"][label] || {}) }
|
|
366
|
+
ticks = [ 0, 25, 50, 75, 100 ].map { |tick| %(<span class="iv-tick" style="left:#{tick}%">#{tick}%</span>) }
|
|
367
|
+
|
|
368
|
+
<<~PLOT
|
|
369
|
+
<div class="intervals">
|
|
370
|
+
<div class="micro sm">Pass rate · #{h(confidence)}</div>
|
|
371
|
+
#{rows.join}
|
|
372
|
+
<div class="iv-row iv-axis" aria-hidden="true"><span></span><span class="iv-track">#{ticks.join}</span><span></span></div>
|
|
373
|
+
<p class="iv-conclusion">#{h(comparison['conclusion'])}</p>
|
|
374
|
+
<p class="iv-caption">#{h(confidence)} from #{h(Format.delimited(method['resamples']))} resamples of whole scenarios</p>
|
|
375
|
+
</div>
|
|
376
|
+
PLOT
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def html_interval_row(label, stats)
|
|
380
|
+
rate = stats["pass_rate"]
|
|
381
|
+
low, high = stats["interval"]
|
|
382
|
+
range = high ? %(<span class="iv-range" style="left:#{low}%;width:#{(high - low).round(1)}%"></span>) : ""
|
|
383
|
+
point = rate ? %(<span class="iv-point" style="left:#{rate}%"></span>) : ""
|
|
384
|
+
figures = "<b>#{rate ? "#{rate}%" : '—'}</b> #{high ? "#{low} to #{high}" : 'no interval'} · #{h(plural(stats['scenarios'].to_i, 'scenario'))}"
|
|
385
|
+
|
|
386
|
+
%(<div class="iv-row"><span class="iv-name">#{h(short_name(model_by_label(label)))}</span>) +
|
|
387
|
+
%(<span class="iv-track">#{range}#{point}</span><span class="iv-figures">#{figures}</span></div>\n)
|
|
388
|
+
end
|
|
389
|
+
|
|
344
390
|
def html_comparison_row(label, stats)
|
|
345
391
|
short, provider = split_label(model_by_label(label))
|
|
346
392
|
total = stats["scenarios"].to_i
|
|
@@ -362,6 +408,7 @@ module ActiveAgent
|
|
|
362
408
|
<td class="num ratio tone-#{tone_for(ratio)}">#{h(fmt_passes(stats['passed'], total))}</td>
|
|
363
409
|
<td class="num">#{h(fmt_score(stats['avg_score']))}</td>
|
|
364
410
|
<td class="num">#{h(fmt_ms(stats['avg_duration_ms']))}</td>
|
|
411
|
+
<td class="num">#{h(fmt_percentiles(stats))}</td>
|
|
365
412
|
<td class="num"#{tokens_title}>#{tokens_cell}</td>
|
|
366
413
|
<td class="num"#{cost_title_attr(results_for(label), estimated: estimated)}>#{cost_cell}</td>
|
|
367
414
|
#{judge_cell}<td class="fault">#{typical_fault_text(label, stats)}</td>
|
|
@@ -417,7 +464,7 @@ module ActiveAgent
|
|
|
417
464
|
<<~BLOCK
|
|
418
465
|
<div class="model">
|
|
419
466
|
<div class="line"><span class="name">#{h(short)}</span><span class="provider">#{h(provider)}</span>#{pick}<span class="pass"><span class="bar bar-#{tone}"><span style="width:#{(ratio * 100).round}%"></span></span><span class="ratio tone-#{tone}">#{h(fmt_passes(stats['passed'], total))}</span></span></div>
|
|
420
|
-
<div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
|
|
467
|
+
<div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span>p50 / p95 <b>#{h(fmt_percentiles(stats))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
|
|
421
468
|
<div class="faults">#{faults_html}</div>
|
|
422
469
|
</div>
|
|
423
470
|
BLOCK
|
|
@@ -830,6 +877,21 @@ module ActiveAgent
|
|
|
830
877
|
.pick-model input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
831
878
|
.pick-model:has(input:checked) { border-color: var(--color-accent-ui); background: var(--color-accent-ui-tint); color: var(--color-accent-ui); }
|
|
832
879
|
.clean { font-family: var(--font-mono); font-size: 11px; color: var(--color-success-text); }
|
|
880
|
+
.intervals { padding: 10px 12px; border-top: 1px solid var(--color-border-light); display: grid; grid-template-columns: minmax(120px, 200px) minmax(160px, 1fr) minmax(190px, auto); align-items: center; gap: 6px 12px; }
|
|
881
|
+
.intervals > .micro, .iv-conclusion, .iv-caption { grid-column: 1 / -1; }
|
|
882
|
+
.iv-row { display: contents; }
|
|
883
|
+
.iv-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
884
|
+
.iv-track { position: relative; height: 14px; }
|
|
885
|
+
.iv-track::before { content: ""; position: absolute; left: 0; right: 0; top: 6px; height: 2px; background: var(--color-muted); }
|
|
886
|
+
.iv-range { position: absolute; top: 3px; height: 8px; box-sizing: border-box; border: 1px solid var(--color-info); border-radius: 999px; background: var(--color-info-soft); }
|
|
887
|
+
.iv-point { position: absolute; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--color-info); }
|
|
888
|
+
.iv-figures { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
|
|
889
|
+
.iv-figures b { color: var(--color-text-primary); }
|
|
890
|
+
.iv-axis .iv-track::before { display: none; }
|
|
891
|
+
.iv-tick { position: absolute; top: 0; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
|
|
892
|
+
.iv-conclusion { margin: 4px 0 0; font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
|
|
893
|
+
.iv-caption { margin: 0; font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
|
|
894
|
+
@media (max-width: 640px) { .intervals { grid-template-columns: minmax(0, 1fr); row-gap: 4px; } .iv-row + .iv-row .iv-name { margin-top: 6px; } .iv-axis { display: none; } }
|
|
833
895
|
.verdict { padding: 10px 12px; border-top: 1px solid var(--color-border-light); font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
|
|
834
896
|
.verdict .micro { margin-right: 8px; }
|
|
835
897
|
.fixes { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
|
@@ -54,10 +54,14 @@ module ActiveAgent
|
|
|
54
54
|
# @param release [Hash, nil] the release of the agent under evaluation,
|
|
55
55
|
# `{ "digest", "revision", "label" }`, carried onto the Report so a
|
|
56
56
|
# published run names the code it scored (see Report.new)
|
|
57
|
+
# @param refine_gate [#call, nil] asked before each recommendation call,
|
|
58
|
+
# after `judge_limit`; false skips it. A caller that scores each
|
|
59
|
+
# scenario in its own Runner (a background job per scenario) shares one
|
|
60
|
+
# run-wide limit this way, since `judge_limit` counts per instance.
|
|
57
61
|
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
58
62
|
instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
|
|
59
63
|
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
60
|
-
around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
|
|
64
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
|
|
61
65
|
@scenarios = scenarios
|
|
62
66
|
@models = models
|
|
63
67
|
@replay = replay
|
|
@@ -75,6 +79,7 @@ module ActiveAgent
|
|
|
75
79
|
@require_judge_scores = require_judge_scores
|
|
76
80
|
@metadata = metadata
|
|
77
81
|
@release = release
|
|
82
|
+
@refine_gate = refine_gate
|
|
78
83
|
@judge_calls = 0
|
|
79
84
|
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
80
85
|
end
|
|
@@ -187,6 +192,7 @@ module ActiveAgent
|
|
|
187
192
|
def refine!(diagnosis, scenario, replay, result)
|
|
188
193
|
return unless @judge && @refine_faults.include?(result.fault)
|
|
189
194
|
return if @judge_calls >= @judge_limit
|
|
195
|
+
return if @refine_gate && !@refine_gate.call
|
|
190
196
|
|
|
191
197
|
@judge_calls += 1
|
|
192
198
|
refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# The summary statistics a report computes over its results.
|
|
6
|
+
module Statistics
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Nearest-rank percentile: the value at rank ceil(pct / 100 × n) of the
|
|
10
|
+
# values sorted ascending, so the result is always one of the values. The
|
|
11
|
+
# dashboard's Metrics view uses the same rule (ActionAgent::MetricsReport),
|
|
12
|
+
# so a model's P95 reads the same in both. nil for no values.
|
|
13
|
+
#
|
|
14
|
+
# @param values [Array<Numeric>] in any order; nils are ignored
|
|
15
|
+
# @param pct [Numeric] 0..100
|
|
16
|
+
# @return [Numeric, nil]
|
|
17
|
+
def percentile(values, pct)
|
|
18
|
+
sorted = values.compact.sort
|
|
19
|
+
return nil if sorted.empty?
|
|
20
|
+
|
|
21
|
+
rank = (sorted.size * pct / 100.0).ceil
|
|
22
|
+
sorted[rank.clamp(1, sorted.size) - 1]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -19,6 +19,8 @@ require_relative "evals/catalog"
|
|
|
19
19
|
require_relative "evals/model_spec"
|
|
20
20
|
require_relative "evals/replay"
|
|
21
21
|
require_relative "evals/format"
|
|
22
|
+
require_relative "evals/statistics"
|
|
23
|
+
require_relative "evals/comparison"
|
|
22
24
|
require_relative "evals/scorer"
|
|
23
25
|
require_relative "evals/diagnosis"
|
|
24
26
|
require_relative "evals/judge"
|
|
@@ -43,7 +43,7 @@ module ActiveAgent
|
|
|
43
43
|
def initialize(kwargs = {})
|
|
44
44
|
super
|
|
45
45
|
|
|
46
|
-
self.json_format_retry_count =
|
|
46
|
+
self.json_format_retry_count = options.max_retries
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
|
|
@@ -222,6 +222,8 @@ module ActiveAgent
|
|
|
222
222
|
# No-Op; Wait for Function call to be complete
|
|
223
223
|
when :thinking_delta, :signature_delta
|
|
224
224
|
# TODO: Add with thinking rendering support
|
|
225
|
+
when :citations_delta
|
|
226
|
+
# No-Op; the citations arrive with the full block at content_block_stop
|
|
225
227
|
else
|
|
226
228
|
raise "Unexpected delta type: #{api_delta.type}"
|
|
227
229
|
end
|
|
@@ -143,6 +143,13 @@ module ActiveAgent
|
|
|
143
143
|
# => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
|
|
144
144
|
when :"tool_calls.function.arguments.done"
|
|
145
145
|
# => => {type: :"tool_calls.function.arguments.done", index: 0, name: "get_current_weather", arguments: "{\"location\":\"Boston, MA\"}", parsed: nil}
|
|
146
|
+
when :"refusal.delta"
|
|
147
|
+
# No-Op; the refusal is merged into the message from the :chunk event
|
|
148
|
+
when :"refusal.done"
|
|
149
|
+
# A refused message ends here, with no content.done
|
|
150
|
+
self.stream_completion_pending = true
|
|
151
|
+
when :"logprobs.content.delta", :"logprobs.content.done", :"logprobs.refusal.delta", :"logprobs.refusal.done"
|
|
152
|
+
# No-Op; sent when the request asks for logprobs
|
|
146
153
|
else
|
|
147
154
|
fail "Unexpected Response Event Type: #{api_response_event.type}"
|
|
148
155
|
end
|
|
@@ -15,6 +15,18 @@ module ActiveAgent
|
|
|
15
15
|
class ResponsesProvider < Base
|
|
16
16
|
include ToolChoiceClearing
|
|
17
17
|
|
|
18
|
+
# Output items of OpenAI's built-in tools (web search, file search,
|
|
19
|
+
# image generation, code interpreter, remote MCP).
|
|
20
|
+
BUILT_IN_TOOL_ITEM_TYPES = %i[
|
|
21
|
+
web_search_call file_search_call image_generation_call code_interpreter_call mcp_call mcp_list_tools
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Prefixes of the progress events those tools stream.
|
|
25
|
+
BUILT_IN_TOOL_EVENT_PREFIXES = %w[
|
|
26
|
+
response.web_search_call. response.file_search_call. response.image_generation_call.
|
|
27
|
+
response.code_interpreter_call response.mcp_call response.mcp_list_tools.
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
18
30
|
# @return [Class]
|
|
19
31
|
def self.options_klass
|
|
20
32
|
Options
|
|
@@ -99,6 +111,7 @@ module ActiveAgent
|
|
|
99
111
|
# - `:"response.content_part.done"` - content part completed
|
|
100
112
|
# - `:"response.output_item.done"` - message, reasoning or function call completed
|
|
101
113
|
# - `:"response.completed"` - response finished
|
|
114
|
+
# - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
|
|
102
115
|
#
|
|
103
116
|
# @param api_response_event [Hash] streaming chunk with :type key
|
|
104
117
|
# @return [void]
|
|
@@ -106,6 +119,12 @@ module ActiveAgent
|
|
|
106
119
|
def process_stream_chunk(api_response_event)
|
|
107
120
|
instrument("stream_chunk.active_agent", chunk_type: api_response_event.type)
|
|
108
121
|
|
|
122
|
+
# Built-in tools report their progress with their own events; the
|
|
123
|
+
# finished tool call arrives as an output item.
|
|
124
|
+
if BUILT_IN_TOOL_EVENT_PREFIXES.any? { api_response_event.type.to_s.start_with?(_1) }
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
109
128
|
case api_response_event.type
|
|
110
129
|
# Response Created
|
|
111
130
|
when :"response.created", :"response.in_progress"
|
|
@@ -124,12 +143,20 @@ module ActiveAgent
|
|
|
124
143
|
message[:content] += api_response_event.delta
|
|
125
144
|
broadcast_stream_update(message, api_response_event.delta)
|
|
126
145
|
|
|
146
|
+
# -> -> -> Content Text Citation (e.g. from web search)
|
|
147
|
+
when :"response.output_text.annotation.added"
|
|
148
|
+
# No-Op: The text is complete at response.output_text.done
|
|
149
|
+
|
|
127
150
|
# -> -> -> Content Text Completed [Full Text]
|
|
128
151
|
when :"response.output_text.done"
|
|
129
152
|
message = message_stack.find { _1[:id] == api_response_event.item_id }
|
|
130
153
|
message[:content] = api_response_event.text
|
|
131
154
|
broadcast_stream_update(message, nil) # Don't double send content
|
|
132
155
|
|
|
156
|
+
# -> -> -> Refusal (the response still ends with response.completed)
|
|
157
|
+
when :"response.refusal.delta", :"response.refusal.done"
|
|
158
|
+
# No-Op: The message has no refusal field to stream it into
|
|
159
|
+
|
|
133
160
|
# -> -> -> Content Function Call Append
|
|
134
161
|
when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
|
|
135
162
|
# No-Op: Wait for FC to Land
|
|
@@ -147,8 +174,13 @@ module ActiveAgent
|
|
|
147
174
|
when :"response.output_item.done"
|
|
148
175
|
process_stream_output_item_done(api_response_event)
|
|
149
176
|
|
|
150
|
-
# Response Completed
|
|
151
|
-
|
|
177
|
+
# Response Completed, or cut short (e.g. by max_output_tokens) with
|
|
178
|
+
# the output it has so far
|
|
179
|
+
when :"response.completed", :"response.incomplete"
|
|
180
|
+
# The streamed response carries no body for resolve_prompt to read
|
|
181
|
+
# usage from, so it is recorded from the final event.
|
|
182
|
+
record_stream_usage(api_response_event.response.usage)
|
|
183
|
+
|
|
152
184
|
# Completion, tool loop included, runs from stream_finished! once
|
|
153
185
|
# the stream has drained, so each turn's tool calls run once.
|
|
154
186
|
self.stream_completion_pending = true
|
|
@@ -174,7 +206,7 @@ module ActiveAgent
|
|
|
174
206
|
# PATCH: API returns an empty array instead of empty string due to a bug in their serialization
|
|
175
207
|
item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
|
|
176
208
|
message_stack << { content: "" }.merge(item_hash)
|
|
177
|
-
when :function_call, :reasoning
|
|
209
|
+
when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
|
|
178
210
|
# No-Op: Wait for the item to land (-> response.output_item.done)
|
|
179
211
|
else
|
|
180
212
|
raise "Unexpected Item Type: #{api_response_event.item.type}"
|
|
@@ -194,7 +226,8 @@ module ActiveAgent
|
|
|
194
226
|
case api_response_event.item.type
|
|
195
227
|
when :message
|
|
196
228
|
# No-Op: Message Up to Date
|
|
197
|
-
when :function_call, :reasoning
|
|
229
|
+
when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
|
|
230
|
+
# Kept like the non-streamed output, which holds every item
|
|
198
231
|
item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
|
|
199
232
|
message_stack << item_hash
|
|
200
233
|
else
|