NousResearch/hermes-agent

Discord thread auto-rename from session titles

Cluster 18430 · gateway. Data as of

1 open item. Fix merged in #60187 on 7 Jul 2026. Check it covers your case.

The short answer

0open issues
1open PR
7items grouped
Known?
Yes. 7 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #60187. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #60187. That is separate from the fix event.
Still open
0 issues and 1 PR.

These issues and PRs concern a feature to automatically rename Discord threads to match generated session titles. Work spans initial feature proposals, implementation variants, and a related fix.

Open issues0

No open issues.

Open PRs1

  • PR#114717feat(gateway): mirror final session titles to Telegram groups

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60187 feat(discord): rename auto-created threads to generated session titles

    merged to main

    files: gateway/platforms/base.py, gateway/run.py, gateway/session.py, plugins/platforms/discord/adapter.py, scripts/release.py, tests/gateway/test_discord_slash_commands.py, tests/gateway/test_fast_command.py

    Show the diff
    • 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
    • gateway/run.py
      @@ -1761,8 +1761,10 @@ def _profile_runtime_scope(profile_home: "Path"):
           EphemeralReply,
           MessageEvent,
           MessageType,
      +    _prefix_within_utf16_limit,
           _reply_anchor_for_event,
           merge_pending_message_event,
      +    utf16_len,
       )
       from gateway.restart import (
           DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
      @@ -13317,6 +13319,91 @@ 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.
      +
      +        Discord thread names are capped at 100 characters measured in UTF-16
      +        code units (emoji count double), so truncate with the UTF-16 helpers
      +        rather than Python code-point slices.
      +        """
      +        cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
      +        if not cleaned:
      +            return "Hermes Chat"
      +        if utf16_len(cleaned) > 80:
      +            cleaned = _prefix_within_utf16_limit(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 +18787,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,
    • 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 @@ 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
      @@ -275,6 +287,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"),
               )
           
       
    • plugins/platforms/discord/adapter.py
      @@ -5171,20 +5171,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)
      @@ -5192,7 +5189,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}"
       
      @@ -5202,6 +5210,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
      @@ -5214,6 +5226,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
      @@ -5232,6 +5248,67 @@ 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
      +        # Discord thread names are budgeted in UTF-16 code units (emoji count
      +        # double) — truncate with the UTF-16 helpers, not code-point slices.
      +        from gateway.platforms.base import utf16_len, _prefix_within_utf16_limit
      +        if utf16_len(cleaned) > 80:
      +            cleaned = _prefix_within_utf16_limit(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 +6092,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
    • scripts/release.py
      @@ -290,6 +290,7 @@
           "[email protected]": "synapsesx",
           "[email protected]": "itsflownium",
           "[email protected]": "dirtyren",
      +    "[email protected]": "rungmc357",
           "[redacted:email]": "flo1t",
           "[redacted:email]": "fanyangCS",
           "[redacted:email]": "bigstar0920",
    • 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
       
       
      [redacted:email]
      +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",
      +    )
      +
      +
      [redacted:email]
      +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
      +
      +
      [redacted:email]
      +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
    • 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"}
      +
      +
      [redacted:email]
      +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"

4 PRs closed without merging

From the close comments: done elsewhere 1 · duplicate 1 · stale or abandoned 1 · other 1

  • #26477 feat(discord): sync thread names from session titles · done elsewhere
  • #27800 fix: rename Discord threads after auto-title · duplicate → #18430, #26477, #15757
  • #29814 feat(discord): auto-rename thread after first bot reply · stale or abandoned → #18430
  • #57679 feat(discord): sync thread names from session titles · other → #60187, #56792
Closed issues · 1
  • Closed#18430feat(discord): auto-rename threads from generated session titles
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#60187
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
7 Jul 2026

For your agent

The same facts, typed.