fix(desktop): error card honors the classifier's retry verdict + failing-session identity (review feedback)

Addresses @helix4u's review on #91493:
- conversation_loop now stamps failure_retryable (the real ClassifiedError
  verdict) next to failure_reason; error_surface prefers it and only falls
  back to the reason set for older results. Fallback set corrected to match
  classify_api_error (auth, format_error, billing_unverified now
  non-retryable).
- The descriptor carries the failing session's provider/model captured at
  classification time; Copy error details prefers them over the foreground
  composer atoms.
- Open logs is labeled 'Open Desktop logs' on remote/cloud connections —
  the local folder holds transport logs, not the remote runtime's.
- API-exception module allowlist widened to botocore/boto3/google/grpc/
  requests/aiohttp so other adapter SDKs don't misclassify as gateway.
This commit is contained in:
Teknium
2026-08-21 14:50:29 -07:00
parent 50f1e414bc
commit 334bcbac93
14 changed files with 152 additions and 19 deletions
+45 -10
View File
@@ -62,13 +62,19 @@ _TRANSPORT_REASONS = {
}
# Reasons that are deterministic for the request — a bare "Retry" repeats the
# same failure, so clients shouldn't lead with it.
# same failure, so clients shouldn't lead with it. Fallback only: results
# from current backends carry the classifier's own verdict in
# ``failure_retryable`` and never consult this set. Kept in sync with
# ``classify_api_error``'s retryable=False verdicts.
_NON_RETRYABLE_REASONS = {
"auth",
"auth_permanent",
"billing",
"billing_unverified",
"content_policy_blocked",
"provider_policy_blocked",
"model_not_found",
"format_error",
"ssl_cert_verification",
}
@@ -99,11 +105,20 @@ _STREAM_DROP_FRAGMENTS = (
# Exception modules that indicate the failure came from an API/transport call
# (vs. a bug in our own dispatcher code, which is a gateway-layer failure).
# Covers every SDK family our provider adapters raise from: OpenAI-compatible
# (openai/httpx/httpcore), Anthropic, Bedrock (botocore/boto3), Google
# (google.*/grpc), plus raw transports (requests/aiohttp/ssl/socket/urllib).
_API_EXC_MODULE_PREFIXES = (
"openai",
"httpx",
"httpcore",
"anthropic",
"botocore",
"boto3",
"google",
"grpc",
"requests",
"aiohttp",
"ssl",
"socket",
"urllib",
@@ -120,8 +135,22 @@ def _looks_like_stream_drop(message: str) -> bool:
return any(fragment in msg for fragment in _STREAM_DROP_FRAGMENTS)
def _surface(layer: str, code: str, retryable: bool) -> dict:
return {"layer": layer, "code": code, "retryable": bool(retryable)}
def _surface(
layer: str,
code: str,
retryable: bool,
provider: str = "",
model: str = "",
) -> dict:
out = {"layer": layer, "code": code, "retryable": bool(retryable)}
# The failing session's identity, captured at classification time so
# clients report the model/provider that actually failed — not whatever
# the foreground composer points at when a button is clicked later.
if provider:
out["provider"] = provider
if model:
out["model"] = model
return out
def build_error_surface_from_result(
@@ -147,18 +176,18 @@ def build_error_surface_from_result(
from hermes_state import is_disk_full_error
if error_text and is_disk_full_error(error_text):
return _surface(LAYER_DISK, "disk_full", False)
return _surface(LAYER_DISK, "disk_full", False, provider, model)
except Exception: # pragma: no cover - defensive import guard
pass
if result.get("billing_block") or reason in ("billing", "billing_unverified"):
return _surface(LAYER_BILLING, reason or "billing", False)
return _surface(LAYER_BILLING, reason or "billing", False, provider, model)
if not reason:
# Failed result without a classified reason (legacy paths).
if _looks_like_stream_drop(error_text):
return _surface(LAYER_STREAMING, "stream_drop", True)
return _surface(LAYER_PROVIDER, "unknown", True)
return _surface(LAYER_STREAMING, "stream_drop", True, provider, model)
return _surface(LAYER_PROVIDER, "unknown", True, provider, model)
layer = _REASON_TO_LAYER.get(reason)
if layer is None:
@@ -168,7 +197,13 @@ def build_error_surface_from_result(
layer = LAYER_STREAMING
else:
layer = LAYER_PROVIDER
return _surface(layer, reason, reason not in _NON_RETRYABLE_REASONS)
# Prefer the classifier's own retry verdict when the result carries it
# (conversation_loop stamps ``failure_retryable`` next to
# ``failure_reason``); the reason-set fallback covers older results.
retryable = result.get("failure_retryable")
if not isinstance(retryable, bool):
retryable = reason not in _NON_RETRYABLE_REASONS
return _surface(layer, reason, retryable, provider, model)
except Exception: # pragma: no cover — never break the error path
logger.debug("error_surface: result classification failed", exc_info=True)
return None
@@ -191,7 +226,7 @@ def build_error_surface_from_exception(
from hermes_state import is_disk_full_error
if is_disk_full_error(exc):
return _surface(LAYER_DISK, "disk_full", False)
return _surface(LAYER_DISK, "disk_full", False, provider, model)
except Exception: # pragma: no cover - defensive import guard
pass
@@ -201,7 +236,7 @@ def build_error_surface_from_exception(
)
if not api_like or not isinstance(exc, Exception):
return _surface(LAYER_GATEWAY, type(exc).__name__, True)
return _surface(LAYER_GATEWAY, type(exc).__name__, True, provider, model)
from agent.error_classifier import classify_api_error