[eric] agents: classify bundled-CLI-missing errors + unwrap exception groups to the real cause

This commit is contained in:
ciregenz
2026-07-28 15:49:59 -07:00
parent 079d527061
commit e16255e06a
3 changed files with 94 additions and 1 deletions
@@ -165,6 +165,22 @@ def is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
))
@typechecked
def is_cli_binary_missing(exc: BaseException, extra_text: str = "") -> bool:
"""True when the bundled Claude CLI binary is gone from disk (the SDK's
CLINotFoundError at spawn time). Field data shows this only on Windows,
where antivirus quarantine deletes the unsigned exe out from under an
installed app; restore-from-quarantine or reinstall is the only fix, so
the card must say that instead of dumping the dead path.
"""
if "CLINotFoundError" in type(exc).__name__:
return True
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
return bool(re.search(r"claude\s+code\s+not\s+found", combined, re.IGNORECASE))
def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
"""Best-effort seconds-until-retry pulled from a throttle error; None if the
upstream didn't say. Only used to label the rate-limit pill, so a miss just
@@ -0,0 +1,15 @@
"""Unwrap (possibly nested) exception groups to the first plain Exception.
anyio task groups deliver a concurrent CLI crash + cancellation as a
BaseExceptionGroup whose str() names the group, not the cause; classifying the
group instead of the member turns a retryable 429 into a raw error card."""
from typing import Optional
def first_real_exception(exc: BaseException) -> Optional[Exception]:
if isinstance(exc, BaseExceptionGroup):
for p_sub in exc.exceptions:
p_found = first_real_exception(p_sub)
if p_found is not None:
return p_found
return None
return exc if isinstance(exc, Exception) else None
+63 -1
View File
@@ -7,7 +7,28 @@ The secret-shaped inputs are built by concatenation on purpose: no contiguous
key-shaped literal lands in this source file (so it never trips gitleaks or
alarms a reader), yet the runtime values are still key-shaped enough to exercise
the scrub. None of these are real keys; they unlock nothing."""
from backend.apps.agents.core.error_classify import redact_for_telemetry
import asyncio
from backend.apps.agents.core.error_classify import (
capacity_retry_wait,
is_auth_error,
is_cli_binary_missing,
is_free_trial_exhausted,
is_transient_capacity_error,
is_unknown_model_error,
redact_for_telemetry,
)
from backend.apps.agents.core.first_real_exception import first_real_exception
# Verbatim field strings from prod analytics (2026-07): the exact shapes users hit.
P_FIELD_POOL_BUSY = (
"Error code: 429 - {'type': 'error', 'error': {'type': 'free_pool_busy', "
"'message': \"OpenSwarm's free pool is busy right now. Sign in for more, or try again shortly.\"}}"
)
P_FIELD_CLI_MISSING = (
"Claude Code not found at: C:\\Users\\Rishi\\AppData\\Local\\openswarm\\app-1.5.6\\resources"
"\\python-env\\Lib\\site-packages\\claude_agent_sdk\\_bundled\\claude.exe"
)
def test_redacts_provider_key_shapes_keeps_context():
@@ -43,3 +64,44 @@ def test_keeps_tail_and_bounds_length():
def test_empty_is_safe():
assert redact_for_telemetry("") == ""
def test_field_pool_busy_is_transient_and_retried():
e = Exception(P_FIELD_POOL_BUSY)
assert is_transient_capacity_error(e)
assert capacity_retry_wait(e, 0) == 5
# Must not be claimed by the branches that would surface a card instead of retrying.
assert not is_free_trial_exhausted(e)
assert not is_auth_error(e)
def test_cli_missing_matches_field_string_and_nothing_else_claims_it():
e = Exception(P_FIELD_CLI_MISSING)
assert is_cli_binary_missing(e)
assert not is_transient_capacity_error(e)
assert not is_auth_error(e)
assert not is_unknown_model_error(e)
def test_cli_missing_matches_sdk_exception_type():
class CLINotFoundError(Exception):
pass
assert is_cli_binary_missing(CLINotFoundError("whatever text"))
def test_first_real_exception_unwraps_nested_groups():
boom = ValueError("boom")
group = BaseExceptionGroup(
"outer", [asyncio.CancelledError(), ExceptionGroup("inner", [boom])]
)
assert first_real_exception(group) is boom
def test_first_real_exception_all_cancelled_is_none():
group = BaseExceptionGroup("outer", [asyncio.CancelledError()])
assert first_real_exception(group) is None
def test_first_real_exception_plain_passthrough():
boom = RuntimeError("x")
assert first_real_exception(boom) is boom