{"repo":"NousResearch/hermes-agent","public_id":"22602","display_label":"Debounce batching for WhatsApp and WeChat messages","summary":"These issues and pull requests concern adding configurable debounce and burst-batching logic to WhatsApp and WeChat/Weixin adapters to group rapid incoming messages before forwarding them. The work covers both text messages and media uploads.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35391,"merged_at":"2026-05-30T14:33:16+00:00"},"resolution":{"state":"partially_resolved","by":"pr-35391"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T14:33:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":35301,"pr_number":35391,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T14:33:16+00:00"}],"members":[{"kind":"issue","number":22602,"title":"[Feature] Add text message debounce batching to WhatsApp and Weixin adapters","url":null,"state":"closed"},{"kind":"issue","number":35301,"title":"WhatsApp and WeChat forward multiple messages without debounce, wasting tokens and degrading UX","url":null,"state":"closed"},{"kind":"pr","number":24274,"title":"fix(gateway/weixin): add media burst-batching to prevent session lockup on rapid file uploads","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"ec3043eb0","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35302,"title":"feat: add text debounce batching for WhatsApp and WeChat platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cddb7283d","number":0},{"kind":"commit","value":"b0ce47daa","number":0},{"kind":"pr","value":"#35391","number":35391}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35391,"title":"fix(gateway): config-driven text-debounce batching for WhatsApp + Weixin","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","gateway/platforms/whatsapp.py","tests/gateway/test_weixin.py","tests/gateway/test_whatsapp_text_batching.py","website/docs/user-guide/messaging/weixin.md","website/docs/user-guide/messaging/whatsapp.md"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -1180,12 +1180,48 @@ def __init__(self, config: PlatformConfig):\n             default=False,\n         )\n \n+        # Text debounce batching (mirrors Telegram adapter pattern).\n+        # iLink delivers messages individually, so rapid multi-message\n+        # bursts (forwarded batches, paste-splits) each trigger a\n+        # separate agent invocation.  Default 3s delay / 5s split delay\n+        # are tuned for iLink's typical delivery cadence.  Tunable via\n+        # config.yaml under\n+        # ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /\n+        # ``text_batch_split_delay_seconds``.\n+        self._text_batch_delay_seconds = self._coerce_float_extra(\n+            \"text_batch_delay_seconds\", 3.0\n+        )\n+        self._text_batch_split_delay_seconds = self._coerce_float_extra(\n+            \"text_batch_split_delay_seconds\", 5.0\n+        )\n+        self._pending_text_batches: Dict[str, MessageEvent] = {}\n+        self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n+\n         if self._account_id and not self._token:\n             persisted = load_weixin_account(hermes_home, self._account_id)\n             if persisted:\n                 self._token = [redacted:secret-assignment]\"token\") or \"\").strip()\n                 self._base_url = str(persisted.get(\"base_url\") or self._base_url).strip().rstrip(\"/\")\n \n+    def _coerce_float_extra(self, key: str, default: float) -> float:\n+        \"\"\"Read a float from ``config.extra``, guarding against bad/non-finite values.\n+\n+        The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and\n+        unparseable values fall back to ``default``.\n+        \"\"\"\n+        import math\n+\n+        value = self.config.extra.get(key) if getattr(self.config, \"extra\", None) else None\n+        if value is None:\n+            return float(default)\n+        try:\n+            parsed = float(value)\n+        except (TypeError, ValueError):\n+            return float(default)\n+        if not math.isfinite(parsed) or parsed < 0:\n+            return float(default)\n+        return parsed\n+\n     @staticmethod\n     def _coerce_list(value: Any) -> List[str]:\n         if value is None:","@@ -1247,6 +1283,11 @@ async def connect(self) -> bool:\n     async def disconnect(self) -> None:\n         _LIVE_ADAPTERS.pop(self._token, None)\n         self._running = False\n+        for task in self._pending_text_batch_tasks.values():\n+            if not task.done():\n+                task.cancel()\n+        self._pending_text_batches.clear()\n+        self._pending_text_batch_tasks.clear()\n         if self._poll_task and not self._poll_task.done():\n             self._poll_task.cancel()\n             try:","@@ -1395,12 +1436,10 @@ async def _process_message(self, message: Dict[str, Any]) -> None:\n             timestamp=datetime.now(),\n         )\n         logger.info(\"[%s] inbound from=%s type=%s media=%d\", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))\n-        await self.handle_message(event)\n-\n-    @property\n-    def enforces_own_access_policy(self) -> bool:\n-        \"\"\"Weixin gates DM/group access at intake via dm_policy/group_policy.\"\"\"\n-        return True\n+        if event.message_type == MessageType.TEXT:\n+            self._enqueue_text_event(event)\n+        else:\n+            await self.handle_message(event)\n \n     def _is_dm_allowed(self, sender_id: str) -> bool:\n         if self._dm_policy == \"disabled\":","@@ -1409,6 +1448,76 @@ def _is_dm_allowed(self, sender_id: str) -> bool:\n             return sender_id in self._allow_from\n         return True\n \n+    @property\n+    def enforces_own_access_policy(self) -> bool:\n+        \"\"\"Weixin gates DM/group access at intake via dm_policy/group_policy.\"\"\"\n+        return True\n+\n+    # ------------------------------------------------------------------\n+    # Text debounce batching\n+    # ------------------------------------------------------------------\n+\n+    _SPLIT_THRESHOLD = 1800  # iLink chunks at ~2048 chars\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        from gateway.session import build_session_key\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+        )\n+\n+    def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Buffer a text event and reset the flush timer.\n+\n+        When users forward multiple messages or send rapid-fire texts\n+        via WeChat, each arrives as a separate iLink message. This\n+        concatenates them and waits for a short quiet period before\n+        dispatching the combined message.\n+        \"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n+        if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            self._pending_text_batches[key] = event\n+        else:\n+            if event.text:\n+                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            if event.media_urls:\n+                existing.media_urls.extend(event.media_urls)\n+                existing.media_types.extend(event.media_types)\n+\n+        prior_task = self._pending_text_batch_tasks.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        self._pending_text_batch_tasks[key] = asyncio.create_task(\n+            self._flush_text_batch(key)\n+        )\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Wait for quiet period then dispatch aggregated text.\"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n+            if self._pending_text_batch_tasks.get(key) is not current_task:\n+                return\n+            event = self._pending_text_batches.pop(key, None)\n+            if not event:\n+                return\n+            await self.handle_message(event)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n+\n     async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:\n         item_type = item.get(\"type\")\n         if item_type == ITEM_IMAGE:"]},{"file":"gateway/platforms/whatsapp.py","hunks":["@@ -278,6 +278,43 @@ def __init__(self, config: PlatformConfig):\n         # notification before the normal \"✓ whatsapp disconnected\" fires.\n         self._shutting_down: bool = False\n \n+        # Text debounce batching (mirrors Telegram adapter pattern).\n+        # WhatsApp often delivers multiple messages in rapid succession\n+        # (e.g. forwarded batches, paste-splits) — without debounce each\n+        # message triggers a separate agent invocation, wasting tokens and\n+        # flooding the user with reply fragments.  Default 5s delay /\n+        # 10s split delay are conservative for WhatsApp's delivery cadence.\n+        # Tunable via config.yaml under\n+        # ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /\n+        # ``text_batch_split_delay_seconds``.\n+        self._text_batch_delay_seconds = self._coerce_float_extra(\n+            \"text_batch_delay_seconds\", 5.0\n+        )\n+        self._text_batch_split_delay_seconds = self._coerce_float_extra(\n+            \"text_batch_split_delay_seconds\", 10.0\n+        )\n+        self._pending_text_batches: Dict[str, MessageEvent] = {}\n+        self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n+\n+    def _coerce_float_extra(self, key: str, default: float) -> float:\n+        \"\"\"Read a float from ``config.extra``, guarding against bad/non-finite values.\n+\n+        The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and\n+        unparseable values fall back to ``default``.\n+        \"\"\"\n+        import math\n+\n+        value = self.config.extra.get(key) if getattr(self.config, \"extra\", None) else None\n+        if value is None:\n+            return float(default)\n+        try:\n+            parsed = float(value)\n+        except (TypeError, ValueError):\n+            return float(default)\n+        if not math.isfinite(parsed) or parsed < 0:\n+            return float(default)\n+        return parsed\n+\n     def _effective_reply_prefix(self) -> str:\n         \"\"\"Return the prefix the Node bridge will add in self-chat mode.\"\"\"\n         whatsapp_mode = os.getenv(\"WHATSAPP_MODE\", \"self-chat\")","@@ -1139,7 +1176,10 @@ async def _poll_messages(self) -> None:\n                         for msg_data in messages:\n                             event = await self._build_message_event(msg_data)\n                             if event:\n-                                await self.handle_message(event)\n+                                if event.message_type == MessageType.TEXT:\n+                                    self._enqueue_text_event(event)\n+                                else:\n+                                    await self.handle_message(event)\n             except asyncio.CancelledError:\n                 break\n             except Exception as e:","@@ -1151,7 +1191,67 @@ async def _poll_messages(self) -> None:\n                 await asyncio.sleep(5)\n             \n             await asyncio.sleep(1)  # Poll interval\n-    \n+\n+    # ── Text debounce batching ──────────────────────────────────────\n+\n+    _SPLIT_THRESHOLD = 6000  # WhatsApp supports ~65K chars; generous threshold\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        from gateway.session import build_session_key\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+        )\n+\n+    def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Buffer a text event and reset the flush timer.\n+\n+        When WhatsApp delivers rapid-fire messages (e.g. forwarded\n+        batches), this concatenates them and waits for a short quiet\n+        period before dispatching the combined message.\n+        \"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n+        if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            self._pending_text_batches[key] = event\n+        else:\n+            if event.text:\n+                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            if event.media_urls:\n+                existing.media_urls.extend(event.media_urls)\n+                existing.media_types.extend(event.media_types)\n+\n+        prior_task = self._pending_text_batch_tasks.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        self._pending_text_batch_tasks[key] = asyncio.create_task(\n+            self._flush_text_batch(key)\n+        )\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Wait for quiet period then dispatch aggregated text.\"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n+            event = self._pending_text_batches.pop(key, None)\n+            if not event:\n+                return\n+            await self.handle_message(event)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n+\n     async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:\n         \"\"\"Build a MessageEvent from bridge message data, downloading images to cache.\"\"\"\n         try:"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -11,6 +11,7 @@\n from gateway.config import PlatformConfig\n from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides\n from gateway.platforms.base import SendResult\n+from gateway.platforms.base import MessageEvent, MessageType\n from gateway.platforms import weixin\n from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter\n from tools.send_message_tool import _parse_target_ref, _send_to_platform","@@ -853,15 +854,27 @@ def test_duplicate_content_with_different_message_ids_is_dropped(self):\n         adapter = _make_adapter()\n         adapter._poll_session = object()\n         adapter.handle_message = AsyncMock()\n+        # Tighten the text-debounce delay so the flush completes quickly.\n+        adapter._text_batch_delay_seconds = 0.05\n+        adapter._text_batch_split_delay_seconds = 0.05\n \n         base_msg = {\n             \"from_user_id\": \"wxid_user1\",\n             \"item_list\": [{\"type\": 1, \"text_item\": {\"text\": \"hello world\"}}],\n         }\n \n-        asyncio.run(adapter._process_message({**base_msg, \"message_id\": \"msg-1\"}))\n-        asyncio.run(adapter._process_message({**base_msg, \"message_id\": \"msg-2\"}))\n+        async def _drive():\n+            # Both inbound messages share the same event loop so the debounce\n+            # task created by the first one survives to be flushed.\n+            await adapter._process_message({**base_msg, \"message_id\": \"msg-1\"})\n+            await adapter._process_message({**base_msg, \"message_id\": \"msg-2\"})\n+            # Wait out the quiet period so the buffered text batch flushes.\n+            await asyncio.sleep(0.2)\n \n+        asyncio.run(_drive())\n+\n+        # Content-dedup drops the second (duplicate) message before it is even\n+        # enqueued, so only one combined dispatch reaches handle_message.\n         assert adapter.handle_message.await_count == 1\n         event = adapter.handle_message.await_args[0][0]\n         assert event.text == \"hello world\"","@@ -882,3 +895,76 @@ def test_content_dedup_not_called_for_messages_without_text(self):\n         assert adapter.handle_message.await_count == 0\n         # is_duplicate should only be called for message_id, never for content\n         assert all(\"content:\" not in str(call) for call in adapter._dedup.is_duplicate.call_args_list)\n+\n+\n+class TestWeixinTextDebounce:\n+    \"\"\"Text-debounce batching for rapid multi-message bursts (issue #35301).\n+\n+    Delays are read from ``config.extra`` (config.yaml), not env vars.\n+    \"\"\"\n+\n+    def test_batch_delays_default_from_config(self):\n+        adapter = _make_adapter()\n+        assert adapter._text_batch_delay_seconds == 3.0\n+        assert adapter._text_batch_split_delay_seconds == 5.0\n+\n+    def test_batch_delays_overridden_via_config_extra(self):\n+        adapter = WeixinAdapter(\n+            PlatformConfig(\n+                enabled=True,\n+                token=\"[redacted:secret-assignment]\",\n+                extra={\n+                    \"account_id\": \"test-account\",\n+                    \"text_batch_delay_seconds\": \"0.5\",\n+                    \"text_batch_split_delay_seconds\": 1.5,\n+                },\n+            )\n+        )\n+        assert adapter._text_batch_delay_seconds == 0.5\n+        assert adapter._text_batch_split_delay_seconds == 1.5\n+\n+    def test_invalid_config_value_falls_back_to_default(self):\n+        adapter = WeixinAdapter(\n+            PlatformConfig(\n+                enabled=True,\n+                token=\"[redacted:secret-assignment]\",\n+                extra={\n+                    \"account_id\": \"test-account\",\n+                    \"text_batch_delay_seconds\": \"not-a-number\",\n+                    \"text_batch_split_delay_seconds\": -4,\n+                },\n+            )\n+        )\n+        assert adapter._text_batch_delay_seconds == 3.0\n+        assert adapter._text_batch_split_delay_seconds == 5.0\n+\n+    def test_rapid_texts_collapse_into_single_dispatch(self):\n+        adapter = _make_adapter()\n+        adapter._text_batch_delay_seconds = 0.05\n+        adapter._text_batch_split_delay_seconds = 0.05\n+        dispatched = []\n+\n+        async def _capture(event):\n+            dispatched.append(event.text)\n+\n+        adapter.handle_message = _capture\n+\n+        def _event(text):\n+            return MessageEvent(\n+                text=text,\n+                message_type=MessageType.TEXT,\n+                source=adapter.build_source(\n+                    chat_id=\"wxid_user1\", chat_type=\"dm\",\n+                    user_id=\"wxid_user1\", user_name=\"wxid_user1\",\n+                ),\n+            )\n+\n+        async def _drive():\n+            adapter._enqueue_text_event(_event(\"one\"))\n+            adapter._enqueue_text_event(_event(\"two\"))\n+            adapter._enqueue_text_event(_event(\"three\"))\n+            assert dispatched == []  # nothing flushed during the burst\n+            await asyncio.sleep(0.2)\n+\n+        asyncio.run(_drive())\n+        assert dispatched == [\"one\\ntwo\\nthree\"]"]},{"file":"tests/gateway/test_whatsapp_text_batching.py","hunks":["@@ -0,0 +1,107 @@\n+\"\"\"Text-debounce batching for the WhatsApp adapter (issue #35301).\n+\n+WhatsApp delivers rapid multi-message bursts (forwarded batches, paste-splits)\n+individually.  Without debounce each fragment triggers a separate agent\n+invocation, wasting tokens and flooding the user with reply fragments.  This\n+mirrors the Telegram/WeCom/Feishu pattern.\n+\n+Batch delays are read from ``config.extra`` (config.yaml), not env vars.\n+\"\"\"\n+\n+import asyncio\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.platforms.whatsapp import WhatsAppAdapter\n+from gateway.session import SessionSource\n+\n+\n+def _make_adapter(**extra):\n+    base = {\"session_name\": \"test\"}\n+    base.update(extra)\n+    return WhatsAppAdapter(PlatformConfig(enabled=True, extra=base))\n+\n+\n+def _event(text):\n+    src = SessionSource(\n+        platform=Platform.WHATSAPP,\n+        chat_id=\"chat123\",\n+        chat_type=\"dm\",\n+        user_id=\"user1\",\n+        user_name=\"tester\",\n+    )\n+    return MessageEvent(text=text, message_type=MessageType.TEXT, source=src)\n+\n+\n+def test_batch_delays_default_from_config():\n+    adapter = _make_adapter()\n+    assert adapter._text_batch_delay_seconds == 5.0\n+    assert adapter._text_batch_split_delay_seconds == 10.0\n+\n+\n+def test_batch_delays_overridden_via_config_extra():\n+    adapter = _make_adapter(\n+        text_batch_delay_seconds=\"2.5\",\n+        text_batch_split_delay_seconds=7,\n+    )\n+    assert adapter._text_batch_delay_seconds == 2.5\n+    assert adapter._text_batch_split_delay_seconds == 7.0\n+\n+\n+def test_invalid_config_value_falls_back_to_default():\n+    adapter = _make_adapter(\n+        text_batch_delay_seconds=\"garbage\",\n+        text_batch_split_delay_seconds=-3,\n+    )\n+    assert adapter._text_batch_delay_seconds == 5.0\n+    assert adapter._text_batch_split_delay_seconds == 10.0\n+\n+\n+def test_env_var_is_ignored(monkeypatch):\n+    # Config-only path: the legacy HERMES_* env var must NOT influence delays.\n+    monkeypatch.setenv(\"HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS\", \"99\")\n+    adapter = _make_adapter()\n+    assert adapter._text_batch_delay_seconds == 5.0\n+\n+\n+def test_rapid_texts_collapse_into_single_dispatch():\n+    adapter = _make_adapter(\n+        text_batch_delay_seconds=0.05,\n+        text_batch_split_delay_seconds=0.05,\n+    )\n+    dispatched = []\n+\n+    async def _capture(event):\n+        dispatched.append(event.text)\n+\n+    adapter.handle_message = _capture\n+\n+    async def _drive():\n+        adapter._enqueue_text_event(_event(\"one\"))\n+        adapter._enqueue_text_event(_event(\"two\"))\n+        adapter._enqueue_text_event(_event(\"three\"))\n+        assert dispatched == []  # nothing flushed during the burst\n+        await asyncio.sleep(0.2)\n+\n+    asyncio.run(_drive())\n+    assert dispatched == [\"one\\ntwo\\nthree\"]\n+\n+\n+def test_lone_message_dispatched_alone():\n+    adapter = _make_adapter(\n+        text_batch_delay_seconds=0.05,\n+        text_batch_split_delay_seconds=0.05,\n+    )\n+    dispatched = []\n+\n+    async def _capture(event):\n+        dispatched.append(event.text)\n+\n+    adapter.handle_message = _capture\n+\n+    async def _drive():\n+        adapter._enqueue_text_event(_event(\"solo\"))\n+        await asyncio.sleep(0.2)\n+\n+    asyncio.run(_drive())\n+    assert dispatched == [\"solo\"]"]},{"file":"website/docs/user-guide/messaging/weixin.md","hunks":["@@ -123,6 +123,8 @@ Set these in `config.yaml` under `platforms.weixin.extra`:\n | `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) |\n | `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) |\n | `split_multiline_messages` | `false` | When `true`, split multi-line replies into multiple chat messages (legacy behavior). When `false`, keep multi-line replies as one message unless they exceed the length limit. |\n+| `text_batch_delay_seconds` | `3.0` | Quiet period (seconds) before a buffered burst of rapid text messages is flushed as one combined request. iLink delivers messages individually, so this debounce avoids one agent invocation per fragment. Set `0` to dispatch each message immediately. |\n+| `text_batch_split_delay_seconds` | `5.0` | Extended flush delay used when the latest fragment is near the split threshold (long messages iLink may have chunked). |\n \n ## Access Policies\n "]},{"file":"website/docs/user-guide/messaging/whatsapp.md","hunks":["@@ -201,6 +201,22 @@ Code blocks and inline code are preserved as-is since WhatsApp supports triple-b\n \n When the agent calls tools (web search, file operations, etc.), WhatsApp displays real-time progress indicators showing which tool is running. This is enabled by default — no configuration needed.\n \n+### Message Batching (Debounce)\n+\n+WhatsApp delivers each message individually, so a rapid burst (forwarded batches, paste-splits, multi-line text) would otherwise trigger a separate agent invocation per fragment — wasting tokens and producing several disjointed replies. The adapter buffers successive text messages from the same chat and dispatches them as one combined request after a short quiet period (default **5s**, extended to **10s** for very long fragments). Tune via `config.yaml`:\n+\n+```yaml\n+# ~/.hermes/config.yaml\n+gateway:\n+  platforms:\n+    whatsapp:\n+      extra:\n+        text_batch_delay_seconds: 5.0         # quiet period before flushing a batch\n+        text_batch_split_delay_seconds: 10.0  # extended delay near the split threshold\n+```\n+\n+Set `text_batch_delay_seconds: 0` to dispatch each message immediately (disables batching).\n+\n ---\n \n ## Troubleshooting\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}