Discord free-response channels and auto-threading behavior

NousResearch/hermes-agent · cluster 9399 · gateway

Data as of

4 open items. Fix merged in #11629. Check it covers your case.

The short answer

3open issues
1open PR
14items grouped
Known?
Yes. 14 related items grouped here, 2 issues 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 partially resolved, deciding PRs #11629, #25444. That is separate from the fix event.
Still open
3 issues and 1 PR.

These issues and PRs concern how Discord free-response channels interact with auto-threading, including whether threads should be created, suppressed, or made opt-in, and how reply messages and wildcard channel matching behave in that context.

Open issues3

  • Issue#15262fix(discord): free_response_channels now correctly honored — breaks existing workflows that relied on wildcard '*' never matching
  • Issue#26058auto_thread disabled for free_response_channels breaks legitimate use case
  • Issue#52766Discord: Add free_response_thread_channels — no @mention but keep auto-threading

Open PRs1

  • PR#112573fix(discord): auto-thread free-response channel messages too

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)
  • #25444 fix(discord): keep free-response channels inline

    merged to main

    files: gateway/platforms/discord.py, tests/gateway/test_discord_free_response.py

    Show the diff
    • gateway/platforms/discord.py
      @@ -4223,7 +4223,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)
      +            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"}
                   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:
    • tests/gateway/test_discord_free_response.py
      @@ -446,6 +446,37 @@ 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_response_channel_skips_auto_thread(adapter, monkeypatch):
      +    """Free-response channels should reply inline, never spawn a new thread.
      +
      +    Without this, every message in a free-response channel would auto-create
      +    a fresh thread (since the channel bypasses the @mention gate, every
      +    message looks like a fresh trigger).  That turns a "lightweight chat"
      +    channel into a thread-spawning machine — see the docs at
      +    website/docs/user-guide/messaging/discord.md which already describe
      +    this as the intended behavior.
      +    """
      +    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="casual chat in free-response channel",
      +    )
      +
      +    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 == "casual chat in free-response channel"
      +    assert event.source.chat_type == "group"
      +
      +
       
       
       @pytest.mark.asyncio
  • #116168 Discord: free-response channels can opt back into auto-threading (#26058, salvage #18455)

    merged to main

    files: cli-config.yaml.example, contributors/emails/[redacted:email], hermes_cli/config_defaults.py, plugins/platforms/discord/adapter.py, tests/gateway/test_discord_free_response.py, website/docs/reference/environment-variables.md, website/docs/user-guide/configuration.md, website/docs/user-guide/messaging/discord.md, website/docs/user-guide/multi-profile-gateways.md

5 PRs closed without merging

From the close comments: merged via another change 2 · duplicate 1 · superseded by another PR 1 · other 1

  • #9624 fix(discord): skip auto-threading reply messages · merged via another change → #11629, #9399, #9650
  • #18455 feat(discord): add free_response_auto_thread opt-in · superseded by another PR → 096bd01, #116168
  • #25311 fix(discord): keep free-response channels inline · merged via another change → d557544, #25444
  • #26074 fix(discord): decouple auto_thread from free_response_channels · other
  • #103990 feat(discord): opt-in auto-thread in free-response channels · duplicate → #86351
Closed issues · 2
  • Closed#9399Discord: bot silently ignores quote-replies in free_response_channels
  • Closed#25310[Bug]: Free-response Discord channels spawn a new thread per message, contradicting documented inline-reply behavior
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.13: 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
#11629, #25444, #116168
Closed issues
2
Attempts
5 PRs closed without merging
Fix link checked
19 Sep 2026

For your agent

The same facts, typed.