diff --git a/run_agent.py b/run_agent.py
index a8bbc515eaaa3..0aa009fae4b7c 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -2529,11 +2529,38 @@ class AIAgent:
"""Extract a human-readable one-liner from an API error.
Handles Cloudflare HTML error pages (502, 503, etc.) by pulling the
-
tag instead of dumping raw HTML. Falls back to a truncated
- str(error) for everything else.
+ tag instead of dumping raw HTML. Network/DNS failures are
+ translated into an offline hint, including when an SDK wraps the
+ original OS error. Falls back to a truncated str(error) otherwise.
"""
raw = str(error)
+ # Linux, macOS, and Windows use different low-level messages when DNS
+ # cannot resolve the provider while the device is offline. SDKs often
+ # wrap that OSError in a generic "Connection error", so inspect the
+ # exception chain before showing the top-level message to the user.
+ network_resolution_markers = (
+ "temporary failure in name resolution",
+ "name or service not known",
+ "nodename nor servname provided, or not known",
+ "getaddrinfo failed",
+ "no address associated with hostname",
+ "network is unreachable",
+ )
+ current: Optional[BaseException] = error
+ seen: set[int] = set()
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ if any(
+ marker in str(current).lower()
+ for marker in network_resolution_markers
+ ):
+ return (
+ "Hermes can't reach the model provider. You may be offline. "
+ "Check your internet connection and try again."
+ )
+ current = current.__cause__ or current.__context__
+
if (
isinstance(error, ValueError)
and "expected ident at line" in raw.lower()
diff --git a/tests/run_agent/test_summarize_api_error.py b/tests/run_agent/test_summarize_api_error.py
index 82d9bcff1f7bd..d82c539a3b211 100644
--- a/tests/run_agent/test_summarize_api_error.py
+++ b/tests/run_agent/test_summarize_api_error.py
@@ -14,6 +14,7 @@ from types import SimpleNamespace
from typing import Any
import httpx
+import pytest
from run_agent import AIAgent
@@ -37,9 +38,42 @@ def test_empty_body_falls_back_to_response_json_error_message():
assert "HTTP 400" in summary
assert "model `foo` does not exist" in summary
+@pytest.mark.parametrize(
+ "technical_message",
+ [
+ "Temporary failure in name resolution",
+ "Name or service not known",
+ "nodename nor servname provided, or not known",
+ "getaddrinfo failed",
+ "No address associated with hostname",
+ "Network is unreachable",
+ ],
+)
+def test_network_resolution_failure_explains_that_the_user_may_be_offline(
+ technical_message,
+):
+ error = OSError(-3, technical_message)
+
+ summary = AIAgent._summarize_api_error(error)
+
+ assert summary == (
+ "Hermes can't reach the model provider. You may be offline. "
+ "Check your internet connection and try again."
+ )
+ assert "name resolution" not in summary.lower()
+def test_wrapped_dns_resolution_failure_gets_the_same_friendly_message():
+ try:
+ try:
+ raise OSError(-3, "Temporary failure in name resolution")
+ except OSError as cause:
+ raise RuntimeError("Connection error.") from cause
+ except RuntimeError as error:
+ summary = AIAgent._summarize_api_error(error)
+ assert "You may be offline" in summary
+ assert "Connection error" not in summary
def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message():
@@ -62,4 +96,3 @@ def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_me
summary = AIAgent._summarize_api_error(err)
assert "HTTP 429" in summary
assert "Gemini HTTP 429: quota exceeded" in summary
-