NousResearch/hermes-agent

Fix stale title generation reloading unloaded Ollama models

Cluster 19027 · agent. Data as of

Nothing open on GitHub. Fix merged in #66078 on 17 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #66078. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #66078. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #66078 fix(title): runtime validator stops stale background titling from reloading unloaded models (salvage #19137)

    merged to main

    files: 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

    Show the diff
    • acp_adapter/server.py
      @@ -1617,6 +1617,11 @@ def _notify_title_update(_title: str) -> None:
                                   self._send_session_info_update(session_id),
                               )
       
      +                # Snapshot the runtime identity; the validator lets the
      +                # background titler skip its LLM call if the session's model
      +                # changed before it fires (#19027).
      +                _title_model = getattr(state.agent, "model", None)
      +                _title_provider = getattr(state.agent, "provider", None)
                       maybe_auto_title(
                           self.session_manager._get_db(),
                           session_id,
      @@ -1630,6 +1635,10 @@ def _notify_title_update(_title: str) -> None:
                               "api_key": [redacted:secret-assignment] "api_key", None),
                               "api_mode": getattr(state.agent, "api_mode", None),
                           },
      +                    runtime_validator=lambda: (
      +                        getattr(state.agent, "model", None) == _title_model
      +                        and getattr(state.agent, "provider", None) == _title_provider
      +                    ),
                           title_callback=_notify_title_update,
                       )
                   except Exception:
    • agent/title_generator.py
      @@ -19,6 +19,12 @@
       FailureCallback = Callable[[str, BaseException], None]
       TitleCallback = Callable[[str], None]
       
      +# Validation callback: () -> bool. Called right before the LLM request in
      +# generate_title(). Return False to skip — e.g. the user switched models
      +# after this background thread captured its runtime snapshot, and sending
      +# the request would reload a model the runtime already evicted (#19027).
      +RuntimeValidator = Callable[[], bool]
      +
       _TITLE_PROMPT = (
           "Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
           "following exchange. The title should capture the main topic or intent. "
      @@ -71,6 +77,7 @@ def generate_title(
           timeout: Optional[float] = None,
           failure_callback: Optional[FailureCallback] = None,
           main_runtime: dict = None,
      +    runtime_validator: Optional[RuntimeValidator] = None,
       ) -> Optional[str]:
           """Generate a session title from the first exchange.
       
      @@ -82,11 +89,26 @@ def generate_title(
           auxiliary call raises — the caller typically wires this to
           ``AIAgent._emit_auxiliary_failure`` so the user sees a warning instead
           of silently accumulating untitled sessions.
      +
      +    ``runtime_validator`` is called right before the LLM request. If it
      +    returns False (e.g. the user's model was switched since the background
      +    thread captured its runtime snapshot), the call is skipped silently —
      +    no request is sent, so a stale title request can't reload a model the
      +    runtime already unloaded (#19027).
           """
           if not _auto_title_enabled():
               logger.debug("Auto-title skipped: auxiliary.title_generation.enabled=false")
               return None
       
      +    if runtime_validator is not None:
      +        try:
      +            if not runtime_validator():
      +                logger.debug("Title generation skipped: runtime validator returned False")
      +                return None
      +        except Exception:
      +            # Fail open: a broken validator must not disable titling.
      +            logger.debug("Title runtime validator raised; proceeding", exc_info=True)
      +
           # Truncate long messages to keep the request small
           user_snippet = user_message[:500] if user_message else ""
           assistant_snippet = assistant_response[:500] if assistant_response else ""
      @@ -193,6 +215,7 @@ def auto_title_session(
           failure_callback: Optional[FailureCallback] = None,
           main_runtime: dict = None,
           title_callback: Optional[TitleCallback] = None,
      +    runtime_validator: Optional[RuntimeValidator] = None,
       ) -> None:
           """Generate and set a session title if one doesn't already exist.
       
      @@ -201,6 +224,7 @@ def auto_title_session(
           - session_db is None
           - session already has a title (user-set or previously auto-generated)
           - title generation fails
      +    - runtime_validator returns False (model was switched)
       
           Never lets an exception escape: this is a daemon-thread target, and an
           escaping exception would spray a raw traceback into the user's terminal
      @@ -220,6 +244,7 @@ def auto_title_session(
                   failure_callback=failure_callback,
                   main_runtime=main_runtime,
                   title_callback=title_callback,
      +            runtime_validator=runtime_validator,
               )
           except Exception as e:
               # WARNING (not debug) so operators see it in agent.log; the message
      @@ -245,6 +270,7 @@ def _auto_title_session(
           failure_callback: Optional[FailureCallback] = None,
           main_runtime: dict = None,
           title_callback: Optional[TitleCallback] = None,
      +    runtime_validator: Optional[RuntimeValidator] = None,
       ) -> None:
           """Body of :func:`auto_title_session` — see its docstring."""
           if not session_db or not session_id:
      @@ -278,7 +304,11 @@ def _auto_title_session(
           set_accounting_context(session_db, session_id)
       
           title = generate_title(
      -        user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
      +        user_message,
      +        assistant_response,
      +        failure_callback=failure_callback,
      +        main_runtime=main_runtime,
      +        runtime_validator=runtime_validator,
           )
           if not title:
               return
      @@ -306,6 +336,7 @@ def maybe_auto_title(
           failure_callback: Optional[FailureCallback] = None,
           main_runtime: dict = None,
           title_callback: Optional[TitleCallback] = None,
      +    runtime_validator: Optional[RuntimeValidator] = None,
       ) -> None:
           """Fire-and-forget title generation after the first exchange.
       
      @@ -337,6 +368,7 @@ def maybe_auto_title(
                   "failure_callback": failure_callback,
                   "main_runtime": main_runtime,
                   "title_callback": title_callback,
      +            "runtime_validator": runtime_validator,
               },
               daemon=True,
               name="auto-title",
    • cli.py
      @@ -12686,6 +12686,12 @@ def run_agent():
                           _title_failure_cb = getattr(
                               self.agent, "_emit_auxiliary_failure", None
                           ) if self.agent else None
      +                    # Snapshot the runtime identity; the validator lets the
      +                    # background titler skip its LLM call if the user switches
      +                    # models before it fires (a stale request would reload an
      +                    # unloaded Ollama model, #19027).
      +                    _title_model = self.model
      +                    _title_provider = self.provider
                           maybe_auto_title(
                               self._session_db,
                               self.session_id,
      @@ -12700,6 +12706,10 @@ def run_agent():
                                   "api_key": self.api_key,
                                   "api_mode": self.api_mode,
                               },
      +                        runtime_validator=lambda: (
      +                            getattr(self, "model", None) == _title_model
      +                            and getattr(self, "provider", None) == _title_provider
      +                        ),
                           )
                       except Exception:
                           pass
    • gateway/run.py
      @@ -19869,6 +19869,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
                                   "Gateway auto-title failure suppressed (not user-visible): %s: %s",
                                   task, exc,
                               )
      +                    # Snapshot the runtime identity; the validator lets the
      +                    # background titler skip its LLM call if the session's
      +                    # model changed before it fires (a stale request would
      +                    # reload an unloaded Ollama model, #19027).
      +                    _title_model = getattr(agent, "model", None) if agent else None
      +                    _title_provider = getattr(agent, "provider", None) if agent else None
                           maybe_auto_title_kwargs = {
                               "failure_callback": _title_failure_cb,
                               "main_runtime": {
      @@ -19878,6 +19884,10 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
                                   "api_key": [redacted:secret-assignment] "api_key", None),
                                   "api_mode": getattr(agent, "api_mode", None),
                               } if agent else None,
      +                        "runtime_validator": (lambda: (
      +                            getattr(agent, "model", None) == _title_model
      +                            and getattr(agent, "provider", None) == _title_provider
      +                        )) if agent else None,
                           }
                           if self._is_telegram_topic_lane(source):
                               maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_telegram_topic_title_rename(
    • scripts/release.py
      @@ -369,6 +369,7 @@
           "[email protected]": "dirtyren",
           "[redacted:email]": "s96919",
           "[redacted:email]": "rasitakyol",
      +    "[redacted:email]": "Thatgfsj",
           "[email protected]": "seagpt",
           "[redacted:email]": "doxe0x",
           "[redacted:email]": "Code-suphub",
    • tests/agent/test_title_generator.py
      @@ -387,6 +387,7 @@ def test_fires_on_first_exchange(self):
                       failure_callback=None,
                       main_runtime=None,
                       title_callback=None,
      +                runtime_validator=None,
                   )
       
           def test_skips_when_title_generation_disabled(self):
      @@ -430,6 +431,7 @@ def _cb(task, exc):
                       failure_callback=_cb,
                       main_runtime=None,
                       title_callback=None,
      +                runtime_validator=None,
                   )
       
           def test_skips_if_no_response(self):
      @@ -513,3 +515,64 @@ def test_not_found_raises_runtime_error_internally(self):
               db.set_session_title.return_value = False
               with pytest.raises(RuntimeError):
                   _persist_session_title(db, "missing", "Some Title")
      +
      +
      +class TestRuntimeValidator:
      +    """runtime_validator gating (#19027): a stale background title request
      +    must not fire when the session's model/provider changed after spawn."""
      +
      +    def test_skips_when_validator_returns_false(self):
      +        with patch("agent.title_generator.call_llm") as mock_llm:
      +            title = generate_title(
      +                "question", "answer",
      +                runtime_validator=lambda: False,
      +            )
      +            assert title is None
      +            mock_llm.assert_not_called()
      +
      +    def test_allows_when_validator_returns_true(self):
      +        mock_response = MagicMock()
      +        mock_response.choices = [MagicMock()]
      +        mock_response.choices[0].message.content = "Validated Title"
      +
      +        with patch("agent.title_generator.call_llm", return_value=mock_response) as mock_llm:
      +            title = generate_title(
      +                "question", "answer",
      +                runtime_validator=lambda: True,
      +            )
      +            assert title == "Validated Title"
      +            mock_llm.assert_called_once()
      +
      +    def test_broken_validator_fails_open(self):
      +        mock_response = MagicMock()
      +        mock_response.choices = [MagicMock()]
      +        mock_response.choices[0].message.content = "Resilient Title"
      +
      +        def _bad_validator():
      +            raise RuntimeError("validator gone")
      +
      +        with patch("agent.title_generator.call_llm", return_value=mock_response) as mock_llm:
      +            title = generate_title(
      +                "question", "answer",
      +                runtime_validator=_bad_validator,
      +            )
      +            assert title == "Resilient Title"
      +            mock_llm.assert_called_once()
      +
      +    def test_forwards_runtime_validator_to_worker(self):
      +        db = MagicMock()
      +        db.get_session_title.return_value = None
      +        history = [
      +            {"role": "user", "content": "hello"},
      +            {"role": "assistant", "content": "hi there"},
      +        ]
      +
      +        def _v():
      +            return True
      +
      +        with patch("agent.title_generator.auto_title_session") as mock_auto:
      +            maybe_auto_title(db, "sess-1", "hello", "hi there", history, runtime_validator=_v)
      +            import time
      +            time.sleep(0.3)
      +            kwargs = mock_auto.call_args.kwargs
      +            assert kwargs["runtime_validator"] is _v
    • tui_gateway/server.py
      @@ -9678,6 +9678,11 @@ def _stream(delta):
                           from agent.title_generator import maybe_auto_title
       
                           _title_key = session.get("session_key") or sid
      +                    # Snapshot the runtime identity; the validator lets the
      +                    # background titler skip its LLM call if the session's
      +                    # model changed before it fires (#19027).
      +                    _title_model = getattr(agent, "model", None)
      +                    _title_provider = getattr(agent, "provider", None)
                           maybe_auto_title(
                               _get_db(),
                               _title_key,
      @@ -9695,6 +9700,10 @@ def _stream(delta):
                                   "api_key": [redacted:secret-assignment] "api_key", None),
                                   "api_mode": getattr(agent, "api_mode", None),
                               },
      +                        runtime_validator=lambda: (
      +                            getattr(agent, "model", None) == _title_model
      +                            and getattr(agent, "provider", None) == _title_provider
      +                        ),
                               # Push the generated title live so the sidebar renames
                               # without waiting for the next list refresh (the titler
                               # runs async, after this turn's refresh already fired).

1 PR closed without merging

From the close comments: merged via another change 1

  • #19137 fix(title): prevent stale background title generation from reloading unloaded Ollama models · merged via another change → ef1c622, #66078
Closed issues · 1
  • Closed#19027[Bug]: Fix: stale title generation request reloads unloaded Ollama model after model switch
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#66078
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
17 Jul 2026

For your agent

The same facts, typed.