Skip to content

Commit 4b60450

Browse files
Tomkessclaude
andcommitted
fix(eval): process ChatError.partial_result so created objects cannot leak
Addresses the three open CodeRabbit findings on this PR. metric_skill / alert_skill -- an object leak, not a reporting gap. The SSE stream can break AFTER create_metric / create_metric_alert has already succeeded server-side. Those ids reached created_metric_ids / alert_id_to_delete only from the normal path, so the ChatError branch broke out of the loop with the id never registered and the `finally` cleanup deleted nothing. The object stayed in the workspace, where the next run sharing it can see and reuse it -- exactly what _delete_metric's own comment says must not happen. Both branches now read exc.partial_result with the same extraction the normal path uses. kda_skill -- narrowed `except Exception` to `(ChatError, httpx.HTTPError)`. The bare form also swallowed bugs in this package: a TypeError in the accumulator came back as a tidy failed run with exit_reason=CHAT_ERROR, indistinguishable from a real GoodData fault. ChatError covers what ChatClient raises deliberately; httpx.HTTPError covers the transport faults it re-raises untouched mid-stream (RemoteProtocolError, ReadError), which the KDA tests exercise directly. Catching ChatError alone would not have been enough. One existing test drove send_message with a bare RuntimeError("stream died") and relied on it being swallowed. It now raises httpx.ReadError -- a fault send_message actually produces -- since a bare RuntimeError propagating is the point of the change. Both leak fixes are pinned by a test verified to fail without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a7c0e55 commit 4b60450

6 files changed

Lines changed: 109 additions & 3 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,18 @@ def _run_once(conv_id: str) -> AlertRunResult:
700700
# Without this the exception escapes run_agentic_alert_skill entirely,
701701
# discarding every K-run already completed along with any exit_reason.
702702
print(f"[CHAT] send_message failed for conversation {conv_id}: {exc}")
703+
# The stream can break AFTER create_metric_alert already succeeded
704+
# server-side. That id only ever reached alert_id_to_delete from the
705+
# normal path below, so breaking here left the alert in the workspace for
706+
# the `finally` cleanup to miss -- a real object leaking out of a failed
707+
# run, not a reporting gap.
708+
partial = exc.partial_result
709+
if partial is not None:
710+
reasoning_steps.extend(partial.reasoning_steps or [])
711+
response_id = partial.response_id or response_id
712+
partial_alert_id, _, partial_tool_called = _extract_alert_call(partial.tool_call_events or [])
713+
if partial_tool_called:
714+
alert_id_to_delete = partial_alert_id
703715
exit_reason = LoopExit.CHAT_ERROR
704716
break
705717
# `turns_used` counts attempts (set above, so a failed send still shows the

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import os
88
from dataclasses import dataclass, field
99

10+
import httpx
11+
1012
from gooddata_eval.core.agentic._gate import (
1113
DEFAULT_GATE,
1214
EvalGate,
@@ -25,7 +27,7 @@
2527
utc_now,
2628
)
2729
from gooddata_eval.core.chat.render import render_answer_text
28-
from gooddata_eval.core.chat.sse_client import ChatClient
30+
from gooddata_eval.core.chat.sse_client import ChatClient, ChatError
2931
from gooddata_eval.core.config import ReasoningEffort
3032
from gooddata_eval.core.models import (
3133
AgenticAssertionError,
@@ -294,7 +296,13 @@ def _accumulate(result: ChatResult) -> None:
294296
turns_used = iteration + 1
295297
try:
296298
chat_result = client.send_message(conv_id, current_question)
297-
except Exception as exc: # noqa: BLE001 -- end this run, not the whole assertion
299+
except (ChatError, httpx.HTTPError) as exc:
300+
# Narrow on purpose. A bare `except Exception` here also swallowed bugs in
301+
# this package -- a TypeError in the accumulator came back as a tidy failed
302+
# run with exit_reason=CHAT_ERROR, indistinguishable from a real GoodData
303+
# fault. ChatError covers what ChatClient raises deliberately; httpx.HTTPError
304+
# covers the transport faults it re-raises untouched mid-stream
305+
# (RemoteProtocolError, ReadError). Anything else is ours and should surface.
298306
_log.warning("KDA send_message failed for conversation %s: %s", conv_id, exc)
299307
partial = getattr(exc, "partial_result", None)
300308
if partial is not None:

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,17 @@ def _execute_single_metric_run(
286286
# GoodData-side fault, so recorded like the simulated-user one below.
287287
timings.agent_s += time.monotonic() - agent_started
288288
print(f"[CHAT] send_message failed for conversation {conversation_id}: {exc}")
289+
# The stream can break AFTER create_metric already succeeded server-side.
290+
# Those ids only ever reached created_metric_ids from the normal path below,
291+
# so breaking here left the metric in the workspace for the `finally` cleanup
292+
# to miss -- a real object leaking out of a failed run, not a reporting gap.
293+
partial = exc.partial_result
294+
if partial is not None:
295+
reasoning_steps.extend(partial.reasoning_steps or [])
296+
response_id = partial.response_id or response_id
297+
for metric_id in _extract_created_metric_ids(partial.tool_call_events or []):
298+
if metric_id not in created_metric_ids:
299+
created_metric_ids.append(metric_id)
289300
exit_reason = LoopExit.CHAT_ERROR
290301
break
291302
agent_elapsed = time.monotonic() - agent_started

packages/gooddata-eval/tests/test_agentic_alert_skill.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,3 +1269,44 @@ def _capture(_submit, _identity, **kwargs):
12691269

12701270
assert scores["turns"] == 1
12711271
assert scores["steps"] == 1
1272+
1273+
1274+
def test_an_alert_created_before_the_stream_broke_is_still_cleaned_up():
1275+
"""The stream can break AFTER create_metric_alert already succeeded server-side.
1276+
1277+
The id reached alert_id_to_delete only from the normal path, so the ChatError branch
1278+
used to leave a real alert behind in the workspace -- an object leaking out of a
1279+
failed run, which then fires for real.
1280+
"""
1281+
partial = ChatResult.model_validate(
1282+
{
1283+
"text_response": "",
1284+
"tool_call_events": [
1285+
{
1286+
"functionName": "create_metric_alert",
1287+
"functionArguments": '{"operator": "GREATER_THAN", "threshold": 500}',
1288+
"result": '{"id": "alert-1"}',
1289+
}
1290+
],
1291+
}
1292+
)
1293+
mock_client = MagicMock()
1294+
mock_client.create_conversation.return_value = "conv-1"
1295+
mock_client.send_message.side_effect = ChatError("stream died after create", partial_result=partial)
1296+
1297+
with (
1298+
patch("gooddata_eval.core.agentic.alert_skill.ChatClient", return_value=mock_client),
1299+
patch("gooddata_eval.core.agentic.alert_skill._delete_alert") as mock_delete,
1300+
):
1301+
run_agentic_alert_skill(
1302+
host="http://host",
1303+
token="tok",
1304+
workspace_id="ws1",
1305+
question="Notify me whenever the number of orders goes above 500",
1306+
expected_output={"operator": "GREATER_THAN", "threshold": 500},
1307+
k=1,
1308+
max_iterations=1,
1309+
)
1310+
1311+
assert mock_delete.call_count == 1
1312+
assert mock_delete.call_args.args[-1] == "alert-1"

packages/gooddata-eval/tests/test_agentic_kda_skill.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1238,7 +1238,10 @@ def test_run_agentic_kda_skill_reports_no_turns_when_the_first_send_fails():
12381238
"""A run that never got a reply must not report a turn it did not take."""
12391239
mock_client = MagicMock()
12401240
mock_client.create_conversation.return_value = "conv-1"
1241-
mock_client.send_message.side_effect = RuntimeError("stream died")
1241+
# A transport fault, not a bare RuntimeError: the handler narrowed to what
1242+
# send_message actually raises, so a bare RuntimeError now (correctly) propagates as
1243+
# the bug in this package that it would be.
1244+
mock_client.send_message.side_effect = httpx.ReadError("stream died")
12421245

12431246
with patch("gooddata_eval.core.agentic.kda_skill.ChatClient", return_value=mock_client):
12441247
summary = run_agentic_kda_skill(

packages/gooddata-eval/tests/test_agentic_metric_skill.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,3 +946,34 @@ def _capture(_submit, _identity, **kwargs):
946946
# they are counts, and a float reads as though a fraction of a turn were possible.
947947
assert isinstance(scores["turns"], int)
948948
assert isinstance(scores["steps"], int)
949+
950+
951+
def test_a_metric_created_before_the_stream_broke_is_still_cleaned_up():
952+
"""The stream can break AFTER create_metric already succeeded server-side.
953+
954+
The id reached created_metric_ids only from the normal path, so the ChatError branch
955+
used to leave a real metric behind in the workspace -- an object leaking out of a
956+
failed run, which the next run then sees.
957+
"""
958+
mock_client = _client()
959+
partial = ChatResult.model_validate(
960+
{
961+
"textResponse": "",
962+
"toolCallEvents": [_create_metric_call('{"data": {"metric_id": "m1", "maql": "SELECT {metric/foo}"}}')],
963+
}
964+
)
965+
mock_client.send_message.side_effect = ChatError("stream died after create", partial_result=partial)
966+
967+
with _patched(mock_client, sdk=True) as (_, mock_sdk_cls):
968+
run_agentic_metric_skill(
969+
host="http://host/api/v1/actions/workspaces/ws1/ai",
970+
token="tok",
971+
workspace_id="ws1",
972+
question="Create metric foo",
973+
expected_output={"maql": "SELECT {metric/foo}"},
974+
k=1,
975+
max_iterations=1,
976+
)
977+
978+
sdk = mock_sdk_cls.create.return_value
979+
sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "m1")

0 commit comments

Comments
 (0)