# NousResearch/hermes-agent · Matrix adapter message length and table formatting fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #68165. Verify it addresses your case.
- **Recommended:** read PR #68165 (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-68165`. 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.50`: 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

- #53026: \[Bug\]: Matrix adapter hardcoded message length is too conservative and breaks table formatting

## Prior & competing PRs

- **#68165: fix(matrix): steer models off markdown tables + configurable message length (salvage #52552, #53083)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `agent/prompt_builder.py`

    ```diff
    @@ -805,8 +805,19 @@ def format_steer_marker(steer_text: str) -> str:
         ),
         "matrix": (
             "You are in a Matrix room communicating with your user. "
    -        "Matrix renders Markdown — bold, italic, code blocks, and links work; "
    -        "the adapter converts your Markdown to HTML for rich display. "
    +        "The adapter converts your Markdown to HTML for rich display — bold, "
    +        "italic, inline code, fenced code blocks, headings, bullet and "
    +        "numbered lists, blockquotes, and links all render.\n\n"
    +        "Do NOT use Markdown tables: many popular Matrix clients (Element X, "
    +        "Beeper, most mobile apps) do not render HTML tables, so the cells "
    +        "collapse into one continuous run of text. Present tabular data as "
    +        "labeled '**Label:** value' lines or bullet lists instead.\n\n"
    +        "Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
    +        "(- [ ] / - [x]) — they are not converted and appear as literal "
    +        "characters.\n\n"
    +        "LINKS: prefer [descriptive link text](url) over bare URLs. When "
    +        "referencing something with an associated URL (events, sources, "
    +        "people), make the name a clickable link.\n\n"
             "You can send media files natively: include MEDIA:/absolute/path/to/file "
             "in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
             "audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
    ```
  - `contributors/emails/[redacted:email]`

    ```diff
    @@ -0,0 +1 @@
    +RKelln
    ```
  - `plugins/platforms/matrix/adapter.py`

    ```diff
    @@ -41,6 +41,7 @@
         MATRIX_RECOVERY_KEY         Recovery key for cross-signing verification after device key rotation
         MATRIX_DM_MENTION_THREADS   Create a thread when bot is @mentioned in a DM (default: false)
         MATRIX_ALLOW_PUBLIC_ROOMS   Allow Matrix tools to create public rooms (default: false)
    +    MATRIX_MAX_MESSAGE_LENGTH   Outbound message chunk size in characters (default: 16000)
         MATRIX_APPROVAL_REQUIRE_SENDER
                                   Require reaction controls to come from the original requester
                                   when requester metadata is available (default: true)
    ```

    ```diff
    @@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt:
         bot_reaction_events: dict[str, str] = field(default_factory=dict)
     
     
    -# Matrix message size limit (4000 chars practical, spec has no hard limit
    -# but clients render poorly above this).
    -MAX_MESSAGE_LENGTH = 4000
    +# Matrix message size limit. The spec allows large events (~65 KB), but very
    +# large bodies can render poorly in some clients. The previous 4,000-char
    +# default was overly conservative and split Markdown tables mid-row (#53026).
    +DEFAULT_MAX_MESSAGE_LENGTH = 16000
    +MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535
    +
    +
    +def _resolve_max_message_length(config) -> int:
    +    """Resolve outbound chunk size from config, env, or plugin registry."""
    +    extra = getattr(config, "extra", {}) or {}
    +    raw = extra.get("max_message_length")
    +    if raw is None:
    +        raw = os.getenv("MATRIX_MAX_MESSAGE_LENGTH")
    +    if raw is None:
    +        try:
    +            from gateway.platform_registry import platform_registry
    +
    +            entry = platform_registry.get("matrix")
    +            if entry and entry.max_message_length:
    +                raw = entry.max_message_length
    +        except Exception:
    +            pass
    +    if raw is None:
    +        return DEFAULT_MAX_MESSAGE_LENGTH
    +    try:
    +        value = int(raw)
    +    except (TypeError, ValueError):
    +        return DEFAULT_MAX_MESSAGE_LENGTH
    +    return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING))
    +
    +
    +# Back-compat alias for callers/tests that import the module constant.
    +MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH
     
     # Store directory for E2EE keys and sync state.
     # Uses get_hermes_home() so each profile gets its own Matrix store.
    ```

    ```diff
    @@ -799,21 +830,28 @@ class MatrixAdapter(BasePlatformAdapter):
         """Gateway adapter for Matrix (any homeserver)."""
     
         supports_code_blocks = True  # Matrix renders fenced code blocks (HTML/markdown)
    -    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
    +    splits_long_messages = True  # send() chunks via truncate_message(max_message_length)
     
         # Matrix clients commonly reserve typed "/" for client-local commands;
         # the adapter accepts "!command" as the alias that always reaches Hermes
         # (see _normalize_matrix_bang_command), so instruction text shows "!".
         typed_command_prefix = "!"
     
    -    # Threshold for detecting Matrix client-side message splits.
    -    # When a chunk is near the ~4000-char practical limit, a continuation
    -    # is almost certain.
    -    _SPLIT_THRESHOLD = 3900
    +    # Class-level defaults so partially-constructed instances (tests build
    +    # adapters via object.__new__ without __init__) keep working; __init__
    +    # overrides both from _resolve_max_message_length().
    +    max_message_length = DEFAULT_MAX_MESSAGE_LENGTH
    +    _split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100
     
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.MATRIX)
     
    +        self.max_message_length = _resolve_max_message_length(config)
    +        # Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH.
    +        self.MAX_MESSAGE_LENGTH = self.max_message_length
    +        # When a chunk is near the outbound limit, a continuation is almost certain.
    +        self._split_threshold = max(100, self.max_message_length - 100)
    +
             self._homeserver: str = (
                 config.extra.get("homeserver", "") or os.getenv("MATRIX_HOMESERVER", "")
             ).rstrip("/")
    ```

    ```diff
    @@ -1612,7 +1650,7 @@ async def send(
                 return SendResult(success=True)
     
             formatted = self.format_message(content)
    -        chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
    +        chunks = self.truncate_message(formatted, self.max_message_length)
     
             last_event_id = None
             for i, chunk in enumerate(chunks):
    ```

    ```diff
    @@ -3607,7 +3645,7 @@ async def _flush_text_batch(self, key: str) -> None:
             try:
                 pending = self._pending_text_batches.get(key)
                 last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
    -            if last_len >= self._SPLIT_THRESHOLD:
    +            if last_len >= self._split_threshold:
                     delay = self._text_batch_split_delay_seconds
                 else:
                     delay = self._text_batch_delay_seconds
    ```

    ```diff
    @@ -4690,6 +4728,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:
             os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
         if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
             os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
    +    if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"):
    +        os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"])
         return None
     
     
    ```

    ```diff
    @@ -4735,7 +4775,7 @@ def register(ctx) -> None:
             allow_all_env="MATRIX_ALLOW_ALL_USERS",
             cron_deliver_env_var="MATRIX_HOME_ROOM",
             standalone_sender_fn=_standalone_send,
    -        max_message_length=4000,
    +        max_message_length=DEFAULT_MAX_MESSAGE_LENGTH,
             emoji="🔐",
             allow_update_command=True,
         )
    ```
  - `tests/agent/test_prompt_builder.py`

    ```diff
    @@ -1169,6 +1169,11 @@ def test_platform_hints_matrix(self):
             assert "Matrix" in hint
             assert "MEDIA:" in hint
             assert "Markdown" in hint
    +        # Regression (#52552): the hint must steer models away from Markdown
    +        # tables — popular Matrix clients don't render HTML tables and the
    +        # cells collapse into one continuous line.
    +        assert "table" in hint.lower()
    +        assert "Do NOT use Markdown tables" in hint
     
         def test_platform_hints_feishu(self):
             hint = PLATFORM_HINTS["feishu"]
    ```
  - `tests/gateway/test_matrix.py`

    ```diff
    @@ -1033,7 +1033,10 @@ async def test_edit_payload_uses_m_replace(self):
     
         @pytest.mark.asyncio
         async def test_long_response_split_preserves_thread_context(self):
    -        long_text = "Intro\n```python\n" + ("print('hello')\n" * 500) + "```\nDone"
    +        # Build a payload guaranteed to exceed the adapter's outbound chunk
    +        # size (configurable since #53026) so send() must split it.
    +        repeats = (self.adapter.max_message_length // 15) + 200
    +        long_text = "Intro\n```python\n" + ("print('hello')\n" * repeats) + "```\nDone"
     
             result = await self.adapter.send(
                 "!room:example.org",
    ```
  - `tests/gateway/test_matrix_message_length.py`

    ```diff
    @@ -0,0 +1,85 @@
    +"""Tests for Matrix outbound message length configuration (#53026)."""
    +import asyncio
    +import os
    +from unittest.mock import AsyncMock, MagicMock, patch
    +
    +import pytest
    +
    +from gateway.config import PlatformConfig
    +
    +
    +def _make_adapter(**extra):
    +    from plugins.platforms.matrix.adapter import MatrixAdapter
    +
    +    config = PlatformConfig(
    +        enabled=True,
    +        token="[redacted:secret-assignment]",
    +        extra={
    +            "homeserver": "https://matrix.example.org",
    +            "user_id": "@bot:example.org",
    +            **extra,
    +        },
    +    )
    +    return MatrixAdapter(config)
    +
    +
    +class TestMatrixMaxMessageLength:
    +    def test_default_limit_is_16000(self):
    +        adapter = _make_adapter()
    +        assert adapter.max_message_length == 16000
    +        assert adapter._split_threshold == 15900
    +
    +    def test_extra_override(self):
    +        adapter = _make_adapter(max_message_length=12000)
    +        assert adapter.max_message_length == 12000
    +        assert adapter._split_threshold == 11900
    +
    +    def test_env_override(self, monkeypatch):
    +        monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
    +        adapter = _make_adapter()
    +        assert adapter.max_message_length == 20000
    +
    +    def test_extra_beats_env(self, monkeypatch):
    +        monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
    +        adapter = _make_adapter(max_message_length=10000)
    +        assert adapter.max_message_length == 10000
    +
    +    def test_invalid_values_fall_back_to_default(self, monkeypatch):
    +        monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "not-a-number")
    +        adapter = _make_adapter()
    +        assert adapter.max_message_length == 16000
    +
    +    def test_values_are_clamped(self):
    +        adapter = _make_adapter(max_message_length=100)
    +        assert adapter.max_message_length == 500
    +        adapter = _make_adapter(max_message_length=999999)
    +        assert adapter.max_message_length == 65535
    +
    +    def test_apply_yaml_config_sets_env(self, monkeypatch):
    +        from plugins.platforms.matrix.adapter import _apply_yaml_config
    +
    +        monkeypatch.delenv("MATRIX_MAX_MESSAGE_LENGTH", raising=False)
    +        _apply_yaml_config({}, {"max_message_length": 12000})
    +        assert os.getenv("MATRIX_MAX_MESSAGE_LENGTH") == "12000"
    +
    +    def test_register_uses_default_limit(self):
    +        from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register
    +
    +        ctx = MagicMock()
    +        register(ctx)
    +        kwargs = ctx.register_platform.call_args[1]
    +        assert kwargs["max_message_length"] == DEFAULT_MAX_MESSAGE_LENGTH
    +
    +    def test_send_uses_configured_limit(self):
    +        adapter = _make_adapter(max_message_length=5000)
    +        adapter._client = MagicMock()
    +        adapter._client.send_message_event = AsyncMock(return_value="evt")
    +        long_text = "x" * 12000
    +
    +        async def _run():
    +            with patch.object(adapter, "truncate_message", wraps=adapter.truncate_message) as trunc:
    +                await adapter.send("!room:example.org", long_text)
    +                trunc.assert_called_once()
    +                assert trunc.call_args[0][1] == 5000
    +
    +        asyncio.run(_run())
    ```
  - `tests/gateway/test_text_batching.py`

    ```diff
    @@ -278,9 +278,9 @@ async def test_different_rooms_not_merged(self):
     
         @pytest.mark.asyncio
         async def test_adaptive_delay_for_near_limit_chunk(self):
    -        """Chunks near the 4000-char limit should trigger longer delay."""
    +        """Chunks near the outbound limit should trigger longer delay."""
             adapter = _make_matrix_adapter()
    -        long_text = "x" * 3950
    +        long_text = "x" * (adapter._split_threshold + 50)
             adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
     
             await asyncio.sleep(0.15)
    ```
  - `website/docs/user-guide/messaging/matrix.md`

    ```diff
    @@ -97,6 +97,7 @@ matrix:
       session_scope: room             # auto|room|thread; room is recommended for project rooms
       auto_thread: true               # Auto-create threads for responses (default: true)
       dm_mention_threads: false       # Create thread when @mentioned in DM (default: false)
    +  max_message_length: 16000       # Outbound chunk size in chars (default: 16000, max: 65535)
     ```
     
     Or via environment variables:
    ```
- **#53083: fix(matrix): make outbound message length configurable (#53026)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#53026`
- **#53084: fix(matrix): make message length limit configurable via MATRIX_MAX_MESSAGE_LENGTH**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#53083`

## Fix status

- Merged to main in PR #68165 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-20`.
- Subsystem: `gateway`
