NousResearch/hermes-agent

WeChat message chunking, truncation, and delivery reliability

Cluster 7565 · gateway. Data as of

1 open item. Fix merged in #7932 on 11 Apr 2026. Check it covers your case.

The short answer

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

These issues and pull requests concern WeChat (Weixin) channel failures around multi-part message delivery, including silent truncation of long replies, rate-limit handling, per-chunk retry logic, and related sending behavior. Several fixes address how message chunks are packed, retried, and rate-limited to prevent dropped trailing segments.

Open issues1

  • Issue#46529[Bug] WeChat iLink channel: replies longer than ~10 segments are silently truncated; only recovery is for the human to send a message

Open PRs0

No open pull requests.

Fixes on record

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

  • #7903 fix(weixin): keep multi-line messages in single bubble by default

    merged to main

    files: gateway/config.py, gateway/platforms/weixin.py, tests/gateway/test_weixin.py, website/docs/user-guide/messaging/weixin.md

    Show the diff
    • gateway/config.py
      @@ -1017,6 +1017,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
               weixin_group_allowed_users = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "").strip()
               if weixin_group_allowed_users:
                   extra["group_allow_from"] = weixin_group_allowed_users
      +        weixin_split_multiline = os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES", "").strip()
      +        if weixin_split_multiline:
      +            extra["split_multiline_messages"] = weixin_split_multiline
               weixin_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip()
               if weixin_home:
                   config.platforms[Platform.WEIXIN].home_channel = HomeChannel(
    • gateway/platforms/weixin.py
      @@ -755,23 +755,58 @@ def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]
           return packed
       
       
      -def _split_text_for_weixin_delivery(content: str, max_length: int) -> List[str]:
      +def _split_text_for_weixin_delivery(
      +    content: str, max_length: int, split_per_line: bool = False,
      +) -> List[str]:
           """Split content into sequential Weixin messages.
       
      -    Prefer one message per top-level line/markdown unit when the author used
      -    explicit line breaks. Oversized units fall back to block-aware packing so
      -    long code fences still split safely.
      +    *compact* (default): Keep everything in a single message whenever it fits
      +    within the platform limit, even when the author used explicit line breaks.
      +    Only fall back to block-aware packing when the payload exceeds
      +    ``max_length``.
      +
      +    *per_line* (``split_per_line=True``): Legacy behavior — top-level line
      +    breaks become separate chat messages; oversized units still use
      +    block-aware packing.
      +
      +    The active mode is controlled via ``config.yaml`` ->
      +    ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``)
      +    or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``.
           """
      -    if len(content) <= max_length and "\n" not in content:
      -        return [content]
      +    if split_per_line:
      +        # Legacy: one message per top-level delivery unit.
      +        if len(content) <= max_length and "\n" not in content:
      +            return [content]
      +        chunks: List[str] = []
      +        for unit in _split_delivery_units_for_weixin(content):
      +            if len(unit) <= max_length:
      +                chunks.append(unit)
      +                continue
      +            chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
      +        return chunks or [content]
       
      -    chunks: List[str] = []
      -    for unit in _split_delivery_units_for_weixin(content):
      -        if len(unit) <= max_length:
      -            chunks.append(unit)
      -            continue
      -        chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
      -    return chunks or [content]
      +    # Compact (default): single message when under the limit.
      +    if len(content) <= max_length:
      +        return [content]
      +    return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
      +
      +
      +def _coerce_bool(value: Any, default: bool = True) -> bool:
      +    """Coerce a config value to bool, tolerating strings like ``"true"``."""
      +    if value is None:
      +        return default
      +    if isinstance(value, bool):
      +        return value
      +    if isinstance(value, (int, float)):
      +        return bool(value)
      +    text = str(value).strip().lower()
      +    if not text:
      +        return default
      +    if text in {"1", "true", "yes", "on"}:
      +        return True
      +    if text in {"0", "false", "no", "off"}:
      +        return False
      +    return default
       
       
       def _extract_text(item_list: List[Dict[str, Any]]) -> str:
      @@ -991,6 +1026,11 @@ def __init__(self, config: PlatformConfig):
                   group_allow_from = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "")
               self._allow_from = self._coerce_list(allow_from)
               self._group_allow_from = self._coerce_list(group_allow_from)
      +        self._split_multiline_messages = _coerce_bool(
      +            extra.get("split_multiline_messages")
      +            or os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES"),
      +            default=False,
      +        )
       
               if self._account_id and not self._token:
                   persisted = load_weixin_account(hermes_home, self._account_id)
      @@ -1330,7 +1370,9 @@ async def _maybe_fetch_typing_ticket(self, user_id: str, context_token: Optional
                   logger.debug("[%s] getConfig failed for %s: %s", self.name, _safe_id(user_id), exc)
       
           def _split_text(self, content: str) -> List[str]:
      -        return _split_text_for_weixin_delivery(content, self.MAX_MESSAGE_LENGTH)
      +        return _split_text_for_weixin_delivery(
      +            content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
      +        )
       
           async def send(
               self,
      @@ -1344,7 +1386,10 @@ async def send(
               context_token = [redacted:secret-assignment] chat_id)
               last_message_id: Optional[str] = None
               try:
      -            for chunk in self._split_text(self.format_message(content)):
      +            chunks = self._split_text(self.format_message(content))
      +            for idx, chunk in enumerate(chunks):
      +                if idx > 0:
      +                    await asyncio.sleep(0.3)
                       client_id = f"hermes-weixin-{uuid.uuid4().hex}"
                       await _send_message(
                           self._session,
    • tests/gateway/test_weixin.py
      @@ -62,15 +62,15 @@ def test_format_message_returns_empty_string_for_none(self):
       
       
       class TestWeixinChunking:
      -    def test_split_text_sends_top_level_newlines_as_separate_messages(self):
      +    def test_split_text_keeps_short_multiline_message_in_single_chunk(self):
               adapter = _make_adapter()
       
               content = adapter.format_message("第一行\n第二行\n第三行")
               chunks = adapter._split_text(content)
       
      -        assert chunks == ["第一行", "第二行", "第三行"]
      +        assert chunks == ["第一行\n第二行\n第三行"]
       
      -    def test_split_text_keeps_indented_followup_with_previous_line(self):
      +    def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
               adapter = _make_adapter()
       
               content = adapter.format_message(
      @@ -81,10 +81,7 @@ def test_split_text_keeps_indented_followup_with_previous_line(self):
               )
               chunks = adapter._split_text(content)
       
      -        assert chunks == [
      -            "- Setting: Timeout\n  Value: 30s",
      -            "- Setting: Retries\n  Value: 3",
      -        ]
      +        assert chunks == [content]
       
           def test_split_text_keeps_complete_code_block_together_when_possible(self):
               adapter = _make_adapter()
      @@ -114,6 +111,23 @@ def test_split_text_safely_splits_long_code_blocks(self):
               assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in chunks)
               assert all(chunk.count("```") >= 2 for chunk in chunks)
       
      +    def test_split_text_can_restore_legacy_multiline_splitting_via_config(self):
      +        adapter = WeixinAdapter(
      +            PlatformConfig(
      +                enabled=True,
      +                extra={
      +                    "account_id": "acct",
      +                    "token": "***",
      +                    "split_multiline_messages": True,
      +                },
      +            )
      +        )
      +
      +        content = adapter.format_message("第一行\n第二行\n第三行")
      +        chunks = adapter._split_text(content)
      +
      +        assert chunks == ["第一行", "第二行", "第三行"]
      +
       
       class TestWeixinConfig:
           def test_apply_env_overrides_configures_weixin(self):
      @@ -127,6 +141,7 @@ def test_apply_env_overrides_configures_weixin(self):
                       "WEIXIN_BASE_URL": "https://ilink.example.com/",
                       "WEIXIN_CDN_BASE_URL": "https://cdn.example.com/c2c/",
                       "WEIXIN_DM_POLICY": "allowlist",
      +                "WEIXIN_SPLIT_MULTILINE_MESSAGES": "true",
                       "WEIXIN_ALLOWED_USERS": "wxid_1,wxid_2",
                       "WEIXIN_HOME_CHANNEL": "wxid_1",
                       "WEIXIN_HOME_CHANNEL_NAME": "Primary DM",
      @@ -142,6 +157,7 @@ def test_apply_env_overrides_configures_weixin(self):
               assert platform_config.extra["base_url"] == "https://ilink.example.com"
               assert platform_config.extra["cdn_base_url"] == "https://cdn.example.com/c2c"
               assert platform_config.extra["dm_policy"] == "allowlist"
      +        assert platform_config.extra["split_multiline_messages"] == "true"
               assert platform_config.extra["allow_from"] == "wxid_1,wxid_2"
               assert platform_config.home_channel == HomeChannel(Platform.WEIXIN, "wxid_1", "Primary DM")
       
    • website/docs/user-guide/messaging/weixin.md
      @@ -66,6 +66,9 @@ WEIXIN_ACCOUNT_ID=your-account-id
       WEIXIN_DM_POLICY=open
       WEIXIN_ALLOWED_USERS=user_id_1,user_id_2
       
      +# Optional: restore legacy multiline splitting behavior
      +# WEIXIN_SPLIT_MULTILINE_MESSAGES=true
      +
       # Optional: home channel for cron/notifications
       WEIXIN_HOME_CHANNEL=chat_id
       WEIXIN_HOME_CHANNEL_NAME=Home
      @@ -88,7 +91,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin
       - **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers
       - **Context token persistence** — disk-backed reply continuity across restarts
       - **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability
      -- **Smart message chunking** — long messages are split at logical boundaries (paragraphs, code fences)
      +- **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries
       - **Typing indicators** — shows "typing…" status in the WeChat client while the agent processes
       - **SSRF protection** — outbound media URLs are validated before download
       - **Message deduplication** — 5-minute sliding window prevents double-processing
      @@ -108,6 +111,7 @@ Set these in `config.yaml` under `platforms.weixin.extra`:
       | `group_policy` | `disabled` | Group access: `open`, `allowlist`, `disabled` |
       | `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. |
       
       ## Access Policies
       
      @@ -211,13 +215,14 @@ WeChat's personal chat does not natively render full Markdown. The adapter refor
       
       ## Message Chunking
       
      -Long messages are split intelligently for chat delivery:
      +Messages are delivered as a single chat message whenever they fit within the platform limit. Only oversized payloads are split for delivery:
       
       - Maximum message length: **4000 characters**
      -- Split points prefer paragraph boundaries and blank lines
      -- Code fences are kept intact (never split mid-block)
      -- Indented continuation lines (sub-items in reformatted tables/lists) stay with their parent
      +- Messages under the limit stay intact even when they contain multiple paragraphs or line breaks
      +- Oversized messages split at logical boundaries (paragraphs, blank lines, code fences)
      +- Code fences are kept intact whenever possible (never split mid-block unless the fence itself exceeds the limit)
       - Oversized individual blocks fall back to the base adapter's truncation logic
      +- A 0.3 s inter-chunk delay prevents WeChat rate-limit drops when multiple chunks are sent
       
       ## Typing Indicators
       
  • #7932 fix(weixin): add per-chunk retry with backoff for text delivery

    merged to main

    files: gateway/platforms/weixin.py, tests/gateway/test_weixin.py

    Show the diff
    • gateway/platforms/weixin.py
      @@ -1017,6 +1017,16 @@ def __init__(self, config: PlatformConfig):
               self._cdn_base_url = str(
                   extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL)
               ).strip().rstrip("/")
      +        self._send_chunk_delay_seconds = float(
      +            extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "0.35")
      +        )
      +        self._send_chunk_retries = int(
      +            extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "2")
      +        )
      +        self._send_chunk_retry_delay_seconds = float(
      +            extra.get("send_chunk_retry_delay_seconds")
      +            or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
      +        )
               self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower()
               self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
               allow_from = extra.get("allow_from")
      @@ -1346,6 +1356,47 @@ def _split_text(self, content: str) -> List[str]:
                   content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
               )
       
      +    async def _send_text_chunk(
      +        self,
      +        *,
      +        chat_id: str,
      +        chunk: str,
      +        context_token: [redacted:secret-assignment]
      +        client_id: str,
      +    ) -> None:
      +        """Send a single text chunk with per-chunk retry and backoff."""
      +        last_error: Optional[Exception] = None
      +        for attempt in range(self._send_chunk_retries + 1):
      +            try:
      +                await _send_message(
      +                    self._session,
      +                    base_url=self._base_url,
      +                    token=self._token,
      +                    to=chat_id,
      +                    text=chunk,
      +                    context_token=[redacted:secret-assignment]
      +                    client_id=client_id,
      +                )
      +                return
      +            except Exception as exc:
      +                last_error = exc
      +                if attempt >= self._send_chunk_retries:
      +                    break
      +                wait = self._send_chunk_retry_delay_seconds * (attempt + 1)
      +                logger.warning(
      +                    "[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s",
      +                    self.name,
      +                    _safe_id(chat_id),
      +                    attempt + 1,
      +                    self._send_chunk_retries + 1,
      +                    wait,
      +                    exc,
      +                )
      +                if wait > 0:
      +                    await asyncio.sleep(wait)
      +        assert last_error is not None
      +        raise last_error
      +
           async def send(
               self,
               chat_id: str,
      @@ -1360,19 +1411,16 @@ async def send(
               try:
                   chunks = self._split_text(self.format_message(content))
                   for idx, chunk in enumerate(chunks):
      -                if idx > 0:
      -                    await asyncio.sleep(0.3)
                       client_id = f"hermes-weixin-{uuid.uuid4().hex}"
      -                await _send_message(
      -                    self._session,
      -                    base_url=self._base_url,
      -                    token=self._token,
      -                    to=chat_id,
      -                    text=chunk,
      +                await self._send_text_chunk(
      +                    chat_id=chat_id,
      +                    chunk=chunk,
                           context_token=[redacted:secret-assignment]
                           client_id=client_id,
                       )
                       last_message_id = client_id
      +                if idx < len(chunks) - 1 and self._send_chunk_delay_seconds > 0:
      +                    await asyncio.sleep(self._send_chunk_delay_seconds)
                   return SendResult(success=True, message_id=last_message_id)
               except Exception as exc:
                   logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
    • tests/gateway/test_weixin.py
      @@ -283,6 +283,55 @@ def test_send_to_platform_routes_weixin_media_to_native_helper(self, send_weixin
               )
       
       
      +class TestWeixinChunkDelivery:
      +    def _connected_adapter(self) -> WeixinAdapter:
      +        adapter = _make_adapter()
      +        adapter._session = object()
      +        adapter._token = "[redacted:secret-assignment]"
      +        adapter._base_url = "https://weixin.example.com"
      +        adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
      +        return adapter
      +
      +    @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock)
      +    @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
      +    def test_send_waits_between_multiple_chunks(self, send_message_mock, sleep_mock):
      +        adapter = self._connected_adapter()
      +        adapter.MAX_MESSAGE_LENGTH = 12
      +
      +        # Use double newlines so _pack_markdown_blocks splits into 3 blocks
      +        result = asyncio.run(adapter.send("wxid_test123", "first\n\nsecond\n\nthird"))
      +
      +        assert result.success is True
      +        assert send_message_mock.await_count == 3
      +        assert sleep_mock.await_count == 2
      +
      +    @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock)
      +    @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
      +    def test_send_retries_failed_chunk_before_continuing(self, send_message_mock, sleep_mock):
      +        adapter = self._connected_adapter()
      +        adapter.MAX_MESSAGE_LENGTH = 12
      +        calls = {"count": 0}
      +
      +        async def flaky_send(*args, **kwargs):
      +            calls["count"] += 1
      +            if calls["count"] == 2:
      +                raise RuntimeError("temporary iLink failure")
      +
      +        send_message_mock.side_effect = flaky_send
      +
      +        # Use double newlines so _pack_markdown_blocks splits into 3 blocks
      +        result = asyncio.run(adapter.send("wxid_test123", "first\n\nsecond\n\nthird"))
      +
      +        assert result.success is True
      +        # 3 chunks, but chunk 2 fails once and retries → 4 _send_message calls total
      +        assert send_message_mock.await_count == 4
      +        # The retried chunk should reuse the same client_id for deduplication
      +        first_try = send_message_mock.await_args_list[1].kwargs
      +        retry = send_message_mock.await_args_list[2].kwargs
      +        assert first_try["text"] == retry["text"]
      +        assert first_try["client_id"] == retry["client_id"]
      +
      +
       class TestWeixinRemoteMediaSafety:
           def test_download_remote_media_blocks_unsafe_urls(self):
               adapter = _make_adapter()

4 PRs closed without merging

From the close comments: merged via another change 2 · other 2

  • #7624 fix(weixin): re-pack message chunks to avoid flooding WeChat chat · merged via another change → #8665
  • #7899 fix weixin chunk delivery retries · merged via another change → #7932, #7903
  • #46679 fix(weixin): wait for rate-limit cooldown mid-send not truncate (#46529) · other
  • #51924 fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt · other → #51924
Closed issues · 2
  • Closed#7565[Bug]: WeChat gateway can only reply to 10 messages.
  • Closed#7836Weixin: multi-part replies can drop trailing chunks while the agent believes all parts were delivered
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.19: 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
#7903, #7932
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
11 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.