diff --git a/run_agent.py b/run_agent.py index 8994f206f..a9a4f7f5c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10797,9 +10797,18 @@ class AIAgent: # already accounts for 413, 429, 529 (transient), context # overflow, and generic-400 heuristics. Local validation # errors (ValueError, TypeError) are programming bugs. + # Exclude UnicodeEncodeError — it's a ValueError subclass + # but is handled separately by the surrogate sanitization + # path above. Exclude json.JSONDecodeError — also a + # ValueError subclass, but it indicates a transient + # provider/network failure (malformed response body, + # truncated stream, routing layer corruption), not a + # local programming bug, and should be retried (#14782). is_local_validation_error = ( isinstance(api_error, (ValueError, TypeError)) - and not isinstance(api_error, UnicodeEncodeError) + and not isinstance( + api_error, (UnicodeEncodeError, json.JSONDecodeError) + ) ) is_client_error = ( is_local_validation_error diff --git a/tests/run_agent/test_jsondecodeerror_retryable.py b/tests/run_agent/test_jsondecodeerror_retryable.py new file mode 100644 index 000000000..201521ddb --- /dev/null +++ b/tests/run_agent/test_jsondecodeerror_retryable.py @@ -0,0 +1,87 @@ +"""Regression guard for #14782: json.JSONDecodeError must not be classified +as a local validation error by the main agent loop. + +`json.JSONDecodeError` inherits from `ValueError`. The agent loop's +non-retryable classifier at run_agent.py treats `ValueError` / `TypeError` +as local programming bugs and skips retry. Without an explicit carve-out, +a transient provider hiccup (malformed response body, truncated stream, +routing-layer corruption) that surfaces as a JSONDecodeError would bypass +the retry path and fail the turn immediately. + +This test mirrors the exact predicate shape used in run_agent.py so that +any future refactor of that predicate must preserve the invariant: + + JSONDecodeError → NOT local validation error (retryable) + UnicodeEncodeError → NOT local validation error (surrogate path) + bare ValueError → IS local validation error (programming bug) + bare TypeError → IS local validation error (programming bug) +""" +from __future__ import annotations + +import json + + +def _mirror_agent_predicate(err: BaseException) -> bool: + """Exact shape of run_agent.py's is_local_validation_error check. + + Kept in lock-step with the source. If you change one, change both — + or, better, refactor the check into a shared helper and have both + sites import it. + """ + return ( + isinstance(err, (ValueError, TypeError)) + and not isinstance(err, (UnicodeEncodeError, json.JSONDecodeError)) + ) + + +class TestJSONDecodeErrorIsRetryable: + + def test_json_decode_error_is_not_local_validation(self): + """Provider returning malformed JSON surfaces as JSONDecodeError — + must be treated as transient so the retry path runs.""" + try: + json.loads("{not valid json") + except json.JSONDecodeError as exc: + assert not _mirror_agent_predicate(exc), ( + "json.JSONDecodeError must be excluded from the " + "ValueError/TypeError local-validation classification." + ) + else: + raise AssertionError("json.loads should have raised") + + def test_unicode_encode_error_is_not_local_validation(self): + """Existing carve-out — surrogate sanitization handles this separately.""" + try: + "\ud800".encode("utf-8") + except UnicodeEncodeError as exc: + assert not _mirror_agent_predicate(exc) + else: + raise AssertionError("encoding lone surrogate should raise") + + def test_bare_value_error_is_local_validation(self): + """Programming bugs that raise bare ValueError must still be + classified as local validation errors (non-retryable).""" + assert _mirror_agent_predicate(ValueError("bad arg")) + + def test_bare_type_error_is_local_validation(self): + assert _mirror_agent_predicate(TypeError("wrong type")) + + +class TestAgentLoopSourceStillHasCarveOut: + """Belt-and-suspenders: the production source must actually include + the json.JSONDecodeError carve-out. Protects against an accidental + revert that happens to leave the test file intact.""" + + def test_run_agent_excludes_jsondecodeerror_from_local_validation(self): + import run_agent + import inspect + src = inspect.getsource(run_agent) + # The predicate we care about must reference json.JSONDecodeError + # in its exclusion tuple. We check for the specific co-occurrence + # rather than the literal string so harmless reformatting doesn't + # break us. + assert "is_local_validation_error" in src + assert "JSONDecodeError" in src, ( + "run_agent.py must carve out json.JSONDecodeError from the " + "is_local_validation_error classification — see #14782." + )