feat(discord): channel history backfill for multi-user sessions

Adds optional channel-context backfill for Discord shared-channel sessions
so the agent can see recent messages it missed between its own turns
(typically when require_mention=true filters out most traffic).

Previously the agent only saw the @mention message that triggered it, which
led to disorienting replies in active multi-user channels where the
conversation context was invisible. With backfill enabled, a configurable
number of recent messages are fetched per-turn and prepended to the trigger
message as a context block, kept separate from sender-prefix logic so
attribution remains clean.

This re-opens the work from #13063 (approved by @OutThisLife on 2026-04-20,
closed when I closed the branch to address the simpolism:main head-branch
issue plus an ordering bug I caught later in live use). Filing against the
freshly-rewritten problem statement in #13054 so the design is grounded in
the failure mode rather than the implementation shape.

The implementation follows the **push-mode last-self-anchored** design from
the two options laid out in #13054. See the issue for the trade-off
discussion vs pull-mode (#13120 was an earlier closed PR using that shape).
Treating this as a reference implementation — happy to rewrite as
last-trigger anchoring or as a hybrid with #13120 if maintainers prefer.

Changes:

- gateway/platforms/discord.py:
  - new `_discord_history_backfill()` / `_discord_history_backfill_limit()`
    helpers (config.extra > env > default), mirroring the existing
    `_discord_require_mention()` shape
  - new `_fetch_channel_context()` that scans `channel.history()` backwards
    from the trigger to the bot's last message (or limit), formats as
    `[Recent channel messages] / [name] msg / ...`, respects DISCORD_ALLOW_BOTS,
    skips system messages
  - per-channel `_last_self_message_id` cache to narrow the fetch window
    on hot paths (avoids full history scan when the bot has spoken recently)
  - **IMPORTANT**: passes `oldest_first=False` explicitly to `channel.history()`.
    discord.py 2.x silently flips the default to True when `after=` is supplied,
    which would select the EARLIEST N messages after our last response instead
    of the LATEST N before the trigger. In high-traffic windows this would
    return stale tool traces and drop the actual final answer the user is
    asking about. See regression test below. Caught in live use during a
    Codex tool-trace burst on May 13 2026.
- gateway/config.py: discord_history_backfill + discord_history_backfill_limit
  settings + yaml→env bridge
- gateway/platforms/base.py: channel_context field on MessageEvent
- gateway/run.py: prepend channel_context after sender-prefix so the
  [sender name] tag applies to the trigger message alone, not to the backfill
- hermes_cli/config.py: defaults for new discord.history_backfill and
  discord.history_backfill_limit keys
- cli-config.yaml.example: documented defaults
- tests/gateway/test_discord_free_response.py: 7 new tests covering
  cold-start backfill, self-message stop boundary, other-bot filtering,
  cache hot-path narrowing, stale-cache fallback, shared-channel +
  per-user backfill paths, and the ordering regression test
  (`test_fetch_channel_context_cache_uses_latest_window_when_after_set`)
- tests/gateway/test_config.py: yaml→env bridge tests
- tests/gateway/test_session.py: prefix-order edge cases
- website/docs/user-guide/messaging/discord.md: env vars + config keys +
  usage docs

Tested on Ubuntu 24.04 — empirically validated in my own multi-bot Discord
research server for the past three weeks.

Fixes #13054
Supersedes #13063 (closed)
This commit is contained in:
snav 2026-05-14 01:46:11 -04:00 committed by Teknium
parent ccb5aae0d2
commit e84fe483bc
10 changed files with 596 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import (
SessionSource,
SessionStore,
@ -430,6 +431,76 @@ class TestBuildSessionContextPrompt:
assert "Multi-user thread" not in prompt
class TestSenderPrefixWithBackfill:
"""Regression: sender prefix must not wrap the backfill context block.
Tests exercise the real GatewayRunner._prepare_inbound_message_text()
method to ensure the [sender_name] prefix applies only to the trigger
message, not the channel_context backfill block.
"""
@pytest.fixture()
def runner(self):
from gateway.run import GatewayRunner
r = GatewayRunner.__new__(GatewayRunner)
r.config = GatewayConfig(group_sessions_per_user=False)
r.adapters = {}
r._model = "test-model"
r._base_url = ""
r._has_setup_skill = lambda: False
return r
@pytest.fixture()
def source(self):
return SessionSource(
platform=Platform.DISCORD,
chat_id="c1",
chat_type="group",
user_name="Alice",
)
@pytest.mark.asyncio
async def test_plain_message_gets_prefix(self, runner, source):
"""Normal message without backfill gets [sender] prefix."""
event = MessageEvent(text="hello world", source=source)
result = await runner._prepare_inbound_message_text(
event=event, source=source, history=[],
)
assert result == "[Alice] hello world"
@pytest.mark.asyncio
async def test_backfill_prefix_only_on_trigger(self, runner, source):
"""Backfill context must NOT get the sender prefix."""
event = MessageEvent(
text="hello world",
source=source,
channel_context="[Recent channel messages]\n[Bob] some context",
)
result = await runner._prepare_inbound_message_text(
event=event, source=source, history=[],
)
assert result.startswith("[Recent channel messages]")
assert "[Alice] [Recent channel messages]" not in result
assert "[New message]\n[Alice] hello world" in result
@pytest.mark.asyncio
async def test_backfill_preserves_context_block(self, runner, source):
"""The backfill block should pass through unchanged — no double-prefixing."""
context = "[Recent channel messages]\n[Bob] first\n[Charlie [bot]] second"
event = MessageEvent(
text="hey everyone", source=source, channel_context=context,
)
result = await runner._prepare_inbound_message_text(
event=event, source=source, history=[],
)
assert result.startswith(context)
assert "[Alice] hey everyone" in result
assert "[Alice] [Bob]" not in result
assert "[Alice] [Charlie" not in result
assert "[Alice] [Recent" not in result
class TestSessionStoreRewriteTranscript:
"""Regression: /retry and /undo must persist truncated history to disk."""