Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,65 @@ def auto_title_session(
logger.debug("Failed to set auto-generated title: %s", e)


def maybe_retitle_session(
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,
every_n_turns: int = 6,
) -> None:
"""Periodically re-evaluate a session's title to keep it relevant as the
conversation evolves. Fires every ``every_n_turns`` user turns AFTER the
initial auto-title (so first-turn handling stays exclusively with
:func:`maybe_auto_title`).

Cheap path:
- Only runs every Nth turn.
- Only generates if conversation_history has at least 3 user messages.
- Compares to the existing title; if the new title differs meaningfully,
it's saved and the callback fires (which drives the thread rename).
"""
if not session_db or not session_id or not user_message or not assistant_response:
return
user_msg_count = sum(1 for m in (conversation_history or []) if m.get("role") == "user")
# First-turn is handled by maybe_auto_title; only act on 3rd+ user turns.
if user_msg_count < 3:
return
if every_n_turns <= 0 or (user_msg_count % every_n_turns) != 0:
return

def _runner():
try:
existing = session_db.get_session_title(session_id) or ""
except Exception:
return
new_title = generate_title(
user_message, assistant_response,
failure_callback=failure_callback, main_runtime=main_runtime,
)
if not new_title:
return
new_title = new_title.strip()
if not new_title or new_title.lower() == existing.strip().lower():
return
try:
session_db.set_session_title(session_id, new_title)
except Exception:
return
if title_callback is not None:
try:
title_callback(new_title)
except Exception:
logger.debug("Retitle callback failed", exc_info=True)

thread = threading.Thread(target=_runner, daemon=True, name="retitle")
thread.start()


def maybe_auto_title(
session_db,
session_id: str,
Expand Down
19 changes: 18 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,24 @@ def load_cli_config() -> Dict[str, Any]:
# only used as a FALLBACK when model.provider / model.base_url
# is not already set — never as an override. The canonical
# location is model.provider (written by `hermes model`).
if not defaults["model"].get("provider"):
#
# Special case: when `model:` is written as a STRING (short
# form — see every profile config in profiles/*/config.yaml),
# the dict has no provider slot at all, so model.provider stays
# at the hardcoded "auto" default and the truthy check below
# short-circuits. That leaves the root-level `provider:` —
# the user's only way to specify provider in the short form —
# silently dropped. Every kanban worker subprocess then hits
# AuthError on the primary provider and falls through to the
# configured fallback chain (regression flooding profile
# error logs throughout May 2026 with thousands of "Primary
# provider auth failed" warnings).
_model_was_string = isinstance(file_config.get("model"), str)
_cur_provider = (defaults["model"].get("provider") or "").strip().lower()
_provider_unset = (not _cur_provider) or (
_model_was_string and _cur_provider == "auto"
)
if _provider_unset:
root_provider = file_config.get("provider")
if root_provider:
defaults["model"]["provider"] = root_provider
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,8 @@ class MessageEvent:
# Reply context
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
reply_to_channel_id: Optional[str] = None # Channel where the replied-to message lives (may differ from current chat — e.g. user replied in a parent channel from inside a thread, or vice versa)
reply_to_author: Optional[str] = None # Display name of the replied-to message author

# Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics,
# Discord channel_skill_bindings). A single name or ordered list.
Expand Down
23 changes: 22 additions & 1 deletion gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -4476,7 +4476,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if not is_thread and not isinstance(message.channel, discord.DMChannel):
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
skip_thread = bool(channel_ids & no_thread_channels)
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"}
is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:
Expand Down Expand Up @@ -4696,10 +4696,29 @@ async def _handle_message(self, message: DiscordMessage) -> None:

reply_to_id = None
reply_to_text = None
reply_to_channel_id = None
reply_to_author = None
if message.reference:
reply_to_id = str(message.reference.message_id)
# message.reference.channel_id is the channel the referenced message
# lives in. When the user replies to a parent-channel message from
# inside a thread (or vice-versa), this differs from message.channel.id.
ref_chan = getattr(message.reference, "channel_id", None)
if ref_chan:
reply_to_channel_id = str(ref_chan)
if message.reference.resolved:
reply_to_text = getattr(message.reference.resolved, "content", None) or None
ref_author = getattr(message.reference.resolved, "author", None)
if ref_author is not None:
reply_to_author = (
getattr(ref_author, "display_name", None)
or getattr(ref_author, "global_name", None)
or getattr(ref_author, "name", None)
)
# Fall back to the current channel when Discord didn't set channel_id
# on the reference (older payloads).
if reply_to_id and not reply_to_channel_id:
reply_to_channel_id = _chan_id or None

event = MessageEvent(
text=event_text,
Expand All @@ -4711,6 +4730,8 @@ async def _handle_message(self, message: DiscordMessage) -> None:
media_types=media_types,
reply_to_message_id=reply_to_id,
reply_to_text=reply_to_text,
reply_to_channel_id=reply_to_channel_id,
reply_to_author=reply_to_author,
timestamp=message.created_at,
auto_skill=_skills,
channel_prompt=_channel_prompt,
Expand Down
179 changes: 177 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1746,6 +1746,21 @@ def _is_telegram_topic_lane(self, source: SessionSource) -> bool:
return False
return True

def _is_discord_thread_lane(self, source: SessionSource) -> bool:
"""True for a Discord thread (auto-created or otherwise) that we can rename.

Used to decide whether session-title auto-generation should also drive a
live thread rename. DMs and plain text channels are excluded — only
actual thread surfaces.
"""
if source.platform != Platform.DISCORD:
return False
if source.chat_type != "thread":
return False
if not source.chat_id or not source.thread_id:
return False
return True

_TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0

def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool:
Expand Down Expand Up @@ -7030,8 +7045,33 @@ async def _prepare_inbound_message_text(
# is referencing. History can contain the same or similar text
# multiple times, and without an explicit pointer the agent has to
# guess (or answer for both subjects). Token overhead is minimal.
reply_snippet = event.reply_to_text[:500]
message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'
#
# Cap the inline quote at 1500 chars to bound prompt cost. If the
# quoted message is longer (or the user wants surrounding context),
# the agent can call discord.fetch_messages with around=<msg_id>
# to pull the full message and its neighbors.
INLINE_REPLY_QUOTE_CAP = 1500
full_quote = event.reply_to_text
reply_snippet = full_quote[:INLINE_REPLY_QUOTE_CAP]
truncated = len(full_quote) > INLINE_REPLY_QUOTE_CAP

author = getattr(event, "reply_to_author", None)
ref_chan = getattr(event, "reply_to_channel_id", None) or source.chat_id
ref_msg = event.reply_to_message_id
author_part = f" by {author}" if author else ""
trunc_note = (
f" (truncated from {len(full_quote)} chars — call discord(action='fetch_messages', "
f"channel_id='{ref_chan}', around='{ref_msg}', limit=1) for the full message)"
if truncated else ""
)
pointer = (
f"[Replying to message {ref_msg}{author_part} "
f"in channel {ref_chan}{trunc_note}: \"{reply_snippet}\"]\n"
f"[If you need more surrounding context, call "
f"discord(action='fetch_messages', channel_id='{ref_chan}', "
f"around='{ref_msg}', limit=20).]\n\n"
)
message_text = f"{pointer}{message_text}"

if "@" in message_text:
try:
Expand Down Expand Up @@ -11423,6 +11463,119 @@ def _log_rename_failure(fut) -> None:

future.add_done_callback(_log_rename_failure)

# ------------------------------------------------------------------
# Discord thread auto-rename (parallel to the Telegram-topic path)
# ------------------------------------------------------------------

_DISCORD_THREAD_NAME_MAX = 100 # Discord hard limit
_DISCORD_RENAME_DEDUPE_TTL_S = 30.0 # ignore identical follow-up renames

def _sanitize_discord_thread_name(self, title: str) -> str:
cleaned = (title or "").strip().replace("\n", " ").replace("\r", " ")
# Collapse runs of whitespace.
cleaned = " ".join(cleaned.split())
if not cleaned:
return cleaned
return cleaned[: self._DISCORD_THREAD_NAME_MAX]

async def _rename_discord_thread_for_session_title(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Best-effort rename of a Discord thread when Hermes (re)titles a session."""
if not self._is_discord_thread_lane(source):
return
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
if adapter is None:
return
client = getattr(adapter, "_client", None)
if client is None:
return
new_name = self._sanitize_discord_thread_name(title)
if not new_name:
return

try:
thread_id_int = int(source.thread_id)
except (TypeError, ValueError):
return

try:
channel = client.get_channel(thread_id_int)
if channel is None:
channel = await client.fetch_channel(thread_id_int)
if channel is None:
return
current_name = getattr(channel, "name", None)
if current_name == new_name:
return
edit = getattr(channel, "edit", None)
if not callable(edit):
return
await edit(name=new_name, reason="Hermes auto-title")
logger.debug(
"Renamed Discord thread %s: %r -> %r",
source.thread_id,
current_name,
new_name,
)
except Exception:
logger.debug("Failed to rename Discord thread for auto-title", exc_info=True)

def _schedule_discord_thread_rename(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Schedule a Discord thread rename from the auto-title background thread."""
if not title or not self._is_discord_thread_lane(source):
return

# Dedupe identical rename requests within a short window so the
# periodic re-title path doesn't spam Discord's rate limiter when
# the title hasn't actually changed.
if not hasattr(self, "_discord_thread_rename_cache"):
self._discord_thread_rename_cache = {}
cache_key = f"{source.chat_id}:{source.thread_id}"
normalized = self._sanitize_discord_thread_name(title)
import time as _time
now = _time.monotonic()
prev = self._discord_thread_rename_cache.get(cache_key)
if prev and prev[0] == normalized and (now - prev[1]) < self._DISCORD_RENAME_DEDUPE_TTL_S:
return
self._discord_thread_rename_cache[cache_key] = (normalized, now)

try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = getattr(self, "_gateway_loop", None)
if loop is None or loop.is_closed():
return
try:
copied_source = dataclasses.replace(source)
except Exception:
copied_source = source
future = safe_schedule_threadsafe(
self._rename_discord_thread_for_session_title(copied_source, session_id, title),
loop,
logger=logger,
log_message="Discord thread title rename failed to schedule",
)
if future is None:
return

def _log_rename_failure(fut) -> None:
try:
fut.result()
except Exception:
logger.debug("Discord thread title rename failed", exc_info=True)

future.add_done_callback(_log_rename_failure)


_TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0

def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool:
Expand Down Expand Up @@ -15860,6 +16013,12 @@ def _approval_notify_sync(approval_data: dict) -> None:
effective_session_id,
title,
)
elif self._is_discord_thread_lane(source):
maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_thread_rename(
source,
effective_session_id,
title,
)
maybe_auto_title(
self._session_db,
effective_session_id,
Expand All @@ -15868,6 +16027,22 @@ def _approval_notify_sync(approval_data: dict) -> None:
all_msgs,
**maybe_auto_title_kwargs,
)
# Periodic re-title — fires only after the conversation
# has accumulated enough turns. Reuses the same callback
# so Discord threads (and Telegram topics) get renamed
# whenever the topic genuinely drifts.
try:
from agent.title_generator import maybe_retitle_session
maybe_retitle_session(
self._session_db,
effective_session_id,
message,
final_response,
all_msgs,
**maybe_auto_title_kwargs,
)
except Exception:
pass
except Exception:
pass

Expand Down
2 changes: 0 additions & 2 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2185,7 +2185,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
Environment="USER={username}"
Environment="LOGNAME={username}"
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
RestartSec=5
Expand Down Expand Up @@ -2220,7 +2219,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
RestartSec=5
Expand Down
Loading