Debounce batching for WhatsApp and WeChat messages
Cluster 22602 · gateway. Data as of
1 open item. Fix merged in #35391 on 30 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #35391. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #35391. That is separate from the fix event. - Still open
- 0 issues and 1 PR.
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.
Open issues0
Open PRs1
- PR#24274fix(gateway/weixin): add media burst-batching to prevent session lockup on rapid file uploads
Fixes on record
-
#35391 fix(gateway): config-driven text-debounce batching for WhatsApp + Weixin
files:
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.mdShow the diff
-
gateway/platforms/weixin.py@@ -1180,12 +1180,48 @@ def __init__(self, config: PlatformConfig): default=False, ) + # Text debounce batching (mirrors Telegram adapter pattern). + # iLink delivers messages individually, so rapid multi-message + # bursts (forwarded batches, paste-splits) each trigger a + # separate agent invocation. Default 3s delay / 5s split delay + # are tuned for iLink's typical delivery cadence. Tunable via + # config.yaml under + # ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` / + # ``text_batch_split_delay_seconds``. + self._text_batch_delay_seconds = self._coerce_float_extra( + "text_batch_delay_seconds", 3.0 + ) + self._text_batch_split_delay_seconds = self._coerce_float_extra( + "text_batch_split_delay_seconds", 5.0 + ) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + if self._account_id and not self._token: persisted = load_weixin_account(hermes_home, self._account_id) if persisted: self._token = [redacted:secret-assignment]"token") or "").strip() self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/") + def _coerce_float_extra(self, key: str, default: float) -> float: + """Read a float from ``config.extra``, guarding against bad/non-finite values. + + The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and + unparseable values fall back to ``default``. + """ + import math + + value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None + if value is None: + return float(default) + try: + parsed = float(value) + except (TypeError, ValueError): + return float(default) + if not math.isfinite(parsed) or parsed < 0: + return float(default) + return parsed + @staticmethod def _coerce_list(value: Any) -> List[str]: if value is None: @@ -1247,6 +1283,11 @@ async def connect(self) -> bool: async def disconnect(self) -> None: _LIVE_ADAPTERS.pop(self._token, None) self._running = False + for task in self._pending_text_batch_tasks.values(): + if not task.done(): + task.cancel() + self._pending_text_batches.clear() + self._pending_text_batch_tasks.clear() if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: @@ -1395,12 +1436,10 @@ async def _process_message(self, message: Dict[str, Any]) -> None: timestamp=datetime.now(), ) logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths)) - await self.handle_message(event) - - @property - def enforces_own_access_policy(self) -> bool: - """Weixin gates DM/group access at intake via dm_policy/group_policy.""" - return True + if event.message_type == MessageType.TEXT: + self._enqueue_text_event(event) + else: + await self.handle_message(event) def _is_dm_allowed(self, sender_id: str) -> bool: if self._dm_policy == "disabled": @@ -1409,6 +1448,76 @@ def _is_dm_allowed(self, sender_id: str) -> bool: return sender_id in self._allow_from return True + @property + def enforces_own_access_policy(self) -> bool: + """Weixin gates DM/group access at intake via dm_policy/group_policy.""" + return True + + # ------------------------------------------------------------------ + # Text debounce batching + # ------------------------------------------------------------------ + + _SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When users forward multiple messages or send rapid-fire texts + via WeChat, each arrives as a separate iLink message. This + concatenates them and waits for a short quiet period before + dispatching the combined message. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for quiet period then dispatch aggregated text.""" + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + if self._pending_text_batch_tasks.get(key) is not current_task: + return + event = self._pending_text_batches.pop(key, None) + if not event: + return + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None: item_type = item.get("type") if item_type == ITEM_IMAGE: -
gateway/platforms/whatsapp.py@@ -278,6 +278,43 @@ def __init__(self, config: PlatformConfig): # notification before the normal "✓ whatsapp disconnected" fires. self._shutting_down: bool = False + # Text debounce batching (mirrors Telegram adapter pattern). + # WhatsApp often delivers multiple messages in rapid succession + # (e.g. forwarded batches, paste-splits) — without debounce each + # message triggers a separate agent invocation, wasting tokens and + # flooding the user with reply fragments. Default 5s delay / + # 10s split delay are conservative for WhatsApp's delivery cadence. + # Tunable via config.yaml under + # ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` / + # ``text_batch_split_delay_seconds``. + self._text_batch_delay_seconds = self._coerce_float_extra( + "text_batch_delay_seconds", 5.0 + ) + self._text_batch_split_delay_seconds = self._coerce_float_extra( + "text_batch_split_delay_seconds", 10.0 + ) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + + def _coerce_float_extra(self, key: str, default: float) -> float: + """Read a float from ``config.extra``, guarding against bad/non-finite values. + + The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and + unparseable values fall back to ``default``. + """ + import math + + value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None + if value is None: + return float(default) + try: + parsed = float(value) + except (TypeError, ValueError): + return float(default) + if not math.isfinite(parsed) or parsed < 0: + return float(default) + return parsed + def _effective_reply_prefix(self) -> str: """Return the prefix the Node bridge will add in self-chat mode.""" whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") @@ -1139,7 +1176,10 @@ async def _poll_messages(self) -> None: for msg_data in messages: event = await self._build_message_event(msg_data) if event: - await self.handle_message(event) + if event.message_type == MessageType.TEXT: + self._enqueue_text_event(event) + else: + await self.handle_message(event) except asyncio.CancelledError: break except Exception as e: @@ -1151,7 +1191,67 @@ async def _poll_messages(self) -> None: await asyncio.sleep(5) await asyncio.sleep(1) # Poll interval - + + # ── Text debounce batching ────────────────────────────────────── + + _SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When WhatsApp delivers rapid-fire messages (e.g. forwarded + batches), this concatenates them and waits for a short quiet + period before dispatching the combined message. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for quiet period then dispatch aggregated text.""" + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: """Build a MessageEvent from bridge message data, downloading images to cache.""" try: -
tests/gateway/test_weixin.py@@ -11,6 +11,7 @@ from gateway.config import PlatformConfig from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides from gateway.platforms.base import SendResult +from gateway.platforms.base import MessageEvent, MessageType from gateway.platforms import weixin from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter 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): adapter = _make_adapter() adapter._poll_session = object() adapter.handle_message = AsyncMock() + # Tighten the text-debounce delay so the flush completes quickly. + adapter._text_batch_delay_seconds = 0.05 + adapter._text_batch_split_delay_seconds = 0.05 base_msg = { "from_user_id": "wxid_user1", "item_list": [{"type": 1, "text_item": {"text": "hello world"}}], } - asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-1"})) - asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-2"})) + async def _drive(): + # Both inbound messages share the same event loop so the debounce + # task created by the first one survives to be flushed. + await adapter._process_message({**base_msg, "message_id": "msg-1"}) + await adapter._process_message({**base_msg, "message_id": "msg-2"}) + # Wait out the quiet period so the buffered text batch flushes. + await asyncio.sleep(0.2) + asyncio.run(_drive()) + + # Content-dedup drops the second (duplicate) message before it is even + # enqueued, so only one combined dispatch reaches handle_message. assert adapter.handle_message.await_count == 1 event = adapter.handle_message.await_args[0][0] assert event.text == "hello world" @@ -882,3 +895,76 @@ def test_content_dedup_not_called_for_messages_without_text(self): assert adapter.handle_message.await_count == 0 # is_duplicate should only be called for message_id, never for content assert all("content:" not in str(call) for call in adapter._dedup.is_duplicate.call_args_list) + + +class TestWeixinTextDebounce: + """Text-debounce batching for rapid multi-message bursts (issue #35301). + + Delays are read from ``config.extra`` (config.yaml), not env vars. + """ + + def test_batch_delays_default_from_config(self): + adapter = _make_adapter() + assert adapter._text_batch_delay_seconds == 3.0 + assert adapter._text_batch_split_delay_seconds == 5.0 + + def test_batch_delays_overridden_via_config_extra(self): + adapter = WeixinAdapter( + PlatformConfig( + enabled=True, + token="[redacted:secret-assignment]", + extra={ + "account_id": "test-account", + "text_batch_delay_seconds": "0.5", + "text_batch_split_delay_seconds": 1.5, + }, + ) + ) + assert adapter._text_batch_delay_seconds == 0.5 + assert adapter._text_batch_split_delay_seconds == 1.5 + + def test_invalid_config_value_falls_back_to_default(self): + adapter = WeixinAdapter( + PlatformConfig( + enabled=True, + token="[redacted:secret-assignment]", + extra={ + "account_id": "test-account", + "text_batch_delay_seconds": "not-a-number", + "text_batch_split_delay_seconds": -4, + }, + ) + ) + assert adapter._text_batch_delay_seconds == 3.0 + assert adapter._text_batch_split_delay_seconds == 5.0 + + def test_rapid_texts_collapse_into_single_dispatch(self): + adapter = _make_adapter() + adapter._text_batch_delay_seconds = 0.05 + adapter._text_batch_split_delay_seconds = 0.05 + dispatched = [] + + async def _capture(event): + dispatched.append(event.text) + + adapter.handle_message = _capture + + def _event(text): + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=adapter.build_source( + chat_id="wxid_user1", chat_type="dm", + user_id="wxid_user1", user_name="wxid_user1", + ), + ) + + async def _drive(): + adapter._enqueue_text_event(_event("one")) + adapter._enqueue_text_event(_event("two")) + adapter._enqueue_text_event(_event("three")) + assert dispatched == [] # nothing flushed during the burst + await asyncio.sleep(0.2) + + asyncio.run(_drive()) + assert dispatched == ["one\ntwo\nthree"] -
tests/gateway/test_whatsapp_text_batching.py@@ -0,0 +1,107 @@ +"""Text-debounce batching for the WhatsApp adapter (issue #35301). + +WhatsApp delivers rapid multi-message bursts (forwarded batches, paste-splits) +individually. Without debounce each fragment triggers a separate agent +invocation, wasting tokens and flooding the user with reply fragments. This +mirrors the Telegram/WeCom/Feishu pattern. + +Batch delays are read from ``config.extra`` (config.yaml), not env vars. +""" + +import asyncio + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.platforms.whatsapp import WhatsAppAdapter +from gateway.session import SessionSource + + +def _make_adapter(**extra): + base = {"session_name": "test"} + base.update(extra) + return WhatsAppAdapter(PlatformConfig(enabled=True, extra=base)) + + +def _event(text): + src = SessionSource( + platform=Platform.WHATSAPP, + chat_id="chat123", + chat_type="dm", + user_id="user1", + user_name="tester", + ) + return MessageEvent(text=text, message_type=MessageType.TEXT, source=src) + + +def test_batch_delays_default_from_config(): + adapter = _make_adapter() + assert adapter._text_batch_delay_seconds == 5.0 + assert adapter._text_batch_split_delay_seconds == 10.0 + + +def test_batch_delays_overridden_via_config_extra(): + adapter = _make_adapter( + text_batch_delay_seconds="2.5", + text_batch_split_delay_seconds=7, + ) + assert adapter._text_batch_delay_seconds == 2.5 + assert adapter._text_batch_split_delay_seconds == 7.0 + + +def test_invalid_config_value_falls_back_to_default(): + adapter = _make_adapter( + text_batch_delay_seconds="garbage", + text_batch_split_delay_seconds=-3, + ) + assert adapter._text_batch_delay_seconds == 5.0 + assert adapter._text_batch_split_delay_seconds == 10.0 + + +def test_env_var_is_ignored(monkeypatch): + # Config-only path: the legacy HERMES_* env var must NOT influence delays. + monkeypatch.setenv("HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS", "99") + adapter = _make_adapter() + assert adapter._text_batch_delay_seconds == 5.0 + + +def test_rapid_texts_collapse_into_single_dispatch(): + adapter = _make_adapter( + text_batch_delay_seconds=0.05, + text_batch_split_delay_seconds=0.05, + ) + dispatched = [] + + async def _capture(event): + dispatched.append(event.text) + + adapter.handle_message = _capture + + async def _drive(): + adapter._enqueue_text_event(_event("one")) + adapter._enqueue_text_event(_event("two")) + adapter._enqueue_text_event(_event("three")) + assert dispatched == [] # nothing flushed during the burst + await asyncio.sleep(0.2) + + asyncio.run(_drive()) + assert dispatched == ["one\ntwo\nthree"] + + +def test_lone_message_dispatched_alone(): + adapter = _make_adapter( + text_batch_delay_seconds=0.05, + text_batch_split_delay_seconds=0.05, + ) + dispatched = [] + + async def _capture(event): + dispatched.append(event.text) + + adapter.handle_message = _capture + + async def _drive(): + adapter._enqueue_text_event(_event("solo")) + await asyncio.sleep(0.2) + + asyncio.run(_drive()) + assert dispatched == ["solo"] -
website/docs/user-guide/messaging/weixin.md@@ -123,6 +123,8 @@ Set these in `config.yaml` under `platforms.weixin.extra`: | `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) | | `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) | | `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. | +| `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. | +| `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). | ## Access Policies -
website/docs/user-guide/messaging/whatsapp.md@@ -201,6 +201,22 @@ Code blocks and inline code are preserved as-is since WhatsApp supports triple-b 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. +### Message Batching (Debounce) + +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`: + +```yaml +# ~/.hermes/config.yaml +gateway: + platforms: + whatsapp: + extra: + text_batch_delay_seconds: 5.0 # quiet period before flushing a batch + text_batch_split_delay_seconds: 10.0 # extended delay near the split threshold +``` + +Set `text_batch_delay_seconds: 0` to dispatch each message immediately (disables batching). + --- ## Troubleshooting
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#35302 feat: add text debounce batching for WhatsApp and WeChat platforms · merged via another change →
cddb728,b0ce47d,#35391
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #35391
- Closed issues
- 2
- Attempts
- 1 PR closed without merging
- Fix link checked
- 30 May 2026