From 0d9ed9214d3682f4d274b8d8e2455261217ebce5 Mon Sep 17 00:00:00 2001 From: Georgio Constantinou <210088133+rungmc357@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:20:47 -0400 Subject: [PATCH] Add semantic titles for Discord auto-threads --- gateway/platforms/base.py | 4 + gateway/run.py | 86 +++++++++++++++ gateway/session.py | 14 +++ plugins/platforms/discord/adapter.py | 105 ++++++++++++++++--- tests/gateway/test_discord_slash_commands.py | 71 +++++++++++++ tests/gateway/test_fast_command.py | 69 +++++++++++- 6 files changed, 335 insertions(+), 14 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1025964dc43..21ca4af6bb4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5445,6 +5445,8 @@ class BasePlatformAdapter(ABC): parent_chat_id: Optional[str] = None, message_id: Optional[str] = None, role_authorized: bool = False, + auto_thread_created: bool = False, + auto_thread_initial_name: Optional[str] = None, ) -> SessionSource: """Helper to build a SessionSource for this platform.""" # Normalize empty topic to None @@ -5466,6 +5468,8 @@ class BasePlatformAdapter(ABC): parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, role_authorized=role_authorized, + auto_thread_created=auto_thread_created, + auto_thread_initial_name=auto_thread_initial_name, ) @abstractmethod diff --git a/gateway/run.py b/gateway/run.py index 6dd6fbdf23a..3102f883430 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13317,6 +13317,86 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew cleaned = cleaned[:117].rstrip() + "..." return cleaned + def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool: + """Return True only for Discord threads Hermes just auto-created.""" + return ( + source.platform == Platform.DISCORD + and source.chat_type == "thread" + and bool(getattr(source, "auto_thread_created", False)) + and bool(source.thread_id) + and bool(getattr(source, "auto_thread_initial_name", None)) + ) + + def _sanitize_discord_thread_title(self, title: str) -> str: + """Return a Discord-safe semantic thread title from a session title.""" + cleaned = re.sub(r"\s+", " ", str(title or "")).strip() + if not cleaned: + return "Hermes Chat" + if len(cleaned) > 80: + cleaned = cleaned[:77].rstrip() + "..." + return cleaned + + async def _rename_discord_auto_thread_for_session_title( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Best-effort semantic rename of a newly auto-created Discord thread.""" + if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source): + return + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None: + return + rename_thread = getattr(adapter, "rename_thread", None) + if rename_thread is None: + return + thread_name = self._sanitize_discord_thread_title(title) + try: + await rename_thread( + str(source.thread_id), + thread_name, + only_if_current_name=getattr(source, "auto_thread_initial_name", None), + ) + except Exception: + logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True) + + def _schedule_discord_semantic_thread_rename( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Schedule Discord auto-thread rename from the auto-title background thread.""" + if not title or not self._is_discord_auto_thread_lane(source): + return + 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_auto_thread_for_session_title(copied_source, session_id, title), + loop, + logger=logger, + log_message="Discord semantic thread rename failed to schedule", + ) + if future is None: + return + + def _log_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Discord semantic thread rename failed", exc_info=True) + + future.add_done_callback(_log_rename_failure) + async def _rename_telegram_topic_for_session_title( self, source: SessionSource, @@ -18700,6 +18780,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew effective_session_id, title, ) + elif self._is_discord_auto_thread_lane(source): + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_semantic_thread_rename( + source, + effective_session_id, + title, + ) maybe_auto_title( getattr(self._session_db, "_db", self._session_db), effective_session_id, diff --git a/gateway/session.py b/gateway/session.py index c0f631ffd2b..fb2e08f4299 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -181,6 +181,14 @@ class SessionSource: # namespacing and the per-turn config/credential scope. profile: Optional[str] = None + # Discord auto-thread metadata. Newly auto-created Discord threads start + # with a fast placeholder title from the raw message, then the gateway can + # rename them after the first agent turn using the generated session title. + # Keep this explicit so pre-existing or human-renamed threads are not + # mistaken for safe rename targets. + auto_thread_created: bool = False + auto_thread_initial_name: Optional[str] = None + # Internal, wire-INVISIBLE trust signal: True when this event was delivered # to the gateway over the per-instance-authenticated relay WebSocket (the # Team Gateway connector). The connector authenticates the gateway's socket @@ -254,6 +262,10 @@ class SessionSource: d["message_id"] = self.message_id if self.profile: d["profile"] = self.profile + if self.auto_thread_created: + d["auto_thread_created"] = True + if self.auto_thread_initial_name: + d["auto_thread_initial_name"] = self.auto_thread_initial_name return d @classmethod @@ -275,6 +287,8 @@ class SessionSource: parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), profile=data.get("profile"), + auto_thread_created=bool(data.get("auto_thread_created", False)), + auto_thread_initial_name=data.get("auto_thread_initial_name"), ) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 1698f65d6c1..755ad69a8fe 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5171,6 +5171,26 @@ class DiscordAdapter(BasePlatformAdapter): # Auto-thread helpers # ------------------------------------------------------------------ + def _derive_auto_thread_name(self, content: str) -> str: + """Return the fast placeholder name used at Discord thread creation time. + + Strip Discord mention syntax (users / roles / channels) so thread + titles don't show raw <@id>, <@&id>, or <#id> markers — the ID + isn't meaningful to humans glancing at the thread list (#6336). + Real semantic naming is done after the first agent turn, when + Hermes has an LLM-generated session title and can safely rename + only this newly-created thread. + """ + content = (content or "").strip() + # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces. + content = re.sub(r"<@[!&]?\d+>", "", content) + content = re.sub(r"<#\d+>", "", content) + content = re.sub(r"\s+", " ", content).strip() + thread_name = content[:80] if content else "Hermes" + if len(content) > 80: + thread_name = thread_name[:77] + "..." + return thread_name + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: """Create a thread from a user message for auto-threading. @@ -5180,19 +5200,7 @@ class DiscordAdapter(BasePlatformAdapter): (e.g. ``Cannot connect to host discord.com:443``) don't immediately burn through to the caller's failure path (#20243). """ - # Build a short thread name from the message. Strip Discord mention - # syntax (users / roles / channels) so thread titles don't end up - # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't - # meaningful to humans glancing at the thread list (#6336). - content = (message.content or "").strip() - # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces. - content = re.sub(r"<@[!&]?\d+>", "", content) - content = re.sub(r"<#\d+>", "", content) - content = re.sub(r"\s+", " ", content).strip() - thread_name = content[:80] if content else "Hermes" - if len(content) > 80: - thread_name = thread_name[:77] + "..." - + thread_name = self._derive_auto_thread_name(message.content or "") display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user" reason = f"Auto-threaded from mention by {display_name}" @@ -5202,6 +5210,10 @@ class DiscordAdapter(BasePlatformAdapter): for attempt in range(2): try: thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) + try: + setattr(thread, "_hermes_auto_thread_initial_name", thread_name) + except Exception: + pass return thread except Exception as direct_error: last_direct_error = direct_error @@ -5214,6 +5226,10 @@ class DiscordAdapter(BasePlatformAdapter): auto_archive_duration=1440, reason=reason, ) + try: + setattr(thread, "_hermes_auto_thread_initial_name", thread_name) + except Exception: + pass return thread except Exception as fallback_error: last_fallback_error = fallback_error @@ -5232,6 +5248,64 @@ class DiscordAdapter(BasePlatformAdapter): ) return None + async def rename_thread( + self, + thread_id: str, + name: str, + *, + only_if_current_name: Optional[str] = None, + ) -> bool: + """Best-effort Discord thread rename. + + ``only_if_current_name`` prevents overwriting human-renamed or + pre-existing threads. This is intentionally a no-op on mismatch. + """ + if not self._client or not DISCORD_AVAILABLE: + return False + + try: + thread_id_int = int(str(thread_id)) + except (TypeError, ValueError): + return False + + cleaned = re.sub(r"\s+", " ", str(name or "")).strip() + if not cleaned: + return False + if len(cleaned) > 80: + cleaned = cleaned[:77].rstrip() + "..." + + try: + thread = self._client.get_channel(thread_id_int) + if thread is None: + thread = await self._client.fetch_channel(thread_id_int) + except Exception: + logger.debug("[%s] Failed to resolve Discord thread %s for rename", self.name, thread_id, exc_info=True) + return False + + current_name = getattr(thread, "name", None) + if only_if_current_name is not None and current_name != only_if_current_name: + logger.info( + "[%s] Discord semantic thread rename skipped for %s: current name %r != expected %r", + self.name, thread_id, current_name, only_if_current_name, + ) + return False + if current_name == cleaned: + return True + + edit = getattr(thread, "edit", None) + if edit is None: + return False + try: + await edit(name=cleaned, reason="Hermes semantic session title") + logger.info( + "[%s] Renamed Discord thread %s from %r to %r", + self.name, thread_id, current_name, cleaned, + ) + return True + except Exception: + logger.debug("[%s] Failed to rename Discord thread %s", self.name, thread_id, exc_info=True) + return False + async def create_handoff_thread( self, parent_chat_id: str, @@ -6015,6 +6089,11 @@ class DiscordAdapter(BasePlatformAdapter): parent_chat_id=parent_channel_id, message_id=str(message.id), role_authorized=role_authorized, + auto_thread_created=auto_threaded_channel is not None, + auto_thread_initial_name=( + getattr(auto_threaded_channel, "_hermes_auto_thread_initial_name", None) + or self._derive_auto_thread_name(message.content or "") + ) if auto_threaded_channel is not None else None, ) # Build media URLs -- download image attachments to local cache so the diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 5ef6812f537..19710626ca6 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -562,6 +562,7 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter): call_kwargs = message.create_thread.await_args[1] assert call_kwargs["name"] == "Hello world, how are you?" assert call_kwargs["auto_archive_duration"] == 1440 + assert thread._hermes_auto_thread_initial_name == "Hello world, how are you?" @pytest.mark.asyncio @@ -659,6 +660,47 @@ async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(ada assert result is None +@pytest.mark.asyncio +async def test_rename_thread_edits_only_when_current_name_matches(adapter): + thread = SimpleNamespace( + id=999, + name="raw user prompt", + edit=AsyncMock(), + ) + adapter._client.get_channel = lambda _id: thread + + result = await adapter.rename_thread( + "999", + "Semantic Session Title", + only_if_current_name="raw user prompt", + ) + + assert result is True + thread.edit.assert_awaited_once_with( + name="Semantic Session Title", + reason="Hermes semantic session title", + ) + + +@pytest.mark.asyncio +async def test_rename_thread_skips_when_human_renamed(adapter): + thread = SimpleNamespace( + id=999, + name="human fixed this already", + edit=AsyncMock(), + ) + adapter._client.get_channel = lambda _id: thread + + result = await adapter.rename_thread( + "999", + "Semantic Session Title", + only_if_current_name="raw user prompt", + ) + + assert result is False + thread.edit.assert_not_awaited() + + # ------------------------------------------------------------------ # Auto-thread integration in _handle_message # ------------------------------------------------------------------ @@ -742,6 +784,35 @@ async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch): assert event.source.chat_id == "999" # redirected to thread assert event.source.chat_type == "thread" assert event.source.thread_id == "999" + assert event.source.auto_thread_created is True + + +@pytest.mark.asyncio +async def test_auto_thread_source_carries_initial_name_for_semantic_rename(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + thread = SimpleNamespace( + id=999, + name="raw user prompt", + _hermes_auto_thread_initial_name="raw user prompt", + ) + adapter._auto_create_thread = AsyncMock(return_value=thread) + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeTextChannel(), content="raw user prompt") + + await adapter._handle_message(msg) + + source = captured_events[0].source + assert source.auto_thread_created is True + assert source.auto_thread_initial_name == "raw user prompt" @pytest.mark.asyncio diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index c1000094007..a5b07c89880 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -4,7 +4,7 @@ import sys import threading import types from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml @@ -87,6 +87,19 @@ def _make_source() -> SessionSource: ) +def _make_discord_auto_thread_source() -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + chat_id="999", + chat_type="thread", + user_id="user-1", + thread_id="999", + parent_chat_id="100", + auto_thread_created=True, + auto_thread_initial_name="raw user prompt", + ) + + def _make_event(text: str) -> MessageEvent: return MessageEvent(text=text, source=_make_source(), message_id="m1") @@ -193,3 +206,57 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch assert result["final_response"] == "ok" assert _CapturingAgent.last_init["service_tier"] == "priority" assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"} + + +@pytest.mark.asyncio +async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path): + _install_fake_agent(monkeypatch) + runner = _make_runner() + runner._session_db = SimpleNamespace(_db=MagicMock()) # type: ignore[assignment] + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env") + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "***", + }, + ) + + import hermes_cli.tools_config as tools_config + monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"}) + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + await runner._run_agent( + message="raw user prompt", + context_prompt="", + history=[], + source=_make_discord_auto_thread_source(), + session_id="session-1", + session_key="agent:main:discord:thread:999", + ) + + mock_title.assert_called_once() + callback = mock_title.call_args.kwargs["title_callback"] + with patch.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule: + callback("Semantic Session Title") + mock_schedule.assert_called_once() + assert mock_schedule.call_args.args[1] == "session-1" + assert mock_schedule.call_args.args[2] == "Semantic Session Title" + + +def test_session_source_preserves_discord_auto_thread_metadata(): + source = _make_discord_auto_thread_source() + + restored = SessionSource.from_dict(source.to_dict()) + + assert restored.auto_thread_created is True + assert restored.auto_thread_initial_name == "raw user prompt"