Files
hermes-agent/gateway/stream_consumer_fallback.py
Haisam Abbas 317891373f fix(streaming): back fallback continuation cut up to last word boundary
When editing content grows past a streaming edit tick, the fallback prefix
can end mid-word. Continuing from that cut drops the broken word's tail and
reads as stutter. Back the cut up to the last space or newline so the tail
of the split word is re-sent; a boundary-less prefix (one long token) keeps
the original cut instead of re-sending the whole reply.
2026-09-20 12:17:23 -07:00

408 lines
21 KiB
Python

"""Fallback delivery for GatewayStreamConsumer: continuation sends after edits
stop working, chunking, cursor cleanup, commentary and silence retraction."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from typing import Any, Callable, Optional
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
from gateway.stream_consumer_fences import ensure_closed_code_fences
logger = logging.getLogger("gateway.stream_consumer")
class StreamFallbackMixin:
"""Non-streaming delivery paths used once progressive edits fail or the turn ends oddly."""
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str], *,
final: bool = False) -> Optional[str]:
"""Send a new chunk threaded to ``reply_to_id``; returns the new message_id."""
text = self._clean_for_display(text)
if not text.strip():
return reply_to_id
try:
result = await self.adapter.send(
chat_id=self.chat_id, content=text, reply_to=reply_to_id,
metadata=self._metadata_for_send(final=final, expect_edits=not final))
if not (result.success and result.message_id):
self._edit_supported = False
return reply_to_id
self._message_id = str(result.message_id)
self._track_preview_ids_from_result(result)
self._already_sent = True
self._last_sent_text = text
self._notify_new_message()
return str(result.message_id)
except Exception as e:
logger.error("Stream send chunk error: %s", e)
return reply_to_id
def _visible_prefix(self) -> str:
"""Return the visible text already shown in the streamed message."""
prefix = self._last_sent_text or ""
if self.cfg.cursor and prefix.endswith(self.cfg.cursor):
prefix = prefix[:-len(self.cfg.cursor)]
return self._clean_for_display(prefix)
def _continuation_text(self, final_text: str) -> str:
"""Return only the part of final_text the user has not already seen."""
prefix = self._fallback_prefix or self._visible_prefix()
if prefix and final_text.startswith(prefix):
cut = len(prefix)
# ``prefix`` is whatever the last successful edit put on screen. Edits
# fire on a throttle tick, not at a word boundary, so that prefix can
# end inside a word. Back the cut up to the last space or newline so
# the continuation re-sends the broken word's tail and reads as an
# ordinary continuation. A prefix with no boundary (one very long
# token) keeps the original cut rather than re-sending the whole reply.
if cut < len(final_text):
boundary = max(
final_text.rfind(" ", 0, cut),
final_text.rfind("\n", 0, cut),
)
if boundary >= 0:
cut = boundary + 1
return final_text[cut:].lstrip()
return final_text
@staticmethod
def _split_text_chunks(text: str, limit: int, len_fn: "Callable[[str], int]" = len,
) -> list[str]:
"""Split text for fallback sends: newline-preferred, fence-balanced across chunks."""
from gateway.platforms.helpers import split_text_fence_aware
return split_text_fence_aware(text, limit, len_fn, prefer_paragraphs=False,
balance_fences=True)
def _truncate_for_stream(self, text: str, limit: int, len_fn: "Callable[[str], int]",
) -> list[str]:
"""Split via the adapter's canonical truncate_message (platform-specific rules);
non-base test doubles / legacy adapters keep the two-argument call shape."""
truncate = getattr(self.adapter, "truncate_message", None)
if not callable(truncate):
return self._split_text_chunks(text, limit, len_fn)
if isinstance(self.adapter, _BasePlatformAdapter):
chunks = truncate(text, limit, len_fn=len_fn)
else:
chunks = truncate(text, limit)
if not isinstance(chunks, (list, tuple)) or not all(isinstance(c, str) for c in chunks):
return self._split_text_chunks(text, limit, len_fn)
return list(chunks)
async def _send_fallback_final(self, text: str) -> None:
"""Send the final continuation after streaming edits stop working (one flood retry
per chunk)."""
if getattr(self, "_egress_declined", False):
# The connector refused this destination earlier in the run. The
# whole point of this path is to deliver the unseen tail as a NEW
# message, which is exactly the re-addressing the egress guard
# exists to stop.
logger.warning(
"suppressing the fallback continuation: the connector already "
"declined this destination for this run"
)
return
# Balance fences BEFORE computing the continuation so the closing fence
# reaches the user even when only the tail is delivered.
final_text = ensure_closed_code_fences(self._clean_for_display(text))
continuation = self._continuation_text(final_text)
self._fallback_final_send = False
if not continuation.strip():
continuation = await self._fallback_when_nothing_unseen(final_text)
if continuation is None:
return
_len_fn, raw_limit = self._fallback_len_budget()
chunks = self._split_text_chunks(continuation, max(500, raw_limit - 100), len_fn=_len_fn)
stale_message_id = self._message_id # partial message to clean up
last_message_id: Optional[str] = None
last_successful_chunk = ""
sent_any_chunk = False
for chunk in chunks:
result = await self._send_with_flood_retry(
content=chunk, retry_log="Flood control on fallback send, retrying in %.1fs")
if not result or not result.success:
# Partial continuation landed: do NOT set _final_response_sent (the
# gateway must still deliver the full answer); _already_sent only
# prevents a duplicate of the partial. Nothing landed: let the
# gateway final send try once more.
self._already_sent = sent_any_chunk
self._message_id = last_message_id
self._last_sent_text = last_successful_chunk
self._fallback_prefix = ""
return
sent_any_chunk = True
last_successful_chunk = chunk
last_message_id = result.message_id or last_message_id
self._notify_new_message()
# Best-effort delete of the frozen partial — ONLY when the FULL final was
# re-sent. If only the missing tail went out, the partial IS the head of
# the answer ("sent only the second half" symptom).
if (stale_message_id and stale_message_id != last_message_id
and not self._fallback_preserve_partial_messages and continuation == final_text):
await self._delete_previews([stale_message_id], label="Fallback partial",
skip_sentinel=False)
self._message_id = last_message_id
self._already_sent = True
# Recorder substitutes the unsplit ledger on a split turn.
self._mark_final_delivered(record=final_text)
self._last_sent_text = chunks[-1]
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
async def _fallback_when_nothing_unseen(self, final_text: str) -> Optional[str]:
"""Fallback entered but the visible prefix already covers ``final_text``: returns the
continuation to send (the whole final when the prefix is from a *previous* segment)
or None when the turn is settled here."""
visible = self._visible_prefix()
# Telegram clients can lose (part of) a streamed preview after a failed
# final edit, so opt-in adapters commit a fresh final send.
if (final_text.strip() and final_text == visible
and getattr(self.adapter, "RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK", False) is True):
delivery = await self._send_empty_fallback_final(final_text)
if delivery == "delivered":
return None
self._already_sent = True
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
# "ambiguous" (timeout: Telegram may have accepted) and "preview" (flood:
# the complete ACKed preview is authoritative) keep dup suppression;
# "failed" lets the gateway perform its normal final send.
self._final_content_delivered = delivery in {"ambiguous", "preview"}
if delivery == "preview":
# This branch is only reached when the ACKed preview already shows the complete final text
# (final_text == _visible_prefix()), so record it as the turn-final payload: the gateway's
# reconciliation then confirms delivery instead of re-sending a second bubble next to the
# never-deleted preview (#71047 Problem B).
self._record_turn_final_payload(final_text)
elif delivery == "ambiguous":
self._delivery_ambiguous = True
else:
self._final_response_sent = False
return None
# The prefix may be from a *previous* segment (before a tool boundary),
# wrongly reading as "already shown" — send final_text as-is.
if final_text.strip() and final_text != visible:
return final_text
# Best-effort strip of a cursor left stuck by the edit failure.
if (self._message_id and self._last_sent_text and self.cfg.cursor
and self._last_sent_text.endswith(self.cfg.cursor)):
clean_text = self._last_sent_text[:-len(self.cfg.cursor)]
with contextlib.suppress(Exception):
result = await self._edit_message(message_id=self._message_id, content=clean_text)
if result.success:
self._last_sent_text = clean_text
self._already_sent = True
# Recorder substitutes the full ledger on a split turn.
self._mark_final_delivered(record=final_text)
return None
def _fallback_len_budget(self) -> "tuple[Callable[[str], int], int]":
"""(len_fn, raw_limit) for fallback chunking — per-chat cap/unit on base adapters."""
raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
_len_fn: "Callable[[str], int]" = len
if isinstance(self.adapter, _BasePlatformAdapter):
_len_fn = self.adapter.message_len_fn
try: # per-chat cap/unit (relay adapter fronting N platforms)
raw_limit = self.adapter.max_message_length_for_chat(self.chat_id)
_len_fn = self.adapter.message_len_fn_for_chat(self.chat_id)
except Exception as e:
logger.debug("per-chat limit resolution failed: %s", e)
return _len_fn, raw_limit
async def _send_with_flood_retry(self, *, content: str, retry_log: str, reply_to=None):
"""adapter.send(final metadata) with ONE bounded flood retry; returns the last
SendResult. Exceptions propagate (callers decide whether a raise is "ambiguous")."""
kwargs = dict(chat_id=self.chat_id, content=content,
metadata=self._metadata_for_send(final=True))
if reply_to is not None:
kwargs["reply_to"] = reply_to
result = None
for attempt in range(2):
result = await self.adapter.send(**kwargs)
if getattr(result, "success", False):
break
retry_delay = self._fallback_flood_retry_delay(result)
if attempt or retry_delay is None:
break # non-flood error, long flood wait, or second failure
raw = getattr(result, "raw_response", None)
if isinstance(raw, dict) and raw.get("partial_overflow"):
break # split head already on screen: re-sending the whole content duplicates it
logger.debug(retry_log, retry_delay)
await asyncio.sleep(retry_delay)
return result
async def _send_empty_fallback_final(self, final_text: str) -> str:
"""Commit a completed answer after Telegram finalization fails: "delivered", "failed"
(gateway may retry), "ambiguous" (a timeout may have landed) or "preview" (flood
control; the complete preview is authoritative)."""
# Segment-scoped only: never delete an earlier finalized preamble.
stale_ids = self._stale_preview_ids(segment_only=True)
try:
result = await self._send_with_flood_retry(
content=final_text, reply_to=self._initial_reply_to_id,
retry_log="Flood control on empty fallback final send; retrying in %.1fs")
except Exception as exc:
logger.debug("Empty fallback final send failed: %s", exc)
return "ambiguous" if self._send_failure_may_have_delivered(exc) else "failed"
if not getattr(result, "success", False):
if self._is_flood_error(result):
return "preview"
return "ambiguous" if self._send_failure_may_have_delivered(result) else "failed"
new_message_id = getattr(result, "message_id", None)
# Telegram reports delete failure by returning False; the flood window that
# broke the finalize can reject this too — one bounded retry.
await self._delete_previews(stale_ids, skip=new_message_id, label="Empty fallback",
retry_on_false=True)
self._segment_preview_message_ids = set()
self._message_id = new_message_id or "__no_edit__"
self._already_sent = True
self._mark_final_delivered()
# Record VERBATIM, not via _record_turn_final_payload: the sealed previews
# were just deleted, so the ledger (still holding sealed heads) would claim
# delivery for text this path removed.
self._delivered_final_text = self._display_payload(final_text)
self._last_sent_text = final_text
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
self._notify_new_message()
return "delivered"
@staticmethod
def _send_failure_may_have_delivered(result_or_exc: Any) -> bool:
"""Return True for timeout failures where retrying may duplicate."""
if getattr(result_or_exc, "retryable", None) is True:
return False
error = str(getattr(result_or_exc, "error", None) or result_or_exc).lower()
name = result_or_exc.__class__.__name__.lower()
return "timeout" in error or "timed out" in error or "timeout" in name
def _fallback_flood_retry_delay(self, result: Any) -> float | None:
"""Return a bounded retry delay for a fallback send, if safe to retry."""
if not self._is_flood_error(result):
return None
try:
delay = float(getattr(result, "retry_after", None) or 3.0)
except (TypeError, ValueError):
delay = 3.0
if delay > self._max_fallback_flood_retry_seconds:
logger.debug("Flood control requests %.1fs; leaving final delivery to the gateway",
delay)
return None
return max(0.0, delay)
def _is_flood_error(self, result) -> bool:
"""Check if a SendResult failure is due to flood control / rate limiting."""
err_lower = (getattr(result, "error", "") or "").lower()
return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower
async def _flush_segment_tail_on_edit_failure(self) -> None:
"""Before a segment reset, send the unseen tail as a new message (and best-effort
strip the stuck cursor from the partial)."""
if not self._fallback_final_send:
await self._try_strip_cursor()
visible = self._fallback_prefix or self._visible_prefix()
tail = self._accumulated
if visible and tail.startswith(visible):
tail = tail[len(visible):].lstrip()
tail = self._clean_for_display(tail)
if not tail.strip():
return
try:
# Interim: must never seal a native stream (see _send_commentary).
_md = dict(self.metadata) if self.metadata else {}
_md["_interim_send"] = True
result = await self.adapter.send(chat_id=self.chat_id, content=tail, metadata=_md)
if result.success:
self._already_sent = True
except Exception as e:
logger.error("Segment-break tail flush error: %s", e)
async def _try_strip_cursor(self) -> None:
"""Best-effort edit removing a stuck cursor when entering fallback mode."""
prefix = self._visible_prefix()
if not self._has_real_preview() or not prefix.strip():
return
with contextlib.suppress(Exception): # never block the fallback path
result = await self._edit_message(message_id=self._message_id, content=prefix)
if getattr(result, "success", False):
self._last_sent_text = prefix
async def _send_commentary(self, text: str) -> bool:
"""Send a completed interim assistant commentary message."""
text = self._clean_for_display(text)
if not text.strip():
return False
try:
# Interim: a stream-is-the-message adapter's seal-interception must not
# turn this into draft(final=true), which would seal the live stream
# with interim text and orphan the true final.
_md = self._metadata_for_send(final=False) or {}
_md["_interim_send"] = True
# reply_to only for reply-anchored threading; Discord/Telegram use
# thread_id metadata and reply_to on every commentary is spam.
_plat = getattr(getattr(self.adapter, "platform", None), "value", None)
_platform_name = str(_plat or getattr(self.adapter, "name", "")).lower()
_needs_reply_anchor = _platform_name in ("buzz", "slack", "mattermost", "feishu")
result = await self.adapter.send(
chat_id=self.chat_id, content=text,
reply_to=self._initial_reply_to_id if _needs_reply_anchor else None, metadata=_md)
# Do NOT set _already_sent: commentary is interim, and the flag would
# suppress the real final after multiple tool calls.
if result.success:
self._notify_new_message()
# Lets run.py confirm whether an interim send carried the final.
# Record the exact delivered text so run.py can confirm whether an interim "preview"
# actually carried the final response, vs. unrelated commentary delivered during a session
# split (#14238).
self._delivered_commentary_texts.append(text)
return result.success
except Exception as e:
logger.error("Commentary send error: %s", e)
return False
def _raw_message_limit(self) -> int:
"""Per-chat length budget (``message_len_fn`` units) before overflow splits; rich
adapters may raise it via ``streaming_overflow_limit`` so a reply that fits one rich
message isn't fragmented at the edit limit."""
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
# isinstance gate keeps MagicMock adapters (mock attrs, not ints) on base.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
base = self.adapter.max_message_length_for_chat(self.chat_id)
except Exception as e:
logger.debug("max_message_length_for_chat failed: %s", e)
try:
cap = self.adapter.streaming_overflow_limit()
except Exception as e:
logger.debug("streaming_overflow_limit check failed: %s", e)
cap = None
if isinstance(cap, int) and cap > base:
return cap
return base
# Fresh send carried exactly ``text`` — record it so the gateway can reconcile the flag against the
# completed response (#71643/#95382 content-vs-flag contract).
async def _suppress_silence_marker(self) -> None:
"""Retract any streamed preview when the final reply is a bare silence marker. Flags
stay False so the gateway's whole-response filter owns what goes out next: "" for a
machinery turn, the visible fallback for a human one."""
# A native-stream bubble isn't a deletable message — close an open one
# (e.g. from an eager re-seed) with an empty finalize so it doesn't hang.
if self._native_stream_opened:
await self._close_empty_native_bubble("Silence-marker native stream close failed: %s")
await self._delete_previews(self._stale_preview_ids(), label="Silence-marker")
self._preview_message_ids = set()
self._message_id = None
self._accumulated = self._stream_ledger = self._last_sent_text = ""
self._already_sent = False
self._clear_turn_final_flags()
logger.info("Suppressed streamed intentional-silence marker (chat=%s)", self.chat_id)