{"repo":"NousResearch/hermes-agent","public_id":"22548","display_label":"Timeout misclassification and fallback loop fixes","summary":"These items address a bug where custom provider timeouts were misreported as empty responses and incorrectly triggered fallback loops, including fixes to error classification and fallback chain logic to prevent self-selection.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22857,"merged_at":"2026-05-10T00:54:07+00:00"},"resolution":{"state":"resolved","by":"pr-22780"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T00:54:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":22548,"pr_number":22780,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-09T19:48:20+00:00"},{"issue_number":22548,"pr_number":22857,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-10T00:54:07+00:00"}],"members":[{"kind":"issue","number":22548,"title":"[Bug]: Local claude-cli custom provider timeout is reported as Empty response and fallback loops","url":null,"state":"closed"},{"kind":"pr","number":22664,"title":"fix(error_classifier,fallback): classify custom-provider timeouts correctly, prevent self-selection fallback loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22857","number":22857},{"kind":"pr","value":"#22780","number":22780},{"kind":"pr","value":"#22643","number":22643},{"kind":"pr","value":"#22798","number":22798}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22780,"title":"fix(fallback): skip chain entries matching current provider/model/base_url","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_provider_fallback.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -8042,6 +8042,32 @@ def _try_activate_fallback(self, reason: \"FailoverReason | None\" = None) -> bool\n         if not fb_provider or not fb_model:\n             return self._try_activate_fallback()  # skip invalid, try next\n \n+        # Skip entries that resolve to the current (provider, model) — falling\n+        # back to the same backend that just failed loops the failure. Compare\n+        # base_url too so two distinct custom_providers entries pointing at the\n+        # same shim/proxy URL also dedup. See issue #22548.\n+        current_provider = (getattr(self, \"provider\", \"\") or \"\").strip().lower()\n+        current_model = (getattr(self, \"model\", \"\") or \"\").strip()\n+        current_base_url = str(getattr(self, \"base_url\", \"\") or \"\").rstrip(\"/\").lower()\n+        fb_base_url_for_dedup = (fb.get(\"base_url\") or \"\").strip().rstrip(\"/\").lower()\n+        if fb_provider == current_provider and fb_model == current_model:\n+            logging.warning(\n+                \"Fallback skip: chain entry %s/%s matches current provider/model\",\n+                fb_provider, fb_model,\n+            )\n+            return self._try_activate_fallback()\n+        if (\n+            fb_base_url_for_dedup\n+            and current_base_url\n+            and fb_base_url_for_dedup == current_base_url\n+            and fb_model == current_model\n+        ):\n+            logging.warning(\n+                \"Fallback skip: chain entry base_url %s matches current backend\",\n+                fb_base_url_for_dedup,\n+            )\n+            return self._try_activate_fallback()\n+\n         # Use centralized router for client construction.\n         # raw_codex=True because the main agent needs direct responses.stream()\n         # access for Codex providers."]},{"file":"tests/run_agent/test_provider_fallback.py","hunks":["@@ -220,3 +220,88 @@ def test_multiple_credentials_all_in_cooldown_returns_false(self):\n \n     def test_many_credentials_available_returns_true(self):\n         assert _pool_may_recover_from_rate_limit(_pool(10)) is True\n+\n+\n+# ── Skip-self dedup (#22548) ───────────────────────────────────────────────\n+\n+\n+class TestFallbackChainDedup:\n+    \"\"\"A fallback chain entry that resolves to the current provider/model\n+    (or the same custom-provider base_url) must be skipped, not retried.\n+    Otherwise a misconfigured chain or two custom_providers entries pointing\n+    at the same shim loop the same failure. See issue #22548.\"\"\"\n+\n+    def test_skips_entry_matching_current_provider_and_model(self):\n+        \"\"\"Chain has [same-as-current, real-fallback]; activate must skip\n+        the first and use the second.\"\"\"\n+        fbs = [\n+            # First entry == current state. Should be skipped.\n+            {\"provider\": \"openrouter\", \"model\": \"z-ai/glm-4.7\"},\n+            # Second entry: real fallback.\n+            {\"provider\": \"zai\", \"model\": \"glm-4.7\"},\n+        ]\n+        agent = _make_agent(fallback_model=fbs)\n+        agent.provider = \"openrouter\"\n+        agent.model = \"z-ai/glm-4.7\"\n+        agent.base_url = \"https://openrouter.ai/api/v1\"\n+\n+        # Stub out resolve_provider_client so we can assert which entry was\n+        # actually used — return a MagicMock client tagged with the provider.\n+        called = []\n+        def _resolve(provider, model=None, raw_codex=False, **kwargs):\n+            called.append((provider, model))\n+            return _mock_client(), model\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", side_effect=_resolve):\n+            with patch(\"hermes_cli.model_normalize.normalize_model_for_provider\", side_effect=lambda m, p: m):\n+                ok = agent._try_activate_fallback()\n+\n+        assert ok is True\n+        # The first entry was skipped — only the second reached resolve.\n+        assert called == [(\"zai\", \"glm-4.7\")], (\n+            f\"expected fallback to skip same-state entry, got call order: {called}\"\n+        )\n+\n+    def test_skips_entry_matching_current_base_url_and_model(self):\n+        \"\"\"Two custom_providers entries pointing at the same shim URL\n+        with the same model should dedup even if their provider names differ.\"\"\"\n+        fbs = [\n+            # Different provider name but same shim URL + model — same backend.\n+            {\"provider\": \"claude-cli-alt\", \"model\": \"claude-opus-4.7\",\n+             \"base_url\": \"http://127.0.0.1:7891/v1\"},\n+            # Real different fallback.\n+            {\"provider\": \"openrouter\", \"model\": \"anthropic/claude-opus-4.7\"},\n+        ]\n+        agent = _make_agent(fallback_model=fbs)\n+        agent.provider = \"claude-cli\"\n+        agent.model = \"claude-opus-4.7\"\n+        agent.base_url = \"http://127.0.0.1:7891/v1\"\n+\n+        called = []\n+        def _resolve(provider, model=None, raw_codex=False, **kwargs):\n+            called.append((provider, model))\n+            return _mock_client(), model\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", side_effect=_resolve):\n+            with patch(\"hermes_cli.model_normalize.normalize_model_for_provider\", side_effect=lambda m, p: m):\n+                ok = agent._try_activate_fallback()\n+\n+        assert ok is True\n+        # Same shim/base_url+model entry skipped, second one used.\n+        assert called == [(\"openrouter\", \"anthropic/claude-opus-4.7\")], (\n+            f\"expected base_url-aware dedup, got call order: {called}\"\n+        )\n+\n+    def test_returns_false_when_only_self_matching_entries(self):\n+        \"\"\"A chain with only self-matching entries exhausts to False.\"\"\"\n+        fbs = [\n+            {\"provider\": \"openrouter\", \"model\": \"z-ai/glm-4.7\"},\n+        ]\n+        agent = _make_agent(fallback_model=fbs)\n+        agent.provider = \"openrouter\"\n+        agent.model = \"z-ai/glm-4.7\"\n+        agent.base_url = \"https://openrouter.ai/api/v1\"\n+\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\") as mock_resolve:\n+            ok = agent._try_activate_fallback()\n+\n+        assert ok is False\n+        mock_resolve.assert_not_called()\n"]}]},{"kind":"pr","number":22857,"title":"fix(error_classifier): classify generic-typed timeout messages as transient (carve-out of #22664)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/error_classifier.py","tests/agent/test_error_classifier.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}