{"repo":"NousResearch/hermes-agent","public_id":"50163","display_label":"Mid-session model switch corrupts session state","summary":"These items address a bug where an incomplete mid-session model switch leaves the original session in a broken or unusable state. The corrections ensure incomplete swaps are treated as no-ops, preserving the prior session intact.","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":50375,"merged_at":"2026-06-21T20:33:24+00:00"},"resolution":{"state":"resolved","by":"pr-50375"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:33:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":50163,"pr_number":50375,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:33:24+00:00"}],"members":[{"kind":"issue","number":50163,"title":"[Bug]: Mid-session model switch failure leaves original conversation dead","url":null,"state":"closed"},{"kind":"pr","number":50189,"title":"fix(gateway): skip DB/override persist when model switch fails","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"99f3072aa06ac9a858ea5f1a753a801c15d76d5e","number":0},{"kind":"pr","value":"#50375","number":50375},{"kind":"pr","value":"#50163","number":50163}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50375,"title":"fix(model-switch): failed in-place swap must be a no-op, not a dead session","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/slash_commands.py","tests/gateway/test_model_command_expensive_confirm.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -6950,6 +6950,21 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:\n                 logger.debug(\"preflight-compression switch warning failed: %s\", exc)\n \n         old_model = self.model\n+        # Snapshot the CLI-level credential/runtime fields BEFORE mutating them\n+        # so a failed in-place agent swap can roll the whole CLI back to the old\n+        # working model.  Otherwise the broken credentials staged below leak into\n+        # the next turn's resolution even though the agent itself rolled back\n+        # (#50163).\n+        _cli_snapshot = {\n+            \"model\": self.model,\n+            \"provider\": self.provider,\n+            \"requested_provider\": self.requested_provider,\n+            \"_explicit_api_key\": [redacted:secret-assignment] \"_explicit_api_key\", None),\n+            \"_explicit_base_url\": getattr(self, \"_explicit_base_url\", None),\n+            \"api_key\": self.api_key,\n+            \"base_url\": self.base_url,\n+            \"api_mode\": self.api_mode,\n+        }\n         self.model = result.new_model\n         self.provider = result.target_provider\n         self.requested_provider = result.target_provider","@@ -6975,7 +6990,17 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:\n                     api_mode=result.api_mode,\n                 )\n             except Exception as exc:\n-                _cprint(f\"  ⚠ Agent swap failed ({exc}); change applied to next session.\")\n+                # The agent rolled itself back to the old working model/client.\n+                # Roll the CLI's own staged fields back too and abort the rest\n+                # of the commit (note + success print) so a failed switch is a\n+                # no-op rather than a dead session (#50163).\n+                for _k, _v in _cli_snapshot.items():\n+                    setattr(self, _k, _v)\n+                _cprint(\n+                    f\"  ⚠ Model switch to {result.new_model} failed ({exc}); \"\n+                    f\"staying on {old_model}.\"\n+                )\n+                return\n \n         self._pending_model_switch_note = (\n             f\"[Note: model was just switched from {old_model} to {result.new_model} \"","@@ -7236,6 +7261,18 @@ def _handle_model_switch(self, cmd_original: str):\n         # Update requested_provider so _ensure_runtime_credentials() doesn't\n         # overwrite the switch on the next turn (it re-resolves from this).\n         old_model = self.model\n+        # Snapshot CLI-level fields before mutation so a failed in-place swap\n+        # rolls the whole CLI back to the old working model (#50163).\n+        _cli_snapshot = {\n+            \"model\": self.model,\n+            \"provider\": self.provider,\n+            \"requested_provider\": self.requested_provider,\n+            \"_explicit_api_key\": [redacted:secret-assignment] \"_explicit_api_key\", None),\n+            \"_explicit_base_url\": getattr(self, \"_explicit_base_url\", None),\n+            \"api_key\": self.api_key,\n+            \"base_url\": self.base_url,\n+            \"api_mode\": self.api_mode,\n+        }\n         self.model = result.new_model\n         self.provider = result.target_provider\n         self.requested_provider = result.target_provider","@@ -7262,7 +7299,15 @@ def _handle_model_switch(self, cmd_original: str):\n                     api_mode=result.api_mode,\n                 )\n             except Exception as exc:\n-                _cprint(f\"  ⚠ Agent swap failed ({exc}); change applied to next session.\")\n+                # Agent rolled itself back; roll the CLI back too and abort so a\n+                # failed switch is a no-op rather than a dead session (#50163).\n+                for _k, _v in _cli_snapshot.items():\n+                    setattr(self, _k, _v)\n+                _cprint(\n+                    f\"  ⚠ Model switch to {result.new_model} failed ({exc}); \"\n+                    f\"staying on {old_model}.\"\n+                )\n+                return\n \n         # Store a note to prepend to the next user message so the model\n         # knows a switch occurred (avoids injecting system messages mid-history"]},{"file":"gateway/slash_commands.py","hunks":["@@ -1193,7 +1193,25 @@ async def _on_model_selected(\n                                     api_mode=result.api_mode,\n                                 )\n                             except Exception as exc:\n-                                logger.warning(\"Picker model switch failed for cached agent: %s\", exc)\n+                                # The in-place swap rolled the agent back to the\n+                                # OLD working model/client and re-raised.  Abort\n+                                # the rest of the commit: do NOT persist the\n+                                # failed model to the DB, do NOT set a session\n+                                # override pointing at the broken model, and do\n+                                # NOT evict the working cached agent.  Otherwise\n+                                # the next message rebuilds a dead agent from the\n+                                # broken override and the conversation is lost\n+                                # (#50163).  A failed switch must be a no-op.\n+                                logger.warning(\n+                                    \"Picker model switch failed for cached agent: %s\", exc\n+                                )\n+                                return t(\n+                                    \"gateway.model.error_prefix\",\n+                                    error=(\n+                                        f\"Model switch to {result.new_model} failed ({exc}); \"\n+                                        f\"staying on {_cur_model}.\"\n+                                    ),\n+                                )\n \n                         # Persist the new model to the session DB so the\n                         # dashboard shows the updated model (#34850).","@@ -1399,7 +1417,20 @@ async def _finish_switch() -> str:\n                         api_mode=result.api_mode,\n                     )\n                 except Exception as exc:\n+                    # In-place swap rolled the agent back to the OLD working\n+                    # model/client and re-raised.  Abort the commit: skip DB\n+                    # persist, session override, cache eviction, and config\n+                    # write so a failed switch is a no-op rather than a dead\n+                    # conversation (#50163).  Without this early return the\n+                    # next message rebuilds a broken agent from the override.\n                     logger.warning(\"In-place model switch failed for cached agent: %s\", exc)\n+                    return t(\n+                        \"gateway.model.error_prefix\",\n+                        error=(\n+                            f\"Model switch to {result.new_model} failed ({exc}); \"\n+                            f\"staying on {current_model}.\"\n+                        ),\n+                    )\n \n             # Persist the new model to the session DB so the dashboard\n             # shows the updated model (#34850)."]},{"file":"tests/gateway/test_model_command_expensive_confirm.py","hunks":["@@ -184,3 +184,53 @@ async def _fail_request_slash_confirm(**kwargs):  # pragma: no cover\n     assert \"gpt-5.5-pro\" in result\n     overrides = list(runner._session_model_overrides.values())\n     assert len(overrides) == 1\n+\n+\n[redacted:email]\n+async def test_failed_inplace_swap_aborts_commit(tmp_path, monkeypatch):\n+    \"\"\"A failed in-place agent swap must be a no-op, not a dead session.\n+\n+    Regression for #50163: the resolution pipeline succeeds (valid model name)\n+    but the cached agent's ``switch_model()`` raises mid-conversation (bad key /\n+    unreachable URL). The agent rolls itself back to the old working model; the\n+    gateway must NOT then commit the broken model as a session override or evict\n+    the working cached agent — otherwise the next message rebuilds a dead agent\n+    and the conversation is lost.\n+    \"\"\"\n+    _setup_isolated_home(tmp_path, monkeypatch, warn=False)\n+    runner = _make_runner()\n+\n+    # Working cached agent whose in-place swap fails (and rolls itself back).\n+    class _FailingAgent:\n+        def __init__(self):\n+            self.model = \"old-model\"\n+            self.provider = \"openrouter\"\n+\n+        def switch_model(self, **kwargs):\n+            # Mirrors agent_runtime_helpers.switch_model: the real method\n+            # restores old state then re-raises. We keep model unchanged.\n+            raise RuntimeError(\"connection refused: bad base_url\")\n+\n+    import threading\n+\n+    agent = _FailingAgent()\n+    runner._agent_cache = {}\n+    runner._agent_cache_lock = threading.Lock()\n+    session_key = runner._session_key_for_source(_make_event(\"/model x\").source)\n+    runner._agent_cache[session_key] = [agent, None]\n+    runner._session_db = None\n+\n+    evicted = []\n+    runner._evict_cached_agent = lambda sk: evicted.append(sk)\n+\n+    result = await runner._handle_model_command(_make_event(\"/model openai/gpt-5.5-pro\"))\n+\n+    # Error surfaced to the user, not a success confirmation.\n+    assert result is not None\n+    assert \"failed\" in result.lower()\n+    # The broken switch must NOT have been committed anywhere.\n+    assert runner._session_model_overrides == {}\n+    # The working cached agent must NOT have been evicted.\n+    assert evicted == []\n+    # The agent stayed on its old model (rolled back).\n+    assert agent.model == \"old-model\""]},{"file":"tui_gateway/server.py","hunks":["@@ -2344,13 +2344,27 @@ def _apply_model_switch(\n             }\n \n     if agent:\n-        agent.switch_model(\n-            new_model=result.new_model,\n-            new_provider=result.target_provider,\n-            api_key=[redacted:secret-assignment]\n-            base_url=result.base_url,\n-            api_mode=result.api_mode,\n-        )\n+        try:\n+            agent.switch_model(\n+                new_model=result.new_model,\n+                new_provider=result.target_provider,\n+                api_key=[redacted:secret-assignment]\n+                base_url=result.base_url,\n+                api_mode=result.api_mode,\n+            )\n+        except Exception as exc:\n+            # The in-place swap rolled the agent back to the old working\n+            # model/client and re-raised.  Abort the commit: do NOT restart the\n+            # slash worker, persist runtime, append the switch marker, set a\n+            # session model_override, or persist to config — all of which would\n+            # otherwise leave the session pinned to a broken model and kill the\n+            # conversation on the next turn (#50163).  A failed switch is a\n+            # no-op; surface a clean error to the client.\n+            logger.warning(\"In-place model switch failed for TUI agent: %s\", exc)\n+            raise ValueError(\n+                f\"Model switch to {result.new_model} failed ({exc}); \"\n+                f\"staying on {getattr(agent, 'model', current_model)}.\"\n+            ) from exc\n         _restart_slash_worker(sid, session)\n         _persist_live_session_runtime(session)\n         _persist_live_session_system_prompt(session)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}