diff --git a/run_agent.py b/run_agent.py index 855b67a847..68d7ed2d78 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3290,6 +3290,29 @@ class AIAgent: """ raw = str(error) + # Empty-message errors (bare `assert`, empty `raise`, or third-party + # SDK accumulators that swallow the original payload) leave users + # staring at `📝 Error:` with no content. Dump the traceback's last + # frame so the real root cause is visible without re-running under + # a debugger. Known trigger: the anthropic SDK's streaming + # accumulator raises `RuntimeError('Unexpected event order, got + # error before "message_start"')` when Bedrock/Anthropic returns + # a service-level error event first (throttling, overload, etc.) — + # the raised exception has a message, but bare AssertionErrors in + # user-authored code paths often don't. + if not raw.strip(): + import traceback as _tb + tb = getattr(error, "__traceback__", None) + if tb is not None: + frames = _tb.extract_tb(tb) + if frames: + last = frames[-1] + return ( + f"{type(error).__name__} at {last.filename}:{last.lineno} " + f"in {last.name}() — {last.line or '(no source)'}" + ) + return f"{type(error).__name__} (no message, no traceback)" + # Cloudflare / proxy HTML pages: grab the for a clean summary if "<!DOCTYPE" in raw or "<html" in raw: m = re.search(r"<title[^>]*>([^<]+)", raw, re.IGNORECASE) diff --git a/tests/agent/test_summarize_api_error.py b/tests/agent/test_summarize_api_error.py new file mode 100644 index 0000000000..9a5cffbfe1 --- /dev/null +++ b/tests/agent/test_summarize_api_error.py @@ -0,0 +1,62 @@ +"""Tests for AIAgent._summarize_api_error — the error-line formatter +used by the retry loop's `📝 Error:` display. + +Regression tests for a gap where exceptions with empty string payloads +(bare `raise`, bare `assert`, or third-party SDK assertions that don't +carry the original error message) would surface as `📝 Error:` with no +content, forcing users to re-run under a debugger to see what failed. +""" +from run_agent import AIAgent + + +def _raise_and_catch(exc: Exception) -> Exception: + """Raise and immediately re-catch so the exception has a real traceback.""" + try: + raise exc + except type(exc) as caught: + return caught + + +class TestSummarizeApiErrorEmptyMessage: + """Empty-message errors still surface actionable context.""" + + def test_bare_assertion_error_shows_traceback_frame(self): + # `raise AssertionError()` produces a truly empty-message error + # (unlike `assert False` which Python annotates with source). + def _trigger(): + raise AssertionError() + + try: + _trigger() + except AssertionError as e: + result = AIAgent._summarize_api_error(e) + + assert "AssertionError" in result + assert "_trigger" in result + assert ".py:" in result + + def test_empty_raise_shows_type_only_when_no_traceback(self): + # Construct an exception without raising it — no __traceback__ + err = RuntimeError("") + result = AIAgent._summarize_api_error(err) + + assert "RuntimeError" in result + assert "no message" in result + + def test_whitespace_only_message_treated_as_empty(self): + err = _raise_and_catch(RuntimeError(" \n\t ")) + result = AIAgent._summarize_api_error(err) + + assert "RuntimeError" in result + # Must include a frame locator — not just the empty payload + assert ".py:" in result or "no message" in result + + def test_non_empty_message_falls_through_to_normal_path(self): + """Non-empty errors must not be captured by the new branch.""" + err = _raise_and_catch(RuntimeError("some real error")) + result = AIAgent._summarize_api_error(err) + + # The normal path returns the raw string (possibly truncated), + # NOT the traceback-frame format introduced by this fix. + assert "some real error" in result + assert ".py:" not in result