{"repo":"NousResearch/hermes-agent","public_id":"19027","display_label":"Fix stale title generation reloading unloaded Ollama models","summary":"These issues and PRs address a bug where stale background title generation requests cause previously unloaded Ollama models to be reloaded after a model switch. Multiple fix attempts and approaches are represented.","subsystem":"agent","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":66078,"merged_at":"2026-07-17T06:07:14+00:00"},"resolution":{"state":"resolved","by":"pr-66078"},"freshness":{"state":"verified","last_verified_at":"2026-07-17T06:07:14+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":19027,"pr_number":66078,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-17T06:07:14+00:00"}],"members":[{"kind":"issue","number":19027,"title":"[Bug]:  Fix: stale title generation request reloads unloaded Ollama model after model switch","url":null,"state":"closed"},{"kind":"pr","number":19137,"title":"fix(title): prevent stale background title generation from reloading unloaded Ollama models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ef1c62210","number":0},{"kind":"pr","value":"#66078","number":66078}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66078,"title":"fix(title): runtime validator stops stale background titling from reloading unloaded models (salvage #19137)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","agent/title_generator.py","cli.py","gateway/run.py","scripts/release.py","tests/agent/test_title_generator.py","tui_gateway/server.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -1617,6 +1617,11 @@ def _notify_title_update(_title: str) -> None:\n                             self._send_session_info_update(session_id),\n                         )\n \n+                # Snapshot the runtime identity; the validator lets the\n+                # background titler skip its LLM call if the session's model\n+                # changed before it fires (#19027).\n+                _title_model = getattr(state.agent, \"model\", None)\n+                _title_provider = getattr(state.agent, \"provider\", None)\n                 maybe_auto_title(\n                     self.session_manager._get_db(),\n                     session_id,","@@ -1630,6 +1635,10 @@ def _notify_title_update(_title: str) -> None:\n                         \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n                         \"api_mode\": getattr(state.agent, \"api_mode\", None),\n                     },\n+                    runtime_validator=lambda: (\n+                        getattr(state.agent, \"model\", None) == _title_model\n+                        and getattr(state.agent, \"provider\", None) == _title_provider\n+                    ),\n                     title_callback=_notify_title_update,\n                 )\n             except Exception:"]},{"file":"agent/title_generator.py","hunks":["@@ -19,6 +19,12 @@\n FailureCallback = Callable[[str, BaseException], None]\n TitleCallback = Callable[[str], None]\n \n+# Validation callback: () -> bool. Called right before the LLM request in\n+# generate_title(). Return False to skip — e.g. the user switched models\n+# after this background thread captured its runtime snapshot, and sending\n+# the request would reload a model the runtime already evicted (#19027).\n+RuntimeValidator = Callable[[], bool]\n+\n _TITLE_PROMPT = (\n     \"Generate a short, descriptive title (3-7 words) for a conversation that starts with the \"\n     \"following exchange. The title should capture the main topic or intent. \"","@@ -71,6 +77,7 @@ def generate_title(\n     timeout: Optional[float] = None,\n     failure_callback: Optional[FailureCallback] = None,\n     main_runtime: dict = None,\n+    runtime_validator: Optional[RuntimeValidator] = None,\n ) -> Optional[str]:\n     \"\"\"Generate a session title from the first exchange.\n ","@@ -82,11 +89,26 @@ def generate_title(\n     auxiliary call raises — the caller typically wires this to\n     ``AIAgent._emit_auxiliary_failure`` so the user sees a warning instead\n     of silently accumulating untitled sessions.\n+\n+    ``runtime_validator`` is called right before the LLM request. If it\n+    returns False (e.g. the user's model was switched since the background\n+    thread captured its runtime snapshot), the call is skipped silently —\n+    no request is sent, so a stale title request can't reload a model the\n+    runtime already unloaded (#19027).\n     \"\"\"\n     if not _auto_title_enabled():\n         logger.debug(\"Auto-title skipped: auxiliary.title_generation.enabled=false\")\n         return None\n \n+    if runtime_validator is not None:\n+        try:\n+            if not runtime_validator():\n+                logger.debug(\"Title generation skipped: runtime validator returned False\")\n+                return None\n+        except Exception:\n+            # Fail open: a broken validator must not disable titling.\n+            logger.debug(\"Title runtime validator raised; proceeding\", exc_info=True)\n+\n     # Truncate long messages to keep the request small\n     user_snippet = user_message[:500] if user_message else \"\"\n     assistant_snippet = assistant_response[:500] if assistant_response else \"\"","@@ -193,6 +215,7 @@ def auto_title_session(\n     failure_callback: Optional[FailureCallback] = None,\n     main_runtime: dict = None,\n     title_callback: Optional[TitleCallback] = None,\n+    runtime_validator: Optional[RuntimeValidator] = None,\n ) -> None:\n     \"\"\"Generate and set a session title if one doesn't already exist.\n ","@@ -201,6 +224,7 @@ def auto_title_session(\n     - session_db is None\n     - session already has a title (user-set or previously auto-generated)\n     - title generation fails\n+    - runtime_validator returns False (model was switched)\n \n     Never lets an exception escape: this is a daemon-thread target, and an\n     escaping exception would spray a raw traceback into the user's terminal","@@ -220,6 +244,7 @@ def auto_title_session(\n             failure_callback=failure_callback,\n             main_runtime=main_runtime,\n             title_callback=title_callback,\n+            runtime_validator=runtime_validator,\n         )\n     except Exception as e:\n         # WARNING (not debug) so operators see it in agent.log; the message","@@ -245,6 +270,7 @@ def _auto_title_session(\n     failure_callback: Optional[FailureCallback] = None,\n     main_runtime: dict = None,\n     title_callback: Optional[TitleCallback] = None,\n+    runtime_validator: Optional[RuntimeValidator] = None,\n ) -> None:\n     \"\"\"Body of :func:`auto_title_session` — see its docstring.\"\"\"\n     if not session_db or not session_id:","@@ -278,7 +304,11 @@ def _auto_title_session(\n     set_accounting_context(session_db, session_id)\n \n     title = generate_title(\n-        user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime\n+        user_message,\n+        assistant_response,\n+        failure_callback=failure_callback,\n+        main_runtime=main_runtime,\n+        runtime_validator=runtime_validator,\n     )\n     if not title:\n         return","@@ -306,6 +336,7 @@ def maybe_auto_title(\n     failure_callback: Optional[FailureCallback] = None,\n     main_runtime: dict = None,\n     title_callback: Optional[TitleCallback] = None,\n+    runtime_validator: Optional[RuntimeValidator] = None,\n ) -> None:\n     \"\"\"Fire-and-forget title generation after the first exchange.\n ","@@ -337,6 +368,7 @@ def maybe_auto_title(\n             \"failure_callback\": failure_callback,\n             \"main_runtime\": main_runtime,\n             \"title_callback\": title_callback,\n+            \"runtime_validator\": runtime_validator,\n         },\n         daemon=True,\n         name=\"auto-title\","]},{"file":"cli.py","hunks":["@@ -12686,6 +12686,12 @@ def run_agent():\n                     _title_failure_cb = getattr(\n                         self.agent, \"_emit_auxiliary_failure\", None\n                     ) if self.agent else None\n+                    # Snapshot the runtime identity; the validator lets the\n+                    # background titler skip its LLM call if the user switches\n+                    # models before it fires (a stale request would reload an\n+                    # unloaded Ollama model, #19027).\n+                    _title_model = self.model\n+                    _title_provider = self.provider\n                     maybe_auto_title(\n                         self._session_db,\n                         self.session_id,","@@ -12700,6 +12706,10 @@ def run_agent():\n                             \"api_key\": self.api_key,\n                             \"api_mode\": self.api_mode,\n                         },\n+                        runtime_validator=lambda: (\n+                            getattr(self, \"model\", None) == _title_model\n+                            and getattr(self, \"provider\", None) == _title_provider\n+                        ),\n                     )\n                 except Exception:\n                     pass"]},{"file":"gateway/run.py","hunks":["@@ -19869,6 +19869,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:\n                             \"Gateway auto-title failure suppressed (not user-visible): %s: %s\",\n                             task, exc,\n                         )\n+                    # Snapshot the runtime identity; the validator lets the\n+                    # background titler skip its LLM call if the session's\n+                    # model changed before it fires (a stale request would\n+                    # reload an unloaded Ollama model, #19027).\n+                    _title_model = getattr(agent, \"model\", None) if agent else None\n+                    _title_provider = getattr(agent, \"provider\", None) if agent else None\n                     maybe_auto_title_kwargs = {\n                         \"failure_callback\": _title_failure_cb,\n                         \"main_runtime\": {","@@ -19878,6 +19884,10 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:\n                             \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n                             \"api_mode\": getattr(agent, \"api_mode\", None),\n                         } if agent else None,\n+                        \"runtime_validator\": (lambda: (\n+                            getattr(agent, \"model\", None) == _title_model\n+                            and getattr(agent, \"provider\", None) == _title_provider\n+                        )) if agent else None,\n                     }\n                     if self._is_telegram_topic_lane(source):\n                         maybe_auto_title_kwargs[\"title_callback\"] = lambda title: self._schedule_telegram_topic_title_rename("]},{"file":"scripts/release.py","hunks":["@@ -369,6 +369,7 @@\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n     \"[redacted:email]\": \"s96919\",\n     \"[redacted:email]\": \"rasitakyol\",\n+    \"[redacted:email]\": \"Thatgfsj\",\n     \"141703117+seagpt@users.noreply.github.com\": \"seagpt\",\n     \"[redacted:email]\": \"doxe0x\",\n     \"[redacted:email]\": \"Code-suphub\","]},{"file":"tests/agent/test_title_generator.py","hunks":["@@ -387,6 +387,7 @@ def test_fires_on_first_exchange(self):\n                 failure_callback=None,\n                 main_runtime=None,\n                 title_callback=None,\n+                runtime_validator=None,\n             )\n \n     def test_skips_when_title_generation_disabled(self):","@@ -430,6 +431,7 @@ def _cb(task, exc):\n                 failure_callback=_cb,\n                 main_runtime=None,\n                 title_callback=None,\n+                runtime_validator=None,\n             )\n \n     def test_skips_if_no_response(self):","@@ -513,3 +515,64 @@ def test_not_found_raises_runtime_error_internally(self):\n         db.set_session_title.return_value = False\n         with pytest.raises(RuntimeError):\n             _persist_session_title(db, \"missing\", \"Some Title\")\n+\n+\n+class TestRuntimeValidator:\n+    \"\"\"runtime_validator gating (#19027): a stale background title request\n+    must not fire when the session's model/provider changed after spawn.\"\"\"\n+\n+    def test_skips_when_validator_returns_false(self):\n+        with patch(\"agent.title_generator.call_llm\") as mock_llm:\n+            title = generate_title(\n+                \"question\", \"answer\",\n+                runtime_validator=lambda: False,\n+            )\n+            assert title is None\n+            mock_llm.assert_not_called()\n+\n+    def test_allows_when_validator_returns_true(self):\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"Validated Title\"\n+\n+        with patch(\"agent.title_generator.call_llm\", return_value=mock_response) as mock_llm:\n+            title = generate_title(\n+                \"question\", \"answer\",\n+                runtime_validator=lambda: True,\n+            )\n+            assert title == \"Validated Title\"\n+            mock_llm.assert_called_once()\n+\n+    def test_broken_validator_fails_open(self):\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"Resilient Title\"\n+\n+        def _bad_validator():\n+            raise RuntimeError(\"validator gone\")\n+\n+        with patch(\"agent.title_generator.call_llm\", return_value=mock_response) as mock_llm:\n+            title = generate_title(\n+                \"question\", \"answer\",\n+                runtime_validator=_bad_validator,\n+            )\n+            assert title == \"Resilient Title\"\n+            mock_llm.assert_called_once()\n+\n+    def test_forwards_runtime_validator_to_worker(self):\n+        db = MagicMock()\n+        db.get_session_title.return_value = None\n+        history = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi there\"},\n+        ]\n+\n+        def _v():\n+            return True\n+\n+        with patch(\"agent.title_generator.auto_title_session\") as mock_auto:\n+            maybe_auto_title(db, \"sess-1\", \"hello\", \"hi there\", history, runtime_validator=_v)\n+            import time\n+            time.sleep(0.3)\n+            kwargs = mock_auto.call_args.kwargs\n+            assert kwargs[\"runtime_validator\"] is _v"]},{"file":"tui_gateway/server.py","hunks":["@@ -9678,6 +9678,11 @@ def _stream(delta):\n                     from agent.title_generator import maybe_auto_title\n \n                     _title_key = session.get(\"session_key\") or sid\n+                    # Snapshot the runtime identity; the validator lets the\n+                    # background titler skip its LLM call if the session's\n+                    # model changed before it fires (#19027).\n+                    _title_model = getattr(agent, \"model\", None)\n+                    _title_provider = getattr(agent, \"provider\", None)\n                     maybe_auto_title(\n                         _get_db(),\n                         _title_key,","@@ -9695,6 +9700,10 @@ def _stream(delta):\n                             \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n                             \"api_mode\": getattr(agent, \"api_mode\", None),\n                         },\n+                        runtime_validator=lambda: (\n+                            getattr(agent, \"model\", None) == _title_model\n+                            and getattr(agent, \"provider\", None) == _title_provider\n+                        ),\n                         # Push the generated title live so the sidebar renames\n                         # without waiting for the next list refresh (the titler\n                         # runs async, after this turn's refresh already fired).\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}