mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(discord): dedup saturated mid-stream overflow previews to stop edit-rate-limit storms
a0a3c716ffixed the exact same failure mode for Telegram (#58563): post-#48648, oversized mid-stream edits truncate to a one-message preview instead of splitting. Once a long streamed reply grows past that cap, every subsequent progressive edit truncates to the SAME preview text — re-sending an identical edit every tick still counts against the platform's edit rate limit for the rest of the stream. Discord's edit_message() has the identical architecture (mid-stream truncate-in-place, both pre-flight and reactive-after-50035 truncation paths) and this file's own docstring already calls out "the Telegram #48648 lesson" it's built on — but the saturated-preview dedup fix itself was never ported over. Fix: track the last truncated preview per (chat_id, message_id), mirroringa0a3c716fexactly. Skip the edit call when the new truncation is identical; still deliver when the visible content actually changes (e.g. the chunk-count marker crosses (1/2) -> (1/3) as the stream grows). State clears on finalize and when content shrinks back under the cap, so dedup can never mask a real edit.
This commit is contained in:
parent
cdcbc3a31d
commit
2e2212be1b
2 changed files with 110 additions and 0 deletions
|
|
@ -833,6 +833,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
# Persistent set of bot-authored lifecycle/status message IDs that
|
||||
# should not act as conversational history boundaries after restart.
|
||||
self._nonconversational_messages = _DiscordNonConversationalMessageTracker()
|
||||
# Last truncated mid-stream preview delivered per (chat_id, message_id).
|
||||
# Once an oversized streaming edit saturates at the 2000-char preview
|
||||
# cap, every subsequent progressive edit truncates to the SAME text;
|
||||
# re-sending it is a no-op that still counts against Discord's edit
|
||||
# rate limit (~1 edit per stream tick for the rest of a long reply).
|
||||
# Mirrors the Telegram #58563 fix. Entries are dropped on finalize.
|
||||
self._last_overflow_preview: Dict[tuple, str] = {}
|
||||
|
||||
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Surface post-startup discord.py task exits to the gateway supervisor.
|
||||
|
|
@ -2153,6 +2160,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
msg = await channel.fetch_message(int(message_id))
|
||||
formatted = self.format_message(content)
|
||||
|
||||
_preview_key = (str(chat_id), str(message_id))
|
||||
_saturated_preview = False
|
||||
if finalize:
|
||||
# Any saturation state for this message is finished with —
|
||||
# the final edit always delivers real (full) content.
|
||||
self._last_overflow_preview.pop(_preview_key, None)
|
||||
|
||||
# Pre-flight: oversized payload. Final edits split-and-deliver;
|
||||
# streaming edits truncate a one-message preview in place.
|
||||
if len(formatted) > self.MAX_MESSAGE_LENGTH:
|
||||
|
|
@ -2163,9 +2177,24 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
formatted = self.truncate_message(
|
||||
formatted, self.MAX_MESSAGE_LENGTH,
|
||||
)[0]
|
||||
_saturated_preview = True
|
||||
# Saturated-preview dedup: past the cap, every progressive
|
||||
# edit truncates to the same text. Re-sending it is a visual
|
||||
# no-op that still counts against Discord's edit rate limit —
|
||||
# skip silently until finalize (mirrors the Telegram #58563
|
||||
# fix).
|
||||
if self._last_overflow_preview.get(_preview_key) == formatted:
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
elif not finalize:
|
||||
# Content shrank back under the cap (segment break / new
|
||||
# message id) — clear stale saturation state so dedup can't
|
||||
# mask a real edit later.
|
||||
self._last_overflow_preview.pop(_preview_key, None)
|
||||
|
||||
try:
|
||||
await msg.edit(content=formatted)
|
||||
if _saturated_preview:
|
||||
self._last_overflow_preview[_preview_key] = formatted
|
||||
except Exception as edit_err:
|
||||
# Reactive split-and-deliver: format_message inflation (or a
|
||||
# server-side rule change) can push the payload past 2,000
|
||||
|
|
@ -2180,7 +2209,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
truncated = self.truncate_message(
|
||||
formatted, self.MAX_MESSAGE_LENGTH,
|
||||
)[0]
|
||||
if self._last_overflow_preview.get(_preview_key) == truncated:
|
||||
# Saturated-preview dedup (see pre-flight path above).
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
await msg.edit(content=truncated)
|
||||
self._last_overflow_preview[_preview_key] = truncated
|
||||
else:
|
||||
raise
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,83 @@ class TestMidStreamOverflowTruncates:
|
|||
assert not edits[0].endswith("...")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Saturated-preview dedup — stop flood-control edit storms (mirrors the
|
||||
# Telegram #58563 fix)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSaturatedPreviewDedup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_saturated_preview_dedups_repeat_oversized_edits(self):
|
||||
"""Once a mid-stream preview saturates at the truncation cap, further
|
||||
oversized edits truncate to the SAME text — re-sending them is a
|
||||
visual no-op that still counts against Discord's edit rate limit
|
||||
(the exact "Telegram #48648 lesson" this file's own docstring
|
||||
already references). The adapter must skip identical saturated
|
||||
previews without an API call."""
|
||||
adapter = _make_adapter()
|
||||
edits = []
|
||||
msg = SimpleNamespace(
|
||||
id=42,
|
||||
edit=AsyncMock(side_effect=lambda *, content: edits.append(content)),
|
||||
)
|
||||
channel, sends = _wire_channel(adapter, original_msg=msg)
|
||||
|
||||
# First oversized edit: delivers the truncated preview (1 API call).
|
||||
r1 = await adapter.edit_message("555", "42", "x" * 2500, finalize=False)
|
||||
assert r1.success is True
|
||||
assert len(edits) == 1
|
||||
|
||||
# Stream keeps growing within the same chunk count (2500-3500 chars
|
||||
# all truncate to the same "...(1/2)" chunk-1 preview) — no API calls.
|
||||
for grow in (3000, 3500):
|
||||
r = await adapter.edit_message("555", "42", "x" * grow, finalize=False)
|
||||
assert r.success is True
|
||||
assert r.message_id == "42"
|
||||
assert len(edits) == 1, "identical saturated previews must not be re-sent"
|
||||
|
||||
# Chunk-count boundary: 4000+ chars cross into "(1/3)" — a real
|
||||
# change that SHOULD be delivered.
|
||||
await adapter.edit_message("555", "42", "x" * 4000, finalize=False)
|
||||
assert len(edits) == 2
|
||||
# ...and saturates again at the new marker.
|
||||
await adapter.edit_message("555", "42", "x" * 4500, finalize=False)
|
||||
assert len(edits) == 2
|
||||
|
||||
# Finalize always delivers real content, even if identical to the
|
||||
# last saturated preview (full split-and-deliver, not a dedup skip).
|
||||
result = await adapter.edit_message("555", "42", "x" * 4500, finalize=True)
|
||||
assert result.success is True
|
||||
assert len(edits) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_shrinking_back_under_cap_clears_dedup_state(self):
|
||||
"""If mid-stream content shrinks back under the cap (e.g. a fresh
|
||||
segment), stale saturation state must not mask the next real
|
||||
oversized edit on this message id."""
|
||||
adapter = _make_adapter()
|
||||
edits = []
|
||||
msg = SimpleNamespace(
|
||||
id=42,
|
||||
edit=AsyncMock(side_effect=lambda *, content: edits.append(content)),
|
||||
)
|
||||
channel, sends = _wire_channel(adapter, original_msg=msg)
|
||||
|
||||
await adapter.edit_message("555", "42", "x" * 2500, finalize=False)
|
||||
assert len(edits) == 1
|
||||
|
||||
# Shrinks back under the cap — delivered in full, clears saturation.
|
||||
await adapter.edit_message("555", "42", "short", finalize=False)
|
||||
assert len(edits) == 2
|
||||
assert edits[-1] == "short"
|
||||
|
||||
# Grows past the cap again with the SAME truncated text as before —
|
||||
# must be delivered again since the dedup state was cleared.
|
||||
await adapter.edit_message("555", "42", "x" * 2500, finalize=False)
|
||||
assert len(edits) == 3
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Final overflow — SPLIT and deliver every chunk
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue