{"repo":"NousResearch/hermes-agent","public_id":"14367","display_label":"Agent SSL errors retry classification fixes","summary":"Multiple issues and fixes addressing how SSL/TLS certificate errors are classified in the agent's retry logic, specifically preventing ssl.SSLError and related exceptions from being incorrectly treated as non-retryable local validation errors.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15134},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-24T12:35:44+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":7230,"pr_number":15134,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14367,"pr_number":15134,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":14367,"title":"ssl.SSLCertVerificationError misclassified as local validation error","url":null,"state":"closed"},{"kind":"pr","number":14374,"title":"fix(agent): retry TLS certificate transport errors","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4e27e498f1b438b2a380cd4be83dc37761fd1412","number":0},{"kind":"pr","value":"#14367","number":14367}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14434,"title":"fix(agent): exclude ssl.SSLError from local validation error check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14374","number":14374}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14445,"title":"fix(agent): exclude ssl.SSLError from is_local_validation_error to prevent non-retryable abort","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4e27e498f","number":0},{"kind":"commit","value":"4e27e498f","number":0},{"kind":"pr","value":"#15134","number":15134},{"kind":"pr","value":"#14367","number":14367}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15134,"title":"fix(agent): consolidated fallback/retry correctness — SSL retry, cooldown, config context, auth fallback parity","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/error_classifier.py","cron/scheduler.py","gateway/run.py","run_agent.py","scripts/release.py","tests/agent/test_error_classifier.py","tests/gateway/test_auth_fallback.py","tests/run_agent/test_primary_runtime_restore.py","tests/run_agent/test_switch_model_fallback_prune.py"],"fix_hunks":[{"file":"agent/error_classifier.py","hunks":["@@ -343,6 +343,11 @@ def classify_api_error(\n     \"\"\"\n     status_code = _extract_status_code(error)\n     error_type = type(error).__name__\n+    # Copilot/GitHub Models RateLimitError may not set .status_code; force 429\n+    # so downstream rate-limit handling (classifier reason, pool rotation,\n+    # fallback gating) fires correctly instead of misclassifying as generic.\n+    if status_code is None and error_type == \"RateLimitError\":\n+        status_code = 429\n     body = _extract_error_body(error)\n     error_code = _extract_error_code(body)\n "]},{"file":"cron/scheduler.py","hunks":["@@ -895,6 +895,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n             resolve_runtime_provider,\n             format_runtime_provider_error,\n         )\n+        from hermes_cli.auth import AuthError\n         try:\n             runtime_kwargs = {\n                 \"requested\": job.get(\"provider\") or os.getenv(\"HERMES_INFERENCE_PROVIDER\"),","@@ -902,6 +903,28 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n             if job.get(\"base_url\"):\n                 runtime_kwargs[\"explicit_base_url\"] = job.get(\"base_url\")\n             runtime = resolve_runtime_provider(**runtime_kwargs)\n+        except AuthError as auth_exc:\n+            # Primary provider auth failed — try fallback chain before giving up.\n+            logger.warning(\"Job '%s': primary auth failed (%s), trying fallback\", job_id, auth_exc)\n+            fb = _cfg.get(\"fallback_providers\") or _cfg.get(\"fallback_model\")\n+            fb_list = (fb if isinstance(fb, list) else [fb]) if fb else []\n+            runtime = None\n+            for entry in fb_list:\n+                if not isinstance(entry, dict):\n+                    continue\n+                try:\n+                    fb_kwargs = {\"requested\": entry.get(\"provider\")}\n+                    if entry.get(\"base_url\"):\n+                        fb_kwargs[\"explicit_base_url\"] = entry[\"base_url\"]\n+                    if entry.get(\"api_key\"):\n+                        fb_kwargs[\"explicit_api_key\"] = entry[\"api_key\"]\n+                    runtime = resolve_runtime_provider(**fb_kwargs)\n+                    logger.info(\"Job '%s': fallback resolved to %s\", job_id, runtime.get(\"provider\"))\n+                    break\n+                except Exception as fb_exc:\n+                    logger.debug(\"Job '%s': fallback %s failed: %s\", job_id, entry.get(\"provider\"), fb_exc)\n+            if runtime is None:\n+                raise RuntimeError(format_runtime_provider_error(auth_exc)) from auth_exc\n         except Exception as exc:\n             message = format_runtime_provider_error(exc)\n             raise RuntimeError(message) from exc"]},{"file":"gateway/run.py","hunks":["@@ -350,16 +350,30 @@ def _expand_whatsapp_auth_aliases(identifier: str) -> set:\n \n \n def _resolve_runtime_agent_kwargs() -> dict:\n-    \"\"\"Resolve provider credentials for gateway-created AIAgent instances.\"\"\"\n+    \"\"\"Resolve provider credentials for gateway-created AIAgent instances.\n+\n+    If the primary provider fails with an authentication error, attempt to\n+    resolve credentials using the fallback provider chain from config.yaml\n+    before giving up.\n+    \"\"\"\n     from hermes_cli.runtime_provider import (\n         resolve_runtime_provider,\n         format_runtime_provider_error,\n     )\n+    from hermes_cli.auth import AuthError\n \n     try:\n         runtime = resolve_runtime_provider(\n             requested=os.getenv(\"HERMES_INFERENCE_PROVIDER\"),\n         )\n+    except AuthError as auth_exc:\n+        # Primary provider auth failed (expired token, revoked key, etc.).\n+        # Try the fallback provider chain before raising.\n+        logger.warning(\"Primary provider auth failed: %s — trying fallback\", auth_exc)\n+        fb_config = _try_resolve_fallback_provider()\n+        if fb_config is not None:\n+            return fb_config\n+        raise RuntimeError(format_runtime_provider_error(auth_exc)) from auth_exc\n     except Exception as exc:\n         raise RuntimeError(format_runtime_provider_error(exc)) from exc\n ","@@ -374,6 +388,48 @@ def _resolve_runtime_agent_kwargs() -> dict:\n     }\n \n \n+def _try_resolve_fallback_provider() -> dict | None:\n+    \"\"\"Attempt to resolve credentials from the fallback_model/fallback_providers config.\"\"\"\n+    from hermes_cli.runtime_provider import resolve_runtime_provider\n+    try:\n+        import yaml as _y\n+        cfg_path = _hermes_home / \"config.yaml\"\n+        if not cfg_path.exists():\n+            return None\n+        with open(cfg_path, encoding=\"utf-8\") as _f:\n+            cfg = _y.safe_load(_f) or {}\n+        fb = cfg.get(\"fallback_providers\") or cfg.get(\"fallback_model\")\n+        if not fb:\n+            return None\n+        # Normalize to list\n+        fb_list = fb if isinstance(fb, list) else [fb]\n+        for entry in fb_list:\n+            if not isinstance(entry, dict):\n+                continue\n+            try:\n+                runtime = resolve_runtime_provider(\n+                    requested=entry.get(\"provider\"),\n+                    explicit_base_url=entry.get(\"base_url\"),\n+                    explicit_api_key=[redacted:secret-assignment]\"api_key\"),\n+                )\n+                logger.info(\"Fallback provider resolved: %s\", runtime.get(\"provider\"))\n+                return {\n+                    \"api_key\": runtime.get(\"api_key\"),\n+                    \"base_url\": runtime.get(\"base_url\"),\n+                    \"provider\": runtime.get(\"provider\"),\n+                    \"api_mode\": runtime.get(\"api_mode\"),\n+                    \"command\": runtime.get(\"command\"),\n+                    \"args\": list(runtime.get(\"args\") or []),\n+                    \"credential_pool\": runtime.get(\"credential_pool\"),\n+                }\n+            except Exception as fb_exc:\n+                logger.debug(\"Fallback entry %s failed: %s\", entry.get(\"provider\"), fb_exc)\n+                continue\n+    except Exception:\n+        pass\n+    return None\n+\n+\n def _build_media_placeholder(event) -> str:\n     \"\"\"Build a text placeholder for media-only events so they aren't dropped.\n "]},{"file":"run_agent.py","hunks":["@@ -31,6 +31,7 @@\n import os\n import random\n import re\n+import ssl\n import sys\n import tempfile\n import time","@@ -2118,12 +2119,14 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n         # (\"switched to anthropic, tui keeps trying openrouter\").\n         old_norm = (old_provider or \"\").strip().lower()\n         new_norm = (new_provider or \"\").strip().lower()\n+        fallback_chain = list(getattr(self, \"_fallback_chain\", []) or [])\n         if old_norm and new_norm and old_norm != new_norm:\n-            self._fallback_chain = [\n-                entry for entry in self._fallback_chain\n+            fallback_chain = [\n+                entry for entry in fallback_chain\n                 if (entry.get(\"provider\") or \"\").strip().lower() not in {old_norm, new_norm}\n             ]\n-            self._fallback_model = self._fallback_chain[0] if self._fallback_chain else None\n+        self._fallback_chain = fallback_chain\n+        self._fallback_model = fallback_chain[0] if fallback_chain else None\n \n         logging.info(\n             \"Model switched in-place: %s (%s) -> %s (%s)\",","@@ -6491,7 +6494,7 @@ def _call():\n \n     # ── Provider fallback ──────────────────────────────────────────────────\n \n-    def _try_activate_fallback(self) -> bool:\n+    def _try_activate_fallback(self, reason: \"FailoverReason | None\" = None) -> bool:\n         \"\"\"Switch to the next fallback model/provider in the chain.\n \n         Called when the current model is failing after retries.  Swaps the","@@ -6503,6 +6506,15 @@ def _try_activate_fallback(self) -> bool:\n         auth resolution and client construction — no duplicated provider→key\n         mappings.\n         \"\"\"\n+        if reason in (FailoverReason.rate_limit, FailoverReason.billing):\n+            # Only start cooldown when leaving the primary provider.  If we're\n+            # already on a fallback and chain-switching, the primary wasn't the\n+            # source of the 429 so the cooldown should not be reset/extended.\n+            fallback_already_active = bool(getattr(self, \"_fallback_activated\", False))\n+            current_provider = (getattr(self, \"provider\", \"\") or \"\").strip().lower()\n+            primary_provider = ((self._primary_runtime or {}).get(\"provider\") or \"\").strip().lower()\n+            if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):\n+                self._rate_limited_until = time.monotonic() + 60\n         if self._fallback_index >= len(self._fallback_chain):\n             return False\n ","@@ -6639,11 +6651,15 @@ def _try_activate_fallback(self) -> bool:\n             # Without this, compression decisions use the primary model's\n             # context window (e.g. 200K) instead of the fallback's (e.g. 32K),\n             # causing oversized sessions to overflow the fallback.\n+            # Also pass _config_context_length so the explicit config override\n+            # (model.context_length in config.yaml) is respected — without this,\n+            # the fallback activation drops to 128K even when config says 204800.\n             if hasattr(self, 'context_compressor') and self.context_compressor:\n                 from agent.model_metadata import get_model_context_length\n                 fb_context_length = get_model_context_length(\n                     self.model, base_url=self.base_url,\n                     api_key=[redacted:secret-assignment] provider=self.provider,\n+                    config_context_length=getattr(self, \"_config_context_length\", None),\n                 )\n                 self.context_compressor.update_model(\n                     model=self.model,","@@ -6682,6 +6698,9 @@ def _restore_primary_runtime(self) -> bool:\n         if not self._fallback_activated:\n             return False\n \n+        if getattr(self, \"_rate_limited_until\", 0) > time.monotonic():\n+            return False  # primary still in rate-limit cooldown, stay on fallback\n+\n         rt = self._primary_runtime\n         try:\n             # ── Core runtime state ──","@@ -10658,7 +10677,7 @@ def _stop_spinner():\n                         )\n                         if not pool_may_recover:\n                             self._emit_status(\"⚠️ Rate limited — switching to fallback provider...\")\n-                            if self._try_activate_fallback():\n+                            if self._try_activate_fallback(reason=classified.reason):\n                                 retry_count = 0\n                                 compression_attempts = 0\n                                 primary_recovery_attempted = False","@@ -10923,6 +10942,14 @@ def _stop_spinner():\n                         and not isinstance(\n                             api_error, (UnicodeEncodeError, json.JSONDecodeError)\n                         )\n+                        # ssl.SSLError (and its subclass SSLCertVerificationError)\n+                        # inherits from OSError *and* ValueError via Python MRO,\n+                        # so the isinstance(ValueError) check above would\n+                        # misclassify a TLS transport failure as a local\n+                        # programming bug and abort without retrying.  Exclude\n+                        # ssl.SSLError explicitly so the error classifier's\n+                        # retryable=True mapping takes effect instead.\n+                        and not isinstance(api_error, ssl.SSLError)\n                     )\n                     is_client_error = (\n                         is_local_validation_error"]},{"file":"scripts/release.py","hunks":["@@ -187,6 +187,11 @@\n     \"[redacted:email]\": \"alexzhu0\",\n     \"[redacted:email]\": \"AntAISecurityLab\",\n     \"[redacted:email]\": \"briandevans\",\n+    \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"CruxExperts\",\n+    \"[redacted:email]\": \"LeonSGP43\",\n+    \"[redacted:email]\": \"vlwkaos\",\n+    \"[redacted:email]\": \"Tranquil-Flow\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"[redacted:email]\": \"0xNyk\","]},{"file":"tests/agent/test_error_classifier.py","hunks":["@@ -1094,3 +1094,37 @@ def test_real_ssl_error_type_classifies_as_timeout(self):\n         result = classify_api_error(e)\n         assert result.reason == FailoverReason.timeout\n         assert result.retryable is True\n+\n+# ── Test: RateLimitError without status_code (Copilot/GitHub Models) ──────────\n+\n+class TestRateLimitErrorWithoutStatusCode:\n+    \"\"\"Regression tests for the Copilot/GitHub Models edge case where the\n+    OpenAI SDK raises RateLimitError but does not populate .status_code.\"\"\"\n+\n+    def _make_rate_limit_error(self, status_code=None):\n+        \"\"\"Create an exception whose class name is 'RateLimitError' with\n+        an optionally missing status_code, mirroring the OpenAI SDK shape.\"\"\"\n+        cls = type(\"RateLimitError\", (Exception,), {})\n+        e = cls(\"You have exceeded your rate limit.\")\n+        e.status_code = status_code  # None simulates the Copilot case\n+        return e\n+\n+    def test_rate_limit_error_without_status_code_classified_as_rate_limit(self):\n+        \"\"\"RateLimitError with status_code=None must classify as rate_limit.\"\"\"\n+        e = self._make_rate_limit_error(status_code=None)\n+        result = classify_api_error(e, provider=\"copilot\", model=\"gpt-4o\")\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_rate_limit_error_with_status_code_429_classified_as_rate_limit(self):\n+        \"\"\"RateLimitError that does set status_code=429 still classifies correctly.\"\"\"\n+        e = self._make_rate_limit_error(status_code=429)\n+        result = classify_api_error(e, provider=\"copilot\", model=\"gpt-4o\")\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_other_error_without_status_code_not_forced_to_rate_limit(self):\n+        \"\"\"A non-RateLimitError with missing status_code must NOT be forced to 429.\"\"\"\n+        cls = type(\"APIError\", (Exception,), {})\n+        e = cls(\"something went wrong\")\n+        e.status_code = None\n+        result = classify_api_error(e, provider=\"copilot\", model=\"gpt-4o\")\n+        assert result.reason != FailoverReason.rate_limit"]},{"file":"tests/gateway/test_auth_fallback.py","hunks":["@@ -0,0 +1,73 @@\n+\"\"\"Test that AuthError triggers fallback provider resolution (#7230).\"\"\"\n+\n+import os\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+\n+class TestResolveRuntimeAgentKwargsAuthFallback:\n+    \"\"\"_resolve_runtime_agent_kwargs should try fallback on AuthError.\"\"\"\n+\n+    def test_auth_error_tries_fallback(self, tmp_path, monkeypatch):\n+        \"\"\"When primary provider raises AuthError, fallback is attempted.\"\"\"\n+        from hermes_cli.auth import AuthError\n+\n+        # Create a config with fallback\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\n+            \"model:\\n  provider: openai-codex\\n\"\n+            \"fallback_model:\\n  provider: openrouter\\n\"\n+            \"  model: meta-llama/llama-4-maverick\\n\"\n+        )\n+\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+\n+        call_count = {\"n\": 0}\n+\n+        def _mock_resolve(**kwargs):\n+            call_count[\"n\"] += 1\n+            requested = kwargs.get(\"requested\", \"\")\n+            if requested and \"codex\" in str(requested).lower():\n+                raise AuthError(\"Codex token refresh failed with status 401\")\n+            return {\n+                \"api_key\": \"fallback-key\",\n+                \"base_url\": \"https://openrouter.ai/api/v1\",\n+                \"provider\": \"openrouter\",\n+                \"api_mode\": \"openai_chat\",\n+                \"command\": None,\n+                \"args\": None,\n+                \"credential_pool\": None,\n+            }\n+\n+        monkeypatch.setenv(\"HERMES_INFERENCE_PROVIDER\", \"openai-codex\")\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=_mock_resolve,\n+        ):\n+            from gateway.run import _resolve_runtime_agent_kwargs\n+            result = _resolve_runtime_agent_kwargs()\n+\n+        assert result[\"provider\"] == \"openrouter\"\n+        assert result[\"api_key\"] == \"fallback-key\"\n+        # Should have been called at least twice (primary + fallback)\n+        assert call_count[\"n\"] >= 2\n+\n+    def test_auth_error_no_fallback_raises(self, tmp_path, monkeypatch):\n+        \"\"\"When primary fails and no fallback configured, RuntimeError is raised.\"\"\"\n+        from hermes_cli.auth import AuthError\n+\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\"model:\\n  provider: openai-codex\\n\")\n+\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+        monkeypatch.setenv(\"HERMES_INFERENCE_PROVIDER\", \"openai-codex\")\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=AuthError(\"token expired\"),\n+        ):\n+            from gateway.run import _resolve_runtime_agent_kwargs\n+            with pytest.raises(RuntimeError):\n+                _resolve_runtime_agent_kwargs()"]},{"file":"tests/run_agent/test_primary_runtime_restore.py","hunks":["@@ -446,3 +446,85 @@ def test_full_cycle_fallback_then_restore(self):\n         assert agent._fallback_index == 0\n         assert agent.provider == \"custom\"\n         assert agent.base_url == \"https://my-llm.example.com/v1\"\n+\n+\n+# =============================================================================\n+# Rate-limit cooldown gate\n+# =============================================================================\n+\n+class TestRateLimitCooldown:\n+    \"\"\"Verify _restore_primary_runtime() respects the 60s rate-limit cooldown.\"\"\"\n+\n+    def test_restore_blocked_during_cooldown(self):\n+        \"\"\"While _rate_limited_until is in the future, restore returns False.\"\"\"\n+        agent = _make_agent(\n+            fallback_model={\"provider\": \"openrouter\", \"model\": \"anthropic/claude-sonnet-4\"},\n+        )\n+        mock_client = _mock_resolve()\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", return_value=(mock_client, None)):\n+            agent._try_activate_fallback()\n+\n+        assert agent._fallback_activated is True\n+\n+        # Manually set cooldown well into the future\n+        agent._rate_limited_until = time.monotonic() + 60\n+\n+        result = agent._restore_primary_runtime()\n+        assert result is False\n+        assert agent._fallback_activated is True  # still on fallback\n+\n+    def test_restore_allowed_after_cooldown_expires(self):\n+        \"\"\"Once the cooldown window passes, restore proceeds normally.\"\"\"\n+        agent = _make_agent(\n+            fallback_model={\"provider\": \"openrouter\", \"model\": \"anthropic/claude-sonnet-4\"},\n+        )\n+        mock_client = _mock_resolve()\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", return_value=(mock_client, None)):\n+            agent._try_activate_fallback()\n+\n+        assert agent._fallback_activated is True\n+\n+        # Cooldown already expired\n+        agent._rate_limited_until = time.monotonic() - 1\n+\n+        with patch(\"run_agent.OpenAI\", return_value=MagicMock()):\n+            result = agent._restore_primary_runtime()\n+\n+        assert result is True\n+        assert agent._fallback_activated is False\n+\n+    def test_cooldown_set_on_rate_limit_reason(self):\n+        \"\"\"_try_activate_fallback with rate_limit reason sets _rate_limited_until.\"\"\"\n+        from run_agent import FailoverReason\n+        agent = _make_agent(\n+            fallback_model={\"provider\": \"openrouter\", \"model\": \"anthropic/claude-sonnet-4\"},\n+        )\n+        before = time.monotonic()\n+        mock_client = _mock_resolve()\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", return_value=(mock_client, None)):\n+            agent._try_activate_fallback(reason=FailoverReason.rate_limit)\n+\n+        assert hasattr(agent, \"_rate_limited_until\")\n+        assert agent._rate_limited_until > before + 50  # ~60s from now\n+\n+    def test_cooldown_not_set_when_already_on_fallback(self):\n+        \"\"\"Chain-switching while already on fallback must not reset cooldown.\"\"\"\n+        from run_agent import FailoverReason\n+        agent = _make_agent(\n+            fallback_model=[\n+                {\"provider\": \"openrouter\", \"model\": \"model-a\"},\n+                {\"provider\": \"anthropic\", \"model\": \"model-b\"},\n+            ],\n+        )\n+        mock_client = _mock_resolve()\n+        with patch(\"agent.auxiliary_client.resolve_provider_client\", return_value=(mock_client, None)):\n+            # First call: leaving primary → cooldown should be set\n+            agent._try_activate_fallback(reason=FailoverReason.rate_limit)\n+            first_cooldown = getattr(agent, \"_rate_limited_until\", 0)\n+\n+            # Second call: already on fallback (provider != primary) → cooldown must not advance\n+            agent._try_activate_fallback(reason=FailoverReason.rate_limit)\n+            second_cooldown = getattr(agent, \"_rate_limited_until\", 0)\n+\n+        # second call should not have extended the cooldown\n+        assert second_cooldown == first_cooldown"]},{"file":"tests/run_agent/test_switch_model_fallback_prune.py","hunks":["@@ -78,6 +78,17 @@ def test_switch_with_empty_chain_stays_empty():\n     assert agent._fallback_model is None\n \n \n+def test_switch_initializes_missing_fallback_attrs():\n+    agent = _make_agent([])\n+    del agent._fallback_chain\n+    del agent._fallback_model\n+\n+    _switch_to_anthropic(agent)\n+\n+    assert agent._fallback_chain == []\n+    assert agent._fallback_model is None\n+\n+\n def test_switch_within_same_provider_preserves_chain():\n     chain = [{\"provider\": \"openrouter\", \"model\": \"x-ai/grok-4\"}]\n     agent = _make_agent(chain)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}