{"repo":"NousResearch/hermes-agent","public_id":"5694","display_label":"Fallback model visibility and user notification improvements","summary":"These items concern surfacing fallback model activations, credential pool exhaustion events, and provider switches to users and agents rather than silently proceeding. Related themes include exposing the actual served model identity and ensuring notifications are emitted promptly rather than suppressed or buffered.","subsystem":"agent","counts":{"members":22,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":11,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61340},"resolution":{"state":"partially_resolved","by":"pr-61340,pr-115925"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T17:38:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07324675324675321},"fix_edges":[{"issue_number":35419,"pr_number":61340,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":54864,"pr_number":115925,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5694,"title":"Feature: clearer recovery UX when model failover is exhausted","url":null,"state":"open"},{"kind":"issue","number":10476,"title":"[Bug]: Credential pool provider fallback is silent — inconsistent with fallback_model notification behavior","url":null,"state":"closed"},{"kind":"issue","number":15506,"title":"Expose response metadata and SSE comments to context engine plugins","url":null,"state":"open"},{"kind":"issue","number":25852,"title":"Feature Request: Runtime fallback model awareness — expose active model to the agent","url":null,"state":"closed"},{"kind":"issue","number":33174,"title":"[Feature] Notify user when fallback provider is activated (gateway/messaging mode)","url":null,"state":"closed"},{"kind":"issue","number":35419,"title":"Bug: successful fallback activation is silent because buffered notices are never flushed","url":null,"state":"closed"},{"kind":"issue","number":47720,"title":"[Enhancement] Add user notification hook when fallback model is activated","url":null,"state":"closed"},{"kind":"issue","number":50229,"title":"[Bug]: Silent fallback failover - buffered status dropped on success, agent runs as wrong model with no notification","url":null,"state":"closed"},{"kind":"issue","number":51573,"title":"Silent fallback cascade: successful fallback recovery hides model identity from user","url":null,"state":"closed"},{"kind":"issue","number":54864,"title":"[Feature]: Surface the actual served / downstream model per response (not just the proxy alias)","url":null,"state":"closed"},{"kind":"issue","number":60046,"title":"Silent model switch on successful fallback — no user notice on gateways; add fallback activation notification + session metadata","url":null,"state":"closed"},{"kind":"issue","number":60759,"title":"[Feature]: Surface actual-model-served in agent context + notify on silent fallback","url":null,"state":"closed"},{"kind":"issue","number":68057,"title":"feat: notify user when fallback model activates","url":null,"state":"closed"},{"kind":"pr","number":10479,"title":"fix: emit notification on credential pool exhaustion before fallback","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":10480,"title":"fix(agent): notify gateway users when credential pool auth exhaustion causes provider switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3fe7f6d27a005a0a75644c1d1d42cef8c8750c5f","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10716,"title":"fix(agent): notify when credential-pool exhaustion activates fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3fe7f6d27a005a0a75644c1d1d42cef8c8750c5f","number":0},{"kind":"pr","value":"#10718","number":10718}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26569,"title":"feat(agent): expose fallback model awareness to the agent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c884ff64eaab0b5002e9bb703a9d3075b8dd8387","number":0},{"kind":"pr","value":"#25852","number":25852}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33891,"title":"feat: opt-in user-visible notification on provider fallback (#33174)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3fe7f6d27a005a0a75644c1d1d42cef8c8750c5f","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35424,"title":"fix(agent): emit fallback activation notice immediately instead of buffering","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47377","number":47377}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":61340,"title":"fix(agent): surface fallback switch notice on successful fallback (#35419)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/conversation_loop.py","run_agent.py","scripts/release.py","tests/run_agent/test_retry_status_buffer.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -1399,6 +1399,7 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n             fb_api_mode = \"bedrock_converse\"\n \n         old_model = agent.model\n+        old_provider = agent.provider\n \n         # Clear the per-config context_length override so the fallback\n         # model's actual context window is resolved instead of inheriting","@@ -1544,6 +1545,16 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n             f\"🔄 Primary model failed — switching to fallback: \"\n             f\"{fb_model} via {fb_provider}\"\n         )\n+        # The buffered line above is dropped on successful recovery, but a\n+        # provider/model switch is a durable state change operators must see\n+        # even when the fallback succeeds.  Record a one-shot notice that the\n+        # success path surfaces exactly once via _emit_pending_fallback_notice\n+        # (see run_agent.py); it is discarded on terminal failure since the\n+        # buffered line is flushed instead.  See fallback-observability fix.\n+        agent._pending_fallback_notice = (\n+            f\"🔄 Switched to fallback model: {old_model} via {old_provider} \"\n+            f\"→ {fb_model} via {fb_provider}\"\n+        )\n         logger.info(\n             \"Fallback activated: %s → %s (%s)\",\n             old_model, fb_model, fb_provider,"]},{"file":"agent/conversation_loop.py","hunks":["@@ -5062,8 +5062,11 @@ def _perform_api_call(next_api_kwargs):\n                 # Reset retry counter/signature on successful content\n                 agent._empty_content_retries = 0\n                 agent._thinking_prefill_retries = 0\n-                # Successful content reached — drop any buffered retry\n-                # status from earlier failed attempts in this turn.\n+                # Successful content reached — surface the one-shot fallback\n+                # switch notice (if a fallback activated this turn) before\n+                # dropping the noisy retry buffer, so a provider/model switch\n+                # stays visible even when the fallback succeeds.\n+                agent._emit_pending_fallback_notice()\n                 agent._clear_status_buffer()\n \n                 from agent.agent_runtime_helpers import ("]},{"file":"run_agent.py","hunks":["@@ -981,6 +981,29 @@ def _clear_status_buffer(self) -> None:\n         except Exception:\n             pass\n \n+    def _emit_pending_fallback_notice(self) -> None:\n+        \"\"\"Surface the one-shot fallback-switch notice on successful recovery.\n+\n+        A provider/model switch is a durable state change operators must see,\n+        unlike transient retry chatter that ``_clear_status_buffer`` drops.\n+        ``try_activate_fallback`` records the switch in\n+        ``self._pending_fallback_notice``; this emits it exactly once via\n+        ``_emit_status`` and then clears it, so a successful fallback still\n+        produces one visible notice.  On terminal failure the buffered switch\n+        line is flushed instead (and this notice discarded) — see\n+        ``_flush_status_buffer`` — so the user always sees the switch once.\n+        \"\"\"\n+        try:\n+            notice = getattr(self, \"_pending_fallback_notice\", None)\n+            if notice:\n+                # Clear before emitting so a (swallowed) callback error can't\n+                # leave the notice set for a stale re-emit on a later turn.\n+                self._pending_fallback_notice = None\n+                self._emit_status(notice)\n+        except Exception:\n+            # Never break the conversation loop on a notice hiccup.\n+            pass\n+\n     def _flush_status_buffer(self) -> None:\n         \"\"\"Emit buffered retry messages — call on terminal failure.\n ","@@ -988,6 +1011,10 @@ def _flush_status_buffer(self) -> None:\n         was tried before the turn gave up.\n         \"\"\"\n         try:\n+            # The buffered trace already carries the fallback switch line, so\n+            # drop any one-shot fallback notice to avoid a stale duplicate\n+            # leaking into a later successful turn.\n+            self._pending_fallback_notice = None\n             buf = getattr(self, \"_retry_status_buffer\", None)\n             if not buf:\n                 return"]},{"file":"scripts/release.py","hunks":["@@ -57,6 +57,7 @@\n     \"[redacted:email]\": \"AndreasHiltner\",  # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep)\n     \"[redacted:email]\": \"allenliang2022\",  # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout)\n     \"[redacted:email]\": \"ManniBr\",  # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles)\n+    \"[redacted:email]\": \"Umi4Life\",  # PR #47377 salvage (agent: emit one-shot fallback switch notice on successful fallback so gateway users see model/provider change; #35419)\n     \"[redacted:email]\": \"austinlaw076\",  # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set)\n     \"[redacted:email]\": \"liuhao1024\",  # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986)\n     \"[redacted:email]\": \"gauravsaxena1997\",  # PR #59868 partial salvage (agent: guard response.text against httpx.ResponseNotRead in _summarize_api_error; #59769)"]},{"file":"tests/run_agent/test_retry_status_buffer.py","hunks":["@@ -135,6 +135,86 @@ def test_mixed_kinds_replay_through_correct_channels():\n     assert warns == [\"warn-1\"]\n \n \n+def test_pending_fallback_notice_emitted_once_on_success():\n+    \"\"\"On successful recovery the one-shot fallback notice is surfaced even\n+    though the noisy retry buffer is dropped.\"\"\"\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+\n+    # Simulate try_activate_fallback: buffer the noisy switch line AND record\n+    # the durable one-shot notice.\n+    agent._buffer_status(\"🔄 Primary model failed — switching to fallback: m2 via p2\")\n+    agent._pending_fallback_notice = \"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"\n+\n+    # Success path order: emit pending notice, then drop the buffer.\n+    agent._emit_pending_fallback_notice()\n+    agent._clear_status_buffer()\n+\n+    # The durable notice was shown exactly once; the buffered retry noise was\n+    # silently dropped.\n+    assert emitted == [\"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"]\n+    assert agent._retry_status_buffer == []\n+    # Notice is cleared so it cannot re-emit on a later turn.\n+    assert agent._pending_fallback_notice is None\n+\n+    # A second success path with no new fallback emits nothing.\n+    agent._emit_pending_fallback_notice()\n+    assert emitted == [\"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"]\n+\n+\n+def test_pending_fallback_notice_noop_when_unset():\n+    \"\"\"No fallback this turn → no notice emitted on the success path.\"\"\"\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+\n+    # No _pending_fallback_notice attribute set at all.\n+    agent._emit_pending_fallback_notice()\n+    assert emitted == []\n+\n+\n+def test_flush_discards_pending_fallback_notice():\n+    \"\"\"On terminal failure the flushed buffer already carries the switch line,\n+    so the one-shot notice is discarded to avoid a stale duplicate later.\"\"\"\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+\n+    agent._buffer_status(\"🔄 Primary model failed — switching to fallback: m2 via p2\")\n+    agent._pending_fallback_notice = \"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"\n+\n+    # Terminal failure flushes the buffered trace...\n+    agent._flush_status_buffer()\n+    assert emitted == [\"🔄 Primary model failed — switching to fallback: m2 via p2\"]\n+    # ...and discards the pending notice so it won't re-emit on a later turn.\n+    assert agent._pending_fallback_notice is None\n+\n+    emitted.clear()\n+    agent._emit_pending_fallback_notice()\n+    assert emitted == []\n+\n+\n+def test_pending_fallback_notice_survives_emit_callback_error():\n+    \"\"\"A failing status callback must not leave the notice set for a stale\n+    re-emit, and must not raise.\"\"\"\n+    agent = _make_bare_agent()\n+    seen = []\n+\n+    def boom(msg):\n+        seen.append(msg)\n+        raise RuntimeError(\"simulated callback failure\")\n+\n+    agent._emit_status = boom\n+    agent._pending_fallback_notice = \"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"\n+\n+    # Should not raise.\n+    agent._emit_pending_fallback_notice()\n+    # Attempt was made and the notice is cleared regardless.\n+    assert seen == [\"🔄 Switched to fallback model: m1 via p1 → m2 via p2\"]\n+    assert agent._pending_fallback_notice is None\n+\n+\n def test_flush_swallows_callback_exceptions():\n     agent = _make_bare_agent()\n     seen = []\n"]}]},{"kind":"pr","number":68327,"title":"fix(agent): show startup fallback model","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"99b366a33c89","number":0},{"kind":"pr","value":"#68327","number":68327},{"kind":"pr","value":"#73772","number":73772},{"kind":"pr","value":"#68327","number":68327},{"kind":"pr","value":"#68327","number":68327},{"kind":"pr","value":"#73772","number":73772},{"kind":"pr","value":"#72440","number":72440},{"kind":"pr","value":"#68327","number":68327},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#68057","number":68057},{"kind":"pr","value":"#73772","number":73772},{"kind":"pr","value":"#68327","number":68327}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":115925,"title":"feat(gateway): footer can show the model that really answered behind a routing proxy or fallback (#54864)","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","agent/served_model.py","agent/turn_finalizer.py","gateway/run_turn.py","gateway/runtime_footer.py","hermes_cli/config_defaults.py","tests/agent/test_served_model.py","tests/gateway/test_runtime_footer.py","website/docs/user-guide/configuration.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}