{"repo":"NousResearch/hermes-agent","public_id":"53026","display_label":"Matrix adapter message length and table formatting fixes","summary":"These issues and PRs address the Matrix adapter's hardcoded message length limit being too restrictive, and introduce a configurable maximum via an environment variable. Related work also attempts to discourage markdown table output that breaks within Matrix's message constraints.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":68165,"merged_at":"2026-07-20T18:10:50+00:00"},"resolution":{"state":"resolved","by":"pr-68165"},"freshness":{"state":"verified","last_verified_at":"2026-07-20T18:10:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":53026,"pr_number":68165,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T18:10:50+00:00"}],"members":[{"kind":"issue","number":53026,"title":"[Bug]: Matrix adapter hardcoded message length is too conservative and breaks table formatting","url":null,"state":"closed"},{"kind":"pr","number":53083,"title":"fix(matrix): make outbound message length configurable (#53026)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53026","number":53026}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":53084,"title":"fix(matrix): make message length limit configurable via MATRIX_MAX_MESSAGE_LENGTH","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53083","number":53083}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":68165,"title":"fix(matrix): steer models off markdown tables + configurable message length (salvage #52552, #53083)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","contributors/emails/[redacted:email]","plugins/platforms/matrix/adapter.py","tests/agent/test_prompt_builder.py","tests/gateway/test_matrix.py","tests/gateway/test_matrix_message_length.py","tests/gateway/test_text_batching.py","website/docs/user-guide/messaging/matrix.md"],"fix_hunks":[{"file":"agent/prompt_builder.py","hunks":["@@ -805,8 +805,19 @@ def format_steer_marker(steer_text: str) -> str:\n     ),\n     \"matrix\": (\n         \"You are in a Matrix room communicating with your user. \"\n-        \"Matrix renders Markdown — bold, italic, code blocks, and links work; \"\n-        \"the adapter converts your Markdown to HTML for rich display. \"\n+        \"The adapter converts your Markdown to HTML for rich display — bold, \"\n+        \"italic, inline code, fenced code blocks, headings, bullet and \"\n+        \"numbered lists, blockquotes, and links all render.\\n\\n\"\n+        \"Do NOT use Markdown tables: many popular Matrix clients (Element X, \"\n+        \"Beeper, most mobile apps) do not render HTML tables, so the cells \"\n+        \"collapse into one continuous run of text. Present tabular data as \"\n+        \"labeled '**Label:** value' lines or bullet lists instead.\\n\\n\"\n+        \"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes \"\n+        \"(- [ ] / - [x]) — they are not converted and appear as literal \"\n+        \"characters.\\n\\n\"\n+        \"LINKS: prefer [descriptive link text](url) over bare URLs. When \"\n+        \"referencing something with an associated URL (events, sources, \"\n+        \"people), make the name a clickable link.\\n\\n\"\n         \"You can send media files natively: include MEDIA:/absolute/path/to/file \"\n         \"in your response. Images (.jpg, .png, .webp) are sent as inline photos, \"\n         \"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, \""]},{"file":"contributors/emails/[redacted:email]","hunks":["@@ -0,0 +1 @@\n+RKelln"]},{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -41,6 +41,7 @@\n     MATRIX_RECOVERY_KEY         Recovery key for cross-signing verification after device key rotation\n     MATRIX_DM_MENTION_THREADS   Create a thread when bot is @mentioned in a DM (default: false)\n     MATRIX_ALLOW_PUBLIC_ROOMS   Allow Matrix tools to create public rooms (default: false)\n+    MATRIX_MAX_MESSAGE_LENGTH   Outbound message chunk size in characters (default: 16000)\n     MATRIX_APPROVAL_REQUIRE_SENDER\n                               Require reaction controls to come from the original requester\n                               when requester metadata is available (default: true)","@@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt:\n     bot_reaction_events: dict[str, str] = field(default_factory=dict)\n \n \n-# Matrix message size limit (4000 chars practical, spec has no hard limit\n-# but clients render poorly above this).\n-MAX_MESSAGE_LENGTH = 4000\n+# Matrix message size limit. The spec allows large events (~65 KB), but very\n+# large bodies can render poorly in some clients. The previous 4,000-char\n+# default was overly conservative and split Markdown tables mid-row (#53026).\n+DEFAULT_MAX_MESSAGE_LENGTH = 16000\n+MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535\n+\n+\n+def _resolve_max_message_length(config) -> int:\n+    \"\"\"Resolve outbound chunk size from config, env, or plugin registry.\"\"\"\n+    extra = getattr(config, \"extra\", {}) or {}\n+    raw = extra.get(\"max_message_length\")\n+    if raw is None:\n+        raw = os.getenv(\"MATRIX_MAX_MESSAGE_LENGTH\")\n+    if raw is None:\n+        try:\n+            from gateway.platform_registry import platform_registry\n+\n+            entry = platform_registry.get(\"matrix\")\n+            if entry and entry.max_message_length:\n+                raw = entry.max_message_length\n+        except Exception:\n+            pass\n+    if raw is None:\n+        return DEFAULT_MAX_MESSAGE_LENGTH\n+    try:\n+        value = int(raw)\n+    except (TypeError, ValueError):\n+        return DEFAULT_MAX_MESSAGE_LENGTH\n+    return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING))\n+\n+\n+# Back-compat alias for callers/tests that import the module constant.\n+MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH\n \n # Store directory for E2EE keys and sync state.\n # Uses get_hermes_home() so each profile gets its own Matrix store.","@@ -799,21 +830,28 @@ class MatrixAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Matrix (any homeserver).\"\"\"\n \n     supports_code_blocks = True  # Matrix renders fenced code blocks (HTML/markdown)\n-    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n+    splits_long_messages = True  # send() chunks via truncate_message(max_message_length)\n \n     # Matrix clients commonly reserve typed \"/\" for client-local commands;\n     # the adapter accepts \"!command\" as the alias that always reaches Hermes\n     # (see _normalize_matrix_bang_command), so instruction text shows \"!\".\n     typed_command_prefix = \"!\"\n \n-    # Threshold for detecting Matrix client-side message splits.\n-    # When a chunk is near the ~4000-char practical limit, a continuation\n-    # is almost certain.\n-    _SPLIT_THRESHOLD = 3900\n+    # Class-level defaults so partially-constructed instances (tests build\n+    # adapters via object.__new__ without __init__) keep working; __init__\n+    # overrides both from _resolve_max_message_length().\n+    max_message_length = DEFAULT_MAX_MESSAGE_LENGTH\n+    _split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.MATRIX)\n \n+        self.max_message_length = _resolve_max_message_length(config)\n+        # Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH.\n+        self.MAX_MESSAGE_LENGTH = self.max_message_length\n+        # When a chunk is near the outbound limit, a continuation is almost certain.\n+        self._split_threshold = max(100, self.max_message_length - 100)\n+\n         self._homeserver: str = (\n             config.extra.get(\"homeserver\", \"\") or os.getenv(\"MATRIX_HOMESERVER\", \"\")\n         ).rstrip(\"/\")","@@ -1612,7 +1650,7 @@ async def send(\n             return SendResult(success=True)\n \n         formatted = self.format_message(content)\n-        chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)\n+        chunks = self.truncate_message(formatted, self.max_message_length)\n \n         last_event_id = None\n         for i, chunk in enumerate(chunks):","@@ -3607,7 +3645,7 @@ async def _flush_text_batch(self, key: str) -> None:\n         try:\n             pending = self._pending_text_batches.get(key)\n             last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n-            if last_len >= self._SPLIT_THRESHOLD:\n+            if last_len >= self._split_threshold:\n                 delay = self._text_batch_split_delay_seconds\n             else:\n                 delay = self._text_batch_delay_seconds","@@ -4690,6 +4728,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:\n         os.environ[\"MATRIX_AUTO_THREAD\"] = str(matrix_cfg[\"auto_thread\"]).lower()\n     if \"dm_mention_threads\" in matrix_cfg and not os.getenv(\"MATRIX_DM_MENTION_THREADS\"):\n         os.environ[\"MATRIX_DM_MENTION_THREADS\"] = str(matrix_cfg[\"dm_mention_threads\"]).lower()\n+    if \"max_message_length\" in matrix_cfg and not os.getenv(\"MATRIX_MAX_MESSAGE_LENGTH\"):\n+        os.environ[\"MATRIX_MAX_MESSAGE_LENGTH\"] = str(matrix_cfg[\"max_message_length\"])\n     return None\n \n ","@@ -4735,7 +4775,7 @@ def register(ctx) -> None:\n         allow_all_env=\"MATRIX_ALLOW_ALL_USERS\",\n         cron_deliver_env_var=\"MATRIX_HOME_ROOM\",\n         standalone_sender_fn=_standalone_send,\n-        max_message_length=4000,\n+        max_message_length=DEFAULT_MAX_MESSAGE_LENGTH,\n         emoji=\"🔐\",\n         allow_update_command=True,\n     )"]},{"file":"tests/agent/test_prompt_builder.py","hunks":["@@ -1169,6 +1169,11 @@ def test_platform_hints_matrix(self):\n         assert \"Matrix\" in hint\n         assert \"MEDIA:\" in hint\n         assert \"Markdown\" in hint\n+        # Regression (#52552): the hint must steer models away from Markdown\n+        # tables — popular Matrix clients don't render HTML tables and the\n+        # cells collapse into one continuous line.\n+        assert \"table\" in hint.lower()\n+        assert \"Do NOT use Markdown tables\" in hint\n \n     def test_platform_hints_feishu(self):\n         hint = PLATFORM_HINTS[\"feishu\"]"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -1033,7 +1033,10 @@ async def test_edit_payload_uses_m_replace(self):\n \n     @pytest.mark.asyncio\n     async def test_long_response_split_preserves_thread_context(self):\n-        long_text = \"Intro\\n```python\\n\" + (\"print('hello')\\n\" * 500) + \"```\\nDone\"\n+        # Build a payload guaranteed to exceed the adapter's outbound chunk\n+        # size (configurable since #53026) so send() must split it.\n+        repeats = (self.adapter.max_message_length // 15) + 200\n+        long_text = \"Intro\\n```python\\n\" + (\"print('hello')\\n\" * repeats) + \"```\\nDone\"\n \n         result = await self.adapter.send(\n             \"!room:example.org\","]},{"file":"tests/gateway/test_matrix_message_length.py","hunks":["@@ -0,0 +1,85 @@\n+\"\"\"Tests for Matrix outbound message length configuration (#53026).\"\"\"\n+import asyncio\n+import os\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _make_adapter(**extra):\n+    from plugins.platforms.matrix.adapter import MatrixAdapter\n+\n+    config = PlatformConfig(\n+        enabled=True,\n+        token=\"[redacted:secret-assignment]\",\n+        extra={\n+            \"homeserver\": \"https://matrix.example.org\",\n+            \"user_id\": \"@bot:example.org\",\n+            **extra,\n+        },\n+    )\n+    return MatrixAdapter(config)\n+\n+\n+class TestMatrixMaxMessageLength:\n+    def test_default_limit_is_16000(self):\n+        adapter = _make_adapter()\n+        assert adapter.max_message_length == 16000\n+        assert adapter._split_threshold == 15900\n+\n+    def test_extra_override(self):\n+        adapter = _make_adapter(max_message_length=12000)\n+        assert adapter.max_message_length == 12000\n+        assert adapter._split_threshold == 11900\n+\n+    def test_env_override(self, monkeypatch):\n+        monkeypatch.setenv(\"MATRIX_MAX_MESSAGE_LENGTH\", \"20000\")\n+        adapter = _make_adapter()\n+        assert adapter.max_message_length == 20000\n+\n+    def test_extra_beats_env(self, monkeypatch):\n+        monkeypatch.setenv(\"MATRIX_MAX_MESSAGE_LENGTH\", \"20000\")\n+        adapter = _make_adapter(max_message_length=10000)\n+        assert adapter.max_message_length == 10000\n+\n+    def test_invalid_values_fall_back_to_default(self, monkeypatch):\n+        monkeypatch.setenv(\"MATRIX_MAX_MESSAGE_LENGTH\", \"not-a-number\")\n+        adapter = _make_adapter()\n+        assert adapter.max_message_length == 16000\n+\n+    def test_values_are_clamped(self):\n+        adapter = _make_adapter(max_message_length=100)\n+        assert adapter.max_message_length == 500\n+        adapter = _make_adapter(max_message_length=999999)\n+        assert adapter.max_message_length == 65535\n+\n+    def test_apply_yaml_config_sets_env(self, monkeypatch):\n+        from plugins.platforms.matrix.adapter import _apply_yaml_config\n+\n+        monkeypatch.delenv(\"MATRIX_MAX_MESSAGE_LENGTH\", raising=False)\n+        _apply_yaml_config({}, {\"max_message_length\": 12000})\n+        assert os.getenv(\"MATRIX_MAX_MESSAGE_LENGTH\") == \"12000\"\n+\n+    def test_register_uses_default_limit(self):\n+        from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register\n+\n+        ctx = MagicMock()\n+        register(ctx)\n+        kwargs = ctx.register_platform.call_args[1]\n+        assert kwargs[\"max_message_length\"] == DEFAULT_MAX_MESSAGE_LENGTH\n+\n+    def test_send_uses_configured_limit(self):\n+        adapter = _make_adapter(max_message_length=5000)\n+        adapter._client = MagicMock()\n+        adapter._client.send_message_event = AsyncMock(return_value=\"evt\")\n+        long_text = \"x\" * 12000\n+\n+        async def _run():\n+            with patch.object(adapter, \"truncate_message\", wraps=adapter.truncate_message) as trunc:\n+                await adapter.send(\"!room:example.org\", long_text)\n+                trunc.assert_called_once()\n+                assert trunc.call_args[0][1] == 5000\n+\n+        asyncio.run(_run())"]},{"file":"tests/gateway/test_text_batching.py","hunks":["@@ -278,9 +278,9 @@ async def test_different_rooms_not_merged(self):\n \n     @pytest.mark.asyncio\n     async def test_adaptive_delay_for_near_limit_chunk(self):\n-        \"\"\"Chunks near the 4000-char limit should trigger longer delay.\"\"\"\n+        \"\"\"Chunks near the outbound limit should trigger longer delay.\"\"\"\n         adapter = _make_matrix_adapter()\n-        long_text = \"x\" * 3950\n+        long_text = \"x\" * (adapter._split_threshold + 50)\n         adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))\n \n         await asyncio.sleep(0.15)"]},{"file":"website/docs/user-guide/messaging/matrix.md","hunks":["@@ -97,6 +97,7 @@ matrix:\n   session_scope: room             # auto|room|thread; room is recommended for project rooms\n   auto_thread: true               # Auto-create threads for responses (default: true)\n   dm_mention_threads: false       # Create thread when @mentioned in DM (default: false)\n+  max_message_length: 16000       # Outbound chunk size in chars (default: 16000, max: 65535)\n ```\n \n Or via environment variables:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}