hermes-agent/agent/title_generator.py
Teknium 31a3822b80
fix(title): contain auto-title thread exceptions instead of dumping tracebacks to the terminal (#65792)
auto_title_session runs as a bare daemon-thread target. Any exception
escaping it hits the default threading excepthook and sprays a raw
traceback into the user's terminal mid-session. The canonical trigger
is the post-'hermes update' stale-module window: the function's lazy
imports read NEW source from disk while already-imported modules
(agent.portal_tags) are still the OLD cached version, producing an
ImportError that repeats on every auto-title attempt until the
long-running process restarts (seen live after 9ce0e67f2 added
set_conversation_context).

The public entrypoint now wraps the body in a catch-all that logs one
WARNING naming the likely cause ('restart the running Hermes process'),
routes the exception through the existing failure_callback channel
(user-visible warning in CLI, debug-suppressed in gateway per #23246),
and never re-raises. This also makes the function honor its own
docstring contract ('silently skips if title generation fails').
2026-07-16 12:41:56 -07:00

268 lines
10 KiB
Python

"""Auto-generate short session titles from the first user/assistant exchange.
Runs asynchronously after the first response is delivered so it never
adds latency to the user-facing reply.
"""
import logging
import threading
from typing import Callable, Optional
from agent.auxiliary_client import call_llm
logger = logging.getLogger(__name__)
# Callback signature: (task_name, exception) -> None. Used to surface
# auxiliary failures to the user through AIAgent._emit_auxiliary_failure
# so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain)
# become visible instead of piling up as NULL session titles.
FailureCallback = Callable[[str, BaseException], None]
TitleCallback = Callable[[str], None]
_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. "
"Write the title in the same language the user is writing in. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
_TITLE_PROMPT_PINNED_LANGUAGE = (
"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. "
"Write the title in {language}. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
return str(
((load_config() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
except Exception:
return ""
def generate_title(
user_message: str,
assistant_response: str,
timeout: Optional[float] = None,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
) -> Optional[str]:
"""Generate a session title from the first exchange.
Uses the main runtime's model when available, falling back to the
auxiliary LLM client (cheapest/fastest available model).
Returns the title string or None on failure.
``failure_callback`` is invoked with ``(task, exception)`` when the
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.
"""
# 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 ""
language = _title_language()
prompt = _TITLE_PROMPT_PINNED_LANGUAGE.format(language=language) if language else _TITLE_PROMPT
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
]
try:
response = call_llm(
task="title_generation",
messages=messages,
max_tokens=500,
temperature=0.3,
timeout=timeout,
main_runtime=main_runtime,
)
content = response.choices[0].message.content or ""
# Strip thinking/reasoning blocks that think-enabled models
# (MiniMax M2.7, DeepSeek, etc.) emit even for simple prompts like
# title generation. Without this the raw <think>...</think> XML
# leaks into session titles. Reuses the canonical scrubber so all
# tag variants (unterminated blocks, orphan closes, mixed case)
# are handled, not just a single literal <think> pair.
from agent.agent_runtime_helpers import strip_think_blocks
title = strip_think_blocks(None, content).strip()
# Clean up: remove quotes, trailing punctuation, prefixes like "Title: "
title = title.strip('"\'')
if title.lower().startswith("title:"):
title = title[6:].strip()
# Enforce reasonable length
if len(title) > 80:
title = title[:77] + "..."
return title if title else None
except Exception as e:
# Log at WARNING so this shows up in agent.log without debug mode.
# Full detail at debug level for operators who need the stack.
logger.warning("Title generation failed: %s", e)
logger.debug("Title generation traceback", exc_info=True)
if failure_callback is not None:
try:
failure_callback("title generation", e)
except Exception:
logger.debug("Title generation failure_callback raised", exc_info=True)
return None
def auto_title_session(
session_db,
session_id: str,
user_message: str,
assistant_response: str,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
) -> None:
"""Generate and set a session title if one doesn't already exist.
Called in a background thread after the first exchange completes.
Silently skips if:
- session_db is None
- session already has a title (user-set or previously auto-generated)
- title generation fails
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
via the default threading excepthook. The canonical trigger is the
post-``hermes update`` stale-module window, where this function's lazy
imports read NEW source from disk while already-cached modules
(``agent.portal_tags`` etc.) are still the OLD version — the resulting
ImportError repeats on every auto-title attempt until the long-running
process restarts.
"""
try:
_auto_title_session(
session_db,
session_id,
user_message,
assistant_response,
failure_callback=failure_callback,
main_runtime=main_runtime,
title_callback=title_callback,
)
except Exception as e:
# WARNING (not debug) so operators see it in agent.log; the message
# names the likely cause so "restart the process" is discoverable.
logger.warning(
"Auto-title failed (harmless; if this started after an update, "
"restart the running Hermes process): %s",
e,
)
logger.debug("Auto-title traceback", exc_info=True)
if failure_callback is not None:
try:
failure_callback("title generation", e)
except Exception:
logger.debug("Auto-title failure_callback raised", exc_info=True)
def _auto_title_session(
session_db,
session_id: str,
user_message: str,
assistant_response: str,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
) -> None:
"""Body of :func:`auto_title_session` — see its docstring."""
if not session_db or not session_id:
return
# Check if title already exists (user may have set one via /title before first response)
try:
existing = session_db.get_session_title(session_id)
if existing:
return
except Exception:
return
# This runs on a bare daemon thread spawned AFTER the turn's ambient
# conversation context was reset, so publish it here from the session id
# we already hold — the title-generation LLM call then carries the same
# ``conversation=`` Portal tag as the turn it titles. Root-of-lineage for
# consistency with the agent loop (a no-op on first exchange, where
# titling happens, but correct if this ever runs on a continuation).
from agent.aux_accounting import set_accounting_context
from agent.portal_tags import set_conversation_context
conversation_id = session_id
try:
conversation_id = session_db.get_conversation_root(session_id) or session_id
except Exception:
pass
set_conversation_context(conversation_id)
# Same for the accounting context, so the title call's token usage is
# recorded against this session (task='title_generation', #23270).
set_accounting_context(session_db, session_id)
title = generate_title(
user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
)
if not title:
return
try:
session_db.set_session_title(session_id, title)
logger.debug("Auto-generated session title: %s", title)
if title_callback is not None:
try:
title_callback(title)
except Exception:
logger.debug("Auto-title callback failed", exc_info=True)
except Exception as e:
logger.debug("Failed to set auto-generated title: %s", e)
def maybe_auto_title(
session_db,
session_id: str,
user_message: str,
assistant_response: str,
conversation_history: list,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
) -> None:
"""Fire-and-forget title generation after the first exchange.
Only generates a title when:
- This appears to be the first user→assistant exchange
- No title is already set
"""
if not session_db or not session_id or not user_message or not assistant_response:
return
# Count user messages in history to detect first exchange.
# conversation_history includes the exchange that just happened,
# so for a first exchange we expect exactly 1 user message
# (or 2 counting system). Be generous: generate on first 2 exchanges.
user_msg_count = sum(1 for m in (conversation_history or []) if m.get("role") == "user")
if user_msg_count > 2:
return
thread = threading.Thread(
target=auto_title_session,
args=(session_db, session_id, user_message, assistant_response),
kwargs={
"failure_callback": failure_callback,
"main_runtime": main_runtime,
"title_callback": title_callback,
},
daemon=True,
name="auto-title",
)
thread.start()