Weixin/WeChat integration fixes and feature work

NousResearch/hermes-agent · cluster 16411

Data as of

Nothing open on GitHub. Fix merged in #16985. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #16985. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #16985. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #16985 fix(weixin): split long messages >2000 chars + harden rate-limit retry

    merged to main

    files: gateway/platforms/weixin.py, scripts/release.py

    Show the diff
    • gateway/platforms/weixin.py
      @@ -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
      @@ -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.
      @@ -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")
      @@ -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
      @@ -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

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

    Show the diff
    • gateway/platforms/weixin.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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

2 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1

  • #16422 fix(weixin): fail fast on permanent iLink error codes instead of retrying · superseded by another PR → #17432, #17287
  • #16757 fix(weixin): split long messages (>2000 chars) into chunks to prevent truncation · merged via another change → a54106b, #16985
Closed issues · 1
  • Closed#16411[Weixin] Long text messages (>2000 chars) are truncated without error
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: 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

Merged PRs
#16985, #17432
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
29 Apr 2026

For your agent

The same facts, typed.