Writing Metrics
A metric scores one trial. Metrics are attached to each task (not once per run), so a suite can grade heterogeneous work — a Q&A task and a coding task can carry different scorers. Every metric, however simple or elaborate, implements the same small protocol.
The protocol
No base class — a metric is any object with these three members (a structural Metric protocol).
What compute_scores receives
input.candidate is the trial under evaluation:
For a trial with an output, candidate.metadata starts with AgentEvalTrial.metadata and then adds
AgentOutput.metadata. If both contain the same key, the value from the output wins.
input.row.data is a dict describing the task and trial:
So an outcome metric reads candidate.output_text and row.data["reference"]; a trajectory
metric reads candidate.evidence.
Declaring outputs
A metric declares its outputs up front. Outputs are required by default: compute_scores must return
every output whose spec has required=True (default is True if not specified). Set required=False only when the producer can
legitimately leave that value unmeasured. The runtime rejects missing required outputs, undeclared or
duplicate outputs, and present values that cannot be coerced to their declared type.
Build specs with the MetricOutputSpec factories. Pass required=False to mark an
output optional.
A metric may emit several outputs — for example an efficiency metric returning both a boolean and a count:
Prefer continuous_score when you want a numeric mean in the run summary. A boolean output
reports per-trial pass/fail but does not aggregate to a numeric mean on its own (though it still
contributes as 0/1 to a view).
Optional outputs
Declare an output as optional when the metric cannot (or not expected) measure it on every otherwise scoreable trial. Return the output when measured and omit it when unmeasured:
required=False flag is a producer contract, not an aggregation policy.
The metric author decides how to interpret and score missing optional metric scores.
Recommended
Omit the output when unmeasured, as in the example.
- The mean is calculated over finite measured values (
count). An omitted output is not averaged in and does not count as zero. - An omission stays in the applicable set as
nan_countand coveragemissing. count + nan_countequals that output’s coveragetotal.
See Reading Results for the two-trial format_ok example.
Not recommended
A metric author is allowed to emit a filler. That puts the trial in the measured set and changes aggregates:
0.0is a finite measurement: it enterscountandmean, and coverage counts it asscored. The mean is pulled toward zero as if the metric measured failure.- NaN is observed but non-finite: it is excluded from
countandmean, incrementsnan_count, and coverage still counts it asscored(notmissing). The trial looks measured when it was not. Noneis rejected.
Use a filler only when you intend that trial to count as measured.
Returning a result
Return a MetricResult whose outputs match output_spec by name:
Reading measurements
Metrics read measurements from input.row.data["trial"]["measurements"]. The complete
TrialMeasurements
schema is always present: unavailable values are None, while a measured zero remains 0 (or
0.0). Persisted or imported measurement values belong exclusively in measurements; free-form
metadata values are never converted into measurements, even when their keys resemble measurement
names.
Reading evidence
candidate.evidence (a CandidateEvidence) holds named descriptors, each exposed through a typed
handle. trace() returns the runner’s primary view as an ATIFTraceHandle | OTLPTraceHandle union.
Always guard for missing evidence, then narrow on handle.format before using format-specific
methods:
OTLP exposes the unflattened span tree as parsed ResourceSpans protobuf messages — the same type
opentelemetry-proto defines and Intake ingests — so a reader walks .scope_spans[].spans[] and
typed attributes rather than decoded JSON. It does not provide a generic tool_calls() method: what
counts as a tool call depends on whatever produced the spans — the Gym projection, or a harness like
Fabric or Codex — each of which names and nests them differently, so extracting them is the
consumer’s job.
Asking for one trace format
A runner may record the same trial in more than one encoding. Harbor registers both under
trace:atif and trace:otlp when the agent under test emits both, and Fabric registers both
whenever it captures an OTLP trace from Relay. Pass format= to choose one and get a narrowed
handle back, with no runtime branch:
trace() with no format still returns whichever encoding the runner made primary. For Harbor
and Fabric alike that is the OTLP trace when one was recorded and the ATIF trace otherwise — there
is no setting to choose between them. A format= request raises KeyError when the trial carries no trace in
that encoding, so a metric that depends on one specific view fails loudly instead of silently
scoring nothing.
The Score by Component guide has a
complete, runnable trajectory metric; the SDK’s example_metrics.py (under
examples/run_agent_eval/) shows filesystem and trace metrics.
How outputs are reported
Each output aggregates in result.summary under <metric.type>.<output>. The aggregate reports
mean, extrema, population and sample statistics, count, and nan_count:
countis the number of finite measured values.nan_countis the number of applicable opportunities that were unmeasured or failed.- An optional output omitted everywhere keeps a row with
count=0andmean=None. summary.metric_coverageseparately reportstotal,scored,missing, andfailed.
See Reading Results for exact examples. To roll several outputs into one reported score, define a view on the task — see Score by Component.