{"repo":"NousResearch/hermes-agent","public_id":"4777","display_label":"Message deduplication fixes for Discord and Slack gateways","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4878,"merged_at":"2026-04-04T01:53:52+00:00"},"resolution":{"state":"resolved","by":"pr-4878"},"freshness":{"state":"stale","last_verified_at":"2026-04-04T01:53:52+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4777,"pr_number":4878,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-04T01:53:52+00:00"}],"members":[{"kind":"issue","number":4777,"title":"fix(gateway): Discord and Slack adapters missing message deduplication","url":null,"state":"closed"},{"kind":"pr","number":4866,"title":"fix(gateway): add message deduplication to Discord and Slack adapters","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4878","number":4878}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4878,"title":"fix: prevent duplicate messages — gateway dedup + partial stream guard","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/platforms/slack.py","run_agent.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -449,6 +449,11 @@ def __init__(self, config: PlatformConfig):\n         self._bot_task: Optional[asyncio.Task] = None\n         # Cap to prevent unbounded growth (Discord threads get archived).\n         self._MAX_TRACKED_THREADS = 500\n+        # Dedup cache: message_id → timestamp.  Prevents duplicate bot\n+        # responses when Discord RESUME replays events after reconnects.\n+        self._seen_messages: Dict[str, float] = {}\n+        self._SEEN_TTL = 300   # 5 minutes\n+        self._SEEN_MAX = 2000  # prune threshold\n \n     async def connect(self) -> bool:\n         \"\"\"Connect to Discord and start receiving events.\"\"\"","@@ -539,6 +544,19 @@ async def on_ready():\n \n             @self._client.event\n             async def on_message(message: DiscordMessage):\n+                # Dedup: Discord RESUME replays events after reconnects (#4777)\n+                msg_id = str(message.id)\n+                now = time.time()\n+                if msg_id in adapter_self._seen_messages:\n+                    return\n+                adapter_self._seen_messages[msg_id] = now\n+                if len(adapter_self._seen_messages) > adapter_self._SEEN_MAX:\n+                    cutoff = now - adapter_self._SEEN_TTL\n+                    adapter_self._seen_messages = {\n+                        k: v for k, v in adapter_self._seen_messages.items()\n+                        if v > cutoff\n+                    }\n+\n                 # Always ignore our own messages\n                 if message.author == self._client.user:\n                     return"]},{"file":"gateway/platforms/slack.py","hunks":["@@ -13,6 +13,7 @@\n import logging\n import os\n import re\n+import time\n from typing import Dict, Optional, Any\n \n try:","@@ -78,6 +79,11 @@ def __init__(self, config: PlatformConfig):\n         self._team_clients: Dict[str, AsyncWebClient] = {}   # team_id → WebClient\n         self._team_bot_user_ids: Dict[str, str] = {}          # team_id → bot_user_id\n         self._channel_team: Dict[str, str] = {}                # channel_id → team_id\n+        # Dedup cache: event_ts → timestamp.  Prevents duplicate bot\n+        # responses when Socket Mode reconnects redeliver events.\n+        self._seen_messages: Dict[str, float] = {}\n+        self._SEEN_TTL = 300   # 5 minutes\n+        self._SEEN_MAX = 2000  # prune threshold\n \n     async def connect(self) -> bool:\n         \"\"\"Connect to Slack via Socket Mode.\"\"\"","@@ -710,6 +716,20 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n \n     async def _handle_slack_message(self, event: dict) -> None:\n         \"\"\"Handle an incoming Slack message event.\"\"\"\n+        # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)\n+        event_ts = event.get(\"ts\", \"\")\n+        if event_ts:\n+            now = time.time()\n+            if event_ts in self._seen_messages:\n+                return\n+            self._seen_messages[event_ts] = now\n+            if len(self._seen_messages) > self._SEEN_MAX:\n+                cutoff = now - self._SEEN_TTL\n+                self._seen_messages = {\n+                    k: v for k, v in self._seen_messages.items()\n+                    if v > cutoff\n+                }\n+\n         # Ignore bot messages (including our own)\n         if event.get(\"bot_id\") or event.get(\"subtype\") == \"bot_message\":\n             return"]},{"file":"run_agent.py","hunks":["@@ -4473,6 +4473,29 @@ def _call():\n                     pass\n                 raise InterruptedError(\"Agent interrupted during streaming API call\")\n         if result[\"error\"] is not None:\n+            if deltas_were_sent[\"yes\"]:\n+                # Streaming failed AFTER some tokens were already delivered to\n+                # the platform.  Re-raising would let the outer retry loop make\n+                # a new API call, creating a duplicate message.  Return a\n+                # partial \"stop\" response instead so the outer loop treats this\n+                # turn as complete (no retry, no fallback).\n+                logger.warning(\n+                    \"Partial stream delivered before error; returning stub \"\n+                    \"response to prevent duplicate messages: %s\",\n+                    result[\"error\"],\n+                )\n+                _stub_msg = SimpleNamespace(\n+                    role=\"assistant\", content=None, tool_calls=None,\n+                    reasoning_content=None,\n+                )\n+                return SimpleNamespace(\n+                    id=\"partial-stream-stub\",\n+                    model=getattr(self, \"model\", \"unknown\"),\n+                    choices=[SimpleNamespace(\n+                        index=0, message=_stub_msg, finish_reason=\"stop\",\n+                    )],\n+                    usage=None,\n+                )\n             raise result[\"error\"]\n         return result[\"response\"]\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}