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
- 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
Open PRs0
Fixes on record
-
#68165 fix(matrix): steer models off markdown tables + configurable message length (salvage #52552, #53083)
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.mdShow 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
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.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #68165
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 20 Jul 2026