{"repo":"NousResearch/hermes-agent","public_id":"6380","display_label":"Stale system prompt identity after mid-session model switch","summary":"These issues and fixes center on system prompts retaining outdated model, provider, or base_url information when the active model changes mid-session, including via fallback, /model switch, or custom providers. One entry covers a desktop composer model selector feature with per-model presets.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52666},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-25T21:44:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12175824175824176},"fix_edges":[{"issue_number":37794,"pr_number":52666,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48173,"pr_number":52666,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48248,"pr_number":52666,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6380,"title":"CLI session displays stale primary model name after fallback activation","url":null,"state":"closed"},{"kind":"issue","number":24215,"title":"[Bug] System prompt injects stale model/provider after /model switch with custom_providers","url":null,"state":"closed"},{"kind":"issue","number":40026,"title":"System prompt 'Model:' / 'Provider:' header is stale after mid-session model switches","url":null,"state":"closed"},{"kind":"issue","number":48173,"title":"Mid-session model switch leaves stale Model/Provider in persisted system prompt","url":null,"state":"closed"},{"kind":"pr","number":6381,"title":"fix(run_agent): invalidate cached system prompt on fallback switch and restore","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c884ff64eaab0b5002e9bb703a9d3075b8dd8387","number":0},{"kind":"commit","value":"110cdd573a4e","number":0},{"kind":"commit","value":"4d1b98807016","number":0},{"kind":"commit","value":"019c11d07e08","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24221,"title":"fix: inject base_url into system prompt volatile metadata","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3353","number":3353}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":24629,"title":"fix(agent): use resolved model/provider in system prompt instead of config-level values","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24634","number":24634}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24634,"title":"fix(agent): use resolved model/provider in system prompt instead of config-level values","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24629","number":24629},{"kind":"pr","value":"#24215","number":24215}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":40034,"title":"fix(agent): rebuild stale runtime prompt headers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7d938cc5c9c7beff22e7cb48886cba33753a5376","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":40049,"title":"fix(system_prompt): resolve Model/Provider header from live config at build time","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7d938cc5c9c7beff22e7cb48886cba33753a5376","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":48196,"title":"fix(hermes_state): clear cached system_prompt on mid-session model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48173","number":48173},{"kind":"pr","value":"#52666","number":52666}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":48197,"title":"fix(state): clear stale system_prompt on mid-session model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48196","number":48196}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":48256,"title":"fix(agent): clear persisted system prompt on mid-session model switch (#48173)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48173","number":48173},{"kind":"pr","value":"#48248","number":48248},{"kind":"pr","value":"#52666","number":52666}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52666,"title":"fix(hermes_state): persist billing route after mid-session /model switch (#48248)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","hermes_state.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n         old_model, old_provider, new_model, new_provider,\n     )\n \n+    # ── Persist billing route to session DB ──\n+    # The agent's _session_db / session_id may not be set in all contexts\n+    # (tests, bare agents without a session DB, etc.).  This ensures the\n+    # dashboard Model cards show the actual provider after a mid-session\n+    # /model switch instead of the stale session-creation provider.\n+    # See #48248 for the full bug description.\n+    _session_db = getattr(agent, \"_session_db\", None)\n+    _session_id = getattr(agent, \"session_id\", None)\n+    if _session_db is not None and _session_id:\n+        try:\n+            _session_db.update_session_billing_route(\n+                _session_id,\n+                provider=agent.provider,\n+                base_url=agent.base_url,\n+                billing_mode=getattr(agent, \"api_mode\", None),\n+            )\n+        except Exception:\n+            logger.warning(\n+                \"Failed to persist billing route after model switch\",\n+                exc_info=True,\n+            )\n \n \n def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,"]},{"file":"hermes_state.py","hunks":["@@ -1584,6 +1584,36 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n+    def update_session_billing_route(\n+        self,\n+        session_id: str,\n+        *,\n+        provider: str,\n+        base_url: str,\n+        billing_mode: Optional[str] = None,\n+    ) -> None:\n+        \"\"\"Unconditionally update the billing provider/base_url for a session.\n+\n+        Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)``\n+        (only filling in NULL), this unconditionally sets the billing fields so\n+        that the dashboard reflects the user's latest /model switch.\n+\n+        Also nulls ``system_prompt`` so the cached snapshot (which embeds a\n+        stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the\n+        behavior of ``update_session_model`` (see #48173, #48248).\n+        \"\"\"\n+        def _do(conn):\n+            conn.execute(\n+                \"\"\"UPDATE sessions SET\n+                   billing_provider = ?,\n+                   billing_base_url = ?,\n+                   billing_mode = COALESCE(?, billing_mode),\n+                   system_prompt = NULL\n+                   WHERE id = ?\"\"\",\n+                (provider, base_url, billing_mode, session_id),\n+            )\n+        self._execute_write(_do)\n+\n     def update_token_counts(\n         self,\n         session_id: str,"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -210,6 +210,54 @@ def test_update_session_model_overwrites_existing(self, db):\n                                model=\"xiaomi/mimo-v2.5-pro\")\n         assert db.get_session(\"s1\")[\"model\"] == \"xiaomi/mimo-v2.5\"\n \n+    def test_update_session_billing_route_overwrites_after_switch(self, db):\n+        \"\"\"A mid-session provider switch must overwrite the billing route.\n+\n+        update_token_counts writes billing fields with\n+        COALESCE(billing_provider, ?) (first-writer-wins), so after a\n+        provider switch the dashboard kept attributing cost to the original\n+        provider (#48248). update_session_billing_route sets them\n+        unconditionally and nulls system_prompt so the next turn rebuilds\n+        the Model:/Provider: header (#48173).\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"telegram\")\n+        # First token update seeds the billing route.\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               billing_provider=\"openrouter\",\n+                               billing_base_url=\"https://openrouter.ai/api/v1\",\n+                               billing_mode=\"api_key\")\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"openrouter\"\n+        # A later token update never changes it (COALESCE first-writer-wins).\n+        db.update_token_counts(\"s1\", input_tokens=10, output_tokens=5,\n+                               billing_provider=\"ollama\",\n+                               billing_base_url=\"http://localhost:11434/v1\",\n+                               billing_mode=\"local\")\n+        assert db.get_session(\"s1\")[\"billing_provider\"] == \"openrouter\"\n+\n+        # Seed a stale prompt snapshot, then switch the billing route.\n+        db.update_system_prompt(\"s1\", \"Model: x/old\\nProvider: openrouter\")\n+        assert db.get_session(\"s1\")[\"system_prompt\"] is not None\n+        db.update_session_billing_route(\n+            \"s1\", provider=\"ollama\",\n+            base_url=\"http://localhost:11434/v1\", billing_mode=\"local\",\n+        )\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"ollama\"\n+        assert sess[\"billing_base_url\"] == \"http://localhost:11434/v1\"\n+        assert sess[\"billing_mode\"] == \"local\"\n+        assert sess[\"system_prompt\"] is None, \\\n+            \"system_prompt must be nulled so the next turn rebuilds Model:/Provider:\"\n+\n+        # billing_mode defaults to COALESCE — omitting it preserves the value.\n+        db.update_session_billing_route(\n+            \"s1\", provider=\"openai\",\n+            base_url=\"https://api.openai.com/v1\",\n+        )\n+        sess = db.get_session(\"s1\")\n+        assert sess[\"billing_provider\"] == \"openai\"\n+        assert sess[\"billing_mode\"] == \"local\"  # preserved (COALESCE on None)\n+\n     def test_parent_session(self, db):\n         db.create_session(session_id=\"parent\", source=\"cli\")\n         db.create_session(session_id=\"child\", source=\"cli\", parent_session_id=\"parent\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}