mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
feat(telegram): native draft streaming via sendMessageDraft (Bot API 9.5+)
Adds Telegram's native streaming-draft API as a streaming transport so DM
replies render with smooth animated previews as tokens arrive, dropping
the per-edit jitter of the legacy editMessageText polling path.
Adapter contract (gateway/platforms/base.py):
- supports_draft_streaming(chat_type, metadata) -> bool. Default False.
Telegram returns True only for DMs and only when the bound python-
telegram-bot version exposes Bot.send_message_draft (PTB 22.6+).
- send_draft(chat_id, draft_id, content, metadata) -> SendResult.
Default raises NotImplementedError. Telegram delegates to PTB's
send_message_draft. Drafts have no message_id (Bot API contract);
SendResult.message_id is None on success.
Telegram adapter (gateway/platforms/telegram.py):
- supports_draft_streaming gates on chat_type='dm' AND PTB capability.
- send_draft trims to MAX_MESSAGE_LENGTH using utf16_len, threads
message_thread_id through metadata, and routes failures back as
SendResult(success=False, error=...) so the consumer can fall back.
Stream consumer (gateway/stream_consumer.py):
- StreamConsumerConfig gains transport ('auto'|'draft'|'edit'|'off')
and chat_type fields.
- run() resolves _use_draft_streaming once via a probe at the top of
the run, allocating a fresh class-wide draft_id_counter so each
response animates as its own preview (no animation collision across
consecutive responses to the same chat).
- _send_or_edit gains a pre-edit branch: when drafts are active AND
not finalizing AND no edit-path message_id is established, the
frame routes through _send_draft_frame instead of edit_message.
Drafts intentionally do NOT set _already_sent so the gateway's
final sendMessage path still fires — drafts have no message_id and
the user needs a real message in their chat history.
- _reset_segment_state bumps the draft_id when the consumer is in
draft mode so each text block after a tool boundary animates as a
fresh preview below the tool-progress bubble (avoids the inter-
tool-call leak openclaw documented in their #32535).
- Per-response fallback: any send_draft failure (transient network,
server reject, capability gap) flips _use_draft_streaming to False
for the rest of the run, gracefully returning to the edit path.
Gateway config (gateway/config.py):
- StreamingConfig.transport default flips edit -> auto. The auto path
is identical to edit on every chat type that doesn't currently
support drafts (groups, supergroups, forum topics, every non-
Telegram platform), so the default is backwards-compatible for
non-DM users.
Lifecycle model (Telegram Bot API 9.5):
1. sendMessageDraft(chat_id, draft_id, text='') opens the bubble.
2. Repeated sendMessageDraft calls with the SAME draft_id animate
the preview as text grows.
3. Drafts have no message_id and cannot be edited or deleted.
4. When the response finishes the gateway's normal sendMessage path
delivers the final answer; the draft preview clears naturally on
the client and the user sees a real message in their history.
Inspired by PR #3412 by @NivOO5. Re-authored against current main
(stream_consumer.py is now ~4x larger than at #3412's branch base, with
new _NEW_SEGMENT/_COMMENTARY/finalize/_on_new_message machinery the
original PR didn't account for) but the design call (DM-only, edit-
fallback, transport=auto|draft|edit|off) is faithful to the original
proposal, with two improvements baked in:
1. Per-response draft_id (monotonic counter, not a time hash) — no
collision risk across consecutive responses on the same chat.
2. Tool-boundary draft_id bump — prevents the inter-tool-call leak
openclaw hit during their rollout (their #32535).
Closes #21439 (duplicate feature request).
This commit is contained in:
parent
80bb5f2947
commit
4ed293b38e
5 changed files with 298 additions and 2 deletions
|
|
@ -1320,6 +1320,52 @@ class BasePlatformAdapter(ABC):
|
|||
"""
|
||||
return len
|
||||
|
||||
def supports_draft_streaming(
|
||||
self,
|
||||
chat_type: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether this adapter supports native streaming-draft updates.
|
||||
|
||||
Telegram Bot API 9.5 introduced ``sendMessageDraft``, which renders an
|
||||
animated streaming preview as the bot calls it repeatedly with the
|
||||
same ``draft_id`` and growing text. Adapters that implement
|
||||
``send_draft`` should return True here for the chat types where the
|
||||
platform supports it (Telegram restricts drafts to private DMs).
|
||||
|
||||
Default implementation returns False. Stream consumers fall back to
|
||||
the edit-based path (``send`` + ``edit_message``) when this returns
|
||||
False or when ``send_draft`` raises.
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
draft_id: int,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send or update an animated streaming-draft preview.
|
||||
|
||||
Reuse the same ``draft_id`` (any non-zero int) across consecutive
|
||||
calls within a single response so the platform animates the preview
|
||||
rather than re-creating it. Different responses must use different
|
||||
``draft_id`` values within the same chat to avoid animating over a
|
||||
prior bubble.
|
||||
|
||||
Drafts have no message_id and cannot be edited, replied to, or
|
||||
deleted via normal message APIs. When the response finishes, the
|
||||
caller delivers the final answer as a regular ``send`` and the
|
||||
draft preview clears naturally on the client.
|
||||
|
||||
Default implementation raises NotImplementedError; adapters that
|
||||
also return True from :meth:`supports_draft_streaming` must override.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not implement send_draft"
|
||||
)
|
||||
|
||||
@property
|
||||
def has_fatal_error(self) -> bool:
|
||||
return self._fatal_error_message is not None
|
||||
|
|
|
|||
|
|
@ -1686,6 +1686,77 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return False
|
||||
|
||||
def supports_draft_streaming(
|
||||
self,
|
||||
chat_type: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Telegram supports sendMessageDraft for private chats only.
|
||||
|
||||
Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots
|
||||
unconditionally for private (DM) chats. Groups, supergroups, and
|
||||
channels still rely on the edit-based path.
|
||||
|
||||
We additionally require ``self._bot`` to expose ``send_message_draft``
|
||||
(added to python-telegram-bot in 22.6); older PTB installs gracefully
|
||||
fall back to the edit path even on DMs.
|
||||
"""
|
||||
if not self._bot or not hasattr(self._bot, "send_message_draft"):
|
||||
return False
|
||||
return (chat_type or "").lower() in ("dm", "private")
|
||||
|
||||
async def send_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
draft_id: int,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Stream a partial message via Telegram's native sendMessageDraft.
|
||||
|
||||
The Bot API animates the preview when the same ``draft_id`` is reused
|
||||
across consecutive calls in the same chat. When the response
|
||||
finishes, the caller sends the final text via the normal ``send``
|
||||
path; the draft preview clears naturally on the client (Telegram has
|
||||
no Bot API to "promote" a draft to a real message — the final
|
||||
``sendMessage`` is what the user receives in their history).
|
||||
"""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="not_connected")
|
||||
if not hasattr(self._bot, "send_message_draft"):
|
||||
return SendResult(success=False, error="api_unavailable")
|
||||
|
||||
# Trim to the same UTF-16 budget the platform enforces on regular
|
||||
# sends. Drafts have the same length contract as messages.
|
||||
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
|
||||
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"text": text,
|
||||
}
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
|
||||
try:
|
||||
ok = await self._bot.send_message_draft(**kwargs)
|
||||
if ok:
|
||||
# Drafts have no message_id; we report success without one
|
||||
# so the caller knows the animation frame landed.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
except Exception as e:
|
||||
# Most likely: BadRequest because this bot/chat doesn't allow
|
||||
# drafts, or a transient server hiccup. The caller treats any
|
||||
# failure as "fall back to edit-based for this response".
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def _send_message_with_thread_fallback(self, **kwargs):
|
||||
"""Send a Telegram message, retrying once without message_thread_id
|
||||
if Telegram returns 'Message thread not found'.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue