fix(eval): reject out-of-range scores from LLM judge tool calls#1819
fix(eval): reject out-of-range scores from LLM judge tool calls#1819mjnovice wants to merge 2 commits into
Conversation
gemini-2.5-flash occasionally returns corrupted numeric scores in its submit_evaluation tool call despite the 0-100 range stated in the tool schema (observed in production: score=989898 with a justification saying the outputs "match perfectly", and score=950 where 95 was intended). extract_tool_call_response passed these values through unvalidated, and no downstream layer on the serverless eval path clamps them, so a single corrupted item blew a 64-item run-level average up to 15559.13%. Clamp finite out-of-range scores to 0-100, prepend a visible warning to the justification so the correction is auditable in the UI, and reject non-finite scores with a ValueError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens legacy LLM-judge evaluation parsing by validating and clamping tool-call score values to prevent corrupted model outputs from poisoning run-level aggregates, while making any corrections auditable in the UI.
Changes:
- Clamp finite out-of-range
scorevalues fromsubmit_evaluationtool calls into[0, 100]and prepend a warning line to the justification. - Reject non-finite scores (
NaN/inf) with aValueError. - Add a focused pytest suite covering normal, boundary, out-of-range, negative, non-finite, and missing-field cases (including production payloads).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py | Adds non-finite validation and clamps out-of-range judge scores, surfacing corrections in justification. |
| packages/uipath/tests/evaluators/test_legacy_llm_helpers.py | Adds regression tests for score parsing/clamping behavior using production-like payloads. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| score = float(arguments["score"]) | ||
| justification = str(arguments["justification"]) | ||
|
|
||
| if not math.isfinite(score): | ||
| error_msg = ( | ||
| f"Non-finite score {score!r} in tool call arguments from model {model}" | ||
| ) | ||
| logger.error(f"❌ {error_msg}") | ||
| raise ValueError(error_msg) |
Proof: raw gemini-2.5-flash
|
Review feedback: clamping fabricates a score the model never gave (950 clamped to 100 when the model likely meant 95). Reject invalid scores with a ValueError instead; track_evaluation_metrics converts it into an ErrorEvaluationResult so the eval item surfaces as an evaluator error with the invalid value in the details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚨 Heads up:
|
|



fix(eval): reject out-of-range scores from LLM judge tool calls
Problem
extract_tool_call_responseinlegacy_llm_helpers.pytakes thescoreargument from the judge model'ssubmit_evaluationtool call and passes it through with no range validation (score = float(arguments["score"])), even though the tool schema declares "Numeric score between 0 and 100".gemini-2.5-flash occasionally emits corrupted numeric tool arguments (digit repetition). When that happens, the raw value flows through
LegacyLlmAsAJudgeEvaluator→ eval runtime → backend storage, and no layer on the serverless eval path validates it. A single corrupted item then poisons the run-level evaluator average.Production proof
Eval run for the "Donor Eligibility Triage Agent" (64 items, default Gemini 2.5 + Sonnet trajectory evaluators). The run list UI showed the Gemini evaluator column as 15559.13% (screenshot in comments / Slack thread).
Trace 1 — trace-1784158433174.json (
STANDARD - Thalassemiaitem), raw gemini-2.5-flash response:{ "name": "submit_evaluation", "arguments": { "justification": "The core meaning and all key facts match perfectly between the expected and actual outputs. ...", "score": 989898 } }Trace 2 — trace-1784158524459.json (
STANDARD - Topical Medicationsitem), raw gemini-2.5-flash response:{ "name": "submit_evaluation", "arguments": { "score": 950, "justification": "The actual output is semantically equivalent to the expected output. All key facts are present and accurate. ..." } }Both justifications describe a near-perfect match — the model clearly intended ~98-100 and ~95. One item at 989,898 averaged over 64 items ≈ 15,559 — exactly the corrupted aggregate shown in the UI.
Note the trace's
Evaluation outputspan shows the display value clamped to 100 (_spans.pynormalize_score_to_100), which masks the corruption at item level while the raw value still poisons the run aggregate — that inconsistency is what made this hard to spot.Repro
pytest packages/uipath/tests/evaluators/test_legacy_llm_helpers.py— the new tests feed the exact production payloads (989898, 950) intoextract_tool_call_response.gemini-2.5-flashover a reasonably large set; the corruption is stochastic (~2 out of ~128 judge calls in the run above). Point the evaluator at a mock LLM gateway returningscore: 989898to force it on the first item.Fix
Reject invalid scores (out-of-range or non-finite) with a
ValueError. Thetrack_evaluation_metricsdecorator on the legacy evaluators converts the exception into anErrorEvaluationResult(score=0,ScoreType.ERROR) with the invalid value in the details, so the eval item surfaces as an evaluator error in the UI instead of silently recording a fabricated score.A companion defense-in-depth PR (UiPath/Agents#5831) makes the backend aggregation treat out-of-range stored values like error scores.
Testing
packages/uipath/tests/evaluators/test_legacy_llm_helpers.py: 10 tests (production payloads 989898/950, negative, boundaries, NaN/±inf, missing field) — all pass.tests/evaluators/suite: 507 passed, no regressions.ruff check,ruff format --check,mypyclean on changed files.🤖 Generated with Claude Code