mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(run_agent): surface traceback frame for empty-message errors in 📝 Error:
## Problem
When the retry loop's `_summarize_api_error()` receives an exception
with an empty `str(error)` (bare `raise AssertionError()`, bare `raise`
without args, or third-party SDK accumulators that discard the original
payload), users see:
📝 Error:
...with nothing after the colon, and no way to diagnose the root cause
without re-running under a debugger.
## Real-world trigger
The anthropic SDK's streaming accumulator raises:
RuntimeError('Unexpected event order, got error before "message_start"')
when Bedrock or Anthropic returns a service-level `error` event as the
first stream event (throttling, overload, 5xx, etc.). The SDK has the
error payload in hand but throws it away before raising — all the user
sees is the cryptic "event order" message with zero context. Even
worse, bare `AssertionError()`s in user-authored code paths often have
empty messages entirely.
## Fix
When the error's string representation is empty or whitespace-only,
walk `error.__traceback__` and format the last frame as:
AssertionError at /path/file.py:123 in some_method() — source line
This gives users an actionable locator without changing the output for
any error with a real message.
## Changes
- `run_agent.py` — `_summarize_api_error()`: added empty-payload guard
at the top of the function before the existing Cloudflare-HTML path.
- `tests/agent/test_summarize_api_error.py` — 4 new tests covering
empty AssertionError, empty-message RuntimeError without traceback,
whitespace-only message, and the fall-through path (non-empty errors
must not be affected).
## Tests
pytest tests/agent/test_summarize_api_error.py -v
# 4 passed in 4.88s
## Risk
- Zero impact on any error with a non-empty message — the new branch
only fires when `str(error).strip()` is falsy.
- `traceback` is a stdlib module; import is lazy (inside the branch).
- `getattr(error, "__traceback__", None)` is the documented API and
handles manually-constructed exceptions that were never raised.
Signed-off-by: Andre Kurait <andrekurait@gmail.com>
This commit is contained in:
parent
b6ca3c28dc
commit
5113976a69
2 changed files with 85 additions and 0 deletions
23
run_agent.py
23
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 <title> for a clean summary
|
||||
if "<!DOCTYPE" in raw or "<html" in raw:
|
||||
m = re.search(r"<title[^>]*>([^<]+)</title>", raw, re.IGNORECASE)
|
||||
|
|
|
|||
62
tests/agent/test_summarize_api_error.py
Normal file
62
tests/agent/test_summarize_api_error.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue