Skip to content

Commit b25cdc8

Browse files
committed
fix(gooddata-eval): dedupe metric-turn extraction, fix output-present check, clean up every created metric
- Consolidate on metric_skill._extract_metric_result as the single implementation; conversation.py now imports it instead of keeping its own copy, so the same isError/reversed-order fix does not need to be applied twice. - _extract_metric_result now also skips a non-dict or empty payload (a value callers unconditionally call .get() on) and prefers the most recent successful create_metric call, matching kda_skill's "last wins" pairing for retries. - conversation._check_output_present's metric branch used to pass as long as create_metric was called at all, regardless of isError -- a turn where every attempt failed was marked successful while the real failure surfaced as the next turn being skipped. It now requires an extracted result. - _execute_single_metric_run tracked only one metric id to delete, taken from the (possibly failed) candidate. A failed-then-retried create_metric left metric_id_to_delete unset, so the metric the successful retry created was never deleted and leaked into the shared workspace. It now deletes every metric_id any create_metric call in the run produced. - Added a run_agentic_conversation-level regression test asserting turn 2 is actually sent (not just that the extractor returns the right value), plus unit tests pinning the retry, all-failed, non-dict-payload, empty-payload and most-recent-success cases. JIRA: QA-29053 risk: low
1 parent e035f36 commit b25cdc8

4 files changed

Lines changed: 168 additions & 44 deletions

File tree

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

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic import BaseModel
1313

1414
from gooddata_eval.core.agentic.alert_skill import render_alert_proposal
15-
from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids
15+
from gooddata_eval.core.agentic.metric_skill import _delete_metric, _extract_created_metric_ids, _extract_metric_result
1616
from gooddata_eval.core.chat.sse_client import ChatClient
1717
from gooddata_eval.core.config import ReasoningEffort
1818
from gooddata_eval.core.models import AgenticEvalOutcome, ChatResult, ToolCallEvent
@@ -120,7 +120,7 @@ def _check_output_present(turn: TurnDefinition, chat_result: ChatResult) -> bool
120120
and getattr(chat_result.created_visualizations, "objects", chat_result.created_visualizations)
121121
)
122122
if otype == "metric":
123-
return any(tc.function_name == "create_metric" for tc in (chat_result.tool_call_events or []))
123+
return _extract_metric_result(chat_result.tool_call_events or []) is not None
124124
if otype == "tool_call":
125125
expected_tool = turn.expected_tool_name
126126
if not expected_tool:
@@ -129,23 +129,6 @@ def _check_output_present(turn: TurnDefinition, chat_result: ChatResult) -> bool
129129
return False
130130

131131

132-
def _extract_metric_from_turn(tool_call_events: list[ToolCallEvent]) -> dict | None:
133-
"""Extract the result payload from the create_metric tool call, if present."""
134-
for tc in tool_call_events:
135-
if tc.function_name != "create_metric":
136-
continue
137-
if not tc.result:
138-
continue
139-
result_data = tc.parsed_result()
140-
if result_data is None:
141-
continue
142-
payload = result_data.get("data", result_data)
143-
if isinstance(payload, dict) and payload.get("isError"):
144-
continue
145-
return payload
146-
return None
147-
148-
149132
def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool | None:
150133
"""Check output correctness against expected_output when defined.
151134
@@ -190,7 +173,7 @@ def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool
190173
return all(results) if results else None
191174

192175
if otype == "metric":
193-
metric_result = _extract_metric_from_turn(chat_result.tool_call_events or [])
176+
metric_result = _extract_metric_result(chat_result.tool_call_events or [])
194177
if not metric_result:
195178
return False
196179
return _normalize_maql(metric_result.get("maql", "")) == _normalize_maql(expected.get("maql", ""))
@@ -366,7 +349,7 @@ def run_agentic_conversation(
366349

367350
# Capture metric output for $ref resolution in subsequent turns.
368351
if final_result and turn.expected_output_type == "metric":
369-
metric_data = _extract_metric_from_turn(all_tool_calls)
352+
metric_data = _extract_metric_result(all_tool_calls)
370353
if metric_data:
371354
turn_outputs[turn.turn_id] = metric_data
372355

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,22 @@ class AgenticMetricSummary:
163163

164164

165165
def _extract_metric_result(tool_call_events: list[ToolCallEvent]) -> dict | None:
166-
for tc in tool_call_events:
167-
if tc.function_name == "create_metric" and tc.result:
168-
result_data = tc.parsed_result()
169-
if result_data is not None:
170-
return result_data.get("data", result_data)
166+
"""Result payload of the create_metric tool call.
167+
168+
Prefers the most recent successful call in this turn -- when the agent retries
169+
after a validation error, the earlier failed attempt must not shadow it. Mirrors
170+
the "last wins" pairing in ``kda_skill._extract_kda_calls``.
171+
"""
172+
for tc in reversed(tool_call_events):
173+
if tc.function_name != "create_metric" or not tc.result:
174+
continue
175+
result_data = tc.parsed_result()
176+
if result_data is None:
177+
continue
178+
payload = result_data.get("data", result_data)
179+
if not isinstance(payload, dict) or not payload or payload.get("isError"):
180+
continue
181+
return payload
171182
return None
172183

173184

@@ -223,7 +234,7 @@ def _execute_single_metric_run(
223234
"""
224235
primary_expected = expected_outputs[0] if expected_outputs else {}
225236
metric_result: dict | None = None
226-
metric_id_to_delete: str | None = None
237+
created_metric_ids: list[str] = []
227238
turns = 0
228239
current_question = question
229240
reasoning_steps: list[str] = []
@@ -235,10 +246,12 @@ def _execute_single_metric_run(
235246
chat_result = client.send_message(conversation_id, current_question)
236247
reasoning_steps.extend(chat_result.reasoning_steps or [])
237248
response_id = chat_result.response_id or response_id
249+
for metric_id in _extract_created_metric_ids(chat_result.tool_call_events or []):
250+
if metric_id not in created_metric_ids:
251+
created_metric_ids.append(metric_id)
238252
candidate = _extract_metric_result(chat_result.tool_call_events or [])
239253
if candidate is not None:
240254
metric_result = candidate
241-
metric_id_to_delete = candidate.get("metric_id")
242255
break
243256
response_text = (chat_result.text_response or "").strip()
244257
if not response_text and not chat_result.tool_call_events:
@@ -265,8 +278,8 @@ def _execute_single_metric_run(
265278
response_id=response_id,
266279
)
267280
finally:
268-
if metric_id_to_delete:
269-
_delete_metric(sdk, workspace_id, metric_id_to_delete)
281+
for metric_id in created_metric_ids:
282+
_delete_metric(sdk, workspace_id, metric_id)
270283

271284

272285
def run_agentic_metric_skill(

‎packages/gooddata-eval/tests/test_agentic_conversation.py‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
ConversationFixture,
99
TurnDefinition,
1010
TurnResult,
11-
_extract_metric_from_turn,
1211
_resolve_refs,
1312
evaluate_agentic_conversation,
1413
run_agentic_conversation,
@@ -97,18 +96,6 @@ def test_resolve_refs_substitutes():
9796
assert result == {"maql": "SELECT {metric/foo}"}
9897

9998

100-
def test_extract_metric_from_turn_skips_failed_retry_returns_successful_one():
101-
"""QA-29053 regression: agent self-corrects an invalid MAQL by retrying create_metric
102-
within the same turn; the successful retry must be captured, not the failed first call."""
103-
tool_calls = [_create_metric_tc_error("invalid MAQL"), _create_metric_tc("units_per_transaction")]
104-
assert _extract_metric_from_turn(tool_calls) == {"metric_id": "units_per_transaction", "maql": "SELECT 1"}
105-
106-
107-
def test_extract_metric_from_turn_returns_none_when_every_attempt_failed():
108-
tool_calls = [_create_metric_tc_error("invalid MAQL"), _create_metric_tc_error("still invalid")]
109-
assert _extract_metric_from_turn(tool_calls) is None
110-
111-
11299
def test_run_agentic_conversation_single_turn():
113100
mock_client = MagicMock()
114101
mock_client.create_conversation.return_value = "conv-1"
@@ -492,6 +479,47 @@ def test_run_agentic_conversation_records_a_failed_turn_when_a_ref_cannot_be_res
492479
assert result.conversation_success is False
493480

494481

482+
def test_run_agentic_conversation_sends_the_next_turn_after_a_self_corrected_retry():
483+
"""QA-29053 regression: turn 1 self-corrects create_metric after a failed first attempt;
484+
turn 2's message must still be sent, resolving its $ref against the successful retry."""
485+
mock_client = MagicMock()
486+
mock_client.create_conversation.return_value = "conv-1"
487+
mock_client.send_message.side_effect = [
488+
_metric_turn_result([_skills_tc("metric"), _create_metric_tc_error("invalid MAQL"), _create_metric_tc("m1")]),
489+
_viz_turn_result(text="Here is your chart", viz=[MagicMock()], tool_calls=[_skills_tc("visualization")]),
490+
]
491+
fixture = ConversationFixture(
492+
id="test-retry",
493+
expected_skills=["metric", "visualization"],
494+
turns=[
495+
TurnDefinition(turn_id="t1", message="Create it", expected_skill="metric", expected_output_type="metric"),
496+
TurnDefinition(
497+
turn_id="t2",
498+
message="Chart it",
499+
expected_skill="visualization",
500+
expected_output={"metrics": ["metric/$ref:t1.metric_id"]},
501+
),
502+
],
503+
)
504+
505+
with (
506+
patch("gooddata_eval.core.agentic.conversation.ChatClient", return_value=mock_client),
507+
patch("gooddata_eval.core.agentic.conversation.GoodDataSdk"),
508+
):
509+
result = run_agentic_conversation(
510+
host="http://host/api/v1/actions/workspaces/ws1/ai",
511+
token="tok",
512+
workspace_id="ws1",
513+
fixture=fixture,
514+
)
515+
516+
assert mock_client.send_message.call_count == 2
517+
mock_client.send_message.assert_any_call("conv-1", "Chart it")
518+
assert result.turn_results[0].skill_success is True
519+
assert result.turn_results[1].no_error is True
520+
assert result.conversation_success is True
521+
522+
495523
def test_run_agentic_conversation_accumulates_reasoning_steps_across_turns():
496524
mock_client = MagicMock()
497525
mock_client.create_conversation.return_value = "conv-1"

‎packages/gooddata-eval/tests/test_agentic_metric_skill.py‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,69 @@
1212
MetricSkillAssertionError,
1313
SimulatedResponseError,
1414
_delete_metric,
15+
_extract_metric_result,
1516
_normalize_maql,
1617
evaluate_agentic_metric_skill,
1718
generate_simulated_response,
1819
run_agentic_metric_skill,
1920
)
20-
from gooddata_eval.core.models import ChatResult
21+
from gooddata_eval.core.models import ChatResult, ToolCallEvent
22+
23+
24+
def _create_metric_call(result: str) -> ToolCallEvent:
25+
return ToolCallEvent(function_name="create_metric", function_arguments="{}", result=result)
26+
27+
28+
_FAILED_RESULT = '{"data": {"isError": true, "error": {"text": "invalid MAQL"}}}'
29+
30+
31+
def test_extract_metric_result_skips_a_failed_retry_and_returns_the_successful_one():
32+
"""QA-29053 regression: agent self-corrects an invalid MAQL by retrying create_metric
33+
within the same turn; the successful retry must be captured, not the failed first call."""
34+
calls = [
35+
_create_metric_call(_FAILED_RESULT),
36+
_create_metric_call('{"data": {"metric_id": "m1", "maql": "SELECT {metric/foo}"}}'),
37+
]
38+
assert _extract_metric_result(calls) == {"metric_id": "m1", "maql": "SELECT {metric/foo}"}
39+
40+
41+
def test_extract_metric_result_returns_none_when_every_attempt_failed():
42+
calls = [_create_metric_call(_FAILED_RESULT), _create_metric_call(_FAILED_RESULT)]
43+
assert _extract_metric_result(calls) is None
44+
45+
46+
def test_extract_metric_result_skips_a_failed_call_after_an_earlier_success():
47+
# The failed call is last, so reversed() reaches it first and must skip past it.
48+
calls = [_create_metric_call('{"data": {"metric_id": "m1"}}'), _create_metric_call(_FAILED_RESULT)]
49+
assert _extract_metric_result(calls) == {"metric_id": "m1"}
50+
51+
52+
def test_extract_metric_result_prefers_the_most_recent_successful_call():
53+
"""Two distinct successful create_metric calls in one turn (not a retry after a
54+
failure) -- the later one wins, consistent with kda_skill's 'last pair' rule."""
55+
calls = [
56+
_create_metric_call('{"data": {"metric_id": "m1"}}'),
57+
_create_metric_call('{"data": {"metric_id": "m2"}}'),
58+
]
59+
assert _extract_metric_result(calls) == {"metric_id": "m2"}
60+
61+
62+
def test_extract_metric_result_skips_a_non_dict_payload():
63+
# The non-dict payload is last, so reversed() reaches it first and must skip past it.
64+
calls = [
65+
_create_metric_call('{"data": {"metric_id": "m2"}}'),
66+
_create_metric_call('{"data": [{"metric_id": "m1"}]}'),
67+
]
68+
assert _extract_metric_result(calls) == {"metric_id": "m2"}
69+
70+
71+
def test_extract_metric_result_skips_an_empty_payload():
72+
# The empty payload is last, so reversed() reaches it first and must skip past it.
73+
calls = [
74+
_create_metric_call('{"data": {"metric_id": "m2"}}'),
75+
_create_metric_call('{"data": {}}'),
76+
]
77+
assert _extract_metric_result(calls) == {"metric_id": "m2"}
2178

2279

2380
def test_normalize_maql_strips_whitespace():
@@ -269,6 +326,49 @@ def test_run_agentic_metric_skill_deletes_created_metric():
269326
mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
270327

271328

329+
def test_run_agentic_metric_skill_deletes_the_metric_created_by_a_self_corrected_retry():
330+
"""QA-29053 regression: a failed create_metric call followed by a successful retry, in the
331+
same turn, used to leave metric_id_to_delete unset -- the metric the retry created leaked
332+
into the shared workspace."""
333+
mock_client = MagicMock()
334+
mock_client.create_conversation.return_value = "conv-1"
335+
mock_client.send_message.return_value = ChatResult.model_validate(
336+
{
337+
"textResponse": "done",
338+
"toolCallEvents": [
339+
{
340+
"functionName": "create_metric",
341+
"functionArguments": "{}",
342+
"result": '{"data": {"isError": true, "error": {"text": "invalid MAQL"}}}',
343+
},
344+
{
345+
"functionName": "create_metric",
346+
"functionArguments": "{}",
347+
"result": '{"data": {"maql": "SELECT {metric/foo}", "metric_id": "foo_metric"}}',
348+
},
349+
],
350+
"reasoningStepCount": 1,
351+
}
352+
)
353+
with (
354+
patch("gooddata_eval.core.agentic.metric_skill.ChatClient", return_value=mock_client),
355+
patch("gooddata_eval.core.agentic.metric_skill.GoodDataSdk") as mock_sdk_cls,
356+
):
357+
mock_sdk = mock_sdk_cls.create.return_value
358+
summary = run_agentic_metric_skill(
359+
host="http://host/api/v1/actions/workspaces/ws1/ai",
360+
token="tok",
361+
workspace_id="ws1",
362+
question="Create metric foo",
363+
expected_output={"maql": "SELECT {metric/foo}"},
364+
k=1,
365+
max_iterations=1,
366+
)
367+
assert summary.best.metric_created is True
368+
assert summary.best.maql_correct is True
369+
mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
370+
371+
272372
def test_run_agentic_metric_skill_deletes_metric_even_when_teardown_fails():
273373
# A metric is created, then conversation teardown raises; the created metric must still
274374
# have been cleaned up (its deletion happens inside the per-run finally, before teardown).

0 commit comments

Comments
 (0)