Skip to content

Commit a0e06bc

Browse files
authored
Merge pull request #1753 from gooddata/QA-29053-fix-metric-turn-extraction
fix(gooddata-eval): keep retrying create_metric result, not the first failed one
2 parents fa00fc3 + a2beff6 commit a0e06bc

4 files changed

Lines changed: 182 additions & 27 deletions

File tree

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

Lines changed: 4 additions & 17 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,19 +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 not None:
141-
return result_data.get("data", result_data)
142-
return None
143-
144-
145132
def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool | None:
146133
"""Check output correctness against expected_output when defined.
147134
@@ -186,7 +173,7 @@ def _check_output_correct(turn: TurnDefinition, chat_result: ChatResult) -> bool
186173
return all(results) if results else None
187174

188175
if otype == "metric":
189-
metric_result = _extract_metric_from_turn(chat_result.tool_call_events or [])
176+
metric_result = _extract_metric_result(chat_result.tool_call_events or [])
190177
if not metric_result:
191178
return False
192179
return _normalize_maql(metric_result.get("maql", "")) == _normalize_maql(expected.get("maql", ""))
@@ -362,7 +349,7 @@ def run_agentic_conversation(
362349

363350
# Capture metric output for $ref resolution in subsequent turns.
364351
if final_result and turn.expected_output_type == "metric":
365-
metric_data = _extract_metric_from_turn(all_tool_calls)
352+
metric_data = _extract_metric_result(all_tool_calls)
366353
if metric_data:
367354
turn_outputs[turn.turn_id] = metric_data
368355

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. Shared
170+
with ``conversation.py``, which imports this instead of keeping its own copy.
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 not isinstance(result_data, dict):
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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def _create_metric_tc(metric_id):
3030
return tc
3131

3232

33+
def _create_metric_tc_error(message):
34+
tc = MagicMock(spec=ToolCallEvent)
35+
tc.function_name = "create_metric"
36+
tc.result = "{}" # truthy; content comes from parsed_result
37+
tc.parsed_result = lambda msg=message: {"data": {"isError": True, "error": {"text": msg}}}
38+
return tc
39+
40+
3341
def _metric_turn_result(tool_calls):
3442
r = MagicMock()
3543
r.text_response = "done"
@@ -471,6 +479,47 @@ def test_run_agentic_conversation_records_a_failed_turn_when_a_ref_cannot_be_res
471479
assert result.conversation_success is False
472480

473481

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+
474523
def test_run_agentic_conversation_accumulates_reasoning_steps_across_turns():
475524
mock_client = MagicMock()
476525
mock_client.create_conversation.return_value = "conv-1"

packages/gooddata-eval/tests/test_agentic_metric_skill.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,75 @@
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."""
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_a_non_dict_decoded_result():
72+
# The whole decoded result (not just its "data" field) is a non-dict here.
73+
calls = [_create_metric_call('{"metric_id": "m2"}'), _create_metric_call("[]")]
74+
assert _extract_metric_result(calls) == {"metric_id": "m2"}
75+
76+
77+
def test_extract_metric_result_skips_an_empty_payload():
78+
# The empty payload is last, so reversed() reaches it first and must skip past it.
79+
calls = [
80+
_create_metric_call('{"data": {"metric_id": "m2"}}'),
81+
_create_metric_call('{"data": {}}'),
82+
]
83+
assert _extract_metric_result(calls) == {"metric_id": "m2"}
2184

2285

2386
def test_normalize_maql_strips_whitespace():
@@ -269,6 +332,49 @@ def test_run_agentic_metric_skill_deletes_created_metric():
269332
mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric")
270333

271334

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

0 commit comments

Comments
 (0)