{"repo":"NousResearch/hermes-agent","public_id":"9399","display_label":"Discord free-response channels and auto-threading behavior","summary":"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.","subsystem":"gateway","counts":{"members":14,"open_issues":3,"open_issues_distinct":3,"open_prs":1,"closed_issues":2,"merged_prs":3,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11629},"resolution":{"state":"partially_resolved","by":"pr-11629,pr-25444"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T16:14:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1257142857142857},"fix_edges":[{"issue_number":6336,"pr_number":11629,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8487,"pr_number":11629,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9399,"pr_number":11629,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15262,"pr_number":116168,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25310,"pr_number":25444,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9399,"title":"Discord: bot silently ignores quote-replies in free_response_channels","url":null,"state":"closed"},{"kind":"issue","number":15262,"title":"fix(discord): free_response_channels now correctly honored — breaks existing workflows that relied on wildcard '*' never matching","url":null,"state":"open"},{"kind":"issue","number":25310,"title":"[Bug]: Free-response Discord channels spawn a new thread per message, contradicting documented inline-reply behavior","url":null,"state":"closed"},{"kind":"issue","number":26058,"title":"auto_thread disabled for free_response_channels breaks legitimate use case","url":null,"state":"open"},{"kind":"issue","number":52766,"title":"Discord: Add free_response_thread_channels — no @mention but keep auto-threading","url":null,"state":"open"},{"kind":"pr","number":9624,"title":"fix(discord): skip auto-threading reply messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11629","number":11629},{"kind":"pr","value":"#9399","number":9399},{"kind":"pr","value":"#9650","number":9650}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11629,"title":"fix(discord): auto-thread cluster — free-response + reply + fallback + mention-name-strip","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","scripts/release.py","tests/gateway/test_discord_free_response.py","tests/gateway/test_discord_slash_commands.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -2285,6 +2285,26 @@ def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None)\n         from gateway.platforms.base import resolve_channel_prompt\n         return resolve_channel_prompt(self.config.extra, channel_id, parent_id)\n \n+    def _discord_require_mention(self) -> bool:\n+        \"\"\"Return whether Discord channel messages require a bot mention.\"\"\"\n+        configured = self.config.extra.get(\"require_mention\")\n+        if configured is not None:\n+            if isinstance(configured, str):\n+                return configured.lower() not in (\"false\", \"0\", \"no\", \"off\")\n+            return bool(configured)\n+        return os.getenv(\"DISCORD_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\", \"off\")\n+\n+    def _discord_free_response_channels(self) -> set:\n+        \"\"\"Return Discord channel IDs where no bot mention is required.\"\"\"\n+        raw = self.config.extra.get(\"free_response_channels\")\n+        if raw is None:\n+            raw = os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"\")\n+        if isinstance(raw, list):\n+            return {str(part).strip() for part in raw if str(part).strip()}\n+        if isinstance(raw, str) and raw.strip():\n+            return {part.strip() for part in raw.split(\",\") if part.strip()}\n+        return set()\n+\n     def _thread_parent_channel(self, channel: Any) -> Any:\n         \"\"\"Return the parent text channel when invoked from a thread.\"\"\"\n         return getattr(channel, \"parent\", None) or channel","@@ -2387,8 +2407,15 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n \n         Returns the created thread object, or ``None`` on failure.\n         \"\"\"\n-        # Build a short thread name from the message\n+        # Build a short thread name from the message. Strip Discord mention\n+        # syntax (users / roles / channels) so thread titles don't end up\n+        # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't\n+        # meaningful to humans glancing at the thread list (#6336).\n         content = (message.content or \"\").strip()\n+        # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces.\n+        content = re.sub(r\"<@[!&]?\\d+>\", \"\", content)\n+        content = re.sub(r\"<#\\d+>\", \"\", content)\n+        content = re.sub(r\"\\s+\", \" \", content).strip()\n         thread_name = content[:80] if content else \"Hermes\"\n         if len(content) > 80:\n             thread_name = thread_name[:77] + \"...\"","@@ -2396,9 +2423,25 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         try:\n             thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)\n             return thread\n-        except Exception as e:\n-            logger.warning(\"[%s] Auto-thread creation failed: %s\", self.name, e)\n-            return None\n+        except Exception as direct_error:\n+            display_name = getattr(getattr(message, \"author\", None), \"display_name\", None) or \"unknown user\"\n+            reason = f\"Auto-threaded from mention by {display_name}\"\n+            try:\n+                seed_msg = await message.channel.send(f\"\\U0001f9f5 Thread created by Hermes: **{thread_name}**\")\n+                thread = await seed_msg.create_thread(\n+                    name=thread_name,\n+                    auto_archive_duration=1440,\n+                    reason=reason,\n+                )\n+                return thread\n+            except Exception as fallback_error:\n+                logger.warning(\n+                    \"[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s\",\n+                    self.name,\n+                    direct_error,\n+                    fallback_error,\n+                )\n+                return None\n \n     async def send_exec_approval(\n         self, chat_id: str, command: str, session_key: str,","@@ -2745,12 +2788,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n                 logger.debug(\"[%s] Ignoring message in ignored channel: %s\", self.name, channel_ids)\n                 return\n \n-            free_channels_raw = os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"\")\n-            free_channels = {ch.strip() for ch in free_channels_raw.split(\",\") if ch.strip()}\n+            free_channels = self._discord_free_response_channels()\n             if parent_channel_id:\n                 channel_ids.add(parent_channel_id)\n \n-            require_mention = os.getenv(\"DISCORD_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\")\n+            require_mention = self._discord_require_mention()\n             # Voice-linked text channels act as free-response while voice is active.\n             # Only the exact bound channel gets the exemption, not sibling threads.\n             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:\n         if not is_thread and not isinstance(message.channel, discord.DMChannel):\n             no_thread_channels_raw = os.getenv(\"DISCORD_NO_THREAD_CHANNELS\", \"\")\n             no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(\",\") if ch.strip()}\n-            skip_thread = bool(channel_ids & no_thread_channels)\n+            skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel\n             auto_thread = os.getenv(\"DISCORD_AUTO_THREAD\", \"true\").lower() in (\"true\", \"1\", \"yes\")\n-            if auto_thread and not skip_thread and not is_voice_linked_channel:\n+            is_reply_message = getattr(message, \"type\", None) == discord.MessageType.reply\n+            if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:\n                 thread = await self._auto_create_thread(message)\n                 if thread:\n                     is_thread = True"]},{"file":"scripts/release.py","hunks":["@@ -238,6 +238,8 @@\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"gnanam1990\",\n     \"[redacted:email]\": \"0xyg3n\",\n+    \"[redacted:email]\": \"Hypn0sis\",\n+    \"[redacted:email]\": \"OwenYWT\",\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n     \"[redacted:email]\": \"LehaoLin\","]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -96,7 +96,7 @@ def adapter(monkeypatch):\n     return adapter\n \n \n-def make_message(*, channel, content: str, mentions=None):\n+def make_message(*, channel, content: str, mentions=None, msg_type=None):\n     author = SimpleNamespace(id=42, display_name=\"Jezza\", name=\"Jezza\")\n     return SimpleNamespace(\n         id=123,","@@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None):\n         created_at=datetime.now(timezone.utc),\n         channel=channel,\n         author=author,\n+        type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default,\n     )\n \n ","@@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt\n     assert event.text == \"allowed without mention\"\n \n \n[redacted:email]\n+async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch):\n+    monkeypatch.delenv(\"DISCORD_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+    adapter.config.extra[\"free_response_channels\"] = [\"789\", \"999\"]\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=789), content=\"allowed from config\")\n+\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.text == \"allowed from config\"\n+\n+\n @pytest.mark.asyncio\n async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")","@@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):\n     assert event.source.thread_id == \"999\"\n \n \n[redacted:email]\n+async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):\n+    \"\"\"Quote-replies should stay in-channel instead of trying to create a thread.\"\"\"\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")\n+    monkeypatch.setenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"123\")\n+\n+    adapter._auto_create_thread = AsyncMock()\n+\n+    message = make_message(\n+        channel=FakeTextChannel(channel_id=123),\n+        content=\"reply without mention\",\n+        msg_type=discord_platform.discord.MessageType.reply,\n+    )\n+\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_not_awaited()\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.text == \"reply without mention\"\n+    assert event.source.chat_id == \"123\"\n+    assert event.source.chat_type == \"group\"\n+\n+\n @pytest.mark.asyncio\n async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):\n     \"\"\"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\n     assert event.source.chat_type == \"group\"\n \n \n[redacted:email]\n+async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch):\n+    \"\"\"Free-response channels must NOT auto-create threads — bot replies inline.\n+\n+    Without this, every message in a free-response channel would spin off a\n+    thread (since the channel bypasses the @mention gate), defeating the\n+    lightweight-chat purpose of free-response mode.\n+    \"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")\n+    monkeypatch.setenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"789\")\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)  # default true\n+\n+    adapter._auto_create_thread = AsyncMock()\n+\n+    message = make_message(\n+        channel=FakeTextChannel(channel_id=789),\n+        content=\"free chat message\",\n+    )\n+\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_not_awaited()\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.source.chat_type == \"group\"\n+\n+\n @pytest.mark.asyncio\n async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):\n     \"\"\"Threads under a voice-linked channel should still require @mention.\"\"\""]},{"file":"tests/gateway/test_discord_slash_commands.py","hunks":["@@ -401,6 +401,8 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):\n     message = SimpleNamespace(\n         content=\"Hello world, how are you?\",\n         create_thread=AsyncMock(return_value=thread),\n+        channel=SimpleNamespace(send=AsyncMock()),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n     )\n \n     result = await adapter._auto_create_thread(message)","@@ -412,6 +414,48 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):\n     assert call_kwargs[\"auto_archive_duration\"] == 1440\n \n \n[redacted:email]\n+async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):\n+    \"\"\"Thread names must not contain raw <@id>, <@&id>, or <#id> markers.\n+\n+    Regression guard for #6336 — previously a message like\n+    ``<@&1490963422786093149> help`` would spawn a thread literally\n+    named ``<@&1490963422786093149> help``.\n+    \"\"\"\n+    thread = SimpleNamespace(id=999, name=\"help\")\n+    message = SimpleNamespace(\n+        content=\"<@&1490963422786093149> <@555> please help <#123>\",\n+        create_thread=AsyncMock(return_value=thread),\n+        channel=SimpleNamespace(send=AsyncMock()),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n+    )\n+\n+    await adapter._auto_create_thread(message)\n+\n+    name = message.create_thread.await_args[1][\"name\"]\n+    assert \"<@\" not in name, f\"role/user mention leaked: {name!r}\"\n+    assert \"<#\" not in name, f\"channel mention leaked: {name!r}\"\n+    assert name == \"please help\"\n+\n+\n[redacted:email]\n+async def test_auto_create_thread_falls_back_to_hermes_when_only_mentions(adapter):\n+    \"\"\"If a message contains only mention syntax, the stripped content is\n+    empty — fall back to the 'Hermes' default rather than ''.\"\"\"\n+    thread = SimpleNamespace(id=999, name=\"Hermes\")\n+    message = SimpleNamespace(\n+        content=\"<@&1490963422786093149>\",\n+        create_thread=AsyncMock(return_value=thread),\n+        channel=SimpleNamespace(send=AsyncMock()),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n+    )\n+\n+    await adapter._auto_create_thread(message)\n+\n+    name = message.create_thread.await_args[1][\"name\"]\n+    assert name == \"Hermes\"\n+\n+\n @pytest.mark.asyncio\n async def test_auto_create_thread_truncates_long_names(adapter):\n     long_text = \"a\" * 200","@@ -419,6 +463,8 @@ async def test_auto_create_thread_truncates_long_names(adapter):\n     message = SimpleNamespace(\n         content=long_text,\n         create_thread=AsyncMock(return_value=thread),\n+        channel=SimpleNamespace(send=AsyncMock()),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n     )\n \n     result = await adapter._auto_create_thread(message)","@@ -430,10 +476,33 @@ async def test_auto_create_thread_truncates_long_names(adapter):\n \n \n @pytest.mark.asyncio\n-async def test_auto_create_thread_returns_none_on_failure(adapter):\n+async def test_auto_create_thread_falls_back_to_seed_message(adapter):\n+    thread = SimpleNamespace(id=555, name=\"Hello\")\n+    seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=thread))\n+    message = SimpleNamespace(\n+        content=\"Hello\",\n+        create_thread=AsyncMock(side_effect=RuntimeError(\"no perms\")),\n+        channel=SimpleNamespace(send=AsyncMock(return_value=seed_message)),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n+    )\n+\n+    result = await adapter._auto_create_thread(message)\n+    assert result is thread\n+    message.channel.send.assert_awaited_once_with(\"🧵 Thread created by Hermes: **Hello**\")\n+    seed_message.create_thread.assert_awaited_once_with(\n+        name=\"Hello\",\n+        auto_archive_duration=1440,\n+        reason=\"Auto-threaded from mention by Jezza\",\n+    )\n+\n+\n[redacted:email]\n+async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(adapter):\n     message = SimpleNamespace(\n         content=\"Hello\",\n         create_thread=AsyncMock(side_effect=RuntimeError(\"no perms\")),\n+        channel=SimpleNamespace(send=AsyncMock(side_effect=RuntimeError(\"send failed\"))),\n+        author=SimpleNamespace(display_name=\"Jezza\"),\n     )\n \n     result = await adapter._auto_create_thread(message)\n"]}]},{"kind":"pr","number":18455,"title":"feat(discord): add free_response_auto_thread opt-in","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"096bd01435","number":0},{"kind":"pr","value":"#116168","number":116168},{"kind":"pr","value":"#116168","number":116168},{"kind":"pr","value":"#116168","number":116168}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25311,"title":"fix(discord): keep free-response channels inline","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d55754456","number":0},{"kind":"pr","value":"#25444","number":25444}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":25444,"title":"fix(discord): keep free-response channels inline","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","tests/gateway/test_discord_free_response.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -4223,7 +4223,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n         if not is_thread and not isinstance(message.channel, discord.DMChannel):\n             no_thread_channels_raw = os.getenv(\"DISCORD_NO_THREAD_CHANNELS\", \"\")\n             no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(\",\") if ch.strip()}\n-            skip_thread = bool(channel_ids & no_thread_channels)\n+            skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel\n             auto_thread = os.getenv(\"DISCORD_AUTO_THREAD\", \"true\").lower() in {\"true\", \"1\", \"yes\"}\n             is_reply_message = getattr(message, \"type\", None) == discord.MessageType.reply\n             if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:"]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -446,6 +446,37 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t\n     assert event.source.chat_type == \"group\"\n \n \n[redacted:email]\n+async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypatch):\n+    \"\"\"Free-response channels should reply inline, never spawn a new thread.\n+\n+    Without this, every message in a free-response channel would auto-create\n+    a fresh thread (since the channel bypasses the @mention gate, every\n+    message looks like a fresh trigger).  That turns a \"lightweight chat\"\n+    channel into a thread-spawning machine — see the docs at\n+    website/docs/user-guide/messaging/discord.md which already describe\n+    this as the intended behavior.\n+    \"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")\n+    monkeypatch.setenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"789\")\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)  # default true\n+\n+    adapter._auto_create_thread = AsyncMock()\n+\n+    message = make_message(\n+        channel=FakeTextChannel(channel_id=789),\n+        content=\"casual chat in free-response channel\",\n+    )\n+\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_not_awaited()\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.text == \"casual chat in free-response channel\"\n+    assert event.source.chat_type == \"group\"\n+\n+\n \n \n @pytest.mark.asyncio\n"]}]},{"kind":"pr","number":26074,"title":"fix(discord): decouple auto_thread from free_response_channels","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":103990,"title":"feat(discord): opt-in auto-thread in free-response channels","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#86351","number":86351},{"kind":"pr","value":"#86351","number":86351},{"kind":"pr","value":"#86351","number":86351}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":112573,"title":"fix(discord): auto-thread free-response channel messages too","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#116168","number":116168},{"kind":"pr","value":"#26058","number":26058}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":116168,"title":"Discord: free-response channels can opt back into auto-threading (#26058, salvage #18455)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}