fix(tools): chunk long messages in send_message_tool before dispatch (#1552)

* fix: prevent infinite 400 failure loop on context overflow (#1630)

When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message.  This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error.  Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.

Three-layer fix:

1. run_agent.py — Fallback heuristic: when a 400 error has a very short
   generic message AND the session is large (>40% of context or >80
   messages), treat it as a probable context overflow and trigger
   compression instead of aborting.

2. run_agent.py + gateway/run.py — Don't persist failed messages:
   when the agent returns failed=True before generating any response,
   skip writing the user's message to the transcript/DB. This prevents
   the session from growing on each failure.

3. gateway/run.py — Smarter error messages: detect context-overflow
   failures and suggest /compact or /reset specifically, instead of a
   generic 'try again' that will fail identically.

* fix(skills): detect prompt injection patterns and block cache file reads

Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):

1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
   (index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
   was the original injection vector — untrusted skill descriptions
   in the catalog contained adversarial text that the model executed.

2. skill_view: warns when skills are loaded from outside the trusted
   ~/.hermes/skills/ directory, and detects common injection patterns
   in skill content ("ignore previous instructions", "<system>", etc.).

Cherry-picked from PR #1562 by ygd58.

* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)

Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.

- Apply truncate_message() chunking in _send_to_platform() before
  dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
  in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement

Cherry-picked from PR #1557 by llbn.

---------

Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
This commit is contained in:
Teknium 2026-03-17 01:52:43 -07:00 committed by GitHub
parent 81f76111b0
commit 12afccd9ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 122 additions and 35 deletions

View file

@ -1182,7 +1182,8 @@ class BasePlatformAdapter(ABC):
""" """
return content return content
def truncate_message(self, content: str, max_length: int = 4096) -> List[str]: @staticmethod
def truncate_message(content: str, max_length: int = 4096) -> List[str]:
""" """
Split a long message into chunks, preserving code block boundaries. Split a long message into chunks, preserving code block boundaries.

View file

@ -9,7 +9,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from gateway.config import Platform from gateway.config import Platform
from tools.send_message_tool import _send_telegram, send_message_tool from tools.send_message_tool import _send_telegram, _send_to_platform, send_message_tool
def _run_async_immediately(coro): def _run_async_immediately(coro):
@ -345,3 +345,49 @@ class TestSendTelegramMediaDelivery:
assert "error" in result assert "error" in result
assert "No deliverable text or media remained" in result["error"] assert "No deliverable text or media remained" in result["error"]
bot.send_message.assert_not_awaited() bot.send_message.assert_not_awaited()
# ---------------------------------------------------------------------------
# Regression: long messages are chunked before platform dispatch
# ---------------------------------------------------------------------------
class TestSendToPlatformChunking:
def test_long_message_is_chunked(self):
"""Messages exceeding the platform limit are split into multiple sends."""
send = AsyncMock(return_value={"success": True, "message_id": "1"})
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
with patch("tools.send_message_tool._send_discord", send):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
SimpleNamespace(enabled=True, token="tok", extra={}),
"ch", long_msg,
)
)
assert result["success"] is True
assert send.await_count >= 3
for call in send.await_args_list:
assert len(call.args[2]) <= 2020 # each chunk fits the limit
def test_telegram_media_attaches_to_last_chunk(self):
"""When chunked, media files are sent only with the last chunk."""
sent_calls = []
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
sent_calls.append(media_files or [])
return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))}
long_msg = "word " * 2000 # ~10000 chars, well over 4096
media = [("/tmp/photo.png", False)]
with patch("tools.send_message_tool._send_telegram", fake_send):
asyncio.run(
_send_to_platform(
Platform.TELEGRAM,
SimpleNamespace(enabled=True, token="tok", extra={}),
"123", long_msg, media_files=media,
)
)
assert len(sent_calls) >= 3
assert all(call == [] for call in sent_calls[:-1])
assert sent_calls[-1] == media

View file

@ -263,18 +263,53 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None): async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
"""Route a message to the appropriate platform sender.""" """Route a message to the appropriate platform sender.
Long messages are automatically chunked to fit within platform limits
using the same smart-splitting algorithm as the gateway adapters
(preserves code-block boundaries, adds part indicators).
"""
from gateway.config import Platform from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter
from gateway.platforms.telegram import TelegramAdapter
from gateway.platforms.discord import DiscordAdapter
from gateway.platforms.slack import SlackAdapter
media_files = media_files or [] media_files = media_files or []
# Platform message length limits (from adapter class attributes)
_MAX_LENGTHS = {
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
}
# Smart-chunk the message to fit within platform limits.
# For short messages or platforms without a known limit this is a no-op.
max_len = _MAX_LENGTHS.get(platform)
if max_len:
chunks = BasePlatformAdapter.truncate_message(message, max_len)
else:
chunks = [message]
# --- Telegram: special handling for media attachments ---
if platform == Platform.TELEGRAM: if platform == Platform.TELEGRAM:
return await _send_telegram( last_result = None
pconfig.token, for i, chunk in enumerate(chunks):
chat_id, is_last = (i == len(chunks) - 1)
message, result = await _send_telegram(
media_files=media_files, pconfig.token,
thread_id=thread_id, chat_id,
) chunk,
media_files=media_files if is_last else [],
thread_id=thread_id,
)
if isinstance(result, dict) and result.get("error"):
return result
last_result = result
return last_result
# --- Non-Telegram platforms ---
if media_files and not message.strip(): if media_files and not message.strip():
return { return {
"error": ( "error": (
@ -289,22 +324,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
"native send_message media delivery is currently only supported for telegram" "native send_message media delivery is currently only supported for telegram"
) )
if platform == Platform.DISCORD: last_result = None
result = await _send_discord(pconfig.token, chat_id, message) for chunk in chunks:
elif platform == Platform.SLACK: if platform == Platform.DISCORD:
result = await _send_slack(pconfig.token, chat_id, message) result = await _send_discord(pconfig.token, chat_id, chunk)
elif platform == Platform.SIGNAL: elif platform == Platform.SLACK:
result = await _send_signal(pconfig.extra, chat_id, message) result = await _send_slack(pconfig.token, chat_id, chunk)
elif platform == Platform.EMAIL: elif platform == Platform.SIGNAL:
result = await _send_email(pconfig.extra, chat_id, message) result = await _send_signal(pconfig.extra, chat_id, chunk)
else: elif platform == Platform.EMAIL:
result = {"error": f"Direct sending not yet implemented for {platform.value}"} result = await _send_email(pconfig.extra, chat_id, chunk)
else:
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
if warning and isinstance(result, dict) and result.get("success"): if isinstance(result, dict) and result.get("error"):
warnings = list(result.get("warnings", [])) return result
last_result = result
if warning and isinstance(last_result, dict) and last_result.get("success"):
warnings = list(last_result.get("warnings", []))
warnings.append(warning) warnings.append(warning)
result["warnings"] = warnings last_result["warnings"] = warnings
return result return last_result
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None): async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None):
@ -415,7 +456,10 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
async def _send_discord(token, chat_id, message): async def _send_discord(token, chat_id, message):
"""Send via Discord REST API (no websocket client needed).""" """Send a single message via Discord REST API (no websocket client needed).
Chunking is handled by _send_to_platform() before this is called.
"""
try: try:
import aiohttp import aiohttp
except ImportError: except ImportError:
@ -423,17 +467,13 @@ async def _send_discord(token, chat_id, message):
try: try:
url = f"https://discord.com/api/v10/channels/{chat_id}/messages" url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"}
chunks = [message[i:i+2000] for i in range(0, len(message), 2000)]
message_ids = []
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for chunk in chunks: async with session.post(url, headers=headers, json={"content": message}) as resp:
async with session.post(url, headers=headers, json={"content": chunk}) as resp: if resp.status not in (200, 201):
if resp.status not in (200, 201): body = await resp.text()
body = await resp.text() return {"error": f"Discord API error ({resp.status}): {body}"}
return {"error": f"Discord API error ({resp.status}): {body}"} data = await resp.json()
data = await resp.json() return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": data.get("id")}
message_ids.append(data.get("id"))
return {"success": True, "platform": "discord", "chat_id": chat_id, "message_ids": message_ids}
except Exception as e: except Exception as e:
return {"error": f"Discord send failed: {e}"} return {"error": f"Discord send failed: {e}"}