fix(title): honor auxiliary.title_generation.enabled=false in WebUI title paths (#6814) (#6877)

* fix(title): honor auxiliary.title_generation.enabled=false in WebUI title paths (#6814)

Hermes Agent gates title generation on auxiliary.title_generation.enabled
(default true), but the WebUI orchestration only used the aux config to pick
a model route, so enabled=false still triggered LLM title calls via the
active chat model or the aux route, and could persist a fallback title.

Add a profile-scoped _aux_title_generation_enabled() helper beside
_get_aux_title_config() with the same default/truthy semantics as the Agent
(boolean and string forms). Gate three automatic paths on it:

- _run_background_title_update(): skip with status skipped /
  title_generation_disabled, provisional title untouched, no fall-through
  to the active agent model.
- _run_background_title_refresh(): skip with refresh_skipped /
  title_generation_disabled.
- generate_session_title_for_session(): explicit (None,
  'title_generation_disabled', '') so manual regenerate returns an
  explicit disabled response and imported-session auto-titling stays quiet.

Checks run inside the profile-scoped worker context so a background job
cannot read another profile after a switch.

Tests (tests/test_issue6814_title_generation_enabled_gate.py, 20 cases):
default-on when omitted, disabled with/without aux route (no LLM call,
title untouched, skipped status, stream_end still emitted), string/boolean
false forms, adaptive refresh path, manual regenerate explicit response,
helper parsing matrix. fail-before: 19 failed on unpatched code.
Regression: title family 154 passed (incl. test_2235 routing cases).

* docs: document auxiliary.title_generation.enabled behavior (#6814)

Addresses the Greptile #6877 review note: document the newly enforced
setting and the manual-regenerate disabled response in
docs/advanced-chat-setup.md.

* fix(title): mirror Hermes agent's canonical is_truthy_value contract exactly

Gate review (Codex) found the enabled-parser diverged from the agent's canonical
contract: it used a 'disable on false/0/no/off' blocklist, but the agent uses
is_truthy_value(value, default=True) — an allowlist (utils.TRUTHY_STRINGS =
{1,true,yes,on}). So None wrongly routed through the string branch and empty/
unrecognized strings wrongly stayed ENABLED. Now: None->True (default), bool
unchanged, str truthy ONLY for {1,true,yes,on}, else bool(value) — byte-for-byte
with agent/title_generator._auto_title_enabled. Test matrix corrected + expanded.

Co-authored-by: X-Shihai <X-Shihai@users.noreply.github.com>

---------

Co-authored-by: X-Shihai <48356232+xushihai@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: X-Shihai <X-Shihai@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-09 07:35:47 +00:00
1 parent 0b839f7ef8
commit 04d5652699
3 files changed
+266

No files matched your search

+33
View File
@@ -3830,6 +3830,31 @@ def _get_aux_title_config() -> dict:
return {}
def _aux_title_generation_enabled() -> bool:
"""Return whether automatic title generation is enabled (default: enabled).
Mirrors Hermes Agent's ``auxiliary.title_generation.enabled`` contract
byte-for-byte via its canonical ``is_truthy_value(value, default=True)``
semantics (agent/title_generator.py -> utils.is_truthy_value):
* ``None`` / missing -> default (True)
* ``bool`` -> unchanged
* ``str`` -> True ONLY when normalized into {"1", "true", "yes", "on"}
(an empty or unrecognized string is False, matching the agent — an
allowlist, NOT a "disable on false/0/no/off" blocklist)
* any other type -> ``bool(value)``
"""
tg = _get_aux_title_config()
val = tg.get('enabled', True)
if val is None:
return True
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.strip().lower() in ('1', 'true', 'yes', 'on')
return bool(val)
def _aux_title_configured() -> bool:
"""Return True when any auxiliary title_generation config field is meaningfully set."""
tg = _get_aux_title_config()
@@ -4355,6 +4380,9 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger):
if not _aux_title_generation_enabled():
_put_title_status(put_event, session_id, 'skipped', 'title_generation_disabled', current)
return
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
@@ -4444,6 +4472,9 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger):
if not _aux_title_generation_enabled():
_put_title_status(put_event, session_id, 'refresh_skipped', 'title_generation_disabled', effective)
return
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
@@ -4505,6 +4536,8 @@ def generate_session_title_for_session(session, *, prefer_latest: bool = False,
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(session, "manual title regeneration", logger_override=logger):
if not _aux_title_generation_enabled():
return None, 'title_generation_disabled', ''
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
if next_title:
return next_title, llm_status, raw_preview
+27
View File
@@ -49,6 +49,33 @@ every new browser turn. The browser only receives a compact status event
(`source`, `label`, message count, compaction metadata, and redacted errors),
never the prefill message bodies.
## Session title generation
Hermes WebUI derives a provisional session title from the first user message
and, after the first response, may call an LLM to generate a better title
(and periodically refresh it for long sessions).
Automatic title-generation LLM calls honor the active Hermes profile's
`auxiliary.title_generation.enabled` setting (default: `true`):
```yaml
auxiliary:
title_generation:
enabled: false
```
When disabled:
- the provisional first-message title stays in place and is never replaced
or overwritten by an automatic LLM call or local fallback;
- the periodic adaptive refresh is skipped;
- the explicit "regenerate title" action returns a
`title_generation_disabled` response instead of calling a title model.
The WebUI's `auto_title_refresh_every` setting remains a separate control for
periodic refreshes of already-generated titles; it does not re-enable
automatic generation when the auxiliary flag is off.
## Gateway-backed browser chat
By default, browser chat runs through WebUI's in-process legacy runtime. Advanced
@@ -0,0 +1,206 @@
"""Regression tests for issue #6814: auxiliary.title_generation.enabled=false
must disable WebUI automatic title-generation LLM calls.
Covers:
1. enabled=false + aux route configured -> neither aux nor agent LLM runs,
provisional title untouched, status skipped/title_generation_disabled.
2. enabled=false + no aux route -> active agent model is NOT used.
3. enabled omitted / truthy -> existing default-on behavior preserved.
4. string and boolean false forms parse identically (canonical parser).
5. adaptive refresh path honors the flag.
6. manual regenerate path returns an explicit disabled response.
7. helper unit cases (default, bool, string forms).
"""
import threading
from unittest.mock import MagicMock, patch
import pytest
from tests._aux_client_helpers import auxiliary_client_modules, patch_tg_config
@pytest.fixture(autouse=True)
def _install_auxiliary_client_modules():
with auxiliary_client_modules():
yield
def _make_provisional_session(user_text, assistant_text='Here is the answer.'):
from api.models import title_from
messages = [
{'role': 'user', 'content': user_text},
{'role': 'assistant', 'content': assistant_text},
]
provisional = title_from(messages, 'Untitled')
s = MagicMock()
s.title = provisional
s.llm_title_generated = False
s.messages = messages
s.session_id = 'test-6814-session'
s.save = MagicMock()
return s, provisional
def _run_update(session, provisional, events):
from api.streaming import _run_background_title_update
_run_background_title_update(
session_id=session.session_id,
user_text=str(session.messages[0]['content']),
assistant_text=str(session.messages[1]['content']),
placeholder_title=provisional,
put_event=lambda e, d: events.append((e, d)),
agent=None,
)
return events
@pytest.mark.parametrize('disabled_value', [False, 'false', '0', 'no', 'off'])
def test_disabled_skips_llm_and_keeps_provisional_title(disabled_value):
"""enabled=false (any canonical form) with an aux route configured:
no LLM call, provisional title untouched, skipped status."""
from api.streaming import _run_background_title_update
user_text = 'Design a REST API for user management.'
s, provisional = _make_provisional_session(user_text)
events = []
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()), \
patch_tg_config({'provider': 'openai', 'model': 'gpt-x', 'enabled': disabled_value}), \
patch('api.streaming._generate_llm_session_title_via_aux') as mock_aux, \
patch('api.streaming._generate_llm_session_title_for_agent') as mock_agent:
_run_update(s, provisional, events)
mock_aux.assert_not_called()
mock_agent.assert_not_called()
assert s.title == provisional # untouched
s.save.assert_not_called()
statuses = [d for e, d in events if e == 'title_status']
assert statuses and statuses[0]['status'] == 'skipped'
assert statuses[0]['reason'] == 'title_generation_disabled'
# stream_end must still be emitted (finally block)
assert any(e == 'stream_end' for e, _ in events)
def test_disabled_without_aux_route_does_not_use_agent_model():
"""enabled=false with no aux route: the active chat model must NOT be
used as a fallback (the original bug path)."""
from api.streaming import _run_background_title_update
user_text = 'Explain quantum entanglement simply.'
s, provisional = _make_provisional_session(user_text)
events = []
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()), \
patch_tg_config({'enabled': False}), \
patch('api.streaming._generate_llm_session_title_for_agent') as mock_agent:
_run_update(s, provisional, events)
mock_agent.assert_not_called()
assert s.title == provisional
statuses = [d for e, d in events if e == 'title_status']
assert statuses[0]['status'] == 'skipped'
assert statuses[0]['reason'] == 'title_generation_disabled'
def test_enabled_omitted_preserves_default_on_behavior():
"""enabled omitted (default true): existing behavior — LLM title runs
and replaces the provisional title."""
from api.streaming import _run_background_title_update
user_text = 'Write a haiku about the moon.'
s, provisional = _make_provisional_session(user_text)
events = []
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()), \
patch_tg_config({'provider': 'openai', 'model': 'gpt-x'}), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('Moon Haiku', 'llm_aux', 'Moon Haiku')):
_run_update(s, provisional, events)
assert s.title == 'Moon Haiku'
s.save.assert_called()
assert s.llm_title_generated is True
def test_enabled_true_string_parses_as_enabled():
from api.streaming import _aux_title_generation_enabled
with patch_tg_config({'enabled': 'true'}):
assert _aux_title_generation_enabled() is True
with patch_tg_config({'enabled': 'True'}):
assert _aux_title_generation_enabled() is True
def test_refresh_path_honors_disabled():
"""Adaptive refresh worker must skip when the flag is false."""
from api.streaming import _run_background_title_refresh
s = MagicMock()
s.title = 'Existing LLM title'
s.llm_title_generated = True
s.messages = [{'role': 'user', 'content': 'u'}, {'role': 'assistant', 'content': 'a'}]
s.session_id = 'test-6814-refresh'
s.save = MagicMock()
events = []
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()), \
patch_tg_config({'enabled': False}), \
patch('api.streaming._generate_llm_session_title_via_aux') as mock_aux, \
patch('api.streaming._generate_llm_session_title_for_agent') as mock_agent:
_run_background_title_refresh(
session_id=s.session_id,
user_text='u', assistant_text='a',
current_title=s.title,
put_event=lambda e, d: events.append((e, d)),
agent=None,
)
mock_aux.assert_not_called()
mock_agent.assert_not_called()
statuses = [d for e, d in events if e == 'title_status']
assert statuses and statuses[0]['status'] == 'refresh_skipped'
assert statuses[0]['reason'] == 'title_generation_disabled'
def test_manual_regenerate_returns_explicit_disabled():
"""Explicit manual regenerate returns an explicit disabled response
instead of calling any title LLM."""
from api.streaming import generate_session_title_for_session
s = MagicMock()
s.title = 'Whatever'
s.llm_title_generated = False
s.messages = [
{'role': 'user', 'content': 'First user message here'},
{'role': 'assistant', 'content': 'First answer here'},
]
s.session_id = 'test-6814-manual'
with patch_tg_config({'enabled': False}), \
patch('api.streaming._generate_llm_session_title_via_aux') as mock_aux, \
patch('api.streaming._generate_llm_session_title_for_agent') as mock_agent:
result = generate_session_title_for_session(s)
assert result == (None, 'title_generation_disabled', '')
mock_aux.assert_not_called()
mock_agent.assert_not_called()
@pytest.mark.parametrize('cfg,expected', [
({'provider': 'openai', 'model': 'gpt-x'}, True), # omitted -> default on
({'enabled': True}, True),
({'enabled': False}, False),
({'enabled': 'true'}, True), # allowlist -> on
({'enabled': 'on'}, True),
({'enabled': '1'}, True),
({'enabled': 'yes'}, True),
({'enabled': 'false'}, False),
({'enabled': '0'}, False),
({'enabled': 'no'}, False),
({'enabled': 'off'}, False),
({'enabled': 'FALSE'}, False),
# Canonical is_truthy_value(default=True): an allowlist, not a blocklist.
({'enabled': ''}, False), # empty string -> NOT truthy -> off
({'enabled': 'garbage'}, False), # unrecognized string -> off
({'enabled': None}, True), # explicit null -> default (on)
])
def test_helper_parses_enabled_forms(cfg, expected):
from api.streaming import _aux_title_generation_enabled
with patch_tg_config(cfg):
assert _aux_title_generation_enabled() is expected