# NousResearch/hermes-agent · Long message truncation vs. chunking across delivery adapters

## Open now

- Nothing open on GitHub.

Multiple bugs and fixes address how cron and agent output exceeding 4,000 characters is handled across email, SMS, and Discord delivery adapters, centering on whether a splits_long_messages flag is declared to enable chunked delivery instead of hard truncation.

## TL;DR

- 15 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 10 PR(s) closed without merging.
- A fix was merged to main in PR #50778. Verify it addresses your case.
- **Recommended:** read PR #50778 (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-50778,pr-130866`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: 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

- #50126: Cron Discord delivery is truncated after routing through DeliveryRouter instead of being split into multiple messages
- #61990: \[Bug\]: No way to override email delivery truncation
- #62751: Bug: SmsAdapter doesn't declare splits_long_messages, so cron/agent output over 4000 chars gets hard-truncated instead of chunked

## Prior & competing PRs

- **#50778: fix(delivery): adapter-aware cron output chunking (salvage #50145)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/delivery.py`, `gateway/platforms/base.py`, `gateway/platforms/bluebubbles.py`, `gateway/platforms/weixin.py`, `gateway/platforms/whatsapp_cloud.py`, `gateway/platforms/yuanbao.py`, `plugins/platforms/discord/adapter.py`, `plugins/platforms/feishu/adapter.py`, `plugins/platforms/matrix/adapter.py`, `plugins/platforms/mattermost/adapter.py`, `plugins/platforms/slack/adapter.py`, `plugins/platforms/teams/adapter.py`, `plugins/platforms/telegram/adapter.py`, `plugins/platforms/whatsapp/adapter.py`, `scripts/release.py`, `tests/gateway/test_delivery.py`
  - fix hunks:
  - `gateway/delivery.py`

    ```diff
    @@ -20,8 +20,13 @@
     
     logger = logging.getLogger(__name__)
     
    +# Cap before gateway-level truncation of cron output for non-chunking platform
    +# delivery.  Telegram's hard API limit is 4096; the headroom covers the "full
    +# output saved to …" footer appended on truncation.  Adapters that split long
    +# messages natively (BasePlatformAdapter.splits_long_messages) bypass this
    +# entirely — the adapter chunks in its own send() and the full output is
    +# preserved.
     MAX_PLATFORM_OUTPUT = 4000
    -TRUNCATED_VISIBLE = 3800
     
     # Matches strings that are *only* a "silence" narration with optional markdown
     # wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
    ```

    ```diff
    @@ -316,15 +321,55 @@ async def _deliver_to_platform(
             if not target.chat_id:
                 raise ValueError(f"No chat ID for {target.platform.value} delivery")
             
    -        # Guard: truncate oversized cron output to stay within platform limits
    +        # Guard: handle oversized cron output.
    +        #
    +        # Two independent decisions:
    +        #   1. AUDIT SAVE — when content exceeds MAX_PLATFORM_OUTPUT, the full
    +        #      output is always written to disk as a recoverable audit trail.
    +        #      This fires regardless of adapter capability (best-effort).
    +        #   2. TRUNCATION — for non-chunking adapters, content above the cap is
    +        #      truncated with a footer pointing to the saved file.  Chunking-
    +        #      capable adapters (splits_long_messages=True) receive the full
    +        #      payload and split natively in their send().
    +        job_id = (metadata or {}).get("job_id", "unknown")
    +        saved_path: Optional[Path] = None
    +
             if len(content) > MAX_PLATFORM_OUTPUT:
    -            job_id = (metadata or {}).get("job_id", "unknown")
    -            saved_path = self._save_full_output(content, job_id)
    -            logger.info("Cron output truncated (%d chars) — full output: %s", len(content), saved_path)
    -            content = (
    -                content[:TRUNCATED_VISIBLE]
    -                + f"\n\n... [truncated, full output saved to {saved_path}]"
    -            )
    +            # Step 1 — audit save (best-effort).  The save is a side-effect
    +            # audit trail, not essential to delivery.  If it fails (full disk,
    +            # permissions), delivery proceeds — the content reaches the adapter
    +            # regardless.
    +            try:
    +                saved_path = self._save_full_output(content, job_id)
    +            except OSError as exc:
    +                logger.warning(
    +                    "Audit save failed for cron output (%d chars, job=%s): %s — "
    +                    "delivery proceeds without audit copy",
    +                    len(content), job_id, exc,
    +                )
    +
    +            # Step 2 — truncation (only for non-chunking adapters).
    +            if getattr(adapter, "splits_long_messages", False):
    +                # Adapter chunks natively — deliver full payload.
    +                if saved_path:
    +                    logger.info(
    +                        "Cron output preserved for chunking adapter (%d chars) — "
    +                        "full output saved to %s",
    +                        len(content), saved_path,
    +                    )
    +            else:
    +                # Non-chunking adapter — truncate with footer.  The footer
    +                # needs a valid path, so if the best-effort save above failed,
    +                # retry it here (a failure now is a real delivery problem).
    +                if saved_path is None:
    +                    saved_path = self._save_full_output(content, job_id)
    +                footer = f"\n\n... [truncated, full output saved to {saved_path}]"
    +                visible = max(0, MAX_PLATFORM_OUTPUT - len(footer))
    +                logger.info(
    +                    "Cron output truncated (%d chars) — full output: %s",
    +                    len(content), saved_path,
    +                )
    +                content = content[:visible] + footer
             
             # Substrate-level anti-loop guard: drop hallucinated "silence narration"
             # (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
    ```
  - `gateway/platforms/base.py`

    ```diff
    @@ -2077,6 +2077,14 @@ class BasePlatformAdapter(ABC):
         # set this to False to stay correct-by-default.
         supports_async_delivery: bool = True
     
    +    # Whether this adapter's ``send()`` splits long content into multiple
    +    # messages via ``truncate_message()``.  When True, the delivery router
    +    # (gateway/delivery.py) skips gateway-level truncation and lets the
    +    # adapter chunk natively — preserving full output on platforms that
    +    # support multi-message delivery (Discord, Telegram, …).  Default False
    +    # (conservative); adapters verified to chunk in ``send()`` set True.
    +    splits_long_messages: bool = False
    +
         # The command prefix users can always TYPE on this platform to reach
         # Hermes commands.  Default "/" (most platforms deliver "/approve" etc.
         # as plain message text).  Platforms where typing a leading "/" is
    ```
  - `gateway/platforms/bluebubbles.py`

    ```diff
    @@ -113,6 +113,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
         platform = Platform.BLUEBUBBLES
         SUPPORTS_MESSAGE_EDITING = False
         MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
     
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.BLUEBUBBLES)
    ```
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -1139,6 +1139,7 @@ class WeixinAdapter(BasePlatformAdapter):
         """Native Hermes adapter for Weixin personal accounts."""
     
         supports_code_blocks = True  # Weixin renders fenced code blocks
    +    splits_long_messages = True  # send() chunks via _split_text()
     
         MAX_MESSAGE_LENGTH = 2000
     
    ```
  - `gateway/platforms/whatsapp_cloud.py`

    ```diff
    @@ -187,6 +187,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
         syntax). The Baileys adapter does the same.
         """
     
    +    splits_long_messages = True  # send() chunks via truncate_message()
    +
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.WHATSAPP_CLOUD)
             extra = config.extra or {}
    ```
  - `gateway/platforms/yuanbao.py`

    ```diff
    @@ -4983,6 +4983,7 @@ class YuanbaoAdapter(BasePlatformAdapter):
     
         PLATFORM = Platform.YUANBAO
         MAX_TEXT_CHUNK: int = 4000  # Yuanbao single message character limit
    +    splits_long_messages = True  # send() auto-chunks via truncate_message(MAX_TEXT_CHUNK)
         MEDIA_MAX_SIZE_MB: int = 50  # Max media file size in MB for upload validation
         REPLY_REF_MAX_ENTRIES: ClassVar[int] = 500  # Max capacity of reference dedup dict
     
    ```
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -733,6 +733,7 @@ class DiscordAdapter(BasePlatformAdapter):
         MAX_MESSAGE_LENGTH = 2000
         _SPLIT_THRESHOLD = 1900  # near the 2000-char split point
         supports_code_blocks = True  # Discord markdown renders fenced code blocks natively
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
     
         # Auto-disconnect from voice channel after this many seconds of inactivity
         VOICE_TIMEOUT = 300
    ```
  - `plugins/platforms/feishu/adapter.py`

    ```diff
    @@ -1410,6 +1410,7 @@ class FeishuAdapter(BasePlatformAdapter):
         """Feishu/Lark bot adapter."""
     
         supports_code_blocks = True  # Feishu renders fenced code blocks
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
     
         MAX_MESSAGE_LENGTH = 8000
         # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
    ```
  - `plugins/platforms/matrix/adapter.py`

    ```diff
    @@ -775,6 +775,7 @@ 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)
     
         # Matrix clients commonly reserve typed "/" for client-local commands;
         # the adapter accepts "!command" as the alias that always reaches Hermes
    ```
  - `plugins/platforms/mattermost/adapter.py`

    ```diff
    @@ -71,6 +71,8 @@ def check_mattermost_requirements() -> bool:
     class MattermostAdapter(BasePlatformAdapter):
         """Gateway adapter for Mattermost (self-hosted or cloud)."""
     
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_POST_LENGTH)
    +
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.MATTERMOST)
     
    ```
  - `plugins/platforms/slack/adapter.py`

    ```diff
    @@ -321,6 +321,7 @@ class SlackAdapter(BasePlatformAdapter):
     
         MAX_MESSAGE_LENGTH = 39000  # Slack API allows 40,000 chars; leave margin
         supports_code_blocks = True  # Slack mrkdwn renders fenced code blocks
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
         # Slack blocks typed native slash commands inside threads ("/approve is
         # not supported in threads. Sorry!").  The adapter rewrites a leading
         # "!" to "/" for known commands (see _handle_slack_message), so "!" is
    ```
  - `plugins/platforms/teams/adapter.py`

    ```diff
    @@ -691,6 +691,7 @@ class TeamsAdapter(BasePlatformAdapter):
         """Microsoft Teams adapter using the microsoft-teams-apps SDK."""
     
         MAX_MESSAGE_LENGTH = 28000  # Teams text message limit (~28 KB)
    +    splits_long_messages = True  # send() chunks via truncate_message()
     
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform("teams"))
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -417,6 +417,7 @@ class TelegramAdapter(BasePlatformAdapter):
         # Telegram message limits
         MAX_MESSAGE_LENGTH = 4096
         supports_code_blocks = True  # Telegram MarkdownV2 renders fenced code blocks
    +    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
         # Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
         # UTF-8 characters. Content above this is sent via the legacy chunking path.
         RICH_MESSAGE_MAX_CHARS = 32768
    ```
  - `plugins/platforms/whatsapp/adapter.py`

    ```diff
    @@ -337,6 +337,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
     
         # Default bridge location resolved via shared helper
         _DEFAULT_BRIDGE_DIR = None  # resolved in __init__
    +    splits_long_messages = True  # send() chunks via truncate_message()
     
         def __init__(self, config: PlatformConfig):
             super().__init__(config, Platform.WHATSAPP)
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "ScotterMonk",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)
         "[redacted:email]": "TutkuEroglu",  # PR #50481 salvage (AGENTS.md stale token-lock adapter path)
         "[redacted:email]": "pmos69",  # PR #29474 salvage (native Antigravity OAuth provider; Gemini CLI sunset #29294/#49701)
         "[redacted:email]": "orbisai0security",  # PR #9560 salvage (session.py path-traversal guard, V-009)
    ```
  - `tests/gateway/test_delivery.py`

    ```diff
    @@ -281,3 +281,143 @@ async def test_platform_send_failure_raises_for_delivery_result(tmp_path, monkey
     
         with pytest.raises(RuntimeError, match="route failed"):
             await router._deliver_to_platform(target, "hello", metadata={"telegram_reply_to_message_id": "9001"})
    +
    +
    +# ---------------------------------------------------------------------------
    +# Cron output truncation / adapter-aware chunking (issue #50126)
    +# ---------------------------------------------------------------------------
    +
    +class ChunkingAdapter:
    +    """Adapter that declares splits_long_messages=True (like Discord/Telegram)."""
    +    splits_long_messages = True
    +
    +    def __init__(self):
    +        self.calls = []
    +
    +    async def send(self, chat_id, content, metadata=None):
    +        self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
    +        return {"success": True}
    +
    +
    +class NonChunkingAdapter:
    +    """Adapter without splits_long_messages (default False — legacy behavior)."""
    +
    +    def __init__(self):
    +        self.calls = []
    +
    +    async def send(self, chat_id, content, metadata=None):
    +        self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
    +        return {"success": True}
    +
    +
    [redacted:email]
    +async def test_long_output_truncated_for_non_chunking_adapter(tmp_path, monkeypatch):
    +    """Non-chunking adapters receive truncated content with a footer + file save."""
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    adapter = NonChunkingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:123")
    +
    +    long_content = "x" * 5000
    +    await router._deliver_to_platform(target, long_content, metadata={"job_id": "job1"})
    +
    +    delivered = adapter.calls[0]["content"]
    +    assert len(delivered) < 5000  # was truncated
    +    assert "truncated" in delivered.lower()
    +    assert "full output saved to" in delivered
    +    # Full output was saved to disk
    +    saved_files = list(tmp_path.glob("cron/output/job1_*.txt"))
    +    assert len(saved_files) == 1
    +    assert saved_files[0].read_text() == long_content
    +
    +
    [redacted:email]
    +async def test_long_output_preserved_for_chunking_adapter(tmp_path, monkeypatch):
    +    """Chunking adapters (splits_long_messages=True) receive the FULL content."""
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    adapter = ChunkingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:123")
    +
    +    long_content = "x" * 5000
    +    await router._deliver_to_platform(target, long_content, metadata={"job_id": "job2"})
    +
    +    delivered = adapter.calls[0]["content"]
    +    assert delivered == long_content  # NOT truncated — adapter handles chunking
    +    assert "truncated" not in delivered.lower()
    +    # Full output still saved to disk as audit trail
    +    saved_files = list(tmp_path.glob("cron/output/job2_*.txt"))
    +    assert len(saved_files) == 1
    +    assert saved_files[0].read_text() == long_content
    +
    +
    [redacted:email]
    +async def test_short_output_never_truncated(tmp_path, monkeypatch):
    +    """Output under the limit passes through untouched for any adapter."""
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    adapter = NonChunkingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:123")
    +
    +    short_content = "x" * 100
    +    await router._deliver_to_platform(target, short_content, metadata={"job_id": "job3"})
    +
    +    assert adapter.calls[0]["content"] == short_content
    +    # Nothing saved to disk
    +    assert not list(tmp_path.glob("cron/output/*.txt"))
    +
    +
    [redacted:email]
    +async def test_audit_save_failure_does_not_break_chunking_delivery(tmp_path, monkeypatch):
    +    """If the audit save fails (disk full, permissions), chunking adapters
    +    still receive the full content — the save is best-effort."""
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +
    +    adapter = ChunkingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:123")
    +
    +    long_content = "x" * 5000
    +
    +    call_count = {"n": 0}
    +
    +    def failing_save(content, job_id):
    +        call_count["n"] += 1
    +        raise OSError("No space left on device")
    +
    +    monkeypatch.setattr(router, "_save_full_output", failing_save)
    +
    +    # Should NOT raise — audit failure is caught for chunking adapters
    +    await router._deliver_to_platform(target, long_content, metadata={"job_id": "job6"})
    +
    +    # Adapter still got the full content
    +    assert adapter.calls[0]["content"] == long_content
    +    # Save was attempted (best-effort, swallowed)
    +    assert call_count["n"] == 1
    +
    +
    [redacted:email]
    +async def test_save_failure_during_truncation_raises_for_non_chunking_adapter(tmp_path, monkeypatch):
    +    """For a non-chunking adapter, the truncation footer needs a valid saved
    +    path. If the save fails there, that is a real delivery problem and the
    +    error propagates (not swallowed like the chunking best-effort save)."""
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +
    +    adapter = NonChunkingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:123")
    +
    +    long_content = "x" * 5000
    +
    +    def failing_save(content, job_id):
    +        raise OSError("No space left on device")
    +
    +    monkeypatch.setattr(router, "_save_full_output", failing_save)
    +
    +    # Non-chunking adapter must truncate → needs a valid saved path → the
    +    # Step 1 best-effort catch swallows the first attempt, but the Step 2
    +    # retry (footer needs the path) re-raises.
    +    with pytest.raises(OSError, match="No space left on device"):
    +        await router._deliver_to_platform(target, long_content, metadata={"job_id": "job7"})
    +
    +
    ```
- **#67728: fix(gateway): declare splits_long_messages on SmsAdapter**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#62752`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#130866: fix(gateway): SMS, email and WeCom deliver long replies and cron output in full instead of failing or truncating**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/scheduler_delivery.py`, `gateway/delivery.py`, `gateway/platforms/helpers.py`, `plugins/platforms/email/adapter.py`, `plugins/platforms/sms/adapter.py`, `plugins/platforms/wecom/adapter.py`, `plugins/platforms/wecom/callback_adapter.py`, `tests/cron/test_scheduler.py`, `tests/gateway/test_email.py`, `tests/gateway/test_sms.py`, `tests/gateway/test_wecom.py`, `tests/gateway/test_wecom_callback.py`, `website/docs/user-guide/messaging/wecom-callback.md`, `website/docs/user-guide/messaging/wecom.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/wecom-callback.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/wecom.md`
- **#50145: fix(delivery): configurable + adapter-aware cron output truncation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `86e4521cb`, `#50778`
- **#62000: fix(email): enable splits_long_messages to avoid 4000-char truncation cap**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `be5e9f72c6681af9dfb75bf480f08844f1499949`, `#130866`
- **#62439: fix(email): declare splits_long_messages to prevent cron output truncation**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#62653: fix(email): set splits_long_messages on EmailAdapter to prevent cron output truncation**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#62752: fix(gateway): declare splits_long_messages on SmsAdapter**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `be5e9f72c6681af9dfb75bf480f08844f1499949`, `#130866`
- **#62843: fix(sms): add splits_long_messages flag to enable chunked delivery for cron/agent output >4000 chars (#62751)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#62752`, `#62751`
- **#67734: fix(gateway): declare splits_long_messages on SmsAdapter**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `be5e9f72c6681af9dfb75bf480f08844f1499949`, `#130866`
- **#79842: fix(email): split long cron output instead of truncating at 4,000 chars**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#108476: fix(email): declare splits_long_messages so long cron output is delivered whole**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#62000`, `#61990`, `#62000`

## Fix status

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