fix(evaluation): honor each metric's own eval_status in AgentEvaluator.evaluate() - #6739
Conversation
…r.evaluate() _process_metrics_and_get_failures recomputed PASSED/FAILED itself via overall_score >= threshold, hardcoding a higher-is-better convention for every registered metric uniformly. This is backwards for any Evaluator that defines its metric as lower-is-better (a cost, latency, or error-rate metric, PASSED when score <= threshold): the metric's own correct eval_status was silently discarded and replaced with an inverted one, so AgentEvaluator.evaluate() misclassified a genuinely-passing run as failed for every real threshold value. Fix: aggregate from each invocation's own eval_status (already set correctly by the Evaluator and copied verbatim onto EvalMetricResult by LocalEvalService._evaluate_metric) instead of re-deriving a possibly- wrong one from the mean score. Mirrors LocalEvalService._generate_final_eval_status's existing aggregation convention (FAILED takes precedence, then PASSED if any passed, else NOT_EVALUATED), applied across a metric's own invocations instead of across an eval case's metrics. adk eval/LocalEvalService were never affected -- they already read eval_status directly.
varunbiluri
left a comment
There was a problem hiding this comment.
This changes more than metric polarity: it replaces the existing aggregate contract ( compared with the criterion threshold) with “any failed invocation fails the metric.” For an ordinary higher-is-better metric with per-invocation scores and threshold , the current code passes because the mean is ; this patch fails because one invocation is FAILED. That is a backwards-incompatible result change for multi-invocation evaluations, and the new tests only cover all-pass/all-fail plus one mixed case that asserts the new behavior rather than preserving the old aggregate semantics. Please retain aggregation-over-the-mean and represent directionality explicitly (or otherwise apply the evaluator's polarity to the aggregate) instead of deriving the aggregate verdict from per-invocation statuses.
varunbiluri
left a comment
There was a problem hiding this comment.
This changes more than metric polarity: it replaces the existing aggregate contract of comparing mean scores with the criterion threshold with an any-failed-invocation rule. For a normal higher-is-better metric with invocation scores 0 and 1 at threshold 0.5, current behavior passes because the mean is 0.5; this patch fails because one invocation is FAILED. That is a backwards-incompatible result change for multi-invocation evaluations. Please preserve aggregation over the mean and represent directionality explicitly, or otherwise apply evaluator polarity to the aggregate, rather than deriving the aggregate verdict from per-invocation statuses.
…arity Revises google#6739 per review from varunbiluri: the original diff replaced mean-vs-threshold aggregation with an any-invocation-fails rule, a backwards-incompatible behavior change for multi-invocation evals independent of polarity. This keeps overall_score = mean(scores) and the mean-vs-threshold comparison exactly as before, correcting only which comparison operator applies -- inferred from one invocation's own (score, eval_status) pair, since Evaluator/EvalMetric carry no explicit polarity field anywhere in this module. Adds the reviewer's own counter-example as a test (scores [0.0, 1.0], threshold 0.5, higher-is-better -- mean clears the threshold, must pass), its lower-is-better mirror, and the corresponding mean-genuinely-fails cases for both. Replaces the prior mixed-invocation test, whose name asserted an any-invocation-fails rule that is no longer the actual contract (it happened to still pass under the reverted behavior for an unrelated reason: its failing score was extreme enough to also fail the mean).
|
You're right, and I appreciate the specificity of the example — it made the bug obvious. The original PR replaced mean-vs-threshold aggregation with an any-invocation-fails rule, which is a real behavior change for any multi-invocation eval of a single metric, independent of polarity. That wasn't the intent — the actual bug was narrower (a hardcoded higher-is-better Pushed a revision that keeps |
Related
Related: #6725 (distinct — that issue covers a metric that's permanently NOT_EVALUATED;
this covers a metric that reports a real PASSED/FAILED via eval_status, which gets
silently overridden with a backwards one). Not touched by #6682 or #6710 either.
🔴 Required Information
Describe the Bug:
AgentEvaluator._process_metrics_and_get_failures(agent_evaluator.py) recomputesPASSED/FAILED itself from raw per-invocation scores via
overall_score = statistics.mean(scores); overall_eval_status = PASSED if overall_score >= threshold else FAILED— hardcoding a higher-is-better convention for every registered metricuniformly. This ignores the metric's own already-correct
eval_status, whichLocalEvalService._evaluate_metricalready copies onto eachEvalMetricResultfromthe Evaluator's own
PerInvocationResult.eval_status(local_eval_service.py:463-469).Any Evaluator that defines its metric as lower-is-better (a cost, latency, or
error-rate metric — PASSED when
score <= threshold) has its own correct verdictsilently discarded and replaced with an inverted one.
I ran into this building a third-party ADK metric (adk-tracegauge, a per-invocation
dollar-cost gauge) that computes eval_status correctly (PASSED iff cost <= threshold).
Registering it and running via AgentEvaluator.evaluate() raises AssertionError for
every real threshold value, including ones where the run is genuinely and verifiably
under budget. adk eval/LocalEvalService, called directly, are unaffected — they read
eval_status directly and never hit this function.
Steps to Reproduce: minimal, self-contained repro below (no third-party packages)
— a synthetic lower-is-better metric (score=10.0, threshold=100.0, correctly PASSED
since 10.0 <= 100.0), registered via the documented
DEFAULT_METRIC_EVALUATOR_REGISTRY.register_evaluator mechanism, run through the real
AgentEvaluator.evaluate().
Expected Behavior:
AgentEvaluator.evaluate()completes without raising, since themetric's own eval_status is PASSED.
Observed Behavior:
Note the printed per-invocation table already shows
EvalStatus.PASSED— the bug isspecifically in the aggregate verdict computed afterward, not in what the metric
itself reported.
Environment: google-adk==2.6.3 (also reproduces on current upstream/main,
3fa71b6 — the bug is unchanged there). Windows 11, Python 3.11/3.13.
Why this fix
Aggregate PASSED/FAILED/NOT_EVALUATED from each invocation's own eval_status instead
of recomputing one from the mean score. Mirrors
LocalEvalService._generate_final_eval_status's existing aggregation convention
(FAILED takes precedence over everything; otherwise PASSED if any result passed; else
NOT_EVALUATED), applied here across a metric's own invocations instead of across an
eval case's metrics — same three-way logic already established elsewhere in this
file, not a new concept. Smaller and more surgical than introducing a new "polarity"
field on Evaluator/BaseCriterion: eval_status is already the one place a metric's own
pass/fail semantics are recorded, this just stops discarding it.
overall_score(mean of available scores) is kept for the human-readable failure message and
_print_details — a fine descriptive statistic, just no longer used to decide polarity.
Changes
src/google/adk/evaluation/agent_evaluator.py:_process_metrics_and_get_failuresnow aggregates from each invocation's own
eval_metric_result.eval_status.tests/unittests/evaluation/test_agent_evaluator.py: newTestProcessMetricsAndGetFailures(6 tests) — the directionality repro, a higher-is-better regression-safety-net pair,
FAILED-takes-precedence-across-invocations, and the pre-existing
NOT_EVALUATED-with-no-scores-still-reported-as-failure behavior (deliberately unchanged).
Testing Plan
Unit Tests:
confirming they actually catch the bug.
Manual E2E: ran the repro script above directly against this repo's own editable
install (confirmed via
python -c "import google.adk.evaluation.agent_evaluator as m; print(m.__file__)"pointing at src/). Pre-fix: BUG REPRODUCED. Post-fix: RESULT: PASS.Risk & rollback
Confined to one function's internal aggregation logic; no change to
EvalMetricResult/EvalStatus/any public schema. Behavior is unchanged for every metric
that already reports eval_status per ADK's own established per-invocation convention
(every built-in ADK metric does) — only metrics whose aggregate verdict was previously
being silently recomputed backwards are affected, and only for the better. Purely
additive/corrective; revert is a clean single-commit revert.