mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] agents: classify bundled-CLI-missing errors + unwrap exception groups to the real cause
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user