NousResearch/hermes-agent

Matrix adapter message length and table formatting fixes

Cluster 53026 · gateway. Data as of

Nothing open on GitHub. Fix merged in #68165 on 20 Jul 2026. Check it covers your case.

The short answer

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

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.

  • #68165 fix(matrix): steer models off markdown tables + configurable message length (salvage #52552, #53083)

    merged to main

    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

    Show the diff
    • agent/prompt_builder.py
      @@ -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]
      @@ -0,0 +1 @@
      +RKelln
    • plugins/platforms/matrix/adapter.py
      @@ -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)
      @@ -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.
      @@ -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("/")
      @@ -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):
      @@ -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
      @@ -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
       
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:

2 PRs closed without merging

From the close comments: stale or abandoned 1 · other 1

  • #53083 fix(matrix): make outbound message length configurable (#53026) · stale or abandoned → #53026
  • #53084 fix(matrix): make message length limit configurable via MATRIX_MAX_MESSAGE_LENGTH · other → #53083
Closed issues · 1
  • Closed#53026[Bug]: Matrix adapter hardcoded message length is too conservative and breaks table formatting
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.50: 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

Subsystem
gateway
Merged PRs
#68165
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
20 Jul 2026

For your agent

The same facts, typed.