Skip to content

Commit c41f542

Browse files
Tomkessclaude
andauthored
refactor(gooddata-eval): centralize agentic outcome fields; fix exit_reason and tool_calls gaps (#1846)
Comprehensive analysis of the 11 agentic evaluators (alert_skill, anomaly_detection, conversation, dashboard_skill, general_question, guardrail, kda_skill, metric_skill, search_tool, visualization, what_if) found the same unfinished-rollout pattern that caused best_run_latency_s (#1845) recurring twice more, live in production data: 1. exit_reason (LoopExit) -- invented specifically so a run that hit max_iterations stops looking identical to a genuine refusal -- was wired into 5 of 8 multi-turn kinds. Missing from anomaly_detection, dashboard_skill, what_if. All three now track it through every break point in their run loop (SUCCESS/AGENT_SILENT/ CHAT_ERROR/SIMULATED_USER_FAILED/BUDGET_EXHAUSTED default), mirroring the exact pattern already proven in kda_skill.py/alert_skill.py. 2. detail's tool_calls key -- timeline_detail() builds both latency_breakdown and tool_calls from the same events so they stay index-aligned, but roughly half the evaluators called build_latency_breakdown() directly and silently never got a tool_calls key. Every evaluator's detail dict now goes through one shared `agentic_detail()` call, so this can't happen again by omission. 3. The root cause of 1 and 2 (and best_run_latency_s before them): every evaluator hand-wrote the same ~7-10 line block copying reasoning_steps/conversation_id/ response_id/detail/runs_passed/runs_effective/(timings)/best_run_latency_s either onto a raised exception or into the returned AgenticEvalOutcome -- 11 independent copies of the same logic, so a new universal field needed 11 edits, and nothing failed loudly when one was missed (AgenticAssertionError's own docstring already documents this happening once before, to `.timings`). New core/agentic/_outcome.py centralizes it: `agentic_detail()` for the detail dict, `raise_agentic_failure()`/`agentic_success()` for the common fields. All 11 evaluators now call these instead of hand-rolling. Per-evaluator duplicate field assignments: ~85 lines total before, 3 after (the 2 remaining are general_question's and guardrail's "no judge verdict at all" branches, which raise a bare JudgeResponseError -- not an AgenticAssertionError subclass, so they can't route through raise_agentic_failure; left as direct attribute sets, same as before). Explicitly NOT addressed here (see PR description for why): - agentic_dashboard_summary not existing on master at all -- it's part of a much larger (2000+ line) unmerged integration branch with its own in-flight PRs; porting it here would duplicate/conflict with that separate effort. - A ~9% gap in agentic_guardrail's .reasoning.json sidecar capture for runs that did have reasoning steps -- traced as far as ruling out config differences and SSE-level field divergence (reasoningSteps/reasoningStepEvents are built from the same accumulator list, so they can't diverge there), but the remaining cause needs a live repro with real reasoning content, not static source reading. Verified: full gooddata-eval suite (1382 passed), ruff lint/format, and ty type-check all clean. New/extended tests assert real exit_reason values (not just that nothing broke) for every break point added, plus tool_calls presence, across all three newly-covered kinds. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4216bed commit c41f542

18 files changed

Lines changed: 483 additions & 275 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# (C) 2026 GoodData Corporation
2+
"""The common tail of every ``evaluate_agentic_*`` function.
3+
4+
Before this module, each of the agentic evaluators hand-wrote the same block: copy
5+
``reasoning_steps``/``conversation_id``/``response_id``/``detail``/``runs_passed``/
6+
``runs_effective``/``best_run_latency_s`` (and sometimes ``timings``) either onto a
7+
raised exception or into the returned ``AgenticEvalOutcome``. Eleven independent copies
8+
of the same ~7 lines meant a new universal field needed eleven edits, not one, and
9+
nothing failed loudly when a copy was missed -- exactly what happened to ``timings``
10+
(see ``AgenticAssertionError``'s docstring: "while it lived in eight copies, two
11+
declared timings and six did not") and again to ``best_run_latency_s`` (every agentic
12+
kind silently reported it as ``null`` until this was noticed and fixed kind-by-kind).
13+
14+
``agentic_detail`` has the same motivation for the ``detail`` dict's timeline fields:
15+
``timeline_detail`` builds both ``latency_breakdown`` and ``tool_calls`` from the same
16+
events so they stay index-aligned, but roughly half the evaluators called
17+
``build_latency_breakdown`` directly and silently never got a ``tool_calls`` key.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any, NoReturn
23+
24+
from gooddata_eval.core.models import (
25+
AgenticAssertionError,
26+
AgenticEvalOutcome,
27+
ReasoningStepEvent,
28+
ToolCallEvent,
29+
timeline_detail,
30+
)
31+
from gooddata_eval.core.timing import PhaseTimings
32+
33+
__all__ = ["agentic_detail", "agentic_success", "raise_agentic_failure"]
34+
35+
36+
def agentic_detail(
37+
tool_call_events: list[ToolCallEvent],
38+
reasoning_step_events: list[ReasoningStepEvent] | None,
39+
**kind_specific: Any,
40+
) -> dict:
41+
"""A kind's full ``detail`` dict: its own fields plus the universal timeline ones.
42+
43+
``kind_specific`` comes first in the merge so a kind can never accidentally shadow
44+
``latency_breakdown``/``tool_calls`` with a same-named field of its own.
45+
"""
46+
return {**kind_specific, **timeline_detail(tool_call_events, reasoning_step_events)}
47+
48+
49+
def raise_agentic_failure(
50+
exception_cls: type[AgenticAssertionError],
51+
message: str,
52+
*,
53+
reasoning_steps: list[str],
54+
conversation_id: str,
55+
response_id: str | None,
56+
detail: dict,
57+
runs_passed: int,
58+
runs_effective: int,
59+
best_run_latency_s: float | None,
60+
timings: PhaseTimings | None = None,
61+
) -> NoReturn:
62+
"""Build ``exception_cls(message)`` with every common field attached, and raise it.
63+
64+
``exception_cls`` must be an ``AgenticAssertionError`` subclass -- that base class is
65+
what declares these fields as legal targets (see its docstring). A kind whose own
66+
"no verdict at all" branch raises a bare ``JudgeResponseError`` instead (not a subclass)
67+
does not go through this helper for that branch.
68+
69+
``timings`` stays optional: only the three kinds that track per-run ``PhaseTimings``
70+
(general_question, metric_skill, dashboard_skill) pass one -- the rest keep their
71+
own ``PhaseTimings()`` zero default, same as before this helper existed.
72+
"""
73+
exc = exception_cls(message)
74+
exc.reasoning_steps = reasoning_steps
75+
exc.conversation_id = conversation_id
76+
exc.response_id = response_id
77+
exc.detail = detail
78+
exc.runs_passed = runs_passed
79+
exc.runs_effective = runs_effective
80+
exc.best_run_latency_s = best_run_latency_s
81+
if timings is not None:
82+
exc.timings = timings
83+
raise exc
84+
85+
86+
def agentic_success(
87+
*,
88+
reasoning_steps: list[str],
89+
conversation_id: str | None,
90+
response_id: str | None,
91+
detail: dict,
92+
runs_passed: int,
93+
runs_effective: int,
94+
best_run_latency_s: float | None,
95+
timings: PhaseTimings | None = None,
96+
) -> AgenticEvalOutcome:
97+
"""The success-path mirror of ``raise_agentic_failure`` -- same fields, same shape."""
98+
kwargs: dict[str, Any] = {
99+
"reasoning_steps": reasoning_steps,
100+
"conversation_id": conversation_id,
101+
"response_id": response_id,
102+
"detail": detail,
103+
"runs_passed": runs_passed,
104+
"runs_effective": runs_effective,
105+
"best_run_latency_s": best_run_latency_s,
106+
}
107+
if timings is not None:
108+
kwargs["timings"] = timings
109+
return AgenticEvalOutcome(**kwargs)

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

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
log_gate_scores,
2222
stamp_gate_metadata,
2323
)
24+
from gooddata_eval.core.agentic._outcome import agentic_detail, agentic_success, raise_agentic_failure
2425
from gooddata_eval.core.agentic._trace_linker import (
2526
RunIdentity,
2627
RunTraceContext,
@@ -40,7 +41,6 @@
4041
ReasoningStepEvent,
4142
ToolCallEvent,
4243
shift_and_index_events,
43-
timeline_detail,
4444
)
4545

4646
try:
@@ -951,51 +951,52 @@ def _write_scores(ctx: RunTraceContext) -> None:
951951

952952
best = summary.best
953953
ev = best.eval
954-
detail = {
955-
"alert_created": ev.alert_created,
956-
"operator_correct": ev.operator_correct,
957-
"threshold_correct": ev.threshold_correct,
958-
"trigger_correct": ev.trigger_correct,
959-
"filters_correct": ev.filters_correct,
960-
"metric_correct": ev.metric_correct,
961-
"recipients_correct": ev.recipients_correct,
962-
"attributes_correct": ev.attributes_correct,
963-
"granularity_correct": ev.granularity_correct,
964-
"actual_alert_arguments": best.actual_alert_arguments,
954+
detail = agentic_detail(
955+
best.tool_call_events,
956+
best.reasoning_step_events,
957+
alert_created=ev.alert_created,
958+
operator_correct=ev.operator_correct,
959+
threshold_correct=ev.threshold_correct,
960+
trigger_correct=ev.trigger_correct,
961+
filters_correct=ev.filters_correct,
962+
metric_correct=ev.metric_correct,
963+
recipients_correct=ev.recipients_correct,
964+
attributes_correct=ev.attributes_correct,
965+
granularity_correct=ev.granularity_correct,
966+
actual_alert_arguments=best.actual_alert_arguments,
965967
# Why the loop stopped. alert_created=False alone cannot tell a refusal from a run
966968
# that hit max_iterations while still on track -- see LoopExit.
967-
"exit_reason": best.exit_reason.value,
968-
"turns_used": best.turns_used,
969-
"max_iterations": max_iterations,
970-
**timeline_detail(best.tool_call_events, best.reasoning_step_events),
971-
}
969+
exit_reason=best.exit_reason.value,
970+
turns_used=best.turns_used,
971+
max_iterations=max_iterations,
972+
)
972973

973974
if not gate_passed(gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k):
974975
gate_note = gate_failure_note(gate, runs_passed, runs_effective)
975-
exc = AlertSkillAssertionError(
976+
raise_agentic_failure(
977+
AlertSkillAssertionError,
976978
f"Alert skill assertion failed. {gate_note} strict_pass={ev.strict_pass}. "
977979
f"alert_created={ev.alert_created}, operator_correct={ev.operator_correct}, "
978980
f"threshold_correct={ev.threshold_correct}, trigger_correct={ev.trigger_correct}, "
979981
f"filters_correct={ev.filters_correct}, metric_correct={ev.metric_correct}, "
980982
f"recipients_correct={ev.recipients_correct}, "
981983
f"attributes_correct={ev.attributes_correct}, "
982984
f"granularity_correct={ev.granularity_correct}. "
983-
f"Actual args: {best.actual_alert_arguments}"
985+
f"Actual args: {best.actual_alert_arguments}",
986+
reasoning_steps=best.reasoning_steps,
987+
conversation_id=best.conversation_id,
988+
response_id=best.response_id,
989+
detail=detail,
990+
runs_passed=runs_passed,
991+
runs_effective=runs_effective,
992+
best_run_latency_s=best.run_latency_s,
984993
)
985-
exc.reasoning_steps = best.reasoning_steps
986-
exc.conversation_id = best.conversation_id
987-
exc.response_id = best.response_id
988-
exc.detail = detail
989-
exc.runs_passed = runs_passed
990-
exc.runs_effective = runs_effective
991-
exc.best_run_latency_s = best.run_latency_s
992-
raise exc
993-
return AgenticEvalOutcome(
994-
runs_passed=runs_passed,
995-
runs_effective=runs_effective,
994+
return agentic_success(
996995
reasoning_steps=best.reasoning_steps,
997996
conversation_id=best.conversation_id,
998997
response_id=best.response_id,
999998
detail=detail,
999+
runs_passed=runs_passed,
1000+
runs_effective=runs_effective,
10001001
best_run_latency_s=best.run_latency_s,
10011002
)

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

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
log_gate_scores,
4141
stamp_gate_metadata,
4242
)
43+
from gooddata_eval.core.agentic._outcome import agentic_detail, agentic_success, raise_agentic_failure
4344
from gooddata_eval.core.agentic._trace_linker import (
4445
RunIdentity,
4546
RunTraceContext,
@@ -56,9 +57,9 @@
5657
AgenticAssertionError,
5758
AgenticEvalOutcome,
5859
ChatResult,
60+
LoopExit,
5961
ReasoningStepEvent,
6062
ToolCallEvent,
61-
build_latency_breakdown,
6263
shift_and_index_events,
6364
)
6465

@@ -287,6 +288,10 @@ class AnomalyRunResult:
287288
# path's best_run_latency_s (see core/runner.py's _run_one_item). Distinct from
288289
# turn_wall_clock_sec above, which is only the final triggering turn.
289290
run_latency_s: float = 0.0
291+
# Why the simulated-user loop stopped -- see LoopExit. `triggered=False` alone cannot
292+
# separate a refusal from a run that hit max_iterations while still on track (matches
293+
# kda_skill.py/alert_skill.py).
294+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
290295

291296

292297
@dataclass
@@ -391,6 +396,10 @@ def _accumulate(result: ChatResult) -> None:
391396
all_tool_call_events.extend(result.tool_call_events or [])
392397
all_reasoning_step_events.extend(result.reasoning_step_events or [])
393398

399+
# Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that
400+
# simply runs out of range() is labelled correctly with no trailing else.
401+
exit_reason = LoopExit.BUDGET_EXHAUSTED
402+
394403
for iteration in range(max_iterations):
395404
try:
396405
chat_result = client.send_message(conv_id, current_question)
@@ -403,6 +412,7 @@ def _accumulate(result: ChatResult) -> None:
403412
_accumulate(partial)
404413
viz_args, execute_result = _extract_anomaly_calls(all_tool_call_events)
405414
turn_completed = False
415+
exit_reason = LoopExit.CHAT_ERROR
406416
break
407417
reasoning_steps.extend(chat_result.reasoning_steps or [])
408418
response_id = chat_result.response_id or response_id
@@ -416,8 +426,10 @@ def _accumulate(result: ChatResult) -> None:
416426
if execute_result is not None:
417427
# The turn that ran the detection, not an earlier disambiguation turn.
418428
turn_wall_clock_sec = chat_result.turn_wall_clock_sec
429+
exit_reason = LoopExit.SUCCESS
419430
break
420431
if not response_text:
432+
exit_reason = LoopExit.AGENT_SILENT
421433
break
422434
if iteration >= max_iterations - 1:
423435
break
@@ -426,6 +438,7 @@ def _accumulate(result: ChatResult) -> None:
426438
disambiguated = True
427439
except Exception as exc: # noqa: BLE001 -- harness-side fault; end only this run
428440
_log.warning("Simulated anomaly user reply failed for conversation %s: %s", conv_id, exc)
441+
exit_reason = LoopExit.SIMULATED_USER_FAILED
429442
break
430443

431444
return AnomalyRunResult(
@@ -434,6 +447,7 @@ def _accumulate(result: ChatResult) -> None:
434447
actual_visualization=viz_args,
435448
actual_execute_result=execute_result,
436449
turn_wall_clock_sec=turn_wall_clock_sec,
450+
exit_reason=exit_reason,
437451
reasoning_steps=reasoning_steps,
438452
response_id=response_id,
439453
tool_call_events=all_tool_call_events,
@@ -487,25 +501,29 @@ class AnomalyDetectionAssertionError(AgenticAssertionError):
487501

488502
def _detail(best: AnomalyRunResult) -> dict[str, Any]:
489503
ev = best.evaluation
490-
return {
491-
"triggered": ev.triggered,
492-
"executed": ev.executed,
493-
"success": ev.success,
494-
"turn_completed": ev.turn_completed,
495-
"metric_correct": ev.metric_correct,
496-
"granularity_correct": ev.granularity_correct,
504+
return agentic_detail(
505+
best.tool_call_events,
506+
best.reasoning_step_events,
507+
triggered=ev.triggered,
508+
executed=ev.executed,
509+
success=ev.success,
510+
turn_completed=ev.turn_completed,
511+
metric_correct=ev.metric_correct,
512+
granularity_correct=ev.granularity_correct,
497513
# Which content checks the fixture pinned -- without it a run that verified nothing
498514
# reads the same as one where everything matched.
499-
"asserted": ev.asserted,
500-
"disambiguated": ev.disambiguated,
501-
"actual_metrics": sorted(_metric_uris(best.actual_visualization)),
502-
"actual_granularity": _inferred_granularity(best.actual_visualization),
515+
asserted=ev.asserted,
516+
disambiguated=ev.disambiguated,
517+
actual_metrics=sorted(_metric_uris(best.actual_visualization)),
518+
actual_granularity=_inferred_granularity(best.actual_visualization),
503519
# Reported, never asserted: whether a real series contains anomalies is a property
504520
# of the data, so a fixture demanding some would fail on the next warehouse refresh.
505-
"anomaly_point_count": _point_count(best.actual_execute_result),
506-
"actual_execute_result": best.actual_execute_result,
507-
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
508-
}
521+
anomaly_point_count=_point_count(best.actual_execute_result),
522+
actual_execute_result=best.actual_execute_result,
523+
# Why the loop stopped. triggered=False alone cannot tell a refusal from a run
524+
# that hit max_iterations while still on track -- see LoopExit.
525+
exit_reason=best.exit_reason.value,
526+
)
509527

510528

511529
def evaluate_agentic_anomaly_detection(
@@ -630,22 +648,24 @@ def _write_scores(ctx: RunTraceContext) -> None:
630648
f"Analysed {detail['actual_metrics']} at {detail['actual_granularity']}. "
631649
f"Actual execute result: {best.actual_execute_result}."
632650
)
633-
exc = AnomalyDetectionAssertionError(message)
634-
exc.reasoning_steps = best.reasoning_steps
635-
exc.conversation_id = best.conversation_id
636-
exc.response_id = best.response_id
637-
exc.detail = detail
638-
exc.runs_passed = runs_passed
639-
exc.runs_effective = len(summary.run_results)
640-
exc.best_run_latency_s = best.run_latency_s
641-
raise exc
642-
643-
return AgenticEvalOutcome(
644-
runs_passed=runs_passed,
645-
runs_effective=len(summary.run_results),
651+
raise_agentic_failure(
652+
AnomalyDetectionAssertionError,
653+
message,
654+
reasoning_steps=best.reasoning_steps,
655+
conversation_id=best.conversation_id,
656+
response_id=best.response_id,
657+
detail=detail,
658+
runs_passed=runs_passed,
659+
runs_effective=len(summary.run_results),
660+
best_run_latency_s=best.run_latency_s,
661+
)
662+
663+
return agentic_success(
646664
reasoning_steps=best.reasoning_steps,
647665
conversation_id=best.conversation_id,
648666
response_id=best.response_id,
649667
detail=detail,
668+
runs_passed=runs_passed,
669+
runs_effective=len(summary.run_results),
650670
best_run_latency_s=best.run_latency_s,
651671
)

0 commit comments

Comments
 (0)