activeagent 1.3.0 → 1.4.0

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.
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The outcome of one evaluation run: every scenario × model Result, a summary
6
+ # per model, criterion statistics, the faults grouped with the fix each
7
+ # calls for, and the model that did best. Renders as a hash, JSON,
8
+ # Markdown, or a self-contained HTML page (ReportHtml).
9
+ class Report
10
+ include ReportHtml
11
+
12
+ # The judge a verdict names when the framework ranked the models by
13
+ # pass rate itself, no judge having been available to rule on them.
14
+ PASS_RATE_JUDGE = "pass rate"
15
+
16
+ attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver
17
+
18
+ # @param tool_resolver [#call, nil] maps a tool name to the MCP server
19
+ # that provides it — `{ "key", "name", "status" }` with status
20
+ # "enabled", "available" or "unknown" — or nil; enriches +fix_items+
21
+ # @param agent_name [String, nil] how fix items name the agent
22
+ # @param links [Hash] route templates for fix item actions:
23
+ # `"mcp"` (`"/mcp/%{key}"`), `"tools"`, `"instructions"`. An action
24
+ # whose route is absent carries `"path" => nil`.
25
+ # @param verdict [Hash, nil] a verdict already recorded for these
26
+ # results — `{ "winner", "rationale", "judge" }`. A report rebuilt
27
+ # from a persisted run renders the pick that run recorded instead of
28
+ # ranking the results again (and re-asking the judge), so the page
29
+ # and the dashboard never name two different best models.
30
+ # @param judge_label [String, nil] how to name the judge when no Judge
31
+ # instance is at hand — a rebuilt run knows only its label.
32
+ def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
33
+ tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil)
34
+ @results = results
35
+ @models = models
36
+ @judge = judge
37
+ @judge_label = judge_label.presence
38
+ @instructions = instructions
39
+ @threshold = threshold
40
+ @metadata = metadata
41
+ @tool_resolver = tool_resolver
42
+ @agent_name = agent_name.presence || "the agent"
43
+ @links = (links || {}).to_h.stringify_keys
44
+ @recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
45
+ end
46
+
47
+ def comparing?
48
+ @models.size > 1
49
+ end
50
+
51
+ # Per model, keyed by label: scenario count, passes, errors, pass rate,
52
+ # mean score, mean latency, tokens, cost and fault counts.
53
+ def summary_by_model
54
+ @summary_by_model ||= @models.to_h do |spec|
55
+ cohort = @results.select { |result| result.label == spec.label }
56
+ scored = cohort.filter_map(&:score)
57
+ durations = cohort.filter_map { |result| result.replay.duration_ms }
58
+ costs = cohort.filter_map { |result| result.replay.cost }
59
+
60
+ [ spec.label, {
61
+ "provider" => spec.provider,
62
+ "model" => spec.model,
63
+ "scenarios" => cohort.size,
64
+ "passed" => cohort.count(&:passed?),
65
+ "errored" => cohort.count(&:errored?),
66
+ "pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
67
+ "avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
68
+ "avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
69
+ "input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
70
+ "output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
71
+ "cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
72
+ "faults" => cohort.filter_map(&:fault).tally
73
+ } ]
74
+ end
75
+ end
76
+
77
+ # Per criterion key: `{ "score", "min", "max", "passed", "total" }` over
78
+ # every result, or a map of model label => those stats when comparing
79
+ # models. A criterion nothing could score is `{ "skipped" => true }`.
80
+ def criterion_scores
81
+ @criterion_scores ||= criterion_keys.to_h do |key|
82
+ stats =
83
+ if comparing?
84
+ @models.to_h do |spec|
85
+ [ spec.label, stats_for(@results.select { |result| result.label == spec.label }.map { |result| result.scores[key] }) ]
86
+ end
87
+ else
88
+ stats_for(@results.map { |result| result.scores[key] })
89
+ end
90
+ [ key, stats ]
91
+ end
92
+ end
93
+
94
+ # Faults across scenarios and models, most frequent first, each with the
95
+ # scenarios it hit and the fix it calls for.
96
+ def recommendations
97
+ @recommendations ||= @results.select(&:fault).group_by(&:fault).map do |fault, faulted|
98
+ {
99
+ "fault" => fault,
100
+ "count" => faulted.size,
101
+ "scenario_keys" => faulted.map { |result| result.scenario.key }.uniq,
102
+ "models" => faulted.map(&:label).uniq,
103
+ "recommendation" => faulted.filter_map(&:recommendation).tally.max_by(&:last)&.first,
104
+ "suggested_tools" => faulted.filter_map(&:suggested_tool).uniq
105
+ }
106
+ end.sort_by { |entry| -entry["count"] }
107
+ end
108
+
109
+ # What to fix: one item per fault, in +recommendations+ order, plus one
110
+ # per distinct instruction change the judge proposed. Each item names
111
+ # the tools involved — the missing tools a scenario expected, the tools
112
+ # that errored, or the tools the judge suggested — the MCP server that
113
+ # provides them when +tool_resolver+ knows it, and the dashboard action
114
+ # that addresses it when +links+ carry the route:
115
+ #
116
+ # { "kind" => "fault" | "instruction", "fault" => "expected_tool_not_called", "count" => 3,
117
+ # "scenario_keys" => [...], "models" => [...], "recommendation" => "...", "quote" => nil,
118
+ # "tools_label" => "missing tools", "tools" => [ { "name", "note", "server" } ],
119
+ # "server" => { "key", "name", "status" } | nil, "note" => "..." | nil,
120
+ # "action" => { "label", "hint", "path" } | nil }
121
+ def fix_items
122
+ @fix_items ||= fault_fix_items + instruction_fix_items
123
+ end
124
+
125
+ # The best model when comparing: the verdict the run recorded when one
126
+ # was handed in, else highest pass rate, then mean score, then lowest
127
+ # cost (a model with no cost estimate ranks after one with), with the
128
+ # judge's rationale when one is available.
129
+ # `{ "winner", "rationale", "judge" }`, or nil for a single model.
130
+ def verdict
131
+ return @recorded_verdict if @recorded_verdict
132
+ return nil unless comparing?
133
+
134
+ @verdict ||= begin
135
+ ranked = summary_by_model.sort_by do |_label, stats|
136
+ [ -stats["pass_rate"].to_f, -stats["avg_score"].to_f, stats["cost"] || Float::INFINITY ]
137
+ end
138
+ winner, stats = ranked.first
139
+ rationale = "Passed #{stats['passed']} of #{stats['scenarios']} scenarios" \
140
+ "#{" with a mean score of #{stats['avg_score']}" if stats['avg_score']}" \
141
+ "#{" at an estimated $#{format('%.4f', stats['cost'])}" if stats['cost']}."
142
+ judged = @judge&.verdict(summary_by_model, instructions: @instructions)
143
+
144
+ {
145
+ "winner" => judged&.dig("winner").presence || winner,
146
+ "rationale" => judged&.dig("rationale").presence || rationale,
147
+ "judge" => judged ? @judge.label : PASS_RATE_JUDGE
148
+ }
149
+ end
150
+ end
151
+
152
+ def winner
153
+ verdict&.dig("winner")
154
+ end
155
+
156
+ def to_h
157
+ {
158
+ "models" => summary_by_model,
159
+ "criteria" => criterion_scores,
160
+ "recommendations" => recommendations,
161
+ "verdict" => verdict,
162
+ "judge" => @judge&.label,
163
+ "metadata" => @metadata.presence,
164
+ "results" => @results.map(&:to_h)
165
+ }.compact
166
+ end
167
+
168
+ def to_json(*args)
169
+ JSON.pretty_generate(to_h, *args)
170
+ end
171
+
172
+ def to_markdown
173
+ scenario_count = @results.map { |result| result.scenario.key }.uniq.size
174
+ lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
175
+ lines << (@judge ? "Judged by `#{@judge.label}`." : "No judge; scored on rules and expectations alone.")
176
+ lines << ""
177
+ lines.concat(summary_table)
178
+ lines << ""
179
+ lines << "**Best model: #{winner}**" if winner
180
+ lines << ""
181
+ lines.concat(matrix_table)
182
+ lines.concat(recommendation_lines)
183
+ lines.concat(detail_lines)
184
+ lines.join("\n")
185
+ end
186
+
187
+ private
188
+
189
+ def criterion_keys
190
+ @results.flat_map { |result| result.scores.keys }.uniq
191
+ end
192
+
193
+ def stats_for(values)
194
+ scored = values.compact
195
+ return { "skipped" => true, "reason" => "No scorable answers" } if scored.empty?
196
+
197
+ {
198
+ "score" => (scored.sum / scored.size).round(3),
199
+ "min" => scored.min.round(3),
200
+ "max" => scored.max.round(3),
201
+ "passed" => scored.count { |value| value >= @threshold },
202
+ "total" => scored.size
203
+ }
204
+ end
205
+
206
+ # --- fix items ---------------------------------------------------------
207
+
208
+ def fault_fix_items
209
+ by_fault = @results.select(&:fault).group_by(&:fault)
210
+ recommendations.map do |entry|
211
+ faulted = by_fault[entry["fault"]]
212
+ tools_label, tools = fix_tools(entry["fault"], faulted)
213
+ server = tools_label == "missing tools" ? shared_server(tools) : nil
214
+
215
+ {
216
+ "kind" => "fault",
217
+ "fault" => entry["fault"],
218
+ "count" => entry["count"],
219
+ "scenario_keys" => entry["scenario_keys"],
220
+ "models" => entry["models"],
221
+ "recommendation" => fix_recommendation(entry, faulted, tools),
222
+ "quote" => nil,
223
+ "tools_label" => tools.any? ? tools_label : nil,
224
+ "tools" => tools,
225
+ "server" => server,
226
+ "note" => fix_note(entry["fault"], faulted, tools),
227
+ "action" => fix_action(tools_label, tools, server)
228
+ }
229
+ end
230
+ end
231
+
232
+ def instruction_fix_items
233
+ @results.select { |result| result.diagnosis&.dig("judge", "instruction_change").present? }
234
+ .group_by { |result| result.diagnosis.dig("judge", "instruction_change").to_s.strip }
235
+ .map do |sentence, cohort|
236
+ {
237
+ "kind" => "instruction",
238
+ "fault" => "instruction change",
239
+ "count" => cohort.size,
240
+ "scenario_keys" => cohort.map { |result| result.scenario.key }.uniq,
241
+ "models" => cohort.map(&:label).uniq,
242
+ # The judge writes one recommendation per result and Runner#refine!
243
+ # puts it on the diagnosis, so it is already the text of the fault
244
+ # card built from the same result: the quote is what this card adds.
245
+ "recommendation" => nil,
246
+ "quote" => sentence,
247
+ "tools_label" => nil,
248
+ "tools" => [],
249
+ "server" => nil,
250
+ "note" => nil,
251
+ "action" => fix_action_for("Add to instructions", "Agent -> Instructions", link("instructions"))
252
+ }
253
+ end
254
+ end
255
+
256
+ # The fix the card asks for: the fault's most frequent recommendation,
257
+ # except on an `expected_tool_not_called` card that names missing tools.
258
+ # That card speaks for the scenarios whose tool was unavailable, so its
259
+ # text comes from those alone — the most frequent recommendation may be
260
+ # a scenario whose tool was there all along (a tie is won by whichever
261
+ # was seen first), whose wording contradicts the card's own tools,
262
+ # server and "Enable …" button. That exception keeps its say in +note+.
263
+ def fix_recommendation(entry, faulted, tools)
264
+ return entry["recommendation"] unless entry["fault"] == "expected_tool_not_called" && tools.any?
265
+
266
+ blocked = faulted.select { |result| unavailable_tools(result).any? }
267
+ blocked.filter_map(&:recommendation).tally.max_by(&:last)&.first || entry["recommendation"]
268
+ end
269
+
270
+ # [label, tools] for a fault: the tools the scenarios expected but the
271
+ # agent could not call, the tools that errored, or the tools the judge
272
+ # suggested — deduplicated by name.
273
+ def fix_tools(fault, faulted)
274
+ case fault
275
+ when "expected_tool_not_called"
276
+ [ "missing tools", faulted.flat_map { |result| unavailable_tools(result) }.uniq.map { |name| tool_entry(name) } ]
277
+ when "missing_capability"
278
+ names = suggested_tool_names(faulted) + faulted.flat_map { |result| unavailable_tools(result) }
279
+ [ "suggested tools", names.uniq.map { |name| tool_entry(name) } ]
280
+ when "tool_error"
281
+ failed = faulted.flat_map { |result| result.replay.failed_tool_calls }.uniq { |call| call["name"].to_s }
282
+ [ "failing tools", failed.map { |call| tool_entry(call["name"], note: call["detail"].to_s.truncate(60).presence) } ]
283
+ else
284
+ [ "suggested tools", suggested_tool_names(faulted).uniq.map { |name| tool_entry(name) } ]
285
+ end
286
+ end
287
+
288
+ def suggested_tool_names(faulted)
289
+ faulted.filter_map { |result| result.suggested_tool&.dig("name").presence }
290
+ end
291
+
292
+ # Tools the scenario expects that the agent could not call: what the
293
+ # diagnosis recorded as unavailable or, for a diagnosis without that
294
+ # evidence, the expected tools outside its toolset (or, failing that,
295
+ # the ones it did not call).
296
+ def unavailable_tools(result)
297
+ evidence = result.diagnosis&.dig("evidence") || {}
298
+ return Array(evidence["unavailable"]).map(&:to_s) if evidence.key?("unavailable")
299
+ return result.scenario.expected_tools - Array(evidence["tools_available"]).map(&:to_s) if evidence.key?("tools_available")
300
+
301
+ result.scenario.expected_tools - result.replay.tool_names
302
+ end
303
+
304
+ def tool_entry(name, note: nil)
305
+ server = resolve_tool(name.to_s)
306
+ { "name" => name.to_s, "note" => note || server&.dig("name"), "server" => server }
307
+ end
308
+
309
+ def resolve_tool(name)
310
+ return nil unless @tool_resolver
311
+
312
+ @resolved_tools ||= {}
313
+ return @resolved_tools[name] if @resolved_tools.key?(name)
314
+
315
+ resolved = @tool_resolver.call(name)
316
+ @resolved_tools[name] =
317
+ if resolved
318
+ server = resolved.to_h.stringify_keys
319
+ { "key" => server["key"].to_s, "name" => server["name"].presence || server["key"].to_s,
320
+ "status" => server["status"].presence || "unknown" }
321
+ end
322
+ end
323
+
324
+ # The one server every tool resolves to, or nil when they differ or any
325
+ # is unknown.
326
+ def shared_server(tools)
327
+ servers = tools.map { |tool| tool["server"] }
328
+ return nil if servers.empty? || servers.any?(&:nil?)
329
+
330
+ servers.uniq { |server| server["key"] }.size == 1 ? servers.first : nil
331
+ end
332
+
333
+ # For missing tools, the scenarios whose expected tool was available
334
+ # but went uncalled — the fix for those is instructions, not enabling
335
+ # a server.
336
+ def fix_note(fault, faulted, tools)
337
+ return nil unless fault == "expected_tool_not_called" && tools.any?
338
+
339
+ exceptions = faulted.select { |result| unavailable_tools(result).empty? }
340
+ return nil if exceptions.empty?
341
+
342
+ exceptions.map { |result| "#{result.scenario.key} is the exception: #{result.summary} #{result.recommendation}".strip }.uniq.join(" ")
343
+ end
344
+
345
+ def fix_action(tools_label, tools, server)
346
+ return nil if tools.empty?
347
+
348
+ case tools_label
349
+ when "missing tools"
350
+ if server && server["status"] != "enabled"
351
+ fix_action_for("Enable #{server['name']} for #{@agent_name}", "MCP Services ->", link("mcp", key: server["key"]))
352
+ else
353
+ fix_action_for("Open tools", "Tools ->", link("tools"))
354
+ end
355
+ when "failing tools"
356
+ fix_action_for("Open failing tools", "Tools ->", link("tools"))
357
+ else
358
+ fix_action_for("Open suggested tools", "Tools ->", link("tools"))
359
+ end
360
+ end
361
+
362
+ def fix_action_for(label, hint, path)
363
+ { "label" => label, "hint" => hint, "path" => path }
364
+ end
365
+
366
+ # The route template for `name` with `%{key}`-style values filled in,
367
+ # or nil when the caller gave none.
368
+ def link(name, **values)
369
+ template = @links[name].to_s.presence
370
+ return template if template.nil? || values.empty?
371
+
372
+ format(template, **values)
373
+ rescue KeyError, ArgumentError
374
+ template
375
+ end
376
+
377
+ # --- Markdown ----------------------------------------------------------
378
+
379
+ def summary_table
380
+ header = [ "| Model | Pass rate | Passed | Mean score | Mean latency | Tokens in/out | Cost | Faults |",
381
+ "|---|---|---|---|---|---|---|---|" ]
382
+ rows = summary_by_model.map do |label, stats|
383
+ faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
384
+ latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
385
+ cost = stats["cost"] ? format("$%.4f", stats["cost"]) : "—"
386
+ "| `#{label}` | #{stats['pass_rate']}% | #{stats['passed']}/#{stats['scenarios']} | #{stats['avg_score'] || '—'} | " \
387
+ "#{latency} | #{stats['input_tokens']}/#{stats['output_tokens']} | #{cost} | #{faults.presence || '—'} |"
388
+ end
389
+ header + rows
390
+ end
391
+
392
+ def matrix_table
393
+ labels = @models.map(&:label)
394
+ header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} |", "|---|#{labels.map { '---' }.join('|')}|" ]
395
+ rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
396
+ cells = labels.map do |label|
397
+ result = cohort.find { |candidate| candidate.label == label }
398
+ next "—" unless result
399
+
400
+ mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
401
+ [ mark, result.score&.round(2), result.fault&.tr("_", " ") ].compact.join(" ")
402
+ end
403
+ "| `#{key}` #{cell(cohort.first.scenario.prompt.truncate(70))} | #{cells.join(' | ')} |"
404
+ end
405
+ header + rows
406
+ end
407
+
408
+ # A prompt may contain " | " (ScenarioParser keeps it), which would
409
+ # otherwise split the table cell.
410
+ def cell(text)
411
+ text.to_s.gsub("|") { "\\|" }
412
+ end
413
+
414
+ def recommendation_lines
415
+ return [] if recommendations.empty?
416
+
417
+ lines = [ "", "## Recommendations", "" ]
418
+ recommendations.each do |entry|
419
+ lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
420
+ entry["suggested_tools"].each do |tool|
421
+ lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
422
+ end
423
+ end
424
+ lines
425
+ end
426
+
427
+ def detail_lines
428
+ lines = [ "", "## Answers", "" ]
429
+ @results.each do |result|
430
+ lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}#{" · score #{result.score.round(2)}" if result.score}"
431
+ lines << ""
432
+ lines << "> #{result.scenario.prompt}"
433
+ lines << ""
434
+ if result.replay.tool_calls.any?
435
+ lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
436
+ end
437
+ lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
438
+ lines << "Error: #{result.replay.error}" if result.replay.error
439
+ lines << ""
440
+ lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
441
+ lines << ""
442
+ end
443
+ lines
444
+ end
445
+ end
446
+ end
447
+ end