feat(discord): add thread_require_mention for multi-bot threads

By default, once Hermes participates in a Discord thread (auto-created on
@mention or replied in once) it auto-responds to every subsequent message
in that thread without requiring further @mentions. That's the right default
for one-on-one conversations and isolated channel threads.

But it's a confirmed footgun in multi-bot threads. When a user invokes one
bot per turn — addressing Codex first, then Hermes — every other bot in the
thread also fires on every message, burning credits and spamming the channel.
Author has hit this personally in active multi-bot research-team threads.

Add a new `discord.thread_require_mention` config key (env:
`DISCORD_THREAD_REQUIRE_MENTION`), default `false` to preserve existing
behavior. When `true`, the in-thread mention shortcut is disabled and
threads are gated the same way channels are. Explicit @mentions still pass
through as expected.

Mirrors the existing helper shape (config.extra > env > default) and the
existing yaml→env bridge pattern used by `require_mention`.

Changes:

- gateway/platforms/discord.py: new `_discord_thread_require_mention()`
  helper; in_bot_thread shortcut now AND's with `not _discord_thread_require_mention()`
- gateway/config.py: bridge `discord.thread_require_mention` from config.yaml
  to `DISCORD_THREAD_REQUIRE_MENTION` env var (mirrors the existing
  `require_mention` bridge two lines above)
- hermes_cli/config.py: add `thread_require_mention: False` default to
  DEFAULT_CONFIG['discord']
- tests/gateway/test_discord_free_response.py: 4 new tests covering default
  behaviour (in-thread shortcut still works), enabled behaviour (mention
  required in threads), enabled+mentioned (mention still passes through),
  and yaml-via-config.extra path. Also clears DISCORD_* env vars in the
  `adapter` fixture so process-env state from the contributor's shell
  doesn't leak into per-test behaviour.
- tests/gateway/test_config.py: 2 new tests covering the yaml→env bridge
  (both the apply-from-yaml and env-precedence-over-yaml paths)
- website/docs/user-guide/messaging/discord.md: document the new env var
  + config key with multi-bot rationale; cross-link from `auto_thread`
  section

Tested on Ubuntu 24.04.
This commit is contained in:
snav 2026-05-13 20:03:15 -04:00 committed by Teknium
parent d557544560
commit d863773c81
6 changed files with 169 additions and 3 deletions

View file

@ -908,6 +908,8 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(discord_cfg, dict):
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):

View file

@ -3577,6 +3577,25 @@ class DiscordAdapter(BasePlatformAdapter):
return {part.strip() for part in s.split(",") if part.strip()}
return set()
def _discord_thread_require_mention(self) -> bool:
"""Return whether thread participation requires @mention to follow up.
When ``False`` (default), once the bot has participated in a thread it
keeps responding to every message in that thread without needing to be
mentioned again useful for one-on-one conversations.
When ``True``, the @mention requirement is enforced inside threads as
well. Set this when multiple bots share a thread and you want each
one to only fire on explicit @mention, avoiding bot-to-bot loops or
unwanted cross-replies.
"""
configured = self.config.extra.get("thread_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_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
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
@ -4209,8 +4228,15 @@ class DiscordAdapter(BasePlatformAdapter):
)
# Skip the mention check if the message is in a thread where
# the bot has previously participated (auto-created or replied in).
in_bot_thread = is_thread and thread_id in self._threads
# the bot has previously participated (auto-created or replied in)
# — UNLESS thread_require_mention is enabled, in which case threads
# are gated the same as channels. Useful when multiple bots share
# a thread.
in_bot_thread = (
is_thread
and thread_id in self._threads
and not self._discord_thread_require_mention()
)
if require_mention and not is_free_channel and not in_bot_thread:
if self._client.user not in message.mentions and not mention_prefix:

View file

@ -1244,6 +1244,7 @@ DEFAULT_CONFIG = {
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES

View file

@ -302,6 +302,43 @@ class TestLoadGatewayConfig:
assert config.thread_sessions_per_user is False
def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch):
"""discord.thread_require_mention in config.yaml should reach the runtime env var."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" thread_require_mention: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
def test_thread_require_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch):
"""Explicit env var should win over config.yaml (env > yaml precedence)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" thread_require_mention: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") # user override
load_gateway_config()
# Env value preserved, not clobbered by yaml.
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()

View file

@ -88,6 +88,20 @@ def adapter(monkeypatch):
monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False)
monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False)
# Clear DISCORD_* env vars the test file exercises so tests don't leak
# process-env state from the contributor's shell into per-test behaviour.
# Individual tests still monkeypatch.setenv() for their own scenarios.
for _var in (
"DISCORD_REQUIRE_MENTION",
"DISCORD_THREAD_REQUIRE_MENTION",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_AUTO_THREAD",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
):
monkeypatch.delenv(_var, raising=False)
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
@ -494,3 +508,73 @@ async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_thread_default_keeps_responding_after_participation(adapter, monkeypatch):
"""Default behavior: once the bot is in a thread, it auto-responds without @mention."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False)
thread = FakeThread(channel_id=456, name="follow-up")
adapter._threads.mark("456") # bot has previously participated
message = make_message(channel=thread, content="follow-up without mention")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_discord_thread_require_mention_gates_followups(adapter, monkeypatch):
"""When thread_require_mention=true, even bot-participated threads need @mention."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
thread = FakeThread(channel_id=456, name="multi-bot thread")
adapter._threads.mark("456") # bot has previously participated
message = make_message(channel=thread, content="ambient chatter — not for me")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_thread_require_mention_still_responds_when_mentioned(adapter, monkeypatch):
"""thread_require_mention=true still lets explicit @mentions through in threads."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
thread = FakeThread(channel_id=456, name="multi-bot thread")
adapter._threads.mark("456")
bot_user = adapter._client.user
message = make_message(
channel=thread,
content=f"<@{bot_user.id}> hey, this one's for you",
mentions=[bot_user],
)
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypatch):
"""thread_require_mention can also be set via config.extra (yaml)."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter.config.extra["thread_require_mention"] = True
thread = FakeThread(channel_id=456, name="multi-bot thread")
adapter._threads.mark("456")
message = make_message(channel=thread, content="ambient — should be ignored")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()

View file

@ -277,6 +277,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
| `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. |
| `DISCORD_COMMAND_SYNC_POLICY` | No | `"safe"` | Controls native slash-command startup sync. `"safe"` diffs existing global commands and only updates what changed, recreating commands when Discord metadata changes cannot be applied via patch. `"bulk"` preserves the old `tree.sync()` behavior. `"off"` skips startup sync entirely. |
| `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. |
| `DISCORD_THREAD_REQUIRE_MENTION` | No | `false` | When `true`, the in-thread mention shortcut is disabled — threads are gated the same as channels, requiring `@mention` even after the bot has already participated. Use this when multiple bots share a thread and you want each to fire only on explicit `@mention`. |
| `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. |
| `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. |
| `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. |
@ -302,6 +303,7 @@ The `discord` section in `~/.hermes/config.yaml` mirrors the env vars above. Con
# Discord-specific settings
discord:
require_mention: true # Require @mention in server channels
thread_require_mention: false # If true, require @mention in threads too (multi-bot threads)
free_response_channels: "" # Comma-separated channel IDs (or YAML list)
auto_thread: true # Auto-create threads on @mention
reactions: true # Add emoji reactions during processing
@ -324,6 +326,20 @@ group_sessions_per_user: true # Isolate sessions per user in shared channels
When enabled, the bot only responds in server channels when directly `@mentioned`. DMs always get a response regardless of this setting.
#### `discord.thread_require_mention`
**Type:** boolean — **Default:** `false`
By default, once the bot has participated in a thread (auto-created on `@mention` or replied in once), it keeps responding to every subsequent message in that thread without needing to be `@mentioned` again. That's the right default for one-on-one conversations.
In **multi-bot threads** where users address one bot per turn, this default becomes a footgun — every other bot in the thread also fires on every message, burning credits and spamming the channel. Set `thread_require_mention: true` to disable the in-thread shortcut and gate threads the same way channels are gated. Explicit `@mentions` still work as before.
```yaml
discord:
require_mention: true
thread_require_mention: true # multi-bot setup
```
#### `discord.free_response_channels`
**Type:** string or list — **Default:** `""`
@ -350,7 +366,7 @@ Free-response channels also **skip auto-threading** — the bot replies inline r
**Type:** boolean — **Default:** `true`
When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating.
When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. Set [`thread_require_mention`](#discordthread_require_mention) to `true` to disable this in-thread shortcut for multi-bot setups.
Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead.