{"repo":"NousResearch/hermes-agent","public_id":"16411","display_label":"Weixin/WeChat integration fixes and feature work","summary":"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.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16985},"resolution":{"state":"resolved","by":"pr-16985"},"freshness":{"state":"verified","last_verified_at":"2026-04-29T12:44:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":16411,"pr_number":16985,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":16411,"pr_number":17432,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":17228,"pr_number":17432,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":16411,"title":"[Weixin] Long text messages (>2000 chars) are truncated without error","url":null,"state":"closed"},{"kind":"pr","number":16422,"title":"fix(weixin): fail fast on permanent iLink error codes instead of retrying","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17432","number":17432},{"kind":"pr","value":"#17287","number":17287}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":16757,"title":"fix(weixin): split long messages (>2000 chars) into chunks to prevent truncation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a54106bbc","number":0},{"kind":"pr","value":"#16985","number":16985}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16985,"title":"fix(weixin): split long messages >2000 chars + harden rate-limit retry","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","scripts/release.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -89,6 +89,7 @@\n RETRY_DELAY_SECONDS = 2\n BACKOFF_DELAY_SECONDS = 30\n SESSION_EXPIRED_ERRCODE = -14\n+RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry\n MESSAGE_DEDUP_TTL_SECONDS = 300\n \n MEDIA_IMAGE = 1","@@ -1113,7 +1114,7 @@ async def qr_login(\n class WeixinAdapter(BasePlatformAdapter):\n     \"\"\"Native Hermes adapter for Weixin personal accounts.\"\"\"\n \n-    MAX_MESSAGE_LENGTH = 4000\n+    MAX_MESSAGE_LENGTH = 2000\n \n     # WeChat does not support editing sent messages — streaming must use the\n     # fallback \"send-final-only\" path so the cursor (▉) is never left visible.","@@ -1138,10 +1139,10 @@ def __init__(self, config: PlatformConfig):\n             extra.get(\"cdn_base_url\") or os.getenv(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)\n         ).strip().rstrip(\"/\")\n         self._send_chunk_delay_seconds = float(\n-            extra.get(\"send_chunk_delay_seconds\") or os.getenv(\"WEIXIN_SEND_CHUNK_DELAY_SECONDS\", \"0.35\")\n+            extra.get(\"send_chunk_delay_seconds\") or os.getenv(\"WEIXIN_SEND_CHUNK_DELAY_SECONDS\", \"1.5\")\n         )\n         self._send_chunk_retries = int(\n-            extra.get(\"send_chunk_retries\") or os.getenv(\"WEIXIN_SEND_CHUNK_RETRIES\", \"2\")\n+            extra.get(\"send_chunk_retries\") or os.getenv(\"WEIXIN_SEND_CHUNK_RETRIES\", \"4\")\n         )\n         self._send_chunk_retry_delay_seconds = float(\n             extra.get(\"send_chunk_retry_delay_seconds\")","@@ -1531,6 +1532,28 @@ async def _send_text_chunk(\n                                 self.name, _safe_id(chat_id),\n                             )\n                             continue\n+                        # Rate limit (-2) — backoff and retry\n+                        is_rate_limited = (\n+                            ret == RATE_LIMIT_ERRCODE\n+                            or errcode == RATE_LIMIT_ERRCODE\n+                        )\n+                        if is_rate_limited:\n+                            errmsg = resp.get(\"errmsg\") or resp.get(\"msg\") or \"rate limited\"\n+                            # Record the error so we raise a descriptive\n+                            # RuntimeError (instead of AssertionError) if the\n+                            # loop exhausts with the server still rate-limiting.\n+                            last_error = RuntimeError(\n+                                f\"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}\"\n+                            )\n+                            if attempt >= self._send_chunk_retries:\n+                                break\n+                            wait = self._send_chunk_retry_delay_seconds * 3  # 3x backoff for rate limit\n+                            logger.warning(\n+                                \"[%s] rate limited for %s; backing off %.1fs before retry\",\n+                                self.name, _safe_id(chat_id), wait,\n+                            )\n+                            await asyncio.sleep(wait)\n+                            continue\n                         errmsg = resp.get(\"errmsg\") or resp.get(\"msg\") or \"unknown error\"\n                         raise RuntimeError(\n                             f\"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}\""]},{"file":"scripts/release.py","hunks":["@@ -581,6 +581,7 @@\n     \"[redacted:email]\": \"hekaru-agent\",\n     \"[redacted:email]\": \"twozle\",\n     \"[redacted:email]\": \"rfilgueiras\",\n+    \"[redacted:email]\": \"Leihb\",\n }\n \n \n"]}]},{"kind":"pr","number":17432,"title":"fix(weixin): recognize ret=-2 errmsg='unknown error' as stale-session signal (#17228)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","tests/gateway/test_weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -92,6 +92,18 @@\n RATE_LIMIT_ERRCODE = -2  # iLink frequency limit — backoff and retry\n MESSAGE_DEDUP_TTL_SECONDS = 300\n \n+\n+def _is_stale_session_ret(\n+    ret: \"Optional[int]\", errcode: \"Optional[int]\", errmsg: \"Optional[str]\",\n+) -> bool:\n+    \"\"\"True when iLink returns ret=-2 / errcode=-2 with 'unknown error',\n+    which is a stale-session signal (same as errcode=-14) rather than\n+    a genuine rate limit.\"\"\"\n+    if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:\n+        return False\n+    return (errmsg or \"\").lower() == \"unknown error\"\n+\n+\n MEDIA_IMAGE = 1\n MEDIA_VIDEO = 2\n MEDIA_FILE = 3","@@ -1254,7 +1266,8 @@ async def _poll_loop(self) -> None:\n                 ret = response.get(\"ret\", 0)\n                 errcode = response.get(\"errcode\", 0)\n                 if ret not in (0, None) or errcode not in (0, None):\n-                    if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE:\n+                    if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE\n+                            or _is_stale_session_ret(ret, errcode, response.get(\"errmsg\"))):\n                         logger.error(\"[%s] Session expired; pausing for 10 minutes\", self.name)\n                         await asyncio.sleep(600)\n                         consecutive_failures = 0","@@ -1519,6 +1532,7 @@ async def _send_text_chunk(\n                         is_session_expired = (\n                             ret == SESSION_EXPIRED_ERRCODE\n                             or errcode == SESSION_EXPIRED_ERRCODE\n+                            or _is_stale_session_ret(ret, errcode, resp.get(\"errmsg\"))\n                         )\n                         # Session expired — strip token and retry once\n                         if is_session_expired and not retried_without_token and context_token:"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -758,3 +758,33 @@ def test_send_file_sets_voice_metadata_for_silk_payload(\n         assert voice_item[\"encode_type\"] == 6\n         assert voice_item[\"sample_rate\"] == 24000\n         assert voice_item[\"bits_per_sample\"] == 16\n+\n+\n+class TestIsStaleSessionRet:\n+    \"\"\"Regression test for #17228: distinguish stale-session ret=-2 from rate-limit ret=-2.\"\"\"\n+\n+    def test_ret_minus_2_with_unknown_error_is_stale(self):\n+        assert weixin._is_stale_session_ret(-2, None, \"unknown error\") is True\n+\n+    def test_errcode_minus_2_with_unknown_error_is_stale(self):\n+        assert weixin._is_stale_session_ret(None, -2, \"unknown error\") is True\n+\n+    def test_unknown_error_case_insensitive(self):\n+        assert weixin._is_stale_session_ret(-2, None, \"Unknown Error\") is True\n+\n+    def test_ret_minus_2_with_freq_limit_is_not_stale(self):\n+        # Genuine rate limit — must NOT be treated as stale session.\n+        assert weixin._is_stale_session_ret(-2, None, \"freq limit\") is False\n+\n+    def test_ret_minus_2_with_no_errmsg_is_not_stale(self):\n+        assert weixin._is_stale_session_ret(-2, None, None) is False\n+        assert weixin._is_stale_session_ret(-2, None, \"\") is False\n+\n+    def test_errcode_minus_14_is_not_matched_here(self):\n+        # -14 is handled by the separate SESSION_EXPIRED_ERRCODE path; the\n+        # helper only disambiguates -2 from a genuine rate limit.\n+        assert weixin._is_stale_session_ret(-14, None, \"session expired\") is False\n+\n+    def test_success_codes_are_not_stale(self):\n+        assert weixin._is_stale_session_ret(0, 0, \"\") is False\n+        assert weixin._is_stale_session_ret(None, None, \"unknown error\") is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}