mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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').
268 lines
10 KiB
Python
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()
|