Evaluations
ActiveAgent::Evals is the evaluation core the dashboard's scenario evaluations run on. It ships inside the activeagent gem so any Rails app on ActiveAgent — and, with one require, any app that calls models another way — can replay a list of tasks across models, score the answers, and be told which tasks its agent cannot do yet and why.
require "active_agent/evals" # loads on its own, without the rest of the frameworkThe module never talks to a model itself: you hand it one callable that runs your agent, and optionally one that asks a judge model for a completion.
What it does
- Scenarios — a pasted list of user messages (
ScenarioParser) or a YAML suite with groups (Suite). Each scenario can name the tools a passing answer should call and content it must or must not contain. - Models — candidate models to compare (
ModelSpec), fromprovider/modelor a bare name whose provider is inferred. - Replay — your callable runs the agent for each scenario × model and returns a
Replay: answer, tool calls (with errors), timing, tokens, cost. - Scoring — rule criteria (
response_present,min_length,max_latency_ms,token_budget,contains,not_contains,llm_judge) plus the scenario's expectations (Scorer). - Diagnosis — each shortfall gets exactly one fault and a recommendation (
Diagnosis); aJudgecan refine it with the tool to add or the instruction to change. - Report — per-model pass rate, mean score, latency, tokens, cost and fault counts; criterion statistics; faults grouped across scenarios into the fix each calls for; a verdict; Markdown, JSON or a self-contained HTML page (
Report).
Minimal example
require "active_agent/evals"
scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(<<~TEXT)
# Orders
Where is my order 4821? | tools: lookup_order
Cancel my subscription | not_contains: I cannot
TEXT
models = ActiveAgent::Evals::ModelSpec.parse_all(%w[claude-sonnet-5 ollama/qwen3:8b], default_provider: "openai")
# The agent's system prompt, for the diagnosis and the judge — with
# ActiveAgent, the instructions template.
instructions = Rails.root.join("app/views/agents/support/instructions.md").read
report = ActiveAgent::Evals::Runner.new(
scenarios: scenarios,
models: models,
available_tools: { "lookup_order" => "Find an order by number" },
instructions: instructions,
replay: ->(scenario, spec) { SupportAgent.evaluate(scenario.prompt, model: spec.model, provider: spec.provider) }
).call
puts report.to_markdown
File.write("eval-report.html", report.to_html)The report renders three ways: to_markdown for terminals and PR comments, to_json for machines, and to_html(theme: nil) — a self-contained page (inline styles, no external assets) on the dashboard's design system that you can archive next to a CI run or hand to a teammate, the way a test suite publishes its report. It carries the same reading the dashboard's suite card does, in the same order: stat tiles, a panel per model with the judge's pick and the verdict, the scenario × model matrix, a disclosure per scenario holding every answer, and then the what-to-fix cards. The Markdown follows the same order: the model table, the matrix, every answer, then the recommendations. theme: is its only argument — "light" or "dark" pins the palette, nil follows the viewer's system preference. It is the same page the dashboard serves at /api/evaluations/:id/runs/:run_id/report.
SupportAgent.evaluate is whatever runs your agent and returns an ActiveAgent::Evals::Replay (or a hash with the same keys). With ActiveAgent that is a prompt(...).generate_now under generate_with spec.provider, model: spec.model; with RubyLLM it is a chat with the model overridden; the module does not care.
Adding a judge
judge = ActiveAgent::Evals::Judge.new(label: "claude-opus-5") do |instructions:, prompt:|
JudgeAgent.prompt(message: prompt, instructions: instructions).generate_now.message.content
end
ActiveAgent::Evals::Runner.new(scenarios:, models:, replay:, judge: judge, instructions: instructions).callWith a judge, every answer also gets a task_completion score (unless the criteria already include an llm_judge), scenarios failing on missing_capability, expected_tool_not_called, ungrounded_answer, missing_content or low_quality get a judge-written recommendation with a suggested tool where one is missing (refine_faults: and judge_limit:, 25 calls per run by default, adjust that on Runner.new), and the verdict carries the judge's rationale. A judge that raises or answers unusably is skipped for that call, so an evaluation never fails because the judge did.
A scorable task_completion grade must independently meet the run's threshold (0.7 by default). Successful tool and content checks cannot turn a failing task-completion grade into a pass. score and avg_score remain the aggregate across scored criteria; avg_task_completion in each model's summary reports the judge's task-completion mean separately. Invalid judge scores are unscorable, and unusable recommendation fields are discarded.
Hosts can correlate the replay and its judge calls with one trace context by passing around_evaluation: to Runner.new. The callable receives the scenario and model spec, yields the evaluation, and returns its result:
around_evaluation = ->(scenario, spec, &evaluate) do
TraceContext.with(scenario_key: scenario.key, model: spec.label) { evaluate.call }
endThe wrapper covers replay, scoring and recommendations. on_result runs after the wrapper finishes; wrapper errors propagate. Direct Runner#evaluate calls bypass the wrapper so a host doing its own scheduling can establish context itself.
Correlating traces with results
Correlation is that wrapper, written once against a telemetry backend instead of per host. It mints a run_id per run and a result_id per evaluation, puts both on every trace opened inside them as eval.-prefixed attributes, and records the trace ids back onto the result: replay.metadata["trace_id"] for the replay and ["judge_trace_ids"] for the judge calls that graded it. A judge call made outside an evaluation — the run-level verdict — lands on the run metadata instead, which is the hash the Report carries, so the verdict is reachable from the report rather than attributed to the last result evaluated.
correlation = ActiveAgent::Evals::Correlation.new(
agent_name: "SupportAgent",
judge_name: "SupportAgentJudge",
tracer: ->(name, action:, attributes:, on_trace:, &block) {
MyTelemetry.with_agent(name, action: action, attributes: attributes,
on_trace: on_trace, synchronous: true, &block)
}
)
report = correlation.with_run("suite" => "support") do |metadata|
Runner.new(
scenarios: scenarios, models: models, metadata: metadata,
around_evaluation: correlation,
replay: ->(scenario, spec) { correlation.replay { agent.run(scenario.prompt) } },
judge: Judge.new(label: "judge-model") { |instructions:, prompt:|
correlation.judge("score") { chat.with_instructions(instructions).ask(prompt).content }
}
).call
endPass the yielded metadata to Runner.new(metadata:): it is the caller's own hash, mutated in place, so a run reopened around a later verdict accumulates onto the metadata the Report already carries.
The tracer is injected, so the evaluation module takes on no telemetry dependency and still loads on its own. Without one, the correlation mints ids and merges metadata while the blocks run untraced, which keeps a suite runnable where no telemetry is configured. trace_keys: chooses which correlation keys become attributes — the default is run_id, result_id, suite, scenario_key, model_label, model and provider, and anything else in the run metadata stays on the report but off the traces.
RubyLLM hosts
An app that drives RubyLLM conversations — RubyLLM.chat, acts_as_chat records — needs two more pieces to run a suite: a judge that asks a RubyLLM chat, and a Replay built from the messages a conversation produced. ActiveAgent::Evals::RubyLLM is both, loaded by its own require so require "active_agent/evals" never pulls in the ruby_llm gem. Both RubyLLM 1.x (1.16 and later) and 2.x work.
require "active_agent/evals/ruby_llm" # requires ruby_llm itselfjudge builds a Judge whose completions come from context.chat(model:, provider:, assume_model_exists:), then with_instructions(instructions).ask(prompt).content. context is anything answering to chat — RubyLLM itself, or a RubyLLM.context built with the host's own keys (the dashboard's ActionAgent::ProviderKey.apply_to writes an owner's keys onto that config block). With a correlation:, every call is traced through Correlation#judge under the kind it serves (score, recommend, verdict), so the judge trace lands on the result it graded.
temperature:is applied withwith_temperature, andconfigure:is called with each chat before it is asked, for any otherwith_*setting.- Any other keyword goes to
chatitself, so it must be oneRubyLLM::Chat.newtakes —protocol:on RubyLLM 2.x. Anything else raisesArgumentErrorwhen the judge is built, not on every call. on_usage:is called after each answered call with{ "kind", "model", "input_tokens", "output_tokens", "cost" }, for a host that meters what its judge spends. An error it raises is logged, never allowed to cost the grade.
replay turns an ordered list of messages — acts_as_chat records of either RubyLLM generation, RubyLLM::Message values, an Array or a relation — into a Replay. Records are read through their own to_llm, so the replay sees what RubyLLM itself would send the model, whichever schema the table has.
- Tool calls come in the order the model made them. A call is errored when the result answering it reports an error at its top level: JSON whose
"error"is a non-empty string, object or array, ortrue; an MCP result with"isError": true; or the inspected{ error: "..." }RubyLLM 1.x stores when a tool returns an error Hash with a String error. Text that merely mentions an error — a log line, source code — is a successful result. The error is the call'sdetail, JSON-encoded unless it is a string and cut to 1,200 bytes. - Tokens are summed over the assistant messages.
input_tokensis the whole prompt: RubyLLM counts cache reads and writes apart from itsinput, and the replay adds them back. - Cost is the sum of RubyLLM's own price for each assistant message, and is left nil when any of them is unpriced rather than reporting part of it.
- The answer is the last assistant message after the last user message. A conversation that stopped before answering has no answer, and the replay's
errorsays where it stopped: at a tool call or its result (a tool awaiting approval, a run cut short), or on the user's own message (a run that failed after the prompt was saved). On RubyLLM 1.x a tool that ends the turn withhaltalso leaves its result last; for such scenarios pass the contentaskreturned asanswer:.
answer:, error: and cost: override what the messages say; duration_ms: and metadata: pass through.
Put together with a correlation, and with the replays and judge calls reported through the activeagents-telemetry-ruby_llm adapter (0.3.0 or later, whose with_agent takes attributes:, on_trace: and synchronous:; 0.3.1 or later on RubyLLM 2.x, for token counts):
require "active_agent/evals/ruby_llm"
context = RubyLLM.context { |config| ActionAgent::ProviderKey.apply_to(config, owner: account) }
correlation = ActiveAgent::Evals::Correlation.new(
agent_name: "SupportChat",
tracer: ->(name, action:, attributes:, on_trace:, &block) {
ActiveAgents::Telemetry::RubyLLM.with_agent(name, action: action, attributes: attributes,
on_trace: on_trace, synchronous: true, &block)
}
)
judge = ActiveAgent::Evals::RubyLLM.judge(
label: "claude-opus-5", model: "claude-opus-5", provider: :anthropic,
context: context, correlation: correlation, temperature: 0
)
report = correlation.with_run("suite" => "support") do |metadata|
ActiveAgent::Evals::Runner.new(
scenarios: scenarios, models: models, metadata: metadata, judge: judge,
around_evaluation: correlation,
replay: ->(scenario, spec) {
started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
chat = correlation.replay { SupportChat.run(scenario.prompt, model: spec.model, context: context) }
ActiveAgent::Evals::RubyLLM.replay(
chat.messages.order(:id),
duration_ms: Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started,
metadata: { "chat_id" => chat.id }
)
}
).call.tap(&:verdict)
endtap(&:verdict) asks for the verdict while the run is still open. The verdict is computed on first use, and a judge call made after with_run has returned is traced without the run's eval.run_id, its trace id never reaching report.metadata["judge_trace_ids"].
Two things about the adapter to know:
- Trace ids are recorded only for traces the adapter accepted for delivery. A trace dropped by
sample_rateor a disabled configuration never reacheson_trace, so its result carries notrace_id. - Keep your own
with_agentscope out of code a replay runs on adapter releases up to 0.3.1. There a nestedwith_agent— such as a "name your traffic" scope aroundchat.ask— replaces the evaluation's scope for the turns inside it: their traces lose theeval.*attributes, delivery goes back to the background thread, and the result never receives itstrace_id. Check the adapter's changelog for nested-scope support before relying on it.
To run such a suite from the mounted dashboard rather than a script, return it from a host adapter — see Running a host application's agent from the mounted dashboard.
Faults
| Fault | Meaning |
|---|---|
run_error | The replay raised, or the agent returned nothing |
tool_error | A tool the agent called returned an error |
missing_capability | The agent said no tool covers the task |
expected_tool_not_called | The scenario expects a tool the agent did not call |
ungrounded_answer | The agent had tools, called none, and still stated specifics (a count, an id, a date) nothing supplied |
forbidden_content / missing_content | A content expectation failed |
low_quality | The answer scored below the threshold (0.7) |
Assigned in that order, most mechanical cause first. missing_capability and expected_tool_not_called are what turn a pasted list of new tasks into a backlog: they say which tasks the current toolset cannot reach and, with a judge, which tool to add.
What to fix
Report#fix_items is that backlog as data — one item per fault, in the same order as recommendations, plus one per distinct instruction change the judge proposed. Each names the scenarios and models it speaks for, the fix it calls for, and the tools involved: the missing tools a scenario expected, the tools that errored, or the tools the judge suggested adding, under a tools_label that says which.
report.fix_items.first
# => { "kind" => "fault", "fault" => "expected_tool_not_called", "count" => 3,
# "scenario_keys" => [...], "models" => ["gpt-5-mini", "ollama/qwen3:8b"],
# "recommendation" => "Enable refund_order for the agent…",
# "quote" => nil, "tools_label" => "missing tools",
# "tools" => [ { "name" => "refund_order", "note" => nil, "server" => {…} } ],
# "server" => { "key" => "orders", "name" => "Orders", "status" => "available" },
# "note" => nil,
# "action" => { "label" => "Enable Orders for SupportAgent",
# "hint" => "MCP Services ->", "path" => "/mcp/orders" } }Optional keywords on Report.new fill that in, and the HTML page renders whatever they supply:
| Keyword | What it adds |
|---|---|
tool_resolver: | A callable name -> { "key", "name", "status" } ("enabled", "available" or "unknown") naming the server behind a tool, so an item can say a tool exists but is not turned on |
agent_name: | How an item names the agent ("the agent" by default) |
links: | Route templates for the actions — "mcp" ("/mcp/%{key}"), "tools", "instructions". Without them an action still carries its label and hint, with "path" => nil |
verdict: / judge_label: | A verdict already recorded for these results, so a report rebuilt from stored results shows the pick that run made rather than ranking them again |
Nothing here needs the dashboard: a CI job that hands Report.new a resolver over its own MCP configuration gets the same cards in its HTML report, and the same JSON to open issues from.
Costs
Every surface writes a pass count, a score and a cost the same way (ActiveAgent::Evals::Format, which the dashboard copies in evalFormat.mjs):
| Figure | Reads as |
|---|---|
| A pass/fail fraction | 14/16 · 88% (14/16 (88%) in Markdown); nothing scored is — |
| A 0..1 score — mean score, criterion score, task completion, judge confidence | 93% |
| The pass threshold | pass ≥ 70% |
| Money | $0.0243 when reported; ~$0.0243 when any part was estimated from tokens × model rates; four decimals, six below $0.001, $0.00 for an explicit zero |
Percentages round half up to whole numbers. Stored values stay 0..1 and USD. A report that shows a ~ figure carries the legend once, in its footer: ~ estimated from tokens × model rates. An estimated figure's tooltip shows the working (estimated: 2,328 in × $5.00/M + 423 out × $30.00/M · catalog rate, plus (fallback rate) for a rate guessed from the model's name).
A Replay's cost is what its answer cost; three optional keys in its metadata say more, and the report reads them back (Result#cost_source, #cost_rate, #judge_usage):
ActiveAgent::Evals::Replay.new(
answer: answer, input_tokens: 2_328, output_tokens: 423, cost: 0.02433,
metadata: {
"cost_source" => "estimated", # reported | estimated | no_usage | unpriced
"cost_rate" => { "input" => 5.0, "output" => 30.0, "source" => "catalog" }, # $ per million tokens
"judge_usage" => { "calls" => 1, "input_tokens" => 400, "output_tokens" => 20, "cost" => 0.0015,
"model" => "claude-opus-5", "by_kind" => { "score" => 1 }, "source" => "traces" }
}
)A cost with no cost_source is the caller's own figure (reported); no cost at all is unpriced. judge_usage is what the judge spent on that one result; calls no result owns — the verdict, authoring criteria — go to the report as Report.new(judge_usage: { "calls" => 1, ... }).
From those, Report#summary_by_model adds, per model, the reported and estimated counts behind cost and priced, and judge_cost and judge_calls; Report#scenario_costs gives each scenario's cost across the models, the judge's, their total, whether any part is estimated, and the same per model label; and Report#judge_usage sums every result's judge calls with the run-level part ("run"). The judge's cost is the evaluation's own overhead and never joins the agent's: it is not part of the verdict's ranking, nor of the prompt the judge is asked to rule with. Report#to_h carries judge_usage only when there is one.
Report.new(release:) (or Runner.new(release:)) names the release of the agent the run scored, { "digest", "revision", "label" }, from ActiveAgent::Release; it lands in to_h["release"] and as a header chip, and a dashboard the report is published to pins the run to that version.
In a dashboard
Runner.new takes on_result: (each Result as it lands) and Runner#evaluate(scenario, spec, replay) scores a replay you already hold, so a dashboard can run replays in background jobs and persist one row per scenario × model. Report#to_h is JSON-ready. The ActionAgent engine's ScenarioEvaluationRunner is the reference integration: it supplies the replay (an AgentRun with a model override), prices tokens, wraps the owner's judge credentials, and stores each result as an EvaluationScenarioResult.
The pieces
| Class | Role |
|---|---|
Scenario | One task: prompt, group, expected tools, content that must / must not appear, notes |
ScenarioParser | Pasted text, JSON, or grouped YAML suites → scenarios. Lines, # Heading groups, backticked prompts with notes, | tools: a, b | contains: x options |
Suite | A YAML suite with groups; later documents override by key, so a deployment can add or reword tasks |
ModelSpec | provider/model or a bare name with provider inference (claude-* → anthropic, gpt-* → openai, name:tag → ollama, vendor/model → openrouter) |
Replay | What your agent produced: answer, tool calls, timing, tokens, cost, error |
Scorer | Rule criteria (response_present, min_length, max_latency_ms, token_budget, contains, not_contains, llm_judge) plus the scenario's expectations |
Diagnosis | One fault per failing result, with an evidence-based recommendation |
Judge | Prompts and parsing for scoring, refining recommendations, and picking a winner; you supply the completion call |
Runner | scenarios × models → Results, calling your replay and the judge |
Report | Per-model summary, criterion statistics, recommendations, fix items, verdict; Markdown / JSON / HTML |
Suites
suite: support_bot
description: The questions the support team fields every week
groups:
- key: orders
name: Orders
scenarios:
- key: orders_1
prompt: Where is my order?
expect:
tools: [lookup_order]
- key: orders_2
prompt: Cancel my subscription
expect:
not_contains: ["I cannot"]
production_only: true # needs data a local database does not havesuite = ActiveAgent::Evals::Suite.load("config/evals/support_bot.yml", "config/evals/acme/support_bot.yml")
suite.scenarios(groups: %w[orders], include_production_only: false)The same grouped document can be pasted into ScenarioParser as YAML or JSON. It retains scenario keys, group keys and display names, expectations, notes and production-only flags:
scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(
File.read("config/evals/support_bot.yml"),
include_production_only: false
)The parser includes production-only scenarios by default for compatibility with Suite; the dashboard's create and replace APIs exclude them by default. Post the YAML/JSON document as scenarios_text and set include_production_only: true alongside it to include those questions. For creation both fields belong in evaluation; replacement accepts them at the top level. Production selection happens during import: the dashboard stores only the selected scenarios and their group keys, not the original document, group display names, or environment flags. Re-import the source document to change that selection. Invalid YAML or a selection containing no scenarios returns an import error without creating a sampled evaluation.
Refreshing a suite
Evaluation#replace_scenarios! writes a catalog's questions onto a persisted evaluation. A scenario whose key survives keeps its record, so results from earlier runs still resolve through it.
What happens to a scenario the new attributes no longer name is the on_removed: option:
evaluation.replace_scenarios!(attributes) # :destroy (default)
evaluation.replace_scenarios!(attributes, on_removed: :disable) # keep, enabled: false:destroy drops the row. :disable keeps it with enabled: false, which takes it out of future runs — the runner selects only enabled scenarios — while leaving the results that scored it readable. Choose :disable for a suite synced from an evolving catalog, where a question can come back or an old report still needs its rows.
Adding to a suite
Evaluation#merge_scenarios! treats its input as an addition to the suite, not the whole of it. A scenario whose key the input does not name is never touched: it keeps its prompt, its position, its enabled flag and its results.
attributes = ActiveAgent::Evals::ScenarioParser.parse(
pasted_text,
reserved_keys: evaluation.scenarios.pluck(:key),
key_prefix: "batch2"
).map { |attrs| attrs.except("position") }
evaluation.merge_scenarios!(attributes, limit: 2_000)
# => { added: ["batch2_orders_1"], updated: ["refund_window"], unchanged: [] }- An existing key gets the given prompt, group, notes and expectations, and keeps its position and
enabledflag unless the attributes set"position"or"enabled". A group, notes or expectations the attributes leave out are cleared. - A new key is appended after the suite's last position, in input order.
limit:refuses, withEvaluation::ScenarioLimitExceeded, a merge that would leave the suite holding more scenarios than that. A refused or failed merge writes nothing.
The parser numbers "position" within its own paste, which is why the example drops it. reserved_keys: makes a scenario with no key take the next <group>_<n> the suite does not hold, ignoring case, so merging the same keyless paste twice adds it twice under distinct keys rather than overwriting the first copy. key_prefix: puts a namespace in front of every generated key, and raises ScenarioParser::ParseError when it has no letters or digits a key can use. Keys a paste names are kept as they are, so a named key the suite holds is updated.
The MCP facade's scenarios_merge tool does exactly this for a coding harness (see the dashboard guide).
Catalogs
A suite describes one agent. A catalog describes every agent a team ships: its products, each with the sets of scenarios that product must answer. The document is YAML that lives in the repository next to the agents it tests, so the prompts are versioned with the code rather than typed into a dashboard:
catalog: support_desk
name: Support Desk
description: What the support agents are expected to handle
products:
- key: triage
name: Triage agent
agent: TriageAgent # the agent this product's sets run against
sets:
- key: smoke
name: Smoke
judge:
kind: rules
scenarios:
- key: refund_request
prompt: A customer asks for a refund on order 1042.
expect:
tools: [lookup_order]
contains: [refund]
notes: Looks the order up before answering.
tags: [billing]
params:
locale: en
- key: production
name: Production data
scenarios:
- key: open_tickets
prompt: Which open tickets mention a refund?
production_only: true
- key: billing
name: Billing agent
sets:
- key: smoke
scenarios:
- key: invoice_copy
prompt: Send me a copy of my last invoice.
expect:
tools: [send_invoice]Every product, set and scenario has a key; a scenario also needs a prompt. A scenario takes the same expect, notes and production_only as a suite's, plus free-form tags and params that the runner carries through to the results. A set may name a judge (kind, model) and criteria, which become the evaluation's when the set is run. Keys the format does not define (owner_team, repository, …) are kept as metadata on the catalog, product or set they sit on.
catalog = ActiveAgent::Evals::Catalog.load("config/evals/support_desk.yml")
catalog.products.map(&:key) # => ["triage", "billing"]
catalog.set(:triage, :smoke).scenarios.size # => 2
catalog.scenarios(product: :triage, include_production_only: false)
suite = catalog.suite_for(:triage, :smoke) # a Suite named "support_desk/triage/smoke"
ActiveAgent::Evals::Runner.new(suite.all_scenarios, ...)Catalog.load takes several paths and Catalog.new several documents: later ones layer over earlier ones by key, so a team-wide catalog can be narrowed or extended by a local file the way Suite.load layers suites. A suite document (suite: and groups:) loads as a catalog of one product whose sets are its groups, so existing suites need no rewrite.
Catalog#to_yaml writes the canonical document (keys in a fixed order, empty values dropped) and Catalog#digest is the SHA-256 of that text, so two copies of a catalog can be compared without diffing them. The mounted dashboard stores catalogs in its own tables, writes the canonical document to Active Storage and runs one set at a time as an evaluation of the product's agent; see Scenario catalogs.
Running a host application's agent from the mounted dashboard
The engine normally replays scenarios with ActionAgent::Agent#test_execute. A host with its own chat or agent runtime can opt individual evaluations into an adapter without replacing the engine's catalog, selection, jobs, result persistence, or report pages:
ActionAgent.configure do |config|
config.scenario_evaluation_adapter_resolver = ->(evaluation) do
next unless evaluation.config["runtime"] == "host_support"
->(evaluation:, owner:, scenarios:, models:, on_result:) do
HostSupportEvaluation.call(
evaluation: evaluation, owner: owner, scenarios: scenarios,
models: models, on_result: on_result
)
end
end
endThe resolver receives the persisted evaluation and returns a callable or nil for the engine's default runtime. The callable receives the engine's resolved owner, already-selected core Scenario and ModelSpec objects, and an on_result callback. It must return an ActiveAgent::Evals::Report and call on_result once for every selected scenario/model result. Missing or duplicate results fail the run instead of leaving a completed report with missing rows. Exceptions also mark the run failed and retain results already written.
The host owns provider execution, tool definitions, and judge configuration. Use the supplied owner rather than a global current user in background jobs; honor evaluation.judge_kind, evaluation.judge_model, and the evaluation's tenant/role configuration. The adapter path does not build the engine's judge or execute its agent. It still passes through dashboard authentication, execution enablement, and quota checks.
Usage accounting is the engine's. Each result the adapter reports is metered as one :execution against the supplied owner — the same unit the default replay path records, one per scenario × model. An adapter should not call ActionAgent.record_usage itself, or every replay is counted twice.
Models reach the adapter already validated: a spec naming a provider the agent cannot serve fails the run with ArgumentError before the adapter is called, so an adapter does not need to re-check models.map(&:provider).
An observed agent can run a persisted evaluation only when its resolver returns an adapter. Direct agent execution remains read-only. A host catalog importer should register that evaluation without an immediate run, then use the regular evaluation run endpoint.
Run metadata persists in the reserved scores["_metadata"] JSON key. Per-result replay metadata persists in diagnosis["_replay_metadata"], is exposed separately as metadata in result API responses, and is restored by EvaluationRun#to_report. Each result also records its evaluated scenario in diagnosis["_scenario_snapshot"]; the public diagnosis excludes these storage keys. Report reconstruction and the result matrix use that snapshot, so refreshing a catalog cannot rewrite old questions, expectations, or notes. The run records its judge label in scores["_judge_label"] as well. Legacy results without snapshots remain readable using the current catalog. This preserves host run/result IDs and response/judge trace IDs without a schema migration. Existing result and report URLs continue to work:
<mount>/api/evaluations/:id/runs/:run_idreturns persisted result JSON.<mount>/api/evaluations/:id/runs/:run_id/reportserves the HTML report.<mount>/evaluations/:id/runs/:run_id/reportopens it within the dashboard.<mount>/evaluations/:idopens an evaluation on its run history, and<mount>/evaluations/:id/runs/:run_idone of its runs — a page of its own for a sampling evaluation, the selected run of a suite's panel otherwise.<mount>/evaluations?evaluation=:idopens a specific evaluation, including one outside the first index page. Add&run=:run_idto open its saved report.
Every serialized run carries number (its position in the evaluation's history, oldest first) and usage, which keeps the agent's spend apart from the judge's: replays (or samples, for a sampling run), cost, per_interaction, tokens and timing on the agent's side — what the interactions cost to serve — and judge (calls, tokens, cost, model, by_kind, source, estimated, and run for the calls no result owns) for the judge model's own calls, present only when a judge was asked. Every interaction with tokens is priced (ActionAgent::EvaluationRunCost): priced and unpriced count the interactions, reported and estimated say how the priced ones were priced, cost_basis sums that up as reported, estimated or mixed, and total is the agent's cost and the judge's together. A scenario run's per-model summaries under scores._models carry the same per model: cost (the effective one), priced, reported, estimated, judge_cost and judge_calls. The index adds run_count and a previous_run summary per evaluation.