{"repo":"NousResearch/hermes-agent","public_id":"16670","display_label":"Compression retry logic for transient network/streaming errors","summary":"These issues and PRs address how the context compressor handles transient transport and streaming errors, specifically improving retry behavior before falling back to a marker that discards context. Multiple entries focus on premature stream closes and incomplete chunked reads.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22846},"resolution":{"state":"partially_resolved","by":"pr-22846"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T00:52:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":18458,"pr_number":22846,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":16670,"title":"Compression fallback marker after incomplete chunked read loses useful context in long sessions","url":null,"state":"closed"},{"kind":"issue","number":18458,"title":"Improve context compression retry/fallback for incomplete chunked reads","url":null,"state":"closed"},{"kind":"pr","number":16737,"title":"fix(compression): retry transient transport errors before fallback marker (#16670)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16670","number":16670},{"kind":"pr","value":"#18458","number":18458}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22653,"title":"fix(context_compressor): treat httpcore streaming premature-close as transient error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22846","number":22846}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22846,"title":"fix(context_compressor): treat httpcore streaming premature-close as transient (salvage #22653)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1840,10 +1840,12 @@ def _is_connection_error(exc: Exception) -> bool:\n     distinct from API errors (4xx/5xx) which indicate the provider IS\n     reachable but returned an error.\n     \"\"\"\n-    from openai import APIConnectionError, APITimeoutError\n-\n-    if isinstance(exc, (APIConnectionError, APITimeoutError)):\n-        return True\n+    try:\n+        from openai import APIConnectionError, APITimeoutError\n+        if isinstance(exc, (APIConnectionError, APITimeoutError)):\n+            return True\n+    except ImportError:\n+        pass\n     # urllib3 / httpx / httpcore connection errors\n     err_type = type(exc).__name__\n     if any(kw in err_type for kw in (\"Connection\", \"Timeout\", \"DNS\", \"SSL\")):","@@ -1853,6 +1855,16 @@ def _is_connection_error(exc: Exception) -> bool:\n         \"connection refused\", \"name or service not known\",\n         \"no route to host\", \"network is unreachable\",\n         \"timed out\", \"connection reset\",\n+        # httpcore / httpx streaming premature-close errors.  These surface\n+        # when a proxy or provider drops the connection mid-stream and are\n+        # transient by nature — the request should be retried or rerouted.\n+        # See issue #18458.\n+        \"incomplete chunked read\",\n+        \"peer closed connection\",\n+        \"response ended prematurely\",\n+        \"unexpected eof\",\n+        \"remoteprotocolerror\",\n+        \"localprotocolerror\",\n     )):\n         return True\n     return False"]},{"file":"agent/context_compressor.py","hunks":["@@ -23,7 +23,7 @@\n import time\n from typing import Any, Dict, List, Optional\n \n-from agent.auxiliary_client import call_llm\n+from agent.auxiliary_client import call_llm, _is_connection_error\n from agent.context_engine import ContextEngine\n from agent.model_metadata import (\n     MINIMUM_CONTEXT_LENGTH,","@@ -1000,6 +1000,14 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                 isinstance(e, json.JSONDecodeError)\n                 or \"expecting value\" in _err_str\n             )\n+            # httpcore / httpx streaming premature-close errors surface as\n+            # ConnectionError subclasses or plain Exception with characteristic\n+            # substrings (\"incomplete chunked read\", \"peer closed connection\",\n+            # \"response ended prematurely\", \"unexpected eof\").  These are\n+            # transient network events; treat them like a timeout so we fall\n+            # back to the main model instead of entering a 60-second cooldown.\n+            # See issue #18458.\n+            _is_streaming_closed = _is_connection_error(e)\n             if _is_json_decode and not _is_model_not_found and not _is_timeout:\n                 logger.error(\n                     \"Context compression failed: auxiliary LLM returned a \"","@@ -1012,7 +1020,7 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                     e,\n                 )\n             if (\n-                (_is_model_not_found or _is_timeout or _is_json_decode)\n+                (_is_model_not_found or _is_timeout or _is_json_decode or _is_streaming_closed)\n                 and self.summary_model\n                 and self.summary_model != self.model\n                 and not getattr(self, \"_summary_model_fallen_back\", False)","@@ -1021,6 +1029,8 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                     _reason = \"returned invalid JSON\"\n                 elif _is_model_not_found:\n                     _reason = \"unavailable\"\n+                elif _is_streaming_closed:\n+                    _reason = \"closed stream prematurely\"\n                 else:\n                     _reason = \"timed out\"\n                 self._fallback_to_main_for_compression(e, _reason)","@@ -1043,10 +1053,10 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                 self._fallback_to_main_for_compression(e, \"failed\")\n                 return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)\n \n-            # Transient errors (timeout, rate limit, network, JSON decode) —\n-            # shorter cooldown for JSON decode since the body shape can flip\n-            # back to valid quickly when an upstream proxy recovers.\n-            _transient_cooldown = 30 if _is_json_decode else 60\n+            # Transient errors (timeout, rate limit, network, JSON decode,\n+            # streaming premature-close) — shorter cooldown for JSON decode and\n+            # streaming-closed since those conditions can self-resolve quickly.\n+            _transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60\n             self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown\n             err_text = str(e).strip() or e.__class__.__name__\n             if len(err_text) > 220:"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -499,6 +499,131 @@ def test_json_decode_error_on_main_uses_short_cooldown(self):\n         assert c._summary_failure_cooldown_until == 1030.0\n \n \n+class TestStreamingClosedFallback:\n+    \"\"\"httpcore / httpx streaming premature-close errors must be classified the\n+    same as timeouts so the compressor retries on the main model instead of\n+    entering a 60-second cooldown.  Issue #18458.\n+\n+    ``_is_connection_error`` is patched here because the test venv may not\n+    have ``openai`` installed (the real function does ``from openai import ...``\n+    inside its body).  We test the *wiring* — that `_generate_summary` calls\n+    ``_is_connection_error`` and acts on its result — not the classifier itself\n+    (that's covered in ``test_auxiliary_client.py::TestIsConnectionError``).\n+    \"\"\"\n+\n+    def _msgs(self):\n+        return [\n+            {\"role\": \"user\", \"content\": \"do something\"},\n+            {\"role\": \"assistant\", \"content\": \"ok\"},\n+        ]\n+\n+    def test_incomplete_chunked_read_falls_back_to_main(self):\n+        \"\"\"``httpcore.RemoteProtocolError: incomplete chunked read`` triggers\n+        the retry-on-main path when ``_is_connection_error`` returns True.\"\"\"\n+        mock_ok = MagicMock()\n+        mock_ok.choices = [MagicMock()]\n+        mock_ok.choices[0].message.content = \"summary via main model\"\n+\n+        err = Exception(\"RemoteProtocolError: incomplete chunked read\")\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"main-model\",\n+                summary_model_override=\"aux-stream-model\",\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=[err, mock_ok],\n+        ) as mock_call, patch(\n+            \"agent.context_compressor._is_connection_error\",\n+            return_value=True,\n+        ):\n+            result = c._generate_summary(self._msgs())\n+\n+        assert mock_call.call_count == 2\n+        assert mock_call.call_args_list[0].kwargs.get(\"model\") == \"aux-stream-model\"\n+        assert \"model\" not in mock_call.call_args_list[1].kwargs\n+        assert result is not None\n+        assert \"summary via main model\" in result\n+\n+    def test_peer_closed_connection_falls_back_to_main(self):\n+        \"\"\"``peer closed connection`` triggers the retry-on-main path.\"\"\"\n+        mock_ok = MagicMock()\n+        mock_ok.choices = [MagicMock()]\n+        mock_ok.choices[0].message.content = \"summary ok\"\n+\n+        err = Exception(\"peer closed connection without sending complete message body\")\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"main-model\",\n+                summary_model_override=\"aux-model\",\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=[err, mock_ok],\n+        ) as mock_call, patch(\n+            \"agent.context_compressor._is_connection_error\",\n+            return_value=True,\n+        ):\n+            result = c._generate_summary(self._msgs())\n+\n+        assert mock_call.call_count == 2\n+        assert result is not None\n+\n+    def test_streaming_closed_on_main_uses_short_cooldown(self):\n+        \"\"\"When already on the main model, a streaming-closed error should use\n+        the 30s cooldown, not the default 60s — these errors are transient.\"\"\"\n+        err = Exception(\"RemoteProtocolError: response ended prematurely\")\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"main-model\",\n+                # No summary_model_override → no fallback path.\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=err,\n+        ), patch(\n+            \"agent.context_compressor._is_connection_error\",\n+            return_value=True,\n+        ), patch(\"agent.context_compressor.time.monotonic\", return_value=1000.0):\n+            result = c._generate_summary(self._msgs())\n+\n+        assert result is None\n+        # Streaming-closed should use the 30s short cooldown.\n+        assert c._summary_failure_cooldown_until == 1030.0\n+\n+    def test_non_streaming_unknown_error_still_uses_long_cooldown(self):\n+        \"\"\"Unclassified errors should retain the 60s default cooldown to\n+        prevent hammering a broken provider.\"\"\"\n+        err = Exception(\"Internal Server Error: something unexpected happened\")\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"main-model\",\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=err,\n+        ), patch(\n+            \"agent.context_compressor._is_connection_error\",\n+            return_value=False,\n+        ), patch(\"agent.context_compressor.time.monotonic\", return_value=1000.0):\n+            result = c._generate_summary(self._msgs())\n+\n+        assert result is None\n+        assert c._summary_failure_cooldown_until == 1060.0\n+\n+\n class TestAuxModelFallbackSurfacedToCallers:\n     \"\"\"When summary_model fails but retry-on-main succeeds, compress() must\n     expose the aux-model failure via _last_aux_model_failure_{model,error}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}