NousResearch/hermes-agent

Session and topic rebinding after compression or thread split

Cluster 8487. Data as of

Nothing open on GitHub. Fix merged in #11629 on 17 Apr 2026. Check it covers your case.

The short answer

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

These issues and fixes concern session IDs, Telegram topic bindings, and Discord thread bindings becoming misaligned after context compression, session splits, or new thread creation. Work spans gateway, state, and platform-specific handling for both Telegram and Discord.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #11629 fix(discord): auto-thread cluster — free-response + reply + fallback + mention-name-strip

    merged to main

    files: gateway/platforms/discord.py, scripts/release.py, tests/gateway/test_discord_free_response.py, tests/gateway/test_discord_slash_commands.py

    Show the diff
    • gateway/platforms/discord.py
      @@ -2285,6 +2285,26 @@ def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None)
               from gateway.platforms.base import resolve_channel_prompt
               return resolve_channel_prompt(self.config.extra, channel_id, parent_id)
       
      +    def _discord_require_mention(self) -> bool:
      +        """Return whether Discord channel messages require a bot mention."""
      +        configured = self.config.extra.get("require_mention")
      +        if configured is not None:
      +            if isinstance(configured, str):
      +                return configured.lower() not in ("false", "0", "no", "off")
      +            return bool(configured)
      +        return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
      +
      +    def _discord_free_response_channels(self) -> set:
      +        """Return Discord channel IDs where no bot mention is required."""
      +        raw = self.config.extra.get("free_response_channels")
      +        if raw is None:
      +            raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
      +        if isinstance(raw, list):
      +            return {str(part).strip() for part in raw if str(part).strip()}
      +        if isinstance(raw, str) and raw.strip():
      +            return {part.strip() for part in raw.split(",") if part.strip()}
      +        return set()
      +
           def _thread_parent_channel(self, channel: Any) -> Any:
               """Return the parent text channel when invoked from a thread."""
               return getattr(channel, "parent", None) or channel
      @@ -2387,8 +2407,15 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
       
               Returns the created thread object, or ``None`` on failure.
               """
      -        # Build a short thread name from the message
      +        # 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] + "..."
      @@ -2396,9 +2423,25 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
               try:
                   thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
                   return thread
      -        except Exception as e:
      -            logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
      -            return None
      +        except Exception as direct_error:
      +            display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
      +            reason = f"Auto-threaded from mention by {display_name}"
      +            try:
      +                seed_msg = await message.channel.send(f"\U0001f9f5 Thread created by Hermes: **{thread_name}**")
      +                thread = await seed_msg.create_thread(
      +                    name=thread_name,
      +                    auto_archive_duration=1440,
      +                    reason=reason,
      +                )
      +                return thread
      +            except Exception as fallback_error:
      +                logger.warning(
      +                    "[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s",
      +                    self.name,
      +                    direct_error,
      +                    fallback_error,
      +                )
      +                return None
       
           async def send_exec_approval(
               self, chat_id: str, command: str, session_key: str,
      @@ -2745,12 +2788,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                       logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
                       return
       
      -            free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
      -            free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
      +            free_channels = self._discord_free_response_channels()
                   if parent_channel_id:
                       channel_ids.add(parent_channel_id)
       
      -            require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
      +            require_mention = self._discord_require_mention()
                   # Voice-linked text channels act as free-response while voice is active.
                   # Only the exact bound channel gets the exemption, not sibling threads.
                   voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
      @@ -2778,9 +2820,10 @@ 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)
      +            skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
                   auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
      -            if auto_thread and not skip_thread and not is_voice_linked_channel:
      +            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:
                       thread = await self._auto_create_thread(message)
                       if thread:
                           is_thread = True
    • scripts/release.py
      @@ -238,6 +238,8 @@
           "[redacted:email]": "malaiwah",
           "[redacted:email]": "gnanam1990",
           "[redacted:email]": "0xyg3n",
      +    "[redacted:email]": "Hypn0sis",
      +    "[redacted:email]": "OwenYWT",
           "[redacted:email]": "v1k22",
           "[redacted:email]": "RucchiZ",
           "[redacted:email]": "LehaoLin",
    • tests/gateway/test_discord_free_response.py
      @@ -96,7 +96,7 @@ def adapter(monkeypatch):
           return adapter
       
       
      -def make_message(*, channel, content: str, mentions=None):
      +def make_message(*, channel, content: str, mentions=None, msg_type=None):
           author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza")
           return SimpleNamespace(
               id=123,
      @@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None):
               created_at=datetime.now(timezone.utc),
               channel=channel,
               author=author,
      +        type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default,
           )
       
       
      @@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt
           assert event.text == "allowed without mention"
       
       
      [redacted:email]
      +async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch):
      +    monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
      +    monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
      +    adapter.config.extra["free_response_channels"] = ["789", "999"]
      +
      +    message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed from config")
      +
      +    await adapter._handle_message(message)
      +
      +    adapter.handle_message.assert_awaited_once()
      +    event = adapter.handle_message.await_args.args[0]
      +    assert event.text == "allowed from config"
      +
      +
       @pytest.mark.asyncio
       async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
           monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
      @@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
           assert event.source.thread_id == "999"
       
       
      [redacted:email]
      +async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):
      +    """Quote-replies should stay in-channel instead of trying to create a thread."""
      +    monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
      +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
      +    monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")
      +
      +    adapter._auto_create_thread = AsyncMock()
      +
      +    message = make_message(
      +        channel=FakeTextChannel(channel_id=123),
      +        content="reply without mention",
      +        msg_type=discord_platform.discord.MessageType.reply,
      +    )
      +
      +    await adapter._handle_message(message)
      +
      +    adapter._auto_create_thread.assert_not_awaited()
      +    adapter.handle_message.assert_awaited_once()
      +    event = adapter.handle_message.await_args.args[0]
      +    assert event.text == "reply without mention"
      +    assert event.source.chat_id == "123"
      +    assert event.source.chat_type == "group"
      +
      +
       @pytest.mark.asyncio
       async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
           """Setting auto_thread to false skips thread creation."""
      @@ -385,6 +426,33 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t
           assert event.source.chat_type == "group"
       
       
      [redacted:email]
      +async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch):
      +    """Free-response channels must NOT auto-create threads — bot replies inline.
      +
      +    Without this, every message in a free-response channel would spin off a
      +    thread (since the channel bypasses the @mention gate), defeating the
      +    lightweight-chat purpose of free-response mode.
      +    """
      +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
      +    monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789")
      +    monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)  # default true
      +
      +    adapter._auto_create_thread = AsyncMock()
      +
      +    message = make_message(
      +        channel=FakeTextChannel(channel_id=789),
      +        content="free chat message",
      +    )
      +
      +    await adapter._handle_message(message)
      +
      +    adapter._auto_create_thread.assert_not_awaited()
      +    adapter.handle_message.assert_awaited_once()
      +    event = adapter.handle_message.await_args.args[0]
      +    assert event.source.chat_type == "group"
      +
      +
       @pytest.mark.asyncio
       async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
           """Threads under a voice-linked channel should still require @mention."""
    • tests/gateway/test_discord_slash_commands.py
      @@ -401,6 +401,8 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
           message = SimpleNamespace(
               content="Hello world, how are you?",
               create_thread=AsyncMock(return_value=thread),
      +        channel=SimpleNamespace(send=AsyncMock()),
      +        author=SimpleNamespace(display_name="Jezza"),
           )
       
           result = await adapter._auto_create_thread(message)
      @@ -412,6 +414,48 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
           assert call_kwargs["auto_archive_duration"] == 1440
       
       
      [redacted:email]
      +async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):
      +    """Thread names must not contain raw <@id>, <@&id>, or <#id> markers.
      +
      +    Regression guard for #6336 — previously a message like
      +    ``<@&1490963422786093149> help`` would spawn a thread literally
      +    named ``<@&1490963422786093149> help``.
      +    """
      +    thread = SimpleNamespace(id=999, name="help")
      +    message = SimpleNamespace(
      +        content="<@&1490963422786093149> <@555> please help <#123>",
      +        create_thread=AsyncMock(return_value=thread),
      +        channel=SimpleNamespace(send=AsyncMock()),
      +        author=SimpleNamespace(display_name="Jezza"),
      +    )
      +
      +    await adapter._auto_create_thread(message)
      +
      +    name = message.create_thread.await_args[1]["name"]
      +    assert "<@" not in name, f"role/user mention leaked: {name!r}"
      +    assert "<#" not in name, f"channel mention leaked: {name!r}"
      +    assert name == "please help"
      +
      +
      [redacted:email]
      +async def test_auto_create_thread_falls_back_to_hermes_when_only_mentions(adapter):
      +    """If a message contains only mention syntax, the stripped content is
      +    empty — fall back to the 'Hermes' default rather than ''."""
      +    thread = SimpleNamespace(id=999, name="Hermes")
      +    message = SimpleNamespace(
      +        content="<@&1490963422786093149>",
      +        create_thread=AsyncMock(return_value=thread),
      +        channel=SimpleNamespace(send=AsyncMock()),
      +        author=SimpleNamespace(display_name="Jezza"),
      +    )
      +
      +    await adapter._auto_create_thread(message)
      +
      +    name = message.create_thread.await_args[1]["name"]
      +    assert name == "Hermes"
      +
      +
       @pytest.mark.asyncio
       async def test_auto_create_thread_truncates_long_names(adapter):
           long_text = "a" * 200
      @@ -419,6 +463,8 @@ async def test_auto_create_thread_truncates_long_names(adapter):
           message = SimpleNamespace(
               content=long_text,
               create_thread=AsyncMock(return_value=thread),
      +        channel=SimpleNamespace(send=AsyncMock()),
      +        author=SimpleNamespace(display_name="Jezza"),
           )
       
           result = await adapter._auto_create_thread(message)
      @@ -430,10 +476,33 @@ async def test_auto_create_thread_truncates_long_names(adapter):
       
       
       @pytest.mark.asyncio
      -async def test_auto_create_thread_returns_none_on_failure(adapter):
      +async def test_auto_create_thread_falls_back_to_seed_message(adapter):
      +    thread = SimpleNamespace(id=555, name="Hello")
      +    seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=thread))
      +    message = SimpleNamespace(
      +        content="Hello",
      +        create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
      +        channel=SimpleNamespace(send=AsyncMock(return_value=seed_message)),
      +        author=SimpleNamespace(display_name="Jezza"),
      +    )
      +
      +    result = await adapter._auto_create_thread(message)
      +    assert result is thread
      +    message.channel.send.assert_awaited_once_with("🧵 Thread created by Hermes: **Hello**")
      +    seed_message.create_thread.assert_awaited_once_with(
      +        name="Hello",
      +        auto_archive_duration=1440,
      +        reason="Auto-threaded from mention by Jezza",
      +    )
      +
      +
      [redacted:email]
      +async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(adapter):
           message = SimpleNamespace(
               content="Hello",
               create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
      +        channel=SimpleNamespace(send=AsyncMock(side_effect=RuntimeError("send failed"))),
      +        author=SimpleNamespace(display_name="Jezza"),
           )
       
           result = await adapter._auto_create_thread(message)

1 PR closed without merging

From the close comments: merged via another change 1

  • #8692 fix(discord): add auto-thread fallback path · merged via another change → #11629, #8487
Closed issues · 1
  • Closed#8487[Bug] Discord bot fails to respond to subsequent mentions in a new channel after the first auto-thread is created
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.67: 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

Merged PRs
#11629
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
17 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.