fix(cicd): populate metrics_summary so oaeval test can evaluate thresholds - #260
Conversation
oaeval test reports every threshold as 'metric not found' because the gate summary returned by OAEvalPlugin.run_evaluation carries no metrics_summary for the CLI threshold-evaluation path to consume. Add an end-to-end regression test (offline mock providers) that fails while the key is missing. Co-Authored-By: Kimi K3 <noreply@kimi.com>
OAEvalPlugin.run_evaluation returned an EvaluationResult whose summary carried only gate counts, so the oaeval test threshold path read an empty metrics mapping and reported every threshold as 'metric not found' regardless of the actual scores (OpenAgentHQ#228). Populate metrics_summary in the returned summary, in the same flat metric-name -> value shape the engine summary (and thus the SDK threshold path) already produces. ThresholdEvaluator stays the single consumer schema; the plugin was the incomplete producer. Co-Authored-By: Kimi K3 <noreply@kimi.com>
…nt assertions Assert the passing threshold is evaluated against the real computed value (exact_match: 1.0000 gte 0.5000, gate PASS), and add a should-fail case (exact_match:gte:1.5) asserting non-zero exit and the failure reported with the real actual value. A suite that only checks the passing direction cannot notice a threshold being silently waved through. Co-Authored-By: Kimi K3 <noreply@kimi.com>
The stored mapping is the plugin's threshold-target mapping: the engine's metric set plus the execution counts (total_items, successful_evaluations, failed_evaluations) the plugin already supports as gate targets. It is a superset of the engine summary's own metrics_summary, not the same shape. Behaviour unchanged. Co-Authored-By: Kimi K3 <noreply@kimi.com>
asyncio.run() calls asyncio.set_event_loop(None) on completion, so after any completed asyncio.run in the process, asyncio.get_event_loop() in the main thread raises RuntimeError instead of auto-creating a loop. run_evaluation called get_event_loop() inside its broad try, so the exception was swallowed into an error summary and every gate threshold was reported as "not found in results" (issue OpenAgentHQ#261). In the full-suite single-process coverage run, an earlier test completes an asyncio.run, which made the OpenAgentHQ#228 regression tests fail in the Coverage CI job. Replace the get_event_loop()+is_running() probe with get_running_loop(): RuntimeError (no loop running, the normal synchronous case) runs the coroutine via asyncio.run(); a live loop keeps the existing behaviour of offloading to a thread pool with the configured timeout. Add a regression test that completes an asyncio.run() before driving 'oaeval test' and asserts the evaluation actually ran (real metric value compared, gate passes) instead of returning the error summary. Co-Authored-By: Kimi K3 <noreply@kimi.com>
|
/oc review pr |
Review: fix/228-gate-metrics-summaryOverall: ✅ Looks good, ready to mergeTwo bugs, two fixes, clean code, solid tests. Bug #228 —
|
| Test | Threshold | Asserts |
|---|---|---|
passing_threshold_is_not_reported_missing |
exact_match:gte:0.5 |
✓ exit 0, ✓ actual 1.0000, ✓ "PASS", ✗ "not found" |
failing_threshold_is_enforced |
exact_match:gte:1.5 |
✓ exit 1, ✓ "1.0000 NOT gte 1.5000", ✗ "not found" |
after_completed_asyncio_run |
exact_match:gte:0.5 |
same as first, but preceded by asyncio.run(asyncio.sleep(0)) |
The bidirectional pair (pass + fail) is important — the author verified both detect a deliberately broken implementation that silently skips missing metrics.
Minor notes (none blocking)
- Double evaluation: CLI's
test_commandparses thresholds into its owncicd_config, callsrun_evaluation(which does not receive them sincethresholdsisn't passed), then re-evaluates its gates against the result'smetrics_summary. Pre-existing, not introduced here. Could be cleaned up in a follow-up by either (a) passing thresholds torun_evaluationand reading its.passed, or (b) havingrun_evaluationskip eval and defer entirely to the CLI. flat_metricssuperset of engine metrics: includestotal_items,successful_evaluations,failed_evaluationsin addition to metric names. Intentionally documented; no downstream consumer breaks because nothing iterates plugin resultmetrics_summaryas a pure metric dict today.- No new lint/violations: confirmed identical per-file findings at
mainvs this branch (UP035,B904inplugin.py;I001in test file).
|
Thanks for running that. On the bot's first note — we noticed the same double-evaluation path while working on this: Happy to send it as a separate PR if you want it — your pick between passing |
|
🎉 Congratulations @Nitjsefnie! Your pull request has been successfully merged into main. 🚀 Thank you for contributing to OpenAgentHQ and helping improve the project. We truly appreciate your contribution and hope to see you back with more amazing PRs! Happy Open Sourcing! ❤️ |

Description
oaeval testfails for every threshold regardless of the actual scores, because the gate summary it reads carries nometrics_summary.OAEvalPlugin.run_evaluationreturns theEvaluationResultbuilt byThresholdEvaluator.evaluate_all_gates, whose summary holds only gate/threshold counts, socli/commands/test.pylooks upmetrics_summary, gets{}, and every threshold resolves toactual=None→Metric '<name>' not found in results.This populates
metrics_summaryin the summary the plugin returns, so the CLI's threshold evaluation sees the same metric values the plugin evaluated its own gates against.ThresholdEvaluatoris unchanged.It also fixes a second defect in the same function, which the regression tests for the first one uncovered — see "Why this PR contains two fixes" below.
Type of Change
Related Issues
Closes #228
Closes #261
How Has This Been Tested?
Reproduced first on unmodified
main(b00ab48) with a fully offline config using the in-repo mock providers, which echoground_truthsoexact_matchscores1.0and agte:0.5threshold must pass:oaeval runon the same config exits 0 and reportsexact_match: 1.0, confirming the metric is computed and the threshold genuinely ought to pass. After the fix the same command exits 0 with"status": "passed"and"actual": 1.0.Three regression tests were added, all driving the real
oaeval testcommand end to end viaCliRunner:exact_match: 1.0000 gte 0.5000) and a passing gate row — not merely the absence of the error string;exact_match:gte:1.5) asserting exit code 1 andexact_match: 1.0000 NOT gte 1.5000;asyncio.run(...)first and then asserts the evaluation still runs (the [BUG] OAEvalPlugin.run_evaluation returns a silent error result in any process where asyncio.run has already completed #261 regression).The first two were checked against a deliberately broken implementation in which missing metrics are treated as passing — that variant waves even
gte:1.5through as passed, and both tests fail on it, so the pair detects enforcement being silently skipped rather than only detecting the original symptom. Each test was also confirmed to fail with its corresponding source change reverted.uv run pytest tests/unit -v --tb=short→ 1011 passed, 4 skipped; the 4 skips are pre-existing environment skips forchromadbandOPENAI_API_KEY)Coveragejob (uv run pytest --cov=openagent_eval --cov-report=term-missing --cov-fail-under=75→ 1065 passed, 4 skipped, 81.08%)uv run ruff check .) — see note belowuv run mypy openagent_eval/) — see note belowChecklist
Additional Notes
Why this PR contains two fixes. The
metrics_summaryfix alone is not observable in any process that has already completed anasyncio.run(...), and I only found that out because the new regression tests passed in isolation and failed when the whole suite ran in one process. The cause is separate and is filed as #261:run_evaluationcalledasyncio.get_event_loop(), butasyncio.runsets the current loop toNonewhen it finishes, so that call raisesRuntimeErrorbefore theif loop.is_running()test is reached, and the broadexcept Exceptionconverts it into a returned result with anerrorkey — which the CLI then reports as "metric not found", i.e. indistinguishable from the bug being fixed here. The fix usesasyncio.get_running_loop()instead, so the behaviour no longer depends on the event-loop policy's current-loop state; the existing thread-pool offload and itstimeoutare unchanged for the case where a loop really is running.I kept them together because the first fix is not demonstrably correct without the second, and separating them would mean landing a regression test that fails in your own CI. Happy to split it into two PRs if you would rather review them independently — say the word and I will.
On the two unchecked test boxes.
ruff check .exits 1 on pre-existing findings atmainitself, so I could not honestly tick that box for this branch and did not want to imply otherwise. Instead I compared per-file, atmainand on this branch, for the two files this PR touches: identical results on both sides (UP035andB904inplugin.py,I001in the test file, plus pre-existing format drift), so this diff adds no new findings. I have deliberately not reformatted or fixed those pre-existing findings, since that would bury a small behavioural fix in unrelated churn — happy to send that separately if you want it. Themypybox is unticked for the same reason: it is not clean atmain, and this diff does not change that either way.No documentation changes seemed warranted, as this restores the documented behaviour of existing functionality rather than changing it — say the word if you would like the CI/CD docs to call it out.
One thing worth your judgement. The mapping I store in
metrics_summaryis the plugin's threshold-target mapping, which is the engine's metric set plus the three execution counts the plugin already supports as gate targets (total_items,successful_evaluations,failed_evaluations). It is therefore a superset of what the engine puts in its ownmetrics_summary, and the code comment says so explicitly rather than claiming parity. I chose the superset deliberately: narrowing it to metrics only would makeoaeval test -t total_items:gte:1report "metric not found", which is the same class of failure this PR fixes. The tradeoff is that a consumer iteratingmetrics_summaryas metric-name → value would now also see those three counts. Nothing in the repo does that with a plugin result today, but if you would rather keep the two summaries schema-identical, narrowing it is a one-line change — your call.Generated by Claude Opus 5 (brief, review), Kimi K3 (implementation)