From 4abfb6bc24308653e13b24dd42ea210bf0c7dd64 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:49:01 -0700 Subject: [PATCH] feat(discord): default history backfill on, expand to per-user + threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to snav's PR #25463 contribution: flip default to on, broaden scope so backfill fires whenever require_mention gates the bot (not just shared-session channels). Why: - The mention-gate creates a session-transcript gap regardless of whether the channel is shared or per-user. In per-user sessions, Alice's session is still missing other participants' messages and her own pre-mention messages — backfill fills both gaps. - Threads naturally scope to thread-only history because discord.py's channel.history() on a thread returns only that thread's messages. - DMs still skip — every DM triggers the bot, so the session transcript is already complete. Changes: - hermes_cli/config.py: discord.history_backfill default → true - gateway/platforms/discord.py: drop the _is_shared gate, keep _is_dm skip and _needed_mention gate; env var DISCORD_HISTORY_BACKFILL default → 'true' - cli-config.yaml.example + website docs: update defaults and prose; add the DISCORD_HISTORY_BACKFILL / _LIMIT env var rows that were documented in the PR description but missing from the env-var table - tests/gateway/test_discord_free_response.py: - flip test_discord_per_user_channel_does_not_backfill → test_discord_per_user_channel_backfills_too (new behavior) - add test_discord_dm_does_not_backfill (DM skip is invariant) - give FakeThread a no-op history() so existing thread tests don't hit a fake discord.Forbidden when backfill now fires on threads too Tests: 160/160 in target files; 400/400 across all tests/gateway/ -k discord. --- cli-config.yaml.example | 2 +- gateway/platforms/discord.py | 23 +++++----- hermes_cli/config.py | 2 +- tests/gateway/test_discord_free_response.py | 47 ++++++++++++++++++-- website/docs/user-guide/messaging/discord.md | 26 +++++++++-- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index c286099a87a..3f98b8868ec 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -689,7 +689,7 @@ platform_toolsets: # auto_thread: true # Auto-create thread on @mention (default: true) # free_response_channels: "" # Channel IDs where no mention is needed # reactions: true # Show processing reactions (default: true) -# history_backfill: false # Recover missed channel messages on mention (default: false) +# history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) # ───────────────────────────────────────────────────────────────────────────── diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 652e8d4af76..a3904630fa9 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3613,7 +3613,7 @@ class DiscordAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() not in ("false", "0", "no", "off") return bool(configured) - return os.getenv("DISCORD_HISTORY_BACKFILL", "false").lower() in ("true", "1", "yes") + return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes") def _discord_history_backfill_limit(self) -> int: """Return the max number of messages to scan backwards for context. @@ -4644,8 +4644,8 @@ class DiscordAdapter(BasePlatformAdapter): # ── History backfill ───────────────────────────────────────── # When require_mention is active, the bot only processes messages - # that @mention it. This means channel messages between bot turns - # are invisible to the session transcript. To recover that context, + # that @mention it. Messages in the channel between bot turns are + # invisible to the session transcript. To recover that context, # fetch recent channel history and prepend it to the user message. # # The fetch window is: everything after the bot's last message in @@ -4653,9 +4653,14 @@ class DiscordAdapter(BasePlatformAdapter): # cold start (no prior bot message found), fetch the last N messages # and stop at the first self-message encountered. # - # This only runs for shared sessions (group_sessions_per_user=False - # or shared threads) where multiple users contribute context the bot - # would otherwise miss. + # Threads naturally scope to thread-only history (channel.history() + # on a thread returns only that thread's messages). DMs are skipped + # because every DM message triggers the bot — there's no mention gap + # to fill; the session transcript already has everything. + # + # Per-user sessions also benefit: Alice's session is missing the + # other-channel-participants' context, and her own messages from + # before she mentioned the bot. Backfill fills that gap. # # Messages that arrive while the bot is processing (between trigger # and response) are not captured — this is an accepted simplification @@ -4663,17 +4668,13 @@ class DiscordAdapter(BasePlatformAdapter): _channel_context = None _is_dm = isinstance(message.channel, discord.DMChannel) if not _is_dm: - _is_shared = ( - (is_thread and not self.config.extra.get("thread_sessions_per_user", False)) - or (not is_thread and not self.config.extra.get("group_sessions_per_user", True)) - ) _needed_mention = ( require_mention and not is_free_channel and not in_bot_thread ) _backfill_enabled = self._discord_history_backfill() - if _is_shared and _needed_mention and _backfill_enabled: + if _needed_mention and _backfill_enabled: _backfill_text = await self._fetch_channel_context( message.channel, before=message, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8bd8e7fa079..c3a8152f4a7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1251,7 +1251,7 @@ DEFAULT_CONFIG = { "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) - "history_backfill": False, # If True, prepend recent channel scrollback when bot is triggered in a shared channel + "history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out) "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index cf81961a201..c69af3e7781 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -87,6 +87,12 @@ class FakeThread: self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name) self.topic = None + def history(self, *, limit, before, after=None, oldest_first=None): + async def _iter(): + return + yield + return _iter() + @pytest.fixture def adapter(monkeypatch): @@ -820,7 +826,9 @@ async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeyp @pytest.mark.asyncio -async def test_discord_per_user_channel_does_not_backfill(adapter, monkeypatch): +async def test_discord_per_user_channel_backfills_too(adapter, monkeypatch): + """Per-user sessions also benefit from backfill: Alice's session is missing + other-channel-participants' context and her own pre-mention messages.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") @@ -837,9 +845,42 @@ async def test_discord_per_user_channel_does_not_backfill(adapter, monkeypatch): await adapter._handle_message(message) - adapter._fetch_channel_context.assert_not_awaited() + adapter._fetch_channel_context.assert_awaited_once() event = adapter.handle_message.await_args.args[0] assert event.text == "hello with mention" - assert event.channel_context is None + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_dm_does_not_backfill(adapter, monkeypatch): + """DMs skip backfill — every DM triggers the bot, so there's no mention gap.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + dm_channel = SimpleNamespace( + id=999, + name=None, + guild=None, + topic=None, + ) + # Make isinstance(channel, discord.DMChannel) return True + monkeypatch.setattr( + discord_platform.discord, "DMChannel", type(dm_channel), raising=False, + ) + + message = make_message( + channel=dm_channel, + content="hello in DM", + mentions=[], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_not_awaited() + if adapter.handle_message.await_args is not None: + event = adapter.handle_message.await_args.args[0] + assert event.channel_context is None diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 605e59e2e95..50f1641f093 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -286,6 +286,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. | | `DISCORD_ALLOWED_CHANNELS` | No | — | Comma-separated channel IDs. When set, the bot **only** responds in these channels (plus DMs if allowed). Overrides `config.yaml` `discord.allowed_channels`. Combine with `DISCORD_IGNORED_CHANNELS` to express allow/deny rules. | | `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. | +| `DISCORD_HISTORY_BACKFILL` | No | `true` | When `true`, prepend recent channel scrollback (since the bot's last response) to the user message when the bot is mentioned. Recovers context the bot would otherwise miss with `require_mention`. Skipped in DMs and free-response channels. Set to `false` to disable. | +| `DISCORD_HISTORY_BACKFILL_LIMIT` | No | `50` | Maximum number of messages to scan backwards when assembling the backfill block. In practice the scan usually stops earlier — at the bot's own last message in the channel. | | `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. | | `DISCORD_ALLOW_MENTION_EVERYONE` | No | `false` | When `false` (default), the bot cannot ping `@everyone` or `@here` even if its response contains those tokens. Set to `true` to opt back in. See [Mention Control](#mention-control) below. | | `DISCORD_ALLOW_MENTION_ROLES` | No | `false` | When `false` (default), the bot cannot ping `@role` mentions. Set to `true` to allow. | @@ -309,6 +311,8 @@ discord: reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading + history_backfill: true # Prepend recent channel scrollback on mention (default: true) + history_backfill_limit: 50 # Max messages to scan backwards (default: 50) channel_prompts: {} # Per-channel ephemeral system prompts allow_mentions: # What the bot is allowed to ping (safe defaults) everyone: false # @everyone / @here pings (default: false) @@ -439,15 +443,29 @@ Behavior: #### `discord.history_backfill` -**Type:** boolean — **Default:** `false` +**Type:** boolean — **Default:** `true` -When enabled, the bot recovers missed channel messages on each `@mention`. With `require_mention: true`, the bot only processes messages that tag it directly — everything else in the channel is invisible. History backfill scans backwards through recent channel history when triggered, collecting messages between the bot's last response and the current mention, and includes them as context. +When enabled, the bot recovers missed channel messages on each `@mention`. With `require_mention: true`, the bot only processes messages that tag it directly — everything else in the channel is invisible to the session transcript. History backfill scans backwards through recent channel history when triggered, collecting messages between the bot's last response and the current mention, and includes them as context. -This is most useful for **shared sessions** (`group_sessions_per_user: false`) where multiple users contribute to the same conversation and the bot needs to see what happened between turns. +Behavior by surface: + +- **Server channels** (with `require_mention: true`): backfill scans the channel since the bot's last response. Useful when other participants posted while the bot wasn't addressed. +- **Threads**: backfill scans the thread only — Discord's `channel.history()` on a thread returns only that thread's messages, not the parent channel. This is the right scope because threads are usually self-contained conversations. +- **DMs**: skipped. Every DM message triggers the bot, so the session transcript is already complete — there's no mention gap to fill. +- **Free-response channels** and **bot's own auto-created threads**: skipped for the same reason — no mention gating means no gap. + +Per-user sessions (`group_sessions_per_user: true`, the default) also benefit: a user's session is missing the context posted by other channel participants and the user's own messages from before they tagged the bot. Backfill fills both gaps. ```yaml discord: - history_backfill: true + history_backfill: true # default +``` + +To turn it off: + +```yaml +discord: + history_backfill: false ``` > **Note:** Messages that arrive *while* the bot is processing (between a trigger and its response) are not captured. This is an accepted simplification — the user can re-send or tag again.