# NousResearch/hermes-agent · WeChat message chunking, truncation, and delivery reliability

## Open now

- 1 issue, 0 PRs open on GitHub.
- #46529: \[Bug\] WeChat iLink channel: replies longer than ~10 segments are silently truncated; only recovery is for the human to send a message

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.

## TL;DR

- 9 member(s): 1 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #7932. Verify it addresses your case.
- **Recommended:** read PR #7932 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-7932`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #7565: \[Bug\]: WeChat gateway can only reply to 10 messages.
- #7836: Weixin: multi-part replies can drop trailing chunks while the agent believes all parts were delivered

## Prior & competing PRs

- **#7903: fix(weixin): keep multi-line messages in single bubble by default**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/config.py`, `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`, `website/docs/user-guide/messaging/weixin.md`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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()
    ```
- **#7624: fix(weixin): re-pack message chunks to avoid flooding WeChat chat**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#8665`
- **#7899: fix weixin chunk delivery retries**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7932`, `#7903`
- **#46679: fix(weixin): wait for rate-limit cooldown mid-send not truncate (#46529)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#51924: fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#51924`

## Fix status

- Merged to main in PR #7932 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-11`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
