{"repo":"NousResearch/hermes-agent","public_id":"22244","display_label":"JSON decode errors in context compression handling","summary":"These items concern a bug where context compression fails due to JSON decode errors. The group includes the bug report and two related fix attempts addressing error handling in the compression code path.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22416,"merged_at":"2026-05-09T08:47:15+00:00"},"resolution":{"state":"resolved","by":"pr-22416"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T08:47:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":22244,"pr_number":22416,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-09T08:47:15+00:00"}],"members":[{"kind":"issue","number":22244,"title":"[Bug]: Context Compression Failure","url":null,"state":"closed"},{"kind":"pr","number":22248,"title":"fix(context): handle JSON decode errors in context compression","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22416","number":22416},{"kind":"pr","value":"#22244","number":22244}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22416,"title":"fix(context): handle JSON decode errors in compression — salvage of #22248","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -763,6 +763,33 @@ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:\n \n         return \"\\n\\n\".join(parts)\n \n+    def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:\n+        \"\"\"Switch from a separate ``summary_model`` back to the main model.\n+\n+        Centralises the bookkeeping shared by every fallback branch in\n+        :meth:`_generate_summary` (model-not-found, timeout, JSON decode,\n+        unknown error): record the aux-model failure for ``/usage``-style\n+        callers, clear the summary model so the next call uses the main one,\n+        and clear the cooldown so the immediate retry can run.\n+\n+        ``reason`` is a short human-readable phrase (\"unavailable\",\n+        \"timed out\", \"returned invalid JSON\", \"failed\") that is interpolated\n+        into the warning log.\n+        \"\"\"\n+        self._summary_model_fallen_back = True\n+        logging.warning(\n+            \"Summary model '%s' %s (%s). \"\n+            \"Falling back to main model '%s' for compression.\",\n+            self.summary_model, reason, e, self.model,\n+        )\n+        _err_text = str(e).strip() or e.__class__.__name__\n+        if len(_err_text) > 220:\n+            _err_text = _err_text[:217].rstrip() + \"...\"\n+        self._last_aux_model_failure_error = _err_text\n+        self._last_aux_model_failure_model = self.summary_model\n+        self.summary_model = \"\"  # empty = use main model\n+        self._summary_failure_cooldown_until = 0.0  # no cooldown — retry immediately\n+\n     def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:\n         \"\"\"Generate a structured summary of conversation turns.\n ","@@ -961,28 +988,42 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                 _status in (408, 429, 502, 504)\n                 or \"timeout\" in _err_str\n             )\n+            # Non-JSON / malformed-body responses from misconfigured providers\n+            # or proxies (e.g. an HTML 502 page returned with\n+            # ``Content-Type: application/json``) bubble up as\n+            # ``json.JSONDecodeError`` from the OpenAI SDK's ``response.json()``,\n+            # or as a wrapping ``APIResponseValidationError`` whose message\n+            # carries the substring \"expecting value\".  Treat these like a\n+            # transient provider failure: one retry on the main model, then a\n+            # short cooldown.  Issue #22244.\n+            _is_json_decode = (\n+                isinstance(e, json.JSONDecodeError)\n+                or \"expecting value\" in _err_str\n+            )\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 \"\n+                    \"non-JSON response. provider=%s summary_model=%s \"\n+                    \"main_model=%s base_url=%s err=%s\",\n+                    self.provider or \"auto\",\n+                    self.summary_model or \"(main)\",\n+                    self.model,\n+                    self.base_url or \"default\",\n+                    e,\n+                )\n             if (\n-                (_is_model_not_found or _is_timeout)\n+                (_is_model_not_found or _is_timeout or _is_json_decode)\n                 and self.summary_model\n                 and self.summary_model != self.model\n                 and not getattr(self, \"_summary_model_fallen_back\", False)\n             ):\n-                self._summary_model_fallen_back = True\n-                logging.warning(\n-                    \"Summary model '%s' unavailable (%s). \"\n-                    \"Falling back to main model '%s' for compression.\",\n-                    self.summary_model, e, self.model,\n-                )\n-                # Record the aux-model failure so callers can warn the user\n-                # even if the retry-on-main succeeds — a misconfigured aux\n-                # model is something the user needs to fix.\n-                _err_text = str(e).strip() or e.__class__.__name__\n-                if len(_err_text) > 220:\n-                    _err_text = _err_text[:217].rstrip() + \"...\"\n-                self._last_aux_model_failure_error = _err_text\n-                self._last_aux_model_failure_model = self.summary_model\n-                self.summary_model = \"\"  # empty = use main model\n-                self._summary_failure_cooldown_until = 0.0  # no cooldown\n+                if _is_json_decode:\n+                    _reason = \"returned invalid JSON\"\n+                elif _is_model_not_found:\n+                    _reason = \"unavailable\"\n+                else:\n+                    _reason = \"timed out\"\n+                self._fallback_to_main_for_compression(e, _reason)\n                 return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)  # retry immediately\n \n             # Unknown-error best-effort retry on main model.  Losing N turns of","@@ -999,26 +1040,13 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi\n                 and self.summary_model != self.model\n                 and not getattr(self, \"_summary_model_fallen_back\", False)\n             ):\n-                self._summary_model_fallen_back = True\n-                logging.warning(\n-                    \"Summary model '%s' failed (%s). \"\n-                    \"Retrying on main model '%s' before giving up.\",\n-                    self.summary_model, e, self.model,\n-                )\n-                # Record the aux-model failure (see 404 branch above) — user\n-                # should know their configured model is broken even if main\n-                # recovers the call.\n-                _err_text = str(e).strip() or e.__class__.__name__\n-                if len(_err_text) > 220:\n-                    _err_text = _err_text[:217].rstrip() + \"...\"\n-                self._last_aux_model_failure_error = _err_text\n-                self._last_aux_model_failure_model = self.summary_model\n-                self.summary_model = \"\"  # empty = use main model\n-                self._summary_failure_cooldown_until = 0.0\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) — shorter cooldown\n-            _transient_cooldown = 60\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             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":["@@ -400,6 +400,104 @@ def test_fallback_only_happens_once_per_compressor(self):\n         assert result is None\n         assert c._summary_model_fallen_back is True\n \n+    def test_json_decode_error_falls_back_to_main_and_succeeds(self):\n+        \"\"\"JSONDecodeError from the OpenAI SDK's ``response.json()`` (raised\n+        when a misconfigured proxy returns HTML/plain-text with\n+        ``Content-Type: application/json``) should trigger the same\n+        retry-on-main path as 404/timeout.  Issue #22244.\"\"\"\n+        import json as _json\n+\n+        mock_ok = MagicMock()\n+        mock_ok.choices = [MagicMock()]\n+        mock_ok.choices[0].message.content = \"summary via main model\"\n+\n+        # Simulate the SDK raising a raw JSONDecodeError with a realistic\n+        # error message (\"Expecting value: line X column Y char Z\").\n+        err_json = _json.JSONDecodeError(\n+            \"Expecting value\", \"<!DOCTYPE html><html>...</html>\", 0\n+        )\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-via-broken-proxy\",\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=[err_json, mock_ok],\n+        ) as mock_call:\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-via-broken-proxy\"\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+        # Aux-model failure recorded so /usage / gateway warnings can surface it\n+        assert c._last_aux_model_failure_model == \"aux-via-broken-proxy\"\n+        assert c._last_aux_model_failure_error is not None\n+        # The 220-char cap is shared with other fallback branches\n+        assert len(c._last_aux_model_failure_error) <= 220\n+\n+    def test_json_decode_error_substring_match_in_wrapped_exception(self):\n+        \"\"\"When the OpenAI SDK wraps the raw JSONDecodeError inside its own\n+        ``APIResponseValidationError`` (or similar), ``isinstance`` no longer\n+        matches but the substring \"expecting value\" still appears in\n+        ``str(e)``.  We detect this case by string match and fall back the\n+        same way.\"\"\"\n+        mock_ok = MagicMock()\n+        mock_ok.choices = [MagicMock()]\n+        mock_ok.choices[0].message.content = \"summary via main model\"\n+\n+        # A plain Exception with the canonical JSON decode error text — what\n+        # the SDK's APIResponseValidationError looks like at str() time.\n+        err_wrapped = Exception(\"Expecting value: line 1 column 1 (char 0)\")\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_wrapped, mock_ok],\n+        ) as mock_call:\n+            result = c._generate_summary(self._msgs())\n+\n+        assert mock_call.call_count == 2\n+        assert result is not None\n+        assert \"summary via main model\" in result\n+\n+    def test_json_decode_error_on_main_uses_short_cooldown(self):\n+        \"\"\"When already on the main model (no separate summary_model, or\n+        fallback already happened), a JSONDecodeError should set the short\n+        30s cooldown, not the default 60s — provider bodies tend to\n+        recover quickly when an upstream proxy comes back online.\"\"\"\n+        import json as _json\n+\n+        err_json = _json.JSONDecodeError(\"Expecting value\", \"<html/>\", 0)\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 → already on main, no fallback path.\n+                quiet_mode=True,\n+            )\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=err_json,\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+        # Short JSON-decode cooldown is 30s, not the default 60s.\n+        assert c._summary_failure_cooldown_until == 1030.0\n+\n \n class TestAuxModelFallbackSurfacedToCallers:\n     \"\"\"When summary_model fails but retry-on-main succeeds, compress() must\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}