Skip to content

Commit d01b9d4

Browse files
authored
fix(email): don't cancel a retrying agent for a recoverable tool error (#2572)
When the model calls a batch tool with a bad argument (e.g. `archive_message_batch` with a stray `mailbox` kwarg), the agent loop correctly rejects it and starts retrying — but the SSE layer couldn't tell that per-tool retry apart from a genuinely fatal failure, so it ended the response and cancelled the still-retrying agent, dead-ending the turn with no answer and no stats line. Reproduced 4/4 on-hardware before this fix. Now a recoverable error surfaces as a non-terminal status line (the user still sees the failure) and the retry can reach completion. Closes #2515 ## Test plan - [x] `python -m pytest tests/unit -k "sse or agent_error" -q` — 399 passed - [x] `python -m pytest hub/agents/email/python/tests -k "sse or translation or query_routes" -q` — 64 passed - [x] New regression test drives the real `/v1/email/query` route + real `SSEOutputHandler` + real translation layer with a fake agent that emits a recoverable per-tool error then retries: asserts `run.cancel_event` and `handler.cancelled` stay unset mid-run and the stream reaches `final` with both the failed and retried tool calls streamed (`test_recoverable_tool_error_does_not_terminate_stream_or_cancel_run`) - [x] `python util/lint.py --all` — all blocking checks pass
1 parent 7fdcd6b commit d01b9d4

10 files changed

Lines changed: 210 additions & 26 deletions

File tree

hub/agents/email/python/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ contract version is tracked separately as
8484

8585
### Fixed
8686

87+
- **A batch-tool retry no longer gets killed mid-recovery by the streaming
88+
layer (#2515).** When the model called a batch tool with a spurious extra
89+
argument (e.g. `archive_message_batch` with a stray `mailbox` kwarg), the
90+
agent loop correctly rejected it and started retrying — but the SSE layer
91+
couldn't tell that per-tool error apart from a genuinely fatal failure, so
92+
it ended the response and cancelled the still-retrying agent, dead-ending
93+
the turn with no answer and no stats line. `print_error` now carries a
94+
`recoverable` flag through to the wire; a recoverable error folds to a
95+
non-terminal status line instead of a terminal `error`, so the retry can
96+
reach completion and the user still sees the failure as it happens.
8797
- **A failed memory startup is now visible in chat, and blames the right
8898
cause (#2519).** When the embedding model wasn't reachable, memory quietly
8999
disabled itself: a log line and a REST field said so, but the agent's

hub/agents/email/python/gaia_agent_email/sse_translation.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@
2929
- **Buffer ``tool_start`` + ``tool_args`` into one ``tool_call``** (spec §6.3): the
3030
handler emits the name first and the arguments separately; the canonical
3131
``tool_call`` carries ``{tool, args}`` together.
32-
- **Fail loudly, never silently.** ``agent_error`` and a governance
33-
``policy_alert`` map to a terminal ``error`` with an actionable ``detail`` — never
34-
a placeholder. The ``None`` queue sentinel is *stream close*, handled by the
35-
drain loop, not a wire event.
32+
- **Fail loudly, never silently.** A fatal top-level ``agent_error`` and a
33+
governance ``policy_alert`` map to a terminal ``error`` with an actionable
34+
``detail`` — never a placeholder. A **recoverable** ``agent_error`` (the
35+
source event's ``recoverable`` flag, set by ``agent.py``'s
36+
``STATE_ERROR_RECOVERY`` retry path) is explicitly NOT terminal — it folds
37+
to a ``status`` line so the run continues (#2515). The ``None`` queue
38+
sentinel is *stream close*, handled by the drain loop, not a wire event.
3639
3740
Spec open questions surfaced in this file (do not block #2016):
3841
- **Q2** — ``policy_alert`` maps to ``error`` (status 403). A governance block is
@@ -240,6 +243,14 @@ def _on_answer(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
240243

241244
def _on_agent_error(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
242245
detail = str(event.get("content") or "Unknown agent error")
246+
if event.get("recoverable"):
247+
# A per-tool error the agent loop is retrying (agent.py's
248+
# STATE_ERROR_RECOVERY path, e.g. a bad tool argument) is not
249+
# terminal — the run continues on this same stream. Fold it to a
250+
# status line, same pattern as ``_on_tool_confirm_denied``, so
251+
# the user still SEES the failure without the stream (and the
252+
# still-retrying agent) being cut out from under it (#2515).
253+
return [{"type": "status", "message": f"Tool call failed, retrying: {detail}"}]
243254
return [{"type": "error", "detail": detail, "status": _ERROR_STATUS_AGENT}]
244255

245256
def _on_policy_alert(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:

hub/agents/email/python/tests/test_query_route.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import json
1717
import threading
18+
import time
1819
import uuid
1920

2021
import pytest
@@ -189,6 +190,48 @@ def process_query(self, query, max_steps=None):
189190
raise ValueError("triage produced malformed JSON at row 4")
190191

191192

193+
class _RecoverableRetryFakeAgent:
194+
"""Reproduces #2515: a per-tool error the agent loop is retrying (e.g. the
195+
live repro — ``archive_message_batch`` called with a spurious ``mailbox``
196+
kwarg), NOT a fatal top-level failure. Pauses right after emitting the
197+
recoverable error so the test can inspect ``run.cancel_event`` /
198+
``handler.cancelled`` BEFORE the retry step runs — proving the streaming
199+
layer didn't cut the response and cancel the still-retrying agent out
200+
from under it.
201+
"""
202+
203+
def __init__(self):
204+
self.conversation_history = []
205+
self.console = None
206+
self._cancel_event = None
207+
self.error_emitted = threading.Event()
208+
209+
def process_query(self, query, max_steps=None):
210+
self.console.print_processing_start(query, 20, "fake-model")
211+
self.console.print_step_header(1, 20)
212+
self.console.print_tool_usage("archive_message_batch")
213+
self.console.print_error(
214+
"Unexpected argument(s) for archive_message_batch: mailbox. "
215+
"Accepted argument(s): message_ids.",
216+
recoverable=True,
217+
)
218+
self.error_emitted.set()
219+
# Give the streaming layer a beat to process the queued event (and,
220+
# pre-fix, cut the stream + cancel this run) before the retry.
221+
if self._cancel_event is not None:
222+
self._cancel_event.wait(timeout=2)
223+
if self._cancel_event.is_set():
224+
self.console.print_final_answer("Cancelled.", streaming=False)
225+
return {"answer": "Cancelled."}
226+
self.console.print_step_header(2, 20)
227+
self.console.print_tool_usage("archive_message_batch")
228+
self.console.pretty_print_json({"message_ids": ["m1"]}, title="Arguments")
229+
self.console.pretty_print_json({"archived": 1})
230+
self.console.print_tool_complete()
231+
self.console.print_final_answer("Archived 1 message.", streaming=False)
232+
return {"answer": "Archived 1 message."}
233+
234+
192235
class _InternalErrorFakeAgent:
193236
"""Mimics the base agent's Lemonade-down branch: it sets an actionable
194237
``final_answer`` and returns a failed result WITHOUT calling
@@ -347,6 +390,51 @@ def test_cancel_unknown_run_id_is_404(app_client):
347390
assert resp.status_code == 404
348391

349392

393+
# ---------------------------------------------------------------------------
394+
# #2515 — a recoverable per-tool error must not end the stream or cancel the
395+
# still-retrying agent
396+
# ---------------------------------------------------------------------------
397+
398+
399+
def test_recoverable_tool_error_does_not_terminate_stream_or_cancel_run(monkeypatch):
400+
fake = _RecoverableRetryFakeAgent()
401+
monkeypatch.setattr(query_routes, "build_query_agent", lambda **k: fake)
402+
client = TestClient(export_openapi.build_app())
403+
run_id = str(uuid.uuid4())
404+
collected = {}
405+
406+
def _stream():
407+
resp = client.post(
408+
"/v1/email/query",
409+
json={"query": "archive stuff", "run_id": run_id, "context": []},
410+
)
411+
collected["text"] = resp.text
412+
413+
t = threading.Thread(target=_stream, daemon=True)
414+
t.start()
415+
416+
assert fake.error_emitted.wait(timeout=10), "recoverable error never emitted"
417+
# Give the async stream generator a moment to drain the queued
418+
# ``agent_error`` event through the translator before asserting nothing
419+
# tore the run down in response to it.
420+
time.sleep(0.3)
421+
run = query_routes.registry.get(run_id)
422+
assert run is not None, "run ended prematurely — was cancelled before the retry"
423+
assert not run.cancel_event.is_set(), "recoverable error set the cancel event"
424+
assert not run.handler.cancelled.is_set(), "recoverable error cancelled the handler"
425+
426+
t.join(timeout=10)
427+
events = _parse_sse(collected["text"])
428+
types = _types(events)
429+
# Both the failed attempt (step 1) AND the retried attempt (step 2) got
430+
# their tool_call streamed — proving the loop was not cut off after the
431+
# recoverable error and reached completion (#2515).
432+
assert types.count("tool_call") == 2
433+
assert types.count("error") == 0
434+
assert types[-1] == "final"
435+
assert events[-1]["answer"] == "Archived 1 message."
436+
437+
350438
# ---------------------------------------------------------------------------
351439
# Error path — a failed run ends with a terminal error event
352440
# ---------------------------------------------------------------------------

hub/agents/email/python/tests/test_sse_translation.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,29 @@ def test_agent_error_maps_to_terminal_error():
222222
assert out == [{"type": "error", "detail": "boom", "status": 500}]
223223

224224

225+
def test_recoverable_agent_error_maps_to_non_terminal_status():
226+
"""#2515: a per-tool error the agent loop is retrying (agent.py's
227+
STATE_ERROR_RECOVERY path) is NOT terminal — the two layers must agree
228+
that "recoverable" means the run continues, not that the wire-level
229+
terminal contract gets loosened for every agent_error."""
230+
out = _tr().translate(
231+
{"type": "agent_error", "content": "boom", "recoverable": True}
232+
)
233+
assert out[0]["type"] not in TERMINAL_TYPES
234+
assert out[0]["type"] == "status"
235+
# The user must still SEE the failure — never silently swallowed.
236+
assert "boom" in out[0]["message"]
237+
238+
239+
def test_recoverable_false_agent_error_is_still_terminal():
240+
# An explicit False (as well as the field's absence, covered above) keeps
241+
# the existing terminal contract — this is not a blanket downgrade.
242+
out = _tr().translate(
243+
{"type": "agent_error", "content": "boom", "recoverable": False}
244+
)
245+
assert out == [{"type": "error", "detail": "boom", "status": 500}]
246+
247+
225248
def test_policy_alert_maps_to_error_with_tail():
226249
out = _tr().translate(
227250
{

src/gaia/agents/base/agent.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2200,7 +2200,9 @@ def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
22002200
)
22012201
logger.error(error_msg)
22022202
self.error_history.append(error_msg)
2203-
self.console.print_error(error_msg)
2203+
# The main loop's is_error/STATE_ERROR_RECOVERY handling retries
2204+
# this — not a fatal top-level failure (#2515).
2205+
self.console.print_error(error_msg, recoverable=True)
22042206
return {
22052207
"status": "error",
22062208
"error": error_msg,
@@ -2226,8 +2228,10 @@ def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
22262228
logger.error(f"Error executing tool {tool_name}: {e}")
22272229
self.error_history.append(str(e)) # Store brief error, not formatted
22282230

2229-
# Print to console immediately so user sees it
2230-
self.console.print_error(formatted_error)
2231+
# Print to console immediately so user sees it. The caller's
2232+
# is_error/STATE_ERROR_RECOVERY handling retries this — not a
2233+
# fatal top-level failure (#2515).
2234+
self.console.print_error(formatted_error, recoverable=True)
22312235

22322236
return {
22332237
"status": "error",
@@ -3116,7 +3120,9 @@ def _process_query_impl(
31163120
)
31173121
# Only print if error wasn't already displayed by _execute_tool
31183122
if not tool_result.get("error_displayed"):
3119-
self.console.print_error(last_error)
3123+
# STATE_ERROR_RECOVERY below retries this — not a
3124+
# fatal top-level failure (#2515).
3125+
self.console.print_error(last_error, recoverable=True)
31203126

31213127
# Switch to error recovery state
31223128
self.execution_state = self.STATE_ERROR_RECOVERY
@@ -3887,9 +3893,12 @@ def _process_query_impl(
38873893
f"Invalid plan format: expected list, got {type(parsed['plan']).__name__}. "
38883894
f"Plan content: {parsed['plan']}"
38893895
)
3896+
# The "continue" below asks the LLM to correct itself —
3897+
# not a fatal top-level failure (#2515).
38903898
self.console.print_error(
38913899
f"LLM returned invalid plan format (expected array, got {type(parsed['plan']).__name__}). "
3892-
"Asking for correction..."
3900+
"Asking for correction...",
3901+
recoverable=True,
38933902
)
38943903

38953904
# Create error recovery prompt
@@ -3923,8 +3932,11 @@ def _process_query_impl(
39233932

39243933
if invalid_steps:
39253934
logger.error(f"Invalid plan steps found: {invalid_steps}")
3935+
# The "continue" below asks the LLM to correct itself —
3936+
# not a fatal top-level failure (#2515).
39263937
self.console.print_error(
3927-
f"Plan contains {len(invalid_steps)} invalid step(s). Asking for correction..."
3938+
f"Plan contains {len(invalid_steps)} invalid step(s). Asking for correction...",
3939+
recoverable=True,
39283940
)
39293941

39303942
# Create detailed error message
@@ -4141,7 +4153,10 @@ def _process_query_impl(
41414153
last_error,
41424154
)
41434155
if not tool_result.get("error_displayed"):
4144-
self.console.print_error(last_error)
4156+
# any_error below switches to STATE_ERROR_RECOVERY
4157+
# and retries — not a fatal top-level failure
4158+
# (#2515, the archive_message_batch repro).
4159+
self.console.print_error(last_error, recoverable=True)
41454160
any_error = True
41464161

41474162
if fanout_repeat_break:
@@ -4365,7 +4380,9 @@ def _process_query_impl(
43654380
)
43664381
# Only print if error wasn't already displayed by _execute_tool
43674382
if not tool_result.get("error_displayed"):
4368-
self.console.print_error(last_error)
4383+
# STATE_ERROR_RECOVERY below retries this — not a
4384+
# fatal top-level failure (#2515).
4385+
self.console.print_error(last_error, recoverable=True)
43694386

43704387
# Switch to error recovery state
43714388
self.execution_state = self.STATE_ERROR_RECOVERY

src/gaia/agents/base/console.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,18 @@ def pretty_print_json(self, data: Dict[str, Any], title: str = None):
261261
# === Status Messages (Required) ===
262262

263263
@abstractmethod
264-
def print_error(self, error_message: str):
265-
"""Print error message."""
264+
def print_error(self, error_message: str, recoverable: bool = False):
265+
"""Print error message.
266+
267+
Args:
268+
error_message: The error to display.
269+
recoverable: True when the agent loop is retrying past this error
270+
(e.g. a per-tool argument-validation failure that enters
271+
``STATE_ERROR_RECOVERY``), False for a genuinely fatal
272+
top-level failure that ends the run. Handlers that translate
273+
this into a downstream wire event (SSE, etc.) use the flag to
274+
decide terminal vs. non-terminal — see #2515.
275+
"""
266276
...
267277

268278
@abstractmethod
@@ -1221,12 +1231,14 @@ def print_tool_complete(self) -> None:
12211231
else:
12221232
print("✅ Tool execution complete")
12231233

1224-
def print_error(self, error_message: str) -> None:
1234+
def print_error(self, error_message: str, recoverable: bool = False) -> None:
12251235
"""
12261236
Print an error message with appropriate styling.
12271237
12281238
Args:
12291239
error_message: The error message to display
1240+
recoverable: Unused by the CLI console (styling is the same
1241+
either way); accepted for interface parity with SSE handlers.
12301242
"""
12311243
# Handle None error messages
12321244
if error_message is None:
@@ -2594,7 +2606,7 @@ def print_tool_complete(self):
25942606
def pretty_print_json(self, data: Dict[str, Any], title: str = None):
25952607
"""No-op implementation."""
25962608

2597-
def print_error(self, error_message: str):
2609+
def print_error(self, error_message: str, recoverable: bool = False):
25982610
"""No-op implementation."""
25992611

26002612
def print_warning(self, warning_message: str):

src/gaia/api/sse_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ def pretty_print_json(self, data: Dict[str, Any], title: str = None):
144144

145145
# === Status Messages (Required) ===
146146

147-
def print_error(self, error_message: str):
147+
def print_error(self, error_message: str, recoverable: bool = False):
148148
"""Print error message."""
149-
self._add_event("error", {"message": error_message})
149+
self._add_event("error", {"message": error_message, "recoverable": recoverable})
150150

151151
def print_warning(self, warning_message: str):
152152
"""Print warning message."""

src/gaia/ui/sse_handler.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -443,13 +443,17 @@ def pretty_print_json(self, data: Dict[str, Any], title: str = None):
443443

444444
# === Status Messages ===
445445

446-
def print_error(self, error_message: str):
447-
self._emit(
448-
{
449-
"type": "agent_error",
450-
"content": str(error_message) if error_message else "Unknown error",
451-
}
452-
)
446+
def print_error(self, error_message: str, recoverable: bool = False):
447+
event: Dict[str, Any] = {
448+
"type": "agent_error",
449+
"content": str(error_message) if error_message else "Unknown error",
450+
}
451+
# Only set when True: keeps the wire shape unchanged for every
452+
# existing (fatal) caller, and lets a downstream translator treat a
453+
# missing/False flag as the pre-#2515 terminal default.
454+
if recoverable:
455+
event["recoverable"] = True
456+
self._emit(event)
453457

454458
def print_warning(self, warning_message: str):
455459
self._emit(

tests/unit/agents/test_console_tool_confirmation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def print_tool_complete(self):
126126
def pretty_print_json(self, data, title=None):
127127
pass
128128

129-
def print_error(self, error_message):
129+
def print_error(self, error_message, recoverable=False):
130130
pass
131131

132132
def print_warning(self, warning_message):

tests/unit/chat/ui/test_sse_handler.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,25 @@ def test_non_string_error_is_converted(self, handler):
625625
events = _drain(handler)
626626
assert "bad value" in events[0]["content"]
627627

628+
def test_recoverable_flag_defaults_to_omitted(self, handler):
629+
# No "recoverable" key at all when the caller doesn't pass it — keeps
630+
# the wire shape unchanged for every existing (fatal) caller (#2515).
631+
handler.print_error("Something went wrong")
632+
events = _drain(handler)
633+
assert "recoverable" not in events[0]
634+
635+
def test_recoverable_true_is_carried_onto_the_event(self, handler):
636+
# A per-tool error the agent loop is retrying (agent.py's
637+
# STATE_ERROR_RECOVERY path) must be distinguishable from a fatal
638+
# top-level failure downstream (#2515).
639+
handler.print_error("retryable failure", recoverable=True)
640+
events = _drain(handler)
641+
assert events[0] == {
642+
"type": "agent_error",
643+
"content": "retryable failure",
644+
"recoverable": True,
645+
}
646+
628647

629648
# ===========================================================================
630649
# SSEOutputHandler.print_warning

0 commit comments

Comments
 (0)