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