NousResearch/hermes-agent

Message deduplication fixes for Discord and Slack gateways

Cluster 4777 · gateway. Data as of

Nothing open on GitHub. Fix merged in #4878 on 4 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #4878. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #4878. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address duplicate message handling in the gateway layer, specifically adding or correcting deduplication logic for Discord and Slack adapters. A partial stream guard is also mentioned as part of the prevention strategy.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4878 fix: prevent duplicate messages — gateway dedup + partial stream guard

    merged to main

    files: gateway/platforms/discord.py, gateway/platforms/slack.py, run_agent.py

    Show the diff
    • gateway/platforms/discord.py
      @@ -449,6 +449,11 @@ def __init__(self, config: PlatformConfig):
               self._bot_task: Optional[asyncio.Task] = None
               # Cap to prevent unbounded growth (Discord threads get archived).
               self._MAX_TRACKED_THREADS = 500
      +        # Dedup cache: message_id → timestamp.  Prevents duplicate bot
      +        # responses when Discord RESUME replays events after reconnects.
      +        self._seen_messages: Dict[str, float] = {}
      +        self._SEEN_TTL = 300   # 5 minutes
      +        self._SEEN_MAX = 2000  # prune threshold
       
           async def connect(self) -> bool:
               """Connect to Discord and start receiving events."""
      @@ -539,6 +544,19 @@ async def on_ready():
       
                   @self._client.event
                   async def on_message(message: DiscordMessage):
      +                # Dedup: Discord RESUME replays events after reconnects (#4777)
      +                msg_id = str(message.id)
      +                now = time.time()
      +                if msg_id in adapter_self._seen_messages:
      +                    return
      +                adapter_self._seen_messages[msg_id] = now
      +                if len(adapter_self._seen_messages) > adapter_self._SEEN_MAX:
      +                    cutoff = now - adapter_self._SEEN_TTL
      +                    adapter_self._seen_messages = {
      +                        k: v for k, v in adapter_self._seen_messages.items()
      +                        if v > cutoff
      +                    }
      +
                       # Always ignore our own messages
                       if message.author == self._client.user:
                           return
    • gateway/platforms/slack.py
      @@ -13,6 +13,7 @@
       import logging
       import os
       import re
      +import time
       from typing import Dict, Optional, Any
       
       try:
      @@ -78,6 +79,11 @@ def __init__(self, config: PlatformConfig):
               self._team_clients: Dict[str, AsyncWebClient] = {}   # team_id → WebClient
               self._team_bot_user_ids: Dict[str, str] = {}          # team_id → bot_user_id
               self._channel_team: Dict[str, str] = {}                # channel_id → team_id
      +        # Dedup cache: event_ts → timestamp.  Prevents duplicate bot
      +        # responses when Socket Mode reconnects redeliver events.
      +        self._seen_messages: Dict[str, float] = {}
      +        self._SEEN_TTL = 300   # 5 minutes
      +        self._SEEN_MAX = 2000  # prune threshold
       
           async def connect(self) -> bool:
               """Connect to Slack via Socket Mode."""
      @@ -710,6 +716,20 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
       
           async def _handle_slack_message(self, event: dict) -> None:
               """Handle an incoming Slack message event."""
      +        # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
      +        event_ts = event.get("ts", "")
      +        if event_ts:
      +            now = time.time()
      +            if event_ts in self._seen_messages:
      +                return
      +            self._seen_messages[event_ts] = now
      +            if len(self._seen_messages) > self._SEEN_MAX:
      +                cutoff = now - self._SEEN_TTL
      +                self._seen_messages = {
      +                    k: v for k, v in self._seen_messages.items()
      +                    if v > cutoff
      +                }
      +
               # Ignore bot messages (including our own)
               if event.get("bot_id") or event.get("subtype") == "bot_message":
                   return
    • run_agent.py
      @@ -4473,6 +4473,29 @@ def _call():
                           pass
                       raise InterruptedError("Agent interrupted during streaming API call")
               if result["error"] is not None:
      +            if deltas_were_sent["yes"]:
      +                # Streaming failed AFTER some tokens were already delivered to
      +                # the platform.  Re-raising would let the outer retry loop make
      +                # a new API call, creating a duplicate message.  Return a
      +                # partial "stop" response instead so the outer loop treats this
      +                # turn as complete (no retry, no fallback).
      +                logger.warning(
      +                    "Partial stream delivered before error; returning stub "
      +                    "response to prevent duplicate messages: %s",
      +                    result["error"],
      +                )
      +                _stub_msg = SimpleNamespace(
      +                    role="assistant", content=None, tool_calls=None,
      +                    reasoning_content=None,
      +                )
      +                return SimpleNamespace(
      +                    id="partial-stream-stub",
      +                    model=getattr(self, "model", "unknown"),
      +                    choices=[SimpleNamespace(
      +                        index=0, message=_stub_msg, finish_reason="stop",
      +                    )],
      +                    usage=None,
      +                )
                   raise result["error"]
               return result["response"]
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #4866 fix(gateway): add message deduplication to Discord and Slack adapters · merged via another change → #4878
Closed issues · 1
  • Closed#4777fix(gateway): Discord and Slack adapters missing message deduplication
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#4878
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
4 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.