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
- 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
Open PRs0
Fixes on record
-
#50778 fix(delivery): adapter-aware cron output chunking (salvage #50145)
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.pyShow 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
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.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 Lab