From 69995424cf445239ff604d6c1badef8f723d5b6a Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Thu, 2 Jul 2026 05:58:55 +0000 Subject: [PATCH] feat(discord): semantic thread titles for auto-created 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 1025964dc43b..21ca4af6bb4d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5445,6 +5445,8 @@ def build_source( 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 @@ def build_source( 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 a5fe58f97a54..58a40f9b2c03 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12857,6 +12857,86 @@ def _sanitize_telegram_topic_title(self, title: str) -> str: 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, @@ -17858,6 +17938,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None: 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 20ac65c7e4f7..6c62d40e1d3f 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -156,6 +156,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 @@ -229,6 +237,10 @@ def to_dict(self) -> Dict[str, Any]: 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 @@ -250,6 +262,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "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 bfe472450410..fd2d2938aefb 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5127,20 +5127,17 @@ async def _create_thread( # Auto-thread helpers # ------------------------------------------------------------------ - async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: - """Create a thread from a user message for auto-threading. - - Returns the created thread object, or ``None`` on failure. Both the - primary ``message.create_thread`` and the seed-message fallback are - retried once after a short backoff so transient connect errors - (e.g. ``Cannot connect to host discord.com:443``) don't immediately - burn through to the caller's failure path (#20243). + 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. """ - # 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() + 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) @@ -5148,7 +5145,18 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: 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. + Returns the created thread object, or ``None`` on failure. Both the + primary ``message.create_thread`` and the seed-message fallback are + retried once after a short backoff so transient connect errors + (e.g. ``Cannot connect to host discord.com:443``) don't immediately + burn through to the caller's failure path (#20243). + """ + 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}" @@ -5158,6 +5166,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: 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 @@ -5170,6 +5182,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: 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 @@ -5188,6 +5204,64 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: ) 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, @@ -5966,6 +6040,11 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool = 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 5ef6812f5375..19710626ca69 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 capture_handle(event): 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 c10000940070..a5b07c89880d 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -4,7 +4,7 @@ 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"