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
- 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
Open PRs0
Fixes on record
-
#66078 fix(title): runtime validator stops stale background titling from reloading unloaded models (salvage #19137)
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.pyShow 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.00means 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 LabAt a glance
- Subsystem
- agent
- Merged PRs
- #66078
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 17 Jul 2026