Long message truncation vs. chunking across delivery adapters

NousResearch/hermes-agent · cluster 50126 · gateway

Data as of

Nothing open on GitHub. Fix merged in #50778. Check it covers your case.

The short answer

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

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.

  • #50778 fix(delivery): adapter-aware cron output chunking (salvage #50145)

    merged to main

    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

    Show the diff
    • gateway/delivery.py
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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"})
      +
      +
  • #130866 fix(gateway): SMS, email and WeCom deliver long replies and cron output in full instead of failing or truncating

    merged to main

    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

10 PRs closed without merging

From the close comments: duplicate 4 · done elsewhere 3 · merged via another change 1 · superseded by another PR 1 · other 1

  • #50145 fix(delivery): configurable + adapter-aware cron output truncation · merged via another change → 86e4521, #50778
  • #62000 fix(email): enable splits_long_messages to avoid 4000-char truncation cap · done elsewhere → be5e9f7, #130866
  • #62439 fix(email): declare splits_long_messages to prevent cron output truncation · duplicate
  • #62653 fix(email): set splits_long_messages on EmailAdapter to prevent cron output truncation · duplicate
  • #62752 fix(gateway): declare splits_long_messages on SmsAdapter · done elsewhere → be5e9f7, #130866
Show 5 more
  • #62843 fix(sms): add splits_long_messages flag to enable chunked delivery for cron/agent output >4000 chars (#62751) · duplicate → #62752, #62751
  • #67728 fix(gateway): declare splits_long_messages on SmsAdapter · superseded by another PR → #62752
  • #67734 fix(gateway): declare splits_long_messages on SmsAdapter · done elsewhere → be5e9f7, #130866
  • #79842 fix(email): split long cron output instead of truncating at 4,000 chars · other
  • #108476 fix(email): declare splits_long_messages so long cron output is delivered whole · duplicate → #62000, #61990
Closed issues · 3
  • Closed#50126Cron Discord delivery is truncated after routing through DeliveryRouter instead of being split into multiple messages
  • Closed#61990[Bug]: No way to override email delivery truncation
  • Closed#62751Bug: SmsAdapter doesn't declare splits_long_messages, so cron/agent output over 4000 chars gets hard-truncated instead of chunked
How this cluster was grouped
  • Link evidence (partly structural links): 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 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
#50778, #130866
Closed issues
3
Attempts
10 PRs closed without merging
Fix link checked
2 Oct 2026

For your agent

The same facts, typed.