Skip to content

Commit 762a59c

Browse files
Tomkessclaude
andcommitted
feat(gooddata-eval): populate best_run_latency_s for agentic evaluators
ItemReport.best_run_latency_s has only ever been set on the single-shot path (_run_one_item times each of its K attempts directly and keeps the best one's own latency). The agentic path drives its K runs and best-of-K selection inside each evaluator, so the runner never had a per-run latency to read -- every agentic kind reported best_run_latency_s: null, which is why tools consuming it (e.g. the Latency Explorer's tool-calls-vs-run-time panel) have always been empty for agentic_*/vis_agentic. Adds AgenticEvalOutcome.best_run_latency_s (and the matching bare annotation on AgenticAssertionError/JudgeResponseError, same pattern already used for timings) and wires it through all 11 agentic evaluators: - general_question, metric_skill, dashboard_skill already track per-run PhaseTimings; best_run_latency_s is derived from the rank-selected run's own timings via the new run_latency_s() helper (excludes langfuse_s, which is deliberately off the critical path). - The other 8 (guardrail, search_tool, alert_skill, anomaly_detection, kda_skill, visualization, what_if, conversation) had no per-run wall-clock at all; each gets a new run_latency_s field on its per-run result, measured the same way _run_one_item already does (time.monotonic() bracketing the whole run). agentic_runner.py copies it onto the ItemReport via a new _apply_best_run_latency(), mirroring the existing _apply_timings(). Verified: full gooddata-eval suite (1382 tests), ruff lint/format, and ty type-check all pass. Added focused tests for the new run_latency_s() helper, _apply_best_run_latency()'s three paths (success/failure/absent), and end-to-end value assertions for general_question and metric_skill (the two kinds with fully deterministic mocked clocks). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1daa2c4 commit 762a59c

19 files changed

Lines changed: 175 additions & 3 deletions

‎packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,18 @@ def _apply_timings(item_report: ItemReport, timings: Any) -> None:
340340
item_report.simulated_user_latency_s = timings.simulated_user_s
341341

342342

343+
def _apply_best_run_latency(item_report: ItemReport, source: Any) -> None:
344+
"""Copy the rank-selected run's own wall time, mirroring the single-shot path's
345+
``ItemReport.best_run_latency_s`` (see ``core/runner.py``'s ``_run_one_item``).
346+
347+
Kinds not yet wired to measure it pass None and keep the field at its own None
348+
default rather than reporting an invented number.
349+
"""
350+
best_run_latency_s = getattr(source, "best_run_latency_s", None)
351+
if best_run_latency_s is not None:
352+
item_report.best_run_latency_s = best_run_latency_s
353+
354+
343355
def run_agentic_items(
344356
items: list[DatasetItem],
345357
host: str,
@@ -425,6 +437,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport:
425437
item_report.response_id = response_id
426438
item_report.best_detail = detail or {}
427439
_apply_timings(item_report, getattr(outcome, "timings", None))
440+
_apply_best_run_latency(item_report, outcome)
428441
_apply_run_counts(item_report, outcome)
429442
except AssertionError as exc:
430443
item_report.gate_passed = False if gated else None
@@ -434,6 +447,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport:
434447
item_report.response_id = getattr(exc, "response_id", None)
435448
item_report.best_detail = getattr(exc, "detail", None) or {}
436449
_apply_timings(item_report, getattr(exc, "timings", None))
450+
_apply_best_run_latency(item_report, exc)
437451
_apply_run_counts(item_report, exc)
438452
# Read off the counts, not off the gate: pass^K fails items where runs did pass,
439453
# and reporting those as pass_at_k False would contradict the Langfuse score of
@@ -448,6 +462,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport:
448462
# judge broke should not also report the agent as costing 0s. Kinds that
449463
# attach no timings to the exception keep their 0.0 defaults.
450464
_apply_timings(item_report, getattr(exc, "timings", None))
465+
_apply_best_run_latency(item_report, exc)
451466
finally:
452467
item_report.latency_s = time.perf_counter() - t0
453468

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import os
88
import re
9+
import time
910
from dataclasses import dataclass, field
1011
from typing import Any
1112

@@ -490,6 +491,9 @@ class AlertRunResult:
490491
# also report operator/threshold/metric/recipients as False.
491492
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
492493
turns_used: int = 0
494+
# This run's own wall time, across all its turns -- mirrors the single-shot
495+
# path's best_run_latency_s (see core/runner.py's _run_one_item).
496+
run_latency_s: float = 0.0
493497

494498

495499
@dataclass
@@ -670,6 +674,7 @@ def run_agentic_alert_skill(
670674

671675
def _run_once(conv_id: str) -> AlertRunResult:
672676
alert_id_to_delete: str | None = None
677+
run_started = time.monotonic()
673678
try:
674679
alert_id: str | None = None
675680
actual_args: dict = {}
@@ -788,6 +793,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
788793
reasoning_step_events=all_reasoning_step_events,
789794
exit_reason=exit_reason,
790795
turns_used=turns_used,
796+
run_latency_s=time.monotonic() - run_started,
791797
)
792798
finally:
793799
if alert_id_to_delete:
@@ -982,6 +988,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
982988
exc.detail = detail
983989
exc.runs_passed = runs_passed
984990
exc.runs_effective = runs_effective
991+
exc.best_run_latency_s = best.run_latency_s
985992
raise exc
986993
return AgenticEvalOutcome(
987994
runs_passed=runs_passed,
@@ -990,4 +997,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
990997
conversation_id=best.conversation_id,
991998
response_id=best.response_id,
992999
detail=detail,
1000+
best_run_latency_s=best.run_latency_s,
9931001
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/anomaly_detection.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import logging
2929
import os
3030
import re
31+
import time
3132
from dataclasses import dataclass, field
3233
from typing import Any
3334

@@ -282,6 +283,10 @@ class AnomalyRunResult:
282283
response_id: str | None = None
283284
tool_call_events: list[ToolCallEvent] = field(default_factory=list)
284285
reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
286+
# This run's own wall time, across all its turns -- mirrors the single-shot
287+
# path's best_run_latency_s (see core/runner.py's _run_one_item). Distinct from
288+
# turn_wall_clock_sec above, which is only the final triggering turn.
289+
run_latency_s: float = 0.0
285290

286291

287292
@dataclass
@@ -360,6 +365,7 @@ def run_agentic_anomaly_detection(
360365
)
361366

362367
def _run_once(conv_id: str) -> AnomalyRunResult:
368+
run_started = time.monotonic()
363369
viz_args: dict | None = None
364370
execute_result: dict | None = None
365371
turn_wall_clock_sec: float | None = None
@@ -432,6 +438,7 @@ def _accumulate(result: ChatResult) -> None:
432438
response_id=response_id,
433439
tool_call_events=all_tool_call_events,
434440
reasoning_step_events=all_reasoning_step_events,
441+
run_latency_s=time.monotonic() - run_started,
435442
)
436443

437444
try:
@@ -630,6 +637,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
630637
exc.detail = detail
631638
exc.runs_passed = runs_passed
632639
exc.runs_effective = len(summary.run_results)
640+
exc.best_run_latency_s = best.run_latency_s
633641
raise exc
634642

635643
return AgenticEvalOutcome(
@@ -639,4 +647,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
639647
conversation_id=best.conversation_id,
640648
response_id=best.response_id,
641649
detail=detail,
650+
best_run_latency_s=best.run_latency_s,
642651
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/conversation.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import json
2020
import os
2121
import re
22+
import time
2223
from dataclasses import dataclass, field
2324
from datetime import date
2425
from typing import ClassVar, Literal
@@ -593,6 +594,11 @@ class ConversationResult:
593594
stalled_turns: int = 0
594595
mode: ConversationMode = "legacy"
595596
judge_model: str | None = None
597+
# This conversation's own wall time, across every turn -- mirrors the single-shot
598+
# path's best_run_latency_s (see core/runner.py's _run_one_item). There is no
599+
# K-of-N selection for this kind (see run_agentic_conversation's docstring), so
600+
# this is simply the one conversation's own time, not a "best of" pick.
601+
run_latency_s: float = 0.0
596602

597603

598604
def _metric_creations(tool_call_events: list[ToolCallEvent]) -> list[tuple[str, bool]]:
@@ -707,6 +713,7 @@ def run_agentic_conversation(
707713
agent sees no history. It is the no-memory baseline a context score is calibrated
708714
against: context-dependent turns are expected to fail there.
709715
"""
716+
run_started = time.monotonic()
710717
resolved_mode = resolve_conversation_mode(mode)
711718
if fresh_conversation_per_turn and initial_conversation_id is not None:
712719
raise ValueError("fresh_conversation_per_turn needs conversations this run creates itself")
@@ -1011,6 +1018,7 @@ def run_agentic_conversation(
10111018
stalled_turns=stalled,
10121019
mode=resolved_mode,
10131020
judge_model=judge.model_name if judged else None,
1021+
run_latency_s=time.monotonic() - run_started,
10141022
)
10151023

10161024

@@ -1216,6 +1224,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
12161224
# for. Saying so explicitly stops the report claiming K runs that never happened.
12171225
exc.runs_passed = 0
12181226
exc.runs_effective = 1
1227+
exc.best_run_latency_s = result.run_latency_s
12191228
raise exc
12201229
return AgenticEvalOutcome(
12211230
reasoning_steps=result.reasoning_steps,
@@ -1224,4 +1233,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
12241233
detail=detail,
12251234
runs_passed=1,
12261235
runs_effective=1,
1236+
best_run_latency_s=result.run_latency_s,
12271237
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/dashboard_skill.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
build_latency_breakdown,
3939
shift_and_index_events,
4040
)
41-
from gooddata_eval.core.timing import PhaseTimings, log_timer, sum_timings
41+
from gooddata_eval.core.timing import PhaseTimings, log_timer, run_latency_s, sum_timings
4242

4343
_DEFAULT_K = 1
4444
# Matches kda_skill and visualization. The reply this loop sends is built from the expectation
@@ -1137,6 +1137,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
11371137
exc.conversation_id = best.conversation_id
11381138
exc.response_id = best.response_id
11391139
exc.timings = item_timings
1140+
exc.best_run_latency_s = run_latency_s(best.timings)
11401141
exc.detail = detail
11411142
exc.runs_passed = runs_passed
11421143
exc.runs_effective = runs_effective
@@ -1149,4 +1150,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
11491150
response_id=best.response_id,
11501151
detail=detail,
11511152
timings=item_timings,
1153+
best_run_latency_s=run_latency_s(best.timings),
11521154
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
ToolCallEvent,
3535
build_latency_breakdown,
3636
)
37-
from gooddata_eval.core.timing import PhaseTimings, log_timer, sum_timings
37+
from gooddata_eval.core.timing import PhaseTimings, log_timer, run_latency_s, sum_timings
3838

3939
_DEFAULT_K = 1
4040

@@ -318,6 +318,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
318318
+ " | ".join(unscored)
319319
)
320320
exc.timings = item_timings
321+
exc.best_run_latency_s = run_latency_s(summary.best.timings)
321322
raise exc
322323

323324
runs_passed = sum(1 for r in summary.scored_run_results if r.passed)
@@ -344,6 +345,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
344345
exc.conversation_id = best.conversation_id
345346
exc.response_id = best.response_id
346347
exc.timings = item_timings
348+
exc.best_run_latency_s = run_latency_s(best.timings)
347349
exc.detail = detail
348350
exc.runs_passed = runs_passed
349351
exc.runs_effective = runs_effective
@@ -356,4 +358,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
356358
response_id=best.response_id,
357359
detail=detail,
358360
timings=item_timings,
361+
best_run_latency_s=run_latency_s(best.timings),
359362
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/guardrail.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import time
67
from dataclasses import dataclass, field
78

89
from gooddata_eval.core.agentic._gate import (
@@ -83,6 +84,9 @@ class GuardrailResult:
8384
# Set when the judge returned something unreadable for THIS run. Excluded from pass@K
8485
# and from Langfuse scoring rather than counted as a failure -- see score_run.
8586
judge_error: str | None = None
87+
# This run's own wall time (agent call + its grading) -- mirrors the single-shot
88+
# path's best_run_latency_s (see core/runner.py's _run_one_item).
89+
run_latency_s: float = 0.0
8690

8791

8892
@dataclass
@@ -117,6 +121,7 @@ def _run_single_guardrail(
117121
Extracted so the two call sites below (the first conversation, which may be supplied,
118122
and the remaining K-1) cannot drift -- they had already duplicated the whole body once.
119123
"""
124+
run_started = time.monotonic()
120125
chat_result = client.send_message(conversation_id, question)
121126
actual_output = render_answer_text(chat_result)
122127
verdict = score_run(judge, input=question, expected_output=expected_output, actual_output=actual_output)
@@ -131,6 +136,7 @@ def _run_single_guardrail(
131136
tool_call_events=list(chat_result.tool_call_events or []),
132137
reasoning_step_events=list(chat_result.reasoning_step_events or []),
133138
judge_error=verdict.error,
139+
run_latency_s=time.monotonic() - run_started,
134140
)
135141

136142

@@ -314,6 +320,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
314320
exc.detail = detail
315321
exc.runs_passed = runs_passed
316322
exc.runs_effective = runs_effective
323+
exc.best_run_latency_s = best.run_latency_s
317324
raise exc
318325
return AgenticEvalOutcome(
319326
runs_passed=runs_passed,
@@ -322,4 +329,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
322329
conversation_id=best.conversation_id,
323330
response_id=best.response_id,
324331
detail=detail,
332+
best_run_latency_s=best.run_latency_s,
325333
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/kda_skill.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import logging
77
import os
8+
import time
89
from dataclasses import dataclass, field
910

1011
import httpx
@@ -198,6 +199,10 @@ class KdaRunResult:
198199
# separate a refusal from a run that hit max_iterations while still on track.
199200
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
200201
turns_used: int = 0
202+
# This run's own wall time, across all its turns -- mirrors the single-shot
203+
# path's best_run_latency_s (see core/runner.py's _run_one_item). Distinct from
204+
# turn_wall_clock_sec above, which is only the final create-triggering turn.
205+
run_latency_s: float = 0.0
201206

202207

203208
@dataclass
@@ -260,6 +265,7 @@ def run_agentic_kda_skill(
260265
)
261266

262267
def _run_once(conv_id: str) -> KdaRunResult:
268+
run_started = time.monotonic()
263269
create_args: dict | None = None
264270
execute_result: dict | None = None
265271
turn_wall_clock_sec: float | None = None
@@ -367,6 +373,7 @@ def _accumulate(result: ChatResult) -> None:
367373
reasoning_step_events=all_reasoning_step_events,
368374
exit_reason=exit_reason,
369375
turns_used=turns_used,
376+
run_latency_s=time.monotonic() - run_started,
370377
)
371378

372379
try:
@@ -552,6 +559,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
552559
exc.detail = detail
553560
exc.runs_passed = runs_passed
554561
exc.runs_effective = runs_effective
562+
exc.best_run_latency_s = best.run_latency_s
555563
raise exc
556564
return AgenticEvalOutcome(
557565
runs_passed=runs_passed,
@@ -560,4 +568,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
560568
conversation_id=best.conversation_id,
561569
response_id=best.response_id,
562570
detail=detail,
571+
best_run_latency_s=best.run_latency_s,
563572
)

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
shift_and_index_events,
4242
timeline_detail,
4343
)
44-
from gooddata_eval.core.timing import PhaseTimings, log_timer, sum_timings
44+
from gooddata_eval.core.timing import PhaseTimings, log_timer, run_latency_s, sum_timings
4545

4646
try:
4747
from openai import OpenAI as _OpenAI
@@ -575,6 +575,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
575575
exc.conversation_id = best.conversation_id
576576
exc.response_id = best.response_id
577577
exc.timings = item_timings
578+
exc.best_run_latency_s = run_latency_s(best.timings)
578579
exc.detail = detail
579580
exc.runs_passed = runs_passed
580581
exc.runs_effective = runs_effective
@@ -587,4 +588,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
587588
response_id=best.response_id,
588589
detail=detail,
589590
timings=item_timings,
591+
best_run_latency_s=run_latency_s(best.timings),
590592
)

0 commit comments

Comments
 (0)