Skip to content

Commit c9a9100

Browse files
committed
feat(gooddata-eval): record why an agentic simulated-user loop stopped
Every agentic evaluator drives a loop that can exit several ways, but the result only ever recorded *whether* the agent produced its output. A run that ran out of turns while doing the right thing was reported identically to one that refused, and identically to one that answered wrongly. It is worse than a missing field, because the downstream checks are all of the form `produced_output and <check>`. An exhausted alert run reports operator_correct, threshold_correct, metric_correct and recipients_correct as False -- four specific-sounding content failures for work the agent was never given the chance to attempt. Adds `LoopExit` (core/models.py) and threads it through all five loops, plus `turns_used`/`max_iterations` in `detail`: success the agent produced its output agent_silent neither text nor a tool call -- genuinely stuck budget_exhausted hit max_iterations; says nothing about being on track simulated_user_failed OUR simulated-user model failed, not the agent chat_error the chat call raised mid-conversation (kda partial path) not_run the loop never started (conversation $ref skip) The default is BUDGET_EXHAUSTED and every other exit assigns explicitly, so a loop that simply runs out of range() is labelled correctly with no trailing else. Two exits were previously invisible and are the reason this is worth doing: - metric_skill catches SimulatedResponseError and breaks. A harness-side outage was scored against the product as metric_created=False/maql_correct=False. - kda_skill breaks on a chat error with a partial result. Deliberately NOT included: - No verdict changes. An exhausted run still fails. The point is that the cases become countable, not that any of them start passing. - No change to any max_iterations default (4-7, already tuned per kind). Whether a budget is too tight becomes answerable from data instead of argued -- which is the actual ask behind GDAI-2200, where ~13% of alert runs are estimated to need 7 turns against a ceiling of 6. Raising the ceiling first would have hidden the interaction with GDAI-2199's MANDATORY STOPs, which make prescribed end-turn-without-a-tool-call behaviour consume budget. - No try/except added to alert_skill's simulated-user call: there a failure already propagates as a hard error rather than being swallowed, which is the behaviour we want. Only metric_skill needed the label. Tests: existing detail assertions extended across all five kinds, plus dedicated coverage for budget_exhausted vs agent_silent vs success (including the turn the tool landed on), simulated_user_failed, and a regression guard asserting that two runs with identical scored booleans differ only in exit_reason -- the exact ambiguity this removes. 739 passed; ruff check clean.
1 parent ebca7d9 commit c9a9100

11 files changed

Lines changed: 347 additions & 0 deletions

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from gooddata_eval.core.models import (
2828
AgenticAssertionError,
2929
AgenticEvalOutcome,
30+
LoopExit,
3031
ReasoningStepEvent,
3132
ToolCallEvent,
3233
build_latency_breakdown,
@@ -473,6 +474,12 @@ class AlertRunResult:
473474
response_id: str | None = None
474475
tool_call_events: list[ToolCallEvent] = field(default_factory=list)
475476
reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
477+
# Why the simulated-user loop stopped, and how many turns it took. Without these a run
478+
# that ran out of turns is indistinguishable from one that refused: both land on
479+
# alert_created=False, and every downstream check is `alert_created and ...`, so both
480+
# also report operator/threshold/metric/recipients as False.
481+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
482+
turns_used: int = 0
476483

477484

478485
@dataclass
@@ -669,7 +676,12 @@ def _run_once(conv_id: str) -> AlertRunResult:
669676
conversation_history: list = []
670677
current_question = question
671678

679+
# Defaults to BUDGET_EXHAUSTED: every other exit sets it explicitly, so a loop
680+
# that simply runs out of range() is correctly labelled without a trailing else.
681+
exit_reason = LoopExit.BUDGET_EXHAUSTED
682+
turns_used = 0
672683
for _iteration in range(max_iterations):
684+
turns_used = _iteration + 1
673685
chat_result = client.send_message(conv_id, current_question)
674686
reasoning_steps.extend(chat_result.reasoning_steps or [])
675687
response_id = chat_result.response_id or response_id
@@ -684,6 +696,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
684696
alert_id, actual_args, tool_called = _extract_alert_call(chat_result.tool_call_events or [])
685697
if tool_called:
686698
alert_id_to_delete = alert_id
699+
exit_reason = LoopExit.SUCCESS
687700
break
688701
response_text = (chat_result.text_response or "").strip()
689702
if not response_text and chat_result.alert_proposals:
@@ -692,10 +705,16 @@ def _run_once(conv_id: str) -> AlertRunResult:
692705
response_text = render_answer_text(chat_result)
693706
# Stop if agent gave a completely empty response (stuck)
694707
if not response_text and not chat_result.tool_call_events:
708+
exit_reason = LoopExit.AGENT_SILENT
695709
break
696710
# Stop before generating a follow-up for the last iteration
697711
if _iteration >= max_iterations - 1:
698712
break
713+
# No try/except here on purpose: a simulated-user failure in this evaluator
714+
# already propagates as a hard error rather than being swallowed into a
715+
# content failure, which is the behaviour we want. Contrast metric_skill,
716+
# which catches SimulatedResponseError and breaks -- that one needs
717+
# LoopExit.SIMULATED_USER_FAILED to stay distinguishable.
699718
follow_up = generate_simulated_alert_response(
700719
response_text, expected, conversation_history, question=question
701720
)
@@ -724,6 +743,8 @@ def _run_once(conv_id: str) -> AlertRunResult:
724743
response_id=response_id,
725744
tool_call_events=all_tool_call_events,
726745
reasoning_step_events=all_reasoning_step_events,
746+
exit_reason=exit_reason,
747+
turns_used=turns_used,
727748
)
728749
finally:
729750
if alert_id_to_delete:
@@ -886,6 +907,11 @@ def _write_scores(ctx: RunTraceContext) -> None:
886907
"attributes_correct": ev.attributes_correct,
887908
"granularity_correct": ev.granularity_correct,
888909
"actual_alert_arguments": best.actual_alert_arguments,
910+
# Why the loop stopped. alert_created=False alone cannot tell a refusal from a run
911+
# that hit max_iterations while still on track -- see LoopExit.
912+
"exit_reason": best.exit_reason.value,
913+
"turns_used": best.turns_used,
914+
"max_iterations": max_iterations,
889915
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
890916
}
891917

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
AgenticAssertionError,
3030
AgenticEvalOutcome,
3131
ChatResult,
32+
LoopExit,
3233
ReasoningStepEvent,
3334
ToolCallEvent,
3435
build_latency_breakdown,
@@ -93,6 +94,10 @@ class TurnResult(BaseModel):
9394
active_skills: list[str] = Field(default_factory=list)
9495
clarification_turns_used: int = 0
9596
output_correct: bool | None = None
97+
# Why this turn's clarification loop stopped -- see LoopExit. output_present=False alone
98+
# cannot separate a turn that ran out of clarification budget from one where the agent
99+
# went silent, and skill_success folds both into the same failure.
100+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
96101

97102
@property
98103
def skill_success(self) -> bool:
@@ -112,6 +117,8 @@ def skill_success(self) -> bool:
112117
# What skill_routing was judged against -- without it, a turn showing
113118
# skill_routing=True and activated_skills=[] looks like a scoring bug.
114119
"active_skills",
120+
# Why the clarification loop ended on this turn.
121+
"exit_reason",
115122
}
116123

117124
def detail(self) -> dict:
@@ -421,6 +428,10 @@ def run_agentic_conversation(
421428
# read as "nothing was active", which is a different claim.
422429
active_skills=sorted(active_skills),
423430
output_correct=False,
431+
# This turn's loop never ran at all -- a $ref pointing at an earlier
432+
# turn's output could not be resolved. Labelling it BUDGET_EXHAUSTED
433+
# (the field default) would claim it ran out of clarification turns.
434+
exit_reason=LoopExit.NOT_RUN,
424435
)
425436
)
426437
continue
@@ -431,6 +442,9 @@ def run_agentic_conversation(
431442
current_message = turn.message
432443
final_result: ChatResult | None = None
433444

445+
# Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop
446+
# that simply runs out of range() is labelled correctly with no trailing else.
447+
turn_exit = LoopExit.BUDGET_EXHAUSTED
434448
for _iter in range(max_clarification_turns + 1):
435449
chat_result = client.send_message(conversation_id, current_message)
436450
final_result = chat_result
@@ -447,6 +461,7 @@ def run_agentic_conversation(
447461
response_id = chat_result.response_id or response_id
448462

449463
if _check_output_present(resolved_turn, chat_result):
464+
turn_exit = LoopExit.SUCCESS
450465
break
451466

452467
response_text = (chat_result.text_response or "").strip()
@@ -455,6 +470,7 @@ def run_agentic_conversation(
455470
if not response_text:
456471
response_text = render_answer_text(chat_result)
457472
if not response_text and not chat_result.tool_call_events:
473+
turn_exit = LoopExit.AGENT_SILENT
458474
break
459475
if clarification_turns >= max_clarification_turns:
460476
break
@@ -500,6 +516,7 @@ def run_agentic_conversation(
500516
active_skills=sorted(active_skills),
501517
clarification_turns_used=clarification_turns,
502518
output_correct=output_correct,
519+
exit_reason=turn_exit,
503520
)
504521
)
505522

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
AgenticAssertionError,
2424
AgenticEvalOutcome,
2525
ChatResult,
26+
LoopExit,
2627
ReasoningStepEvent,
2728
ToolCallEvent,
2829
build_latency_breakdown,
@@ -181,6 +182,10 @@ class KdaRunResult:
181182
response_id: str | None = None
182183
tool_call_events: list[ToolCallEvent] = field(default_factory=list)
183184
reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
185+
# Why the simulated-user loop stopped -- see LoopExit. `triggered=False` alone cannot
186+
# separate a refusal from a run that hit max_iterations while still on track.
187+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
188+
turns_used: int = 0
184189

185190

186191
@dataclass
@@ -268,7 +273,12 @@ def _accumulate(result: ChatResult) -> None:
268273
all_tool_call_events.extend(result.tool_call_events or [])
269274
all_reasoning_step_events.extend(result.reasoning_step_events or [])
270275

276+
# Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that
277+
# simply runs out of range() is labelled correctly with no trailing else.
278+
exit_reason = LoopExit.BUDGET_EXHAUSTED
279+
turns_used = 0
271280
for iteration in range(max_iterations):
281+
turns_used = iteration + 1
272282
try:
273283
chat_result = client.send_message(conv_id, current_question)
274284
except Exception as exc: # noqa: BLE001 -- end this run, not the whole assertion
@@ -282,6 +292,7 @@ def _accumulate(result: ChatResult) -> None:
282292
if create_args is not None:
283293
turn_wall_clock_sec = partial.turn_wall_clock_sec
284294
turn_completed = False
295+
exit_reason = LoopExit.CHAT_ERROR
285296
break
286297
reasoning_steps.extend(chat_result.reasoning_steps or [])
287298
response_id = chat_result.response_id or response_id
@@ -296,8 +307,10 @@ def _accumulate(result: ChatResult) -> None:
296307
# final either way -- execute_result may still be None (e.g. the skill's
297308
# execute tool isn't available at all when data-sharing is off for the org).
298309
turn_wall_clock_sec = chat_result.turn_wall_clock_sec
310+
exit_reason = LoopExit.SUCCESS
299311
break
300312
if not response_text:
313+
exit_reason = LoopExit.AGENT_SILENT
301314
break
302315
if iteration >= max_iterations - 1:
303316
break
@@ -313,6 +326,7 @@ def _accumulate(result: ChatResult) -> None:
313326
disambiguated = True
314327
except Exception as exc: # noqa: BLE001 -- safety net, not the assertion; end only this run
315328
_log.warning("Simulated KDA user reply failed for conversation %s: %s", conv_id, exc)
329+
exit_reason = LoopExit.SIMULATED_USER_FAILED
316330
break
317331

318332
ev = _evaluate_run(create_args, execute_result, turn_completed, disambiguated)
@@ -326,6 +340,8 @@ def _accumulate(result: ChatResult) -> None:
326340
response_id=response_id,
327341
tool_call_events=all_tool_call_events,
328342
reasoning_step_events=all_reasoning_step_events,
343+
exit_reason=exit_reason,
344+
turns_used=turns_used,
329345
)
330346

331347
try:
@@ -482,6 +498,10 @@ def _write_scores(ctx: RunTraceContext) -> None:
482498
"disambiguated": ev.disambiguated,
483499
"actual_create_args": best.actual_create_args,
484500
"actual_execute_result": best.actual_execute_result,
501+
# Why the loop stopped -- see LoopExit.
502+
"exit_reason": best.exit_reason.value,
503+
"turns_used": best.turns_used,
504+
"max_iterations": max_iterations,
485505
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
486506
}
487507

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from gooddata_eval.core.models import (
2828
AgenticAssertionError,
2929
AgenticEvalOutcome,
30+
LoopExit,
3031
ReasoningStepEvent,
3132
ToolCallEvent,
3233
build_latency_breakdown,
@@ -159,6 +160,11 @@ class MetricRunResult:
159160
response_id: str | None = None
160161
tool_call_events: list[ToolCallEvent] = field(default_factory=list)
161162
reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
163+
# Why the simulated-user loop stopped. metric_created=False alone cannot separate a run
164+
# that ran out of turns, one where the agent went silent, and one where the harness's
165+
# own simulated user failed -- see LoopExit.
166+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
167+
turns_used: int = 0
162168
timings: PhaseTimings = field(default_factory=PhaseTimings)
163169

164170

@@ -256,6 +262,9 @@ def _execute_single_metric_run(
256262
reasoning_index_offset = 0
257263

258264
try:
265+
# Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that
266+
# simply runs out of range() is labelled correctly with no trailing else.
267+
exit_reason = LoopExit.BUDGET_EXHAUSTED
259268
for _iteration in range(max_iterations):
260269
turns += 1
261270
agent_started = time.monotonic()
@@ -282,11 +291,13 @@ def _execute_single_metric_run(
282291
f"{agent_elapsed:.2f}s; metric result received"
283292
)
284293
metric_result = candidate
294+
exit_reason = LoopExit.SUCCESS
285295
break
286296
response_text = (chat_result.text_response or "").strip()
287297
if not response_text:
288298
response_text = render_answer_text(chat_result)
289299
if not response_text and not chat_result.tool_call_events:
300+
exit_reason = LoopExit.AGENT_SILENT
290301
break
291302
if _iteration >= max_iterations - 1:
292303
break
@@ -305,6 +316,9 @@ def _execute_single_metric_run(
305316
f"[timer] metric_skill {conversation_id} gpt-4o-mini simulated user failed after "
306317
f"{simulated_elapsed:.2f}s"
307318
)
319+
# A harness-side fault, not an agent one. Recorded so reporting can treat it
320+
# as an error instead of scoring the agent down for it.
321+
exit_reason = LoopExit.SIMULATED_USER_FAILED
308322
break
309323
simulated_elapsed = time.monotonic() - simulated_started
310324
timings.simulated_user_s += simulated_elapsed
@@ -328,6 +342,8 @@ def _execute_single_metric_run(
328342
tool_call_events=all_tool_call_events,
329343
reasoning_step_events=all_reasoning_step_events,
330344
timings=timings,
345+
exit_reason=exit_reason,
346+
turns_used=turns,
331347
)
332348
finally:
333349
for metric_id in created_metric_ids:
@@ -492,6 +508,11 @@ def _write_scores(ctx: RunTraceContext) -> None:
492508
"maql_correct": best.maql_correct,
493509
"expected_maql_candidates": [c.get("maql", "") for c in expected_outputs_list],
494510
"actual_maql": best.actual_maql,
511+
# Why the loop stopped. metric_created=False alone cannot tell a refusal from a run
512+
# that hit max_iterations while still on track -- see LoopExit.
513+
"exit_reason": best.exit_reason.value,
514+
"turns_used": best.turns_used,
515+
"max_iterations": max_iterations,
495516
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
496517
}
497518

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
AgenticAssertionError,
3333
AgenticEvalOutcome,
3434
CreatedVisualization,
35+
LoopExit,
3536
ReasoningStepEvent,
3637
ToolCallEvent,
3738
build_latency_breakdown,
@@ -57,6 +58,9 @@ class RunResult:
5758
response_id: str | None = None
5859
tool_call_events: list[ToolCallEvent] = field(default_factory=list)
5960
reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
61+
# Why the simulated-user loop stopped. visualization_created=False alone cannot separate
62+
# a refusal from a run that hit max_iterations while still on track -- see LoopExit.
63+
exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
6064

6165

6266
@dataclass
@@ -191,6 +195,9 @@ def _execute_single_run(
191195

192196
current_result = client.send_message(conversation_id, question)
193197

198+
# Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that
199+
# simply runs out of range() is labelled correctly with no trailing else.
200+
exit_reason = LoopExit.BUDGET_EXHAUSTED
194201
for iteration in range(max_iterations):
195202
total_turns += 1.0
196203
total_steps += float(current_result.reasoning_step_count)
@@ -207,9 +214,14 @@ def _execute_single_run(
207214

208215
viz_produced = bool(current_result.created_visualizations and current_result.created_visualizations.objects)
209216
if viz_produced:
217+
exit_reason = LoopExit.SUCCESS
210218
break
219+
# render_answer_text (QA-29230) is the broader emptiness test: a turn carrying
220+
# search results but no plain text is not silent. Keeping it means AGENT_SILENT
221+
# labels only genuinely empty turns.
211222
response_text = render_answer_text(current_result)
212223
if not response_text:
224+
exit_reason = LoopExit.AGENT_SILENT
213225
break
214226
if iteration >= max_iterations - 1:
215227
break
@@ -235,6 +247,7 @@ def _execute_single_run(
235247
response_id=response_id,
236248
tool_call_events=all_tool_call_events,
237249
reasoning_step_events=all_reasoning_step_events,
250+
exit_reason=exit_reason,
238251
)
239252

240253

@@ -422,6 +435,11 @@ def _write_scores(ctx: RunTraceContext) -> None:
422435
ev = best.eval_result
423436
detail = {
424437
**evaluation_result_detail(ev),
438+
# Why the loop stopped -- see LoopExit. total_turns is already the turn count for
439+
# this run, so it doubles as turns_used.
440+
"exit_reason": best.exit_reason.value,
441+
"turns_used": int(best.total_turns),
442+
"max_iterations": max_iterations,
425443
"latency_breakdown": build_latency_breakdown(best.tool_call_events, best.reasoning_step_events),
426444
}
427445

0 commit comments

Comments
 (0)