# NousResearch/hermes-agent · Weixin/WeChat integration fixes and feature work

## Open now

- Nothing open on GitHub.

These items cover adding native Weixin/WeChat support via iLink Bot API and a series of fixes addressing stale-session detection, permanent error handling, rate-limit retry, and splitting long messages over 2000 characters.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #16985. Verify it addresses your case.
- **Recommended:** read PR #16985 (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: `resolved` · deciding PR(s): `pr-16985`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: 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.

## Closed / duplicate issues

- #16411: \[Weixin\] Long text messages (>2000 chars) are truncated without error

## Prior & competing PRs

- **#16985: fix(weixin): split long messages >2000 chars + harden rate-limit retry**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`, `scripts/release.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -89,6 +89,7 @@
     RETRY_DELAY_SECONDS = 2
     BACKOFF_DELAY_SECONDS = 30
     SESSION_EXPIRED_ERRCODE = -14
    +RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry
     MESSAGE_DEDUP_TTL_SECONDS = 300
     
     MEDIA_IMAGE = 1
    ```

    ```diff
    @@ -1113,7 +1114,7 @@ async def qr_login(
     class WeixinAdapter(BasePlatformAdapter):
         """Native Hermes adapter for Weixin personal accounts."""
     
    -    MAX_MESSAGE_LENGTH = 4000
    +    MAX_MESSAGE_LENGTH = 2000
     
         # WeChat does not support editing sent messages — streaming must use the
         # fallback "send-final-only" path so the cursor (▉) is never left visible.
    ```

    ```diff
    @@ -1138,10 +1139,10 @@ def __init__(self, config: PlatformConfig):
                 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")
    +            extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "1.5")
             )
             self._send_chunk_retries = int(
    -            extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "2")
    +            extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "4")
             )
             self._send_chunk_retry_delay_seconds = float(
                 extra.get("send_chunk_retry_delay_seconds")
    ```

    ```diff
    @@ -1531,6 +1532,28 @@ async def _send_text_chunk(
                                     self.name, _safe_id(chat_id),
                                 )
                                 continue
    +                        # Rate limit (-2) — backoff and retry
    +                        is_rate_limited = (
    +                            ret == RATE_LIMIT_ERRCODE
    +                            or errcode == RATE_LIMIT_ERRCODE
    +                        )
    +                        if is_rate_limited:
    +                            errmsg = resp.get("errmsg") or resp.get("msg") or "rate limited"
    +                            # Record the error so we raise a descriptive
    +                            # RuntimeError (instead of AssertionError) if the
    +                            # loop exhausts with the server still rate-limiting.
    +                            last_error = RuntimeError(
    +                                f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
    +                            )
    +                            if attempt >= self._send_chunk_retries:
    +                                break
    +                            wait = self._send_chunk_retry_delay_seconds * 3  # 3x backoff for rate limit
    +                            logger.warning(
    +                                "[%s] rate limited for %s; backing off %.1fs before retry",
    +                                self.name, _safe_id(chat_id), wait,
    +                            )
    +                            await asyncio.sleep(wait)
    +                            continue
                             errmsg = resp.get("errmsg") or resp.get("msg") or "unknown error"
                             raise RuntimeError(
                                 f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
    ```
  - `scripts/release.py`

    ```diff
    @@ -581,6 +581,7 @@
         "[redacted:email]": "hekaru-agent",
         "[redacted:email]": "twozle",
         "[redacted:email]": "rfilgueiras",
    +    "[redacted:email]": "Leihb",
     }
     
     
    ```
- **#17432: fix(weixin): recognize ret=-2 errmsg='unknown error' as stale-session signal (#17228)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -92,6 +92,18 @@
     RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry
     MESSAGE_DEDUP_TTL_SECONDS = 300
     
    +
    +def _is_stale_session_ret(
    +    ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]",
    +) -> bool:
    +    """True when iLink returns ret=-2 / errcode=-2 with 'unknown error',
    +    which is a stale-session signal (same as errcode=-14) rather than
    +    a genuine rate limit."""
    +    if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
    +        return False
    +    return (errmsg or "").lower() == "unknown error"
    +
    +
     MEDIA_IMAGE = 1
     MEDIA_VIDEO = 2
     MEDIA_FILE = 3
    ```

    ```diff
    @@ -1254,7 +1266,8 @@ async def _poll_loop(self) -> None:
                     ret = response.get("ret", 0)
                     errcode = response.get("errcode", 0)
                     if ret not in (0, None) or errcode not in (0, None):
    -                    if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE:
    +                    if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE
    +                            or _is_stale_session_ret(ret, errcode, response.get("errmsg"))):
                             logger.error("[%s] Session expired; pausing for 10 minutes", self.name)
                             await asyncio.sleep(600)
                             consecutive_failures = 0
    ```

    ```diff
    @@ -1519,6 +1532,7 @@ async def _send_text_chunk(
                             is_session_expired = (
                                 ret == SESSION_EXPIRED_ERRCODE
                                 or errcode == SESSION_EXPIRED_ERRCODE
    +                            or _is_stale_session_ret(ret, errcode, resp.get("errmsg"))
                             )
                             # Session expired — strip token and retry once
                             if is_session_expired and not retried_without_token and context_token:
    ```
  - `tests/gateway/test_weixin.py`

    ```diff
    @@ -758,3 +758,33 @@ def test_send_file_sets_voice_metadata_for_silk_payload(
             assert voice_item["encode_type"] == 6
             assert voice_item["sample_rate"] == 24000
             assert voice_item["bits_per_sample"] == 16
    +
    +
    +class TestIsStaleSessionRet:
    +    """Regression test for #17228: distinguish stale-session ret=-2 from rate-limit ret=-2."""
    +
    +    def test_ret_minus_2_with_unknown_error_is_stale(self):
    +        assert weixin._is_stale_session_ret(-2, None, "unknown error") is True
    +
    +    def test_errcode_minus_2_with_unknown_error_is_stale(self):
    +        assert weixin._is_stale_session_ret(None, -2, "unknown error") is True
    +
    +    def test_unknown_error_case_insensitive(self):
    +        assert weixin._is_stale_session_ret(-2, None, "Unknown Error") is True
    +
    +    def test_ret_minus_2_with_freq_limit_is_not_stale(self):
    +        # Genuine rate limit — must NOT be treated as stale session.
    +        assert weixin._is_stale_session_ret(-2, None, "freq limit") is False
    +
    +    def test_ret_minus_2_with_no_errmsg_is_not_stale(self):
    +        assert weixin._is_stale_session_ret(-2, None, None) is False
    +        assert weixin._is_stale_session_ret(-2, None, "") is False
    +
    +    def test_errcode_minus_14_is_not_matched_here(self):
    +        # -14 is handled by the separate SESSION_EXPIRED_ERRCODE path; the
    +        # helper only disambiguates -2 from a genuine rate limit.
    +        assert weixin._is_stale_session_ret(-14, None, "session expired") is False
    +
    +    def test_success_codes_are_not_stale(self):
    +        assert weixin._is_stale_session_ret(0, 0, "") is False
    +        assert weixin._is_stale_session_ret(None, None, "unknown error") is False
    ```
- **#16422: fix(weixin): fail fast on permanent iLink error codes instead of retrying**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#17432`, `#17287`
- **#16757: fix(weixin): split long messages (>2000 chars) into chunks to prevent truncation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `a54106bbc`, `#16985`

## Fix status

- Merged to main in PR #16985 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-29`.
