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
- 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.
Next step: read #11629, then check whether your report is one of the 3 still open.
Read #11629Pick up a Lab taskThese 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
-
#11629 fix(discord): auto-thread cluster — free-response + reply + fallback + mention-name-strip
files:
gateway/platforms/discord.py,scripts/release.py,tests/gateway/test_discord_free_response.py,tests/gateway/test_discord_slash_commands.pyShow 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
files:
gateway/platforms/discord.py,tests/gateway/test_discord_free_response.pyShow 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)
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
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.00means 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