* 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:
3 files changed
+266
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user