2656 lines
126 KiB
Python
2656 lines
126 KiB
Python
"""Gateway streaming consumer — bridges sync agent callbacks to async platform delivery.
|
|
|
|
The agent fires stream_delta_callback(text) synchronously from its worker thread;
|
|
on_delta() queues it (queue.Queue) and the async run() task buffers, rate-limits,
|
|
and progressively edits a single platform message (send, then editMessageText —
|
|
supported everywhere; draft/native transports are optional per adapter).
|
|
|
|
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import inspect
|
|
import logging
|
|
import queue
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Optional
|
|
|
|
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
|
|
from gateway.platforms.base import _custom_unit_to_cp
|
|
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
|
from gateway.config import (
|
|
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
|
|
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
|
|
DEFAULT_STREAMING_CURSOR as _DEFAULT_STREAMING_CURSOR,
|
|
)
|
|
from gateway.response_filters import (
|
|
is_intentional_silence_response as _is_intentional_silence_response,
|
|
is_partial_silence_marker as _is_partial_silence_marker,
|
|
)
|
|
import contextlib
|
|
|
|
logger = logging.getLogger("gateway.stream_consumer")
|
|
|
|
# Queue sentinels (see run() for handling).
|
|
_DONE = object() # stream complete
|
|
_NEW_SEGMENT = object() # finalize current message, start a fresh one
|
|
_COMMENTARY = object() # (_COMMENTARY, text): completed interim commentary
|
|
_TOOL_PROGRESS = object() # (_TOOL_PROGRESS, line): overlay line for the native bubble
|
|
# (_FINAL_TEXT, text): authoritative completed final_response (incl. post-stream
|
|
# augmentation such as verifier footers) enqueued just before _DONE, so the
|
|
# finalize/seal delivers the TRUE final and the recorded payload reconciles.
|
|
_FINAL_TEXT = object()
|
|
# (_FLUSH, threading.Event): barrier — drain loop finalizes/delivers anything
|
|
# buffered, then sets the event. Used by flush_pending_sync() before a blocking
|
|
# interactive prompt so the prompt lands below buffered prose, not above it.
|
|
_FLUSH = object()
|
|
# Interaction boundary (approval OR clarify prompt): finalize the current
|
|
# stream; post-prompt output goes via send() or a re-opened stream.
|
|
_APPROVAL_BOUNDARY = object()
|
|
# EAGER native re-seed right after a clarify answer, before the first post-answer
|
|
# delta. On WeCom typing is driven by the seed frame (send_typing is a no-op)
|
|
# and lazy re-seed on first delta measured 48s of dead air.
|
|
_REOPEN_SEED = object()
|
|
|
|
# Boundary finalize text when nothing has accumulated yet; overridable per
|
|
# boundary via close_for_approval_prompt(placeholder=...).
|
|
_DEFAULT_BOUNDARY_PLACEHOLDER = "⏸ 等待审批中..."
|
|
|
|
|
|
def escape_code_fences_for_display(text: str) -> str:
|
|
"""Replace each ``` with \\`\\`\\` so text can be wrapped in an outer ``` block.
|
|
|
|
Reasoning content that quotes code would otherwise break the outer fence.
|
|
"""
|
|
if not isinstance(text, str) or "```" not in text:
|
|
return text
|
|
return text.replace("```", "\\`\\`\\`")
|
|
|
|
|
|
def ensure_closed_code_fences(text: str) -> str:
|
|
"""Append a closing ``` and/or ` if the text has orphaned code markers.
|
|
|
|
Output truncated mid-code-block (token limit, finish_reason="length") would
|
|
otherwise render everything after the orphan as one code block / inline
|
|
span. Trade-off: a spurious close creates a brief empty span at the end,
|
|
far less harmful than the alternative. Odd ``` count → append a fence on
|
|
its own line; then, with complete ```…``` regions stripped, odd ` count →
|
|
append a backtick.
|
|
"""
|
|
if not isinstance(text, str) or not text:
|
|
return text
|
|
|
|
if text.count("```") % 2 == 1:
|
|
text = text.rstrip("\n") + "\n```"
|
|
|
|
# Strip complete fenced regions (and any trailing unclosed ``` that leaks
|
|
# through) so their internal backticks don't pollute the standalone count.
|
|
import re
|
|
without_fences = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
|
without_fences = re.sub(r"```[^`]*$", "", without_fences)
|
|
|
|
if without_fences.count("`") % 2 == 1:
|
|
text = text + "`"
|
|
|
|
return text
|
|
|
|
|
|
@dataclass
|
|
class StreamConsumerConfig:
|
|
"""Runtime config for a single stream consumer instance."""
|
|
edit_interval: float = _DEFAULT_STREAMING_EDIT_INTERVAL
|
|
buffer_threshold: int = _DEFAULT_STREAMING_BUFFER_THRESHOLD
|
|
cursor: str = _DEFAULT_STREAMING_CURSOR
|
|
buffer_only: bool = False
|
|
# When >0, deliver the final as a fresh message if the preview has been
|
|
# visible at least this long, so the visible timestamp reflects completion
|
|
# rather than first token. 0 = always edit in place. Enabled per-platform.
|
|
fresh_final_after_seconds: float = 0.0
|
|
# "auto"/"draft": native draft streaming when adapter+chat support it, else
|
|
# edit. "edit": progressive editMessageText. "off": handled by the gateway
|
|
# before the consumer is built.
|
|
transport: str = "edit"
|
|
# Originating chat type ("dm", "group", ...); gates draft streaming, which
|
|
# is platform-specific (Telegram drafts are DM-only).
|
|
chat_type: str = ""
|
|
|
|
|
|
class GatewayStreamConsumer:
|
|
"""Async consumer that progressively edits a platform message with streamed tokens.
|
|
|
|
Usage::
|
|
|
|
consumer = GatewayStreamConsumer(adapter, chat_id, config, metadata=metadata)
|
|
agent = AIAgent(..., stream_delta_callback=consumer.on_delta)
|
|
task = asyncio.create_task(consumer.run())
|
|
# ... run agent in thread pool ...
|
|
consumer.finish() # signal completion
|
|
await task # wait for final edit
|
|
"""
|
|
|
|
# Consecutive flood-control failures before progressive edits are disabled
|
|
# for the rest of the stream.
|
|
_MAX_FLOOD_STRIKES = 3
|
|
|
|
# Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and
|
|
# run_agent.py _strip_think_blocks() tag variants.
|
|
_OPEN_THINK_TAGS = (
|
|
"<REASONING_SCRATCHPAD>", "<think>", "<reasoning>",
|
|
"<THINKING>", "<thinking>", "<thought>",
|
|
)
|
|
_CLOSE_THINK_TAGS = (
|
|
"</REASONING_SCRATCHPAD>", "</think>", "</reasoning>",
|
|
"</THINKING>", "</thinking>", "</thought>",
|
|
)
|
|
|
|
# Class-wide monotonic counter for draft ids (Telegram animates a draft only
|
|
# when the same non-zero draft_id is reused within a response). Seeded from
|
|
# a RANDOM nonce, not 0 or the clock: draft_id is the wire identity for the
|
|
# relay connector's per-(channel, draft_id) sealed-stream tombstones, which
|
|
# outlive this (scale-to-zero) process — a replayed id is answered out of
|
|
# the OLD tombstone and the reply is silently dropped. Epoch-ms seeds still
|
|
# collide on same-ms starts/forks/clock steps; 49 bits keeps ids + turn
|
|
# counts inside the connector's JS number range (2^53).
|
|
_draft_id_counter: int = secrets.randbits(49)
|
|
|
|
def __init__(
|
|
self,
|
|
adapter: Any,
|
|
chat_id: str,
|
|
config: Optional[StreamConsumerConfig] = None,
|
|
metadata: Optional[dict] = None,
|
|
on_new_message: Optional[callable] = None,
|
|
on_before_finalize: Optional[Callable[[], Any]] = None,
|
|
initial_reply_to_id: Optional[str] = None,
|
|
run_still_current: Optional[Callable[[], bool]] = None,
|
|
):
|
|
self.adapter = adapter
|
|
self.chat_id = chat_id
|
|
self.cfg = config or StreamConsumerConfig()
|
|
self.metadata = metadata
|
|
# No-arg callback fired whenever a fresh content bubble is created
|
|
# (first send, commentary, overflow chunk, fallback continuation); the
|
|
# gateway uses it to open the next tool-progress bubble BELOW resumed
|
|
# content instead of editing the old one above. Exceptions swallowed.
|
|
self._on_new_message = on_new_message
|
|
# Fired once on entering the finalization path so the gateway can pause
|
|
# typing refreshes before a slow rich-text final edit.
|
|
self._on_before_finalize = on_before_finalize
|
|
self._initial_reply_to_id = initial_reply_to_id
|
|
|
|
# Per-turn id passed to adapter.send_stream_frame() so concurrent
|
|
# consumers (/background, parallel subagents) don't interfere.
|
|
import uuid
|
|
self._turn_id = str(uuid.uuid4())
|
|
|
|
self._queue: queue.Queue = queue.Queue()
|
|
self._accumulated = ""
|
|
# Mirror of ``_accumulated`` NOT truncated when overflow splits seal
|
|
# head chunks; records a reconcilable turn-final payload for splits.
|
|
self._stream_ledger = ""
|
|
self._message_id: Optional[str] = None
|
|
# time.monotonic() when ``_message_id`` was first assigned; fresh-final
|
|
# logic uses it to detect long-lived previews.
|
|
self._message_created_ts: Optional[float] = None
|
|
# Every real preview message id put on screen this response (first send
|
|
# + continuation messages). Fresh-final deletes them all so a reply
|
|
# split at the edit limit leaves no stale fragments above the final.
|
|
self._preview_message_ids: "set[str]" = set()
|
|
# IDs from only the active text segment: failure recovery must never
|
|
# delete an earlier finalized preamble/commentary message.
|
|
self._segment_preview_message_ids: "set[str]" = set()
|
|
self._already_sent = False
|
|
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
|
self._last_edit_time = 0.0
|
|
self._last_sent_text = "" # Track last-sent text to skip redundant edits
|
|
# True when the most recent _send_or_edit split across continuation
|
|
# messages (the adapter adopted a new message id).
|
|
self._last_edit_overflowed = False
|
|
self._fallback_final_send = False
|
|
self._fallback_prefix = ""
|
|
# True when fallback sends only the missing tail after a partial
|
|
# overflow delivery: the visible prefix is content, not a stale preview.
|
|
self._fallback_preserve_partial_messages = False
|
|
# Telegram bounds edit retries at 5s; a final-delivery fallback must not
|
|
# hold the stream task through a longer flood cooldown.
|
|
self._max_fallback_flood_retry_seconds = 5.0
|
|
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
|
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
|
self._final_response_sent = False
|
|
# Final content reached the user even if the cosmetic final edit
|
|
# (cursor removal) then failed.
|
|
self._final_content_delivered = False
|
|
# Exact cleaned payload of the turn-final delivery that set the flags
|
|
# above. The gateway compares it against the completed final_response
|
|
# before trusting the flags (a successful finalize edit may carry only a
|
|
# stale preview snapshot). ``None`` = no record → legacy trust.
|
|
self._delivered_final_text: Optional[str] = None
|
|
# Answer delivered across multiple sealed messages (overflow split /
|
|
# continuation adoption). With a recorded payload, delivered_final_matches
|
|
# can still reconcile; payload-less split delivery must NOT inherit
|
|
# legacy trust (it swallowed complete replies after partial splits).
|
|
self._turn_split_delivery = False
|
|
# A full-final send timed out in a way that MAY have reached the
|
|
# platform — the only payload-less case that keeps legacy trust, since
|
|
# re-sending risks a duplicate rather than recovering a loss.
|
|
self._delivery_ambiguous = False
|
|
self._delivered_commentary_texts: list[str] = []
|
|
# Finalized visible text of each segment, so has_delivered_text still
|
|
# matches after _reset_segment_state clears _last_sent_text.
|
|
self._delivered_segment_texts: list[str] = []
|
|
# Only platforms needing an explicit finalize call (e.g. DingTalk AI
|
|
# Cards) force a redundant final edit. ``is True`` keeps MagicMock
|
|
# adapters in tests from enabling this path.
|
|
self._adapter_requires_finalize: bool = (
|
|
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
|
|
)
|
|
|
|
# Returns False after /new or /stop; run() then abandons the stream
|
|
# instead of delivering stale deltas.
|
|
self._run_still_current = run_still_current or (lambda: True)
|
|
|
|
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
|
|
self._in_think_block = False
|
|
self._think_buffer = ""
|
|
|
|
# Draft streaming: resolved at the start of run(). Animated frames go
|
|
# via adapter.send_draft instead of edits; the final answer still uses
|
|
# the normal first-send path (drafts have no message_id).
|
|
self._use_draft_streaming = False
|
|
self._draft_id: Optional[int] = None
|
|
# First draft failure permanently disables drafts for this response.
|
|
self._draft_failures = 0
|
|
self._before_finalize_notified = False
|
|
# Native streaming (e.g. WeCom msgtype "stream"): the ONLY delivery
|
|
# channel for the turn — seed, cumulative updates, and finish=true all
|
|
# go through adapter.send_stream_frame(). Resolved at the start of
|
|
# run(); disabled on any failure so the consumer falls back to edit/send.
|
|
self._use_native_streaming = False
|
|
# Seed frame sent (even though it has zero visible content); fallback
|
|
# logic uses it to decide whether the stream must be finalized first.
|
|
self._native_stream_opened = False
|
|
# Visible chars last pushed to the native stream; throttles frames
|
|
# under WeCom's 30 frames/min ceiling.
|
|
self._native_last_pushed_len = 0
|
|
# Boundary state, set by close_for_approval_prompt(); race-free because
|
|
# boundaries are processed serially. ``_boundary_reopen``: keep native
|
|
# streaming enabled so post-prompt output re-opens a fresh stream via
|
|
# the lazy re-seed (clarify: short waits) instead of degrading to send()
|
|
# (approval: unbounded waits, stream may go stale).
|
|
self._boundary_placeholder = _DEFAULT_BOUNDARY_PLACEHOLDER
|
|
self._boundary_reason = "Approval"
|
|
self._boundary_reopen = False
|
|
# Boundary asked to reopen but nothing has re-seeded yet; keeps got_done
|
|
# from opening a stream just to emit a lone "✅" placeholder.
|
|
self._awaiting_reopen_after_boundary = False
|
|
# An EAGER re-seed (_REOPEN_SEED) already opened a fresh bubble before
|
|
# any content, so got_done must actively finalize it when the agent
|
|
# produces nothing — otherwise a blank typing bubble hangs forever.
|
|
self._reopen_seeded_eagerly = False
|
|
|
|
# Tool-progress overlay (native streaming only): lines from
|
|
# on_tool_progress() shown in the bubble until real text arrives.
|
|
self._tool_progress_lines: list[str] = []
|
|
self._tool_progress_active: bool = False
|
|
|
|
def _stream_is_message(self) -> bool:
|
|
"""Whether THIS chat's transport treats the stream as the message.
|
|
|
|
Prefers the adapter's per-chat probe (a multi-platform relay adapter's
|
|
class attribute can only reflect its primary identity), falling back to
|
|
the legacy attribute. Both are resolved on the CLASS to stay
|
|
MagicMock-safe (auto-created instance attributes are truthy).
|
|
"""
|
|
probe = getattr(type(self.adapter), "stream_is_message_for_chat", None)
|
|
if callable(probe):
|
|
try:
|
|
return probe(self.adapter, str(self.chat_id)) is True
|
|
except Exception:
|
|
return False
|
|
return getattr(self.adapter, "draft_stream_is_message", False) is True
|
|
|
|
@property
|
|
def accepts_tool_progress(self) -> bool:
|
|
"""True only when native streaming is active (gates in-stream tool progress)."""
|
|
return self._use_native_streaming
|
|
|
|
def on_tool_progress(self, line: str) -> None:
|
|
"""Thread-safe: overlay a tool-progress line in the native bubble until the next delta."""
|
|
if line:
|
|
self._queue.put((_TOOL_PROGRESS, line))
|
|
|
|
def _compose_frame_content(self) -> str:
|
|
"""Native frame content: text, with any tool-progress lines below a rule."""
|
|
if self._accumulated and self._tool_progress_lines:
|
|
return self._accumulated + "\n\n---\n" + "\n".join(self._tool_progress_lines)
|
|
elif self._accumulated:
|
|
return self._accumulated
|
|
elif self._tool_progress_lines:
|
|
return "\n".join(self._tool_progress_lines)
|
|
return ""
|
|
|
|
def _metadata_for_send(
|
|
self,
|
|
*,
|
|
final: bool = False,
|
|
expect_edits: bool = False,
|
|
) -> dict | None:
|
|
"""Per-send metadata for stream-created messages.
|
|
|
|
``final`` sets notify=True (Mattermost treats notify-worthy sends as
|
|
final content when deciding whether a broken thread root may fall back
|
|
flat). ``expect_edits`` keeps editable previews on Telegram's legacy
|
|
send path while final sends may use richer delivery.
|
|
"""
|
|
meta = dict(self.metadata) if self.metadata else {}
|
|
if self._initial_reply_to_id:
|
|
meta["reply_to_message_id"] = self._initial_reply_to_id
|
|
if expect_edits:
|
|
meta["expect_edits"] = True
|
|
if final:
|
|
meta["notify"] = True
|
|
return meta or None
|
|
|
|
@property
|
|
def already_sent(self) -> bool:
|
|
"""True if at least one message was sent or edited during the run."""
|
|
return self._already_sent
|
|
|
|
@property
|
|
def final_response_sent(self) -> bool:
|
|
"""True when the stream consumer delivered the final assistant reply."""
|
|
return self._final_response_sent
|
|
|
|
@property
|
|
def message_id(self) -> str | None:
|
|
"""Message ID of the last-sent or edited message."""
|
|
return self._message_id
|
|
|
|
@property
|
|
def final_content_delivered(self) -> bool:
|
|
"""True when the final content reached the user, even if the cosmetic final edit failed."""
|
|
return self._final_content_delivered
|
|
|
|
async def _notify_before_finalize(self) -> None:
|
|
"""Run the pre-finalize hook exactly once, swallowing hook errors."""
|
|
if self._before_finalize_notified:
|
|
return
|
|
self._before_finalize_notified = True
|
|
if self._on_before_finalize is None:
|
|
return
|
|
try:
|
|
result = self._on_before_finalize()
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
except Exception:
|
|
pass
|
|
|
|
async def _edit_message(
|
|
self,
|
|
*,
|
|
message_id: str,
|
|
content: str,
|
|
finalize: bool = False,
|
|
):
|
|
"""Edit via the adapter, passing routing metadata when supported."""
|
|
# Contract: adapters must accept finalize= even when False (test-guarded).
|
|
kwargs = {
|
|
"chat_id": self.chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"finalize": finalize,
|
|
}
|
|
if self.metadata:
|
|
try:
|
|
params = inspect.signature(self.adapter.edit_message).parameters
|
|
if "metadata" in params or any(
|
|
param.kind is inspect.Parameter.VAR_KEYWORD
|
|
for param in params.values()
|
|
):
|
|
kwargs["metadata"] = self.metadata
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return await self.adapter.edit_message(**kwargs)
|
|
|
|
def _append_accumulated(self, text: str) -> None:
|
|
"""Append to the live buffer and the split-stable stream ledger."""
|
|
if not text:
|
|
return
|
|
# Real text overwrites the tool-progress overlay.
|
|
if self._tool_progress_lines:
|
|
self._tool_progress_lines.clear()
|
|
self._tool_progress_active = False
|
|
self._accumulated += text
|
|
self._stream_ledger += text
|
|
|
|
def _mark_skip_redundant_finalize(self) -> None:
|
|
"""Mark the turn final as delivered by a prior mid-stream edit.
|
|
|
|
Records what was ACKED on the wire, not ``_accumulated``: a throttled
|
|
edit stream can reach this state with the last acked edit still holding
|
|
an earlier (cursor-suffixed) preview, and recording the accumulator
|
|
would let that frozen preview suppress the corrective send.
|
|
"""
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
acked = self._last_sent_text or self._accumulated
|
|
if self.cfg.cursor and acked.endswith(self.cfg.cursor):
|
|
acked = acked[: -len(self.cfg.cursor)]
|
|
self._record_turn_final_payload(acked)
|
|
|
|
def _record_turn_final_payload(self, text: str) -> None:
|
|
"""Record what the user actually saw as this turn's final answer.
|
|
|
|
Normalized like ``_send_or_edit`` output (media-directive strip + fence
|
|
closing) so the gateway can compare it to the completed final_response.
|
|
On a multi-message split ``text`` is only the trailing chunk (overflow
|
|
truncates ``_accumulated`` as head chunks seal), so the un-truncated
|
|
``_stream_ledger`` is recorded instead — else the gateway sees a
|
|
mismatch and re-sends an answer the user already received.
|
|
"""
|
|
source = text or ""
|
|
if self._turn_split_delivery and self._stream_ledger:
|
|
source = self._stream_ledger
|
|
self._delivered_final_text = ensure_closed_code_fences(
|
|
self._clean_for_display(source)
|
|
).strip()
|
|
|
|
def delivered_final_matches(self, final_text: str) -> Optional[bool]:
|
|
"""Tri-state reconcile of the recorded turn-final payload against ``final_text``.
|
|
|
|
A *successful* finalize edit can still carry only a stale preview, so
|
|
call success alone must not confirm delivery.
|
|
True: recorded payload (or an earlier segment/commentary) matches —
|
|
suppressing the normal final send is safe. False: recorded payload
|
|
differs, or payload-less multi-message split (flag alone must not
|
|
suppress). None: nothing recorded on a non-split legacy/ambiguous path;
|
|
caller keeps its flag-trusting behavior.
|
|
"""
|
|
target = ensure_closed_code_fences(
|
|
self._clean_for_display(final_text or "")
|
|
).strip()
|
|
if not target:
|
|
return None
|
|
if self._delivered_final_text is None:
|
|
if self._turn_split_delivery:
|
|
return False
|
|
# No recorded payload: judge against the FINAL content rather than
|
|
# trusting the flag — a consumer whose visible text lacks the
|
|
# completed response (first-edit prefix, mid-stream truncation) has
|
|
# demonstrably NOT delivered it. ``_already_sent`` gates the match:
|
|
# draft frames set ``_last_sent_text`` but are ephemeral and
|
|
# deliberately don't set ``_already_sent``.
|
|
if self._already_sent and self.has_delivered_text(final_text):
|
|
return True
|
|
# Only a timed-out full-final send that MAY have landed keeps legacy
|
|
# trust — re-sending risks a duplicate.
|
|
if self._delivery_ambiguous:
|
|
return None
|
|
return False
|
|
if self._delivered_final_text.strip() == target:
|
|
return True
|
|
# A segment break / commentary may have delivered it under another record.
|
|
return bool(self.has_delivered_text(final_text))
|
|
|
|
def has_delivered_text(self, text: str) -> bool:
|
|
"""Return True if *text* was already delivered as visible chat content."""
|
|
target = self._clean_for_display(text or "").strip()
|
|
if not target:
|
|
return False
|
|
visible_prefix = self._visible_prefix().strip()
|
|
if visible_prefix == target:
|
|
return True
|
|
return any(
|
|
sent.strip() == target
|
|
for sent in (*self._delivered_commentary_texts, *self._delivered_segment_texts)
|
|
)
|
|
|
|
def on_segment_break(self) -> None:
|
|
"""Finalize the current stream segment and start a fresh message."""
|
|
self._queue.put(_NEW_SEGMENT)
|
|
|
|
def close_for_approval_prompt(
|
|
self,
|
|
placeholder: str | None = None,
|
|
reason: str = "Approval",
|
|
reopen: bool = False,
|
|
) -> asyncio.Future:
|
|
"""Queue an interaction boundary (approval / clarify prompt) from sync context.
|
|
|
|
run() processes it serially: finalize the current native stream with
|
|
accumulated text (``placeholder`` when nothing accumulated yet), then
|
|
per ``reopen``: False (approval; long unbounded waits, stream may go
|
|
stale) disables native streaming and batches post-prompt output into
|
|
one send(); True (clarify) keeps native enabled so post-prompt output
|
|
re-opens a fresh stream via the lazy re-seed, degrading to send() if
|
|
that fails. ``reason`` labels log lines ("Approval"/"Clarify").
|
|
|
|
Returns a (Future, cancelled_flag) tuple; the Future resolves True once
|
|
processed. cancelled_flag is kept for callers that set it on timeout
|
|
but the handler no longer reads it (finalize always runs). Without
|
|
native streaming returns a bare, already-resolved Future.
|
|
"""
|
|
loop = None
|
|
with contextlib.suppress(RuntimeError):
|
|
loop = asyncio.get_running_loop()
|
|
|
|
if not self._use_native_streaming:
|
|
f = asyncio.Future() if loop else concurrent.futures.Future()
|
|
f.set_result(True)
|
|
return f
|
|
|
|
# Instance attributes are race-free: boundaries are processed one at a time.
|
|
self._boundary_placeholder = placeholder or _DEFAULT_BOUNDARY_PLACEHOLDER
|
|
self._boundary_reason = reason or "Approval"
|
|
self._boundary_reopen = bool(reopen)
|
|
|
|
boundary_future = loop.create_future() if loop else concurrent.futures.Future()
|
|
|
|
cancelled_flag = {"cancelled": False}
|
|
self._queue.put((_APPROVAL_BOUNDARY, boundary_future, cancelled_flag))
|
|
return boundary_future, cancelled_flag
|
|
|
|
def on_commentary(self, text: str) -> None:
|
|
"""Queue a completed interim assistant commentary message."""
|
|
if text:
|
|
self._queue.put((_COMMENTARY, text))
|
|
|
|
def flush_pending_sync(self, timeout: float = 5.0) -> bool:
|
|
"""Block the agent worker thread until everything queued so far is delivered.
|
|
|
|
Enqueues a ``(_FLUSH, Event)`` barrier; run() drains earlier items
|
|
(FIFO), finalizes the current segment, then sets the event. Returns
|
|
False on timeout so the caller proceeds even if the consumer task is
|
|
not running. Used before a blocking interactive prompt (clarify poll)
|
|
so the question doesn't land ABOVE its own explanation.
|
|
"""
|
|
evt = threading.Event()
|
|
try:
|
|
self._queue.put((_FLUSH, evt))
|
|
except Exception:
|
|
return False
|
|
return evt.wait(timeout=max(0.0, float(timeout)))
|
|
|
|
def request_reopen_seed(self) -> None:
|
|
"""Thread-safe: request an EAGER native re-seed after a clarify answer.
|
|
|
|
Posts _REOPEN_SEED so run() sends an empty seed frame before the first
|
|
post-answer delta (WeCom typing bubble reappears immediately). No-op
|
|
unless reopen-pending on a native stream with no stream open, so a
|
|
stray call can't open a spurious bubble mid-stream or on approval.
|
|
"""
|
|
if (
|
|
self._use_native_streaming
|
|
and self._awaiting_reopen_after_boundary
|
|
and not self._native_stream_opened
|
|
):
|
|
self._queue.put(_REOPEN_SEED)
|
|
|
|
def _notify_new_message(self) -> None:
|
|
"""Fire the on_new_message callback, swallowing any errors."""
|
|
cb = self._on_new_message
|
|
if cb is None:
|
|
return
|
|
try:
|
|
cb()
|
|
except Exception:
|
|
logger.debug("on_new_message callback error", exc_info=True)
|
|
|
|
@staticmethod
|
|
def _signal_flush(flush_event) -> None:
|
|
"""Wake a thread blocked in flush_pending_sync(), swallowing errors.
|
|
|
|
Every loop path that consumed a ``_FLUSH`` barrier (including early
|
|
``continue`` paths) must call this; a missed set isn't a deadlock
|
|
(bounded wait) but stalls the caller for the full timeout.
|
|
"""
|
|
if flush_event is None:
|
|
return
|
|
with contextlib.suppress(Exception):
|
|
flush_event.set()
|
|
|
|
def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None:
|
|
if preserve_no_edit and self._message_id == "__no_edit__":
|
|
return
|
|
# Retain the segment's visible text so has_delivered_text still matches.
|
|
if self._last_sent_text:
|
|
finalized = self._clean_for_display(self._last_sent_text).strip()
|
|
if finalized:
|
|
self._delivered_segment_texts.append(finalized)
|
|
self._message_id = None
|
|
self._message_created_ts = None
|
|
self._accumulated = ""
|
|
self._stream_ledger = ""
|
|
self._last_sent_text = ""
|
|
self._fallback_final_send = False
|
|
self._fallback_prefix = ""
|
|
self._fallback_preserve_partial_messages = False
|
|
self._segment_preview_message_ids = set()
|
|
self._tool_progress_lines = []
|
|
self._tool_progress_active = False
|
|
# A segment boundary means what we delivered was an interim preamble —
|
|
# clear the final flags so a premature setter can't fool the gateway.
|
|
# Safe: got_done returns before any reset; run.py reads these only
|
|
# after the consumer task exits.
|
|
self._final_response_sent = False
|
|
self._final_content_delivered = False
|
|
self._delivered_final_text = None
|
|
self._delivery_ambiguous = False
|
|
self._turn_split_delivery = False
|
|
# Telegram-shaped drafts: bump draft_id so the next segment animates as
|
|
# a fresh preview below the tool-progress bubbles instead of over the
|
|
# prior finalized draft. Stream-is-the-message adapters (relay Slack)
|
|
# keep ONE stream per turn — a bump there opened a new platform stream
|
|
# per tool boundary, leaving one frozen cursor-suffixed message per
|
|
# segment; the connector's suffix-delta logic appends segments instead.
|
|
if self._use_draft_streaming and not self._stream_is_message():
|
|
type(self)._draft_id_counter += 1
|
|
self._draft_id = type(self)._draft_id_counter
|
|
|
|
async def _handle_approval_boundary(self, boundary_future, cancelled_flag=None) -> None:
|
|
"""Serially process an interaction boundary dequeued by run().
|
|
|
|
Finalize the current stream with accumulated text (stable message for
|
|
pre-prompt content), then route post-prompt output per
|
|
``_boundary_reopen``. The stream is not kept open across approval
|
|
because the WeCom finalize ack only confirms server receipt: after a
|
|
long idle gap the client may stop tracking the stream, and a suppressed
|
|
final send would then leave the user with nothing.
|
|
"""
|
|
_reason = self._boundary_reason or "Approval"
|
|
delivery_failed = False
|
|
try:
|
|
if self._native_stream_opened:
|
|
finalize_text = self._accumulated or self._boundary_placeholder
|
|
finalize_ok = False
|
|
try:
|
|
result = await self.adapter.send_stream_frame(
|
|
finalize_text,
|
|
finalize=True,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
finalize_ok = bool(result)
|
|
except Exception as e:
|
|
logger.warning("%s boundary: finalize failed: %s", _reason, e)
|
|
|
|
if not finalize_ok:
|
|
# Typing bubble may still show partial content; deliver
|
|
# pre-prompt text via send() so the user at least sees it.
|
|
logger.warning(
|
|
"%s boundary: finalize not confirmed, "
|
|
"falling back to send() for pre-prompt text (chat=%s)",
|
|
_reason, self.chat_id,
|
|
)
|
|
fallback_ok = False
|
|
try:
|
|
send_result = await self.adapter.send(
|
|
self.chat_id, finalize_text,
|
|
)
|
|
fallback_ok = getattr(send_result, "success", False)
|
|
except Exception as send_err:
|
|
logger.warning(
|
|
"%s boundary: fallback send also failed: %s",
|
|
_reason, send_err,
|
|
)
|
|
if not fallback_ok:
|
|
logger.error(
|
|
"%s boundary: both finalize and fallback send failed "
|
|
"(chat=%s) — pre-prompt text may not have been delivered",
|
|
_reason, self.chat_id,
|
|
)
|
|
delivery_failed = True
|
|
else:
|
|
logger.debug(
|
|
"%s boundary: finalized stream (chat=%s, turn=%s)",
|
|
_reason, self.chat_id, self._turn_id,
|
|
)
|
|
|
|
if self._boundary_reopen:
|
|
# Clarify: keep native enabled; marking the stream closed makes
|
|
# the next post-prompt delta re-open a fresh one via the lazy
|
|
# re-seed in _send_or_edit. Do NOT set buffer_only — post-prompt
|
|
# output should stream. The gap between this INFO and the
|
|
# "Re-opened native stream" INFO is the typing-reappear latency.
|
|
self._native_stream_opened = False
|
|
self._native_last_pushed_len = 0
|
|
self._awaiting_reopen_after_boundary = True
|
|
self._reset_segment_state()
|
|
logger.info(
|
|
"[latency] Clarify boundary finalized, awaiting first "
|
|
"post-answer delta to re-seed (chat=%s, turn=%s)",
|
|
self.chat_id, self._turn_id,
|
|
)
|
|
else:
|
|
# Approval: post-approval output goes via send(). buffer_only
|
|
# delivers it in one shot on got_done, avoiding mid-stream
|
|
# flushes that create multiple messages on non-editable platforms.
|
|
self._use_native_streaming = False
|
|
self._native_stream_opened = False
|
|
self._native_last_pushed_len = 0
|
|
self.cfg.buffer_only = True
|
|
self._reset_segment_state()
|
|
|
|
boundary_ok = not delivery_failed
|
|
|
|
except Exception as e:
|
|
logger.warning("%s boundary processing failed: %s", _reason, e)
|
|
boundary_ok = False
|
|
finally:
|
|
if boundary_future is not None:
|
|
try:
|
|
if (
|
|
isinstance(boundary_future, (asyncio.Future, concurrent.futures.Future))
|
|
and not boundary_future.done()
|
|
):
|
|
boundary_future.set_result(boundary_ok)
|
|
except Exception:
|
|
pass
|
|
|
|
def on_delta(self, text: str) -> None:
|
|
"""Thread-safe callback — called from the agent's worker thread.
|
|
|
|
When *text* is ``None``, signals a tool boundary: the current message
|
|
is finalized and subsequent text will be sent as a new message so it
|
|
appears below any tool-progress messages the gateway sent in between.
|
|
"""
|
|
if text:
|
|
self._queue.put(text)
|
|
elif text is None:
|
|
self.on_segment_break()
|
|
|
|
def finish(self, final_text: Optional[str] = None) -> None:
|
|
"""Signal stream completion.
|
|
|
|
``final_text`` is the AUTHORITATIVE completed final_response (incl.
|
|
post-stream augmentation the accumulator never saw); the drain loop
|
|
adopts it as the finalize payload so no corrective send is needed.
|
|
Interrupt/error paths that can't know the final call ``finish()`` bare.
|
|
"""
|
|
if final_text is not None:
|
|
self._queue.put((_FINAL_TEXT, final_text))
|
|
self._queue.put(_DONE)
|
|
|
|
# ── Think-block filtering ────────────────────────────────────────
|
|
# Some models emit inline <think>...</think> blocks in content. The agent
|
|
# strips them from the final response, but intermediate edits go out before
|
|
# that, so mirror the CLI's _stream_delta state machine here.
|
|
|
|
def _filter_and_accumulate(self, text: str) -> None:
|
|
"""Append a delta to the buffer, discarding think blocks.
|
|
|
|
Partial tags at buffer boundaries are held in ``_think_buffer`` until
|
|
enough characters arrive to decide.
|
|
"""
|
|
buf = self._think_buffer + text
|
|
self._think_buffer = ""
|
|
|
|
while buf:
|
|
# Case-insensitive: models emit <Think>, <THINKING>, …
|
|
lower_buf = buf.lower()
|
|
if self._in_think_block:
|
|
best_idx = -1
|
|
best_len = 0
|
|
for tag in self._CLOSE_THINK_TAGS:
|
|
idx = lower_buf.find(tag.lower())
|
|
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
|
best_idx = idx
|
|
best_len = len(tag)
|
|
|
|
if best_len:
|
|
self._in_think_block = False
|
|
buf = buf[best_idx + best_len:]
|
|
else:
|
|
# Hold a tail that could be a partial close tag; discard the rest.
|
|
max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS)
|
|
self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf
|
|
return
|
|
else:
|
|
# Earliest opening tag at a block boundary (start of text, or
|
|
# newline + optional whitespace) — prose that merely *mentions*
|
|
# a tag must not trigger.
|
|
best_idx = -1
|
|
best_len = 0
|
|
for tag in self._OPEN_THINK_TAGS:
|
|
tag_lower = tag.lower()
|
|
search_start = 0
|
|
while True:
|
|
idx = lower_buf.find(tag_lower, search_start)
|
|
if idx == -1:
|
|
break
|
|
# Block-boundary check (mirrors cli.py logic)
|
|
if idx == 0:
|
|
is_boundary = (
|
|
not self._accumulated
|
|
or self._accumulated.endswith("\n")
|
|
)
|
|
else:
|
|
preceding = buf[:idx]
|
|
last_nl = preceding.rfind("\n")
|
|
if last_nl == -1:
|
|
is_boundary = (
|
|
(not self._accumulated
|
|
or self._accumulated.endswith("\n"))
|
|
and preceding.strip() == ""
|
|
)
|
|
else:
|
|
is_boundary = preceding[last_nl + 1:].strip() == ""
|
|
|
|
if is_boundary and (best_idx == -1 or idx < best_idx):
|
|
best_idx = idx
|
|
best_len = len(tag)
|
|
break # first boundary hit for this tag is enough
|
|
search_start = idx + 1
|
|
|
|
if best_len:
|
|
self._append_accumulated(buf[:best_idx])
|
|
self._in_think_block = True
|
|
buf = buf[best_idx + best_len:]
|
|
else:
|
|
# Hold back a partial open tag at the tail.
|
|
held_back = 0
|
|
for tag in self._OPEN_THINK_TAGS:
|
|
tag_lower = tag.lower()
|
|
for i in range(1, len(tag)):
|
|
if lower_buf.endswith(tag_lower[:i]) and i > held_back:
|
|
held_back = i
|
|
if held_back:
|
|
self._append_accumulated(buf[:-held_back])
|
|
self._think_buffer = buf[-held_back:]
|
|
else:
|
|
# An orphan </think> (thinking-mode toggle dropped the
|
|
# open, or incomplete upstream stripping) is noise.
|
|
self._append_accumulated(self._strip_orphan_close_tags(buf))
|
|
return
|
|
|
|
@classmethod
|
|
def _strip_orphan_close_tags(cls, text: str) -> str:
|
|
"""Remove close tags (plus trailing whitespace) that have no matching open.
|
|
|
|
Mirrors ``agent/think_scrubber.py::StreamingThinkScrubber`` so the
|
|
progressive display matches the post-stream scrubber.
|
|
"""
|
|
if "</" not in text:
|
|
return text
|
|
text_lower = text.lower()
|
|
out: list[str] = []
|
|
i = 0
|
|
while i < len(text):
|
|
matched = False
|
|
if text_lower[i:i + 2] == "</":
|
|
for tag in cls._CLOSE_THINK_TAGS:
|
|
tag_lower = tag.lower()
|
|
tag_len = len(tag_lower)
|
|
if text_lower[i:i + tag_len] == tag_lower:
|
|
j = i + tag_len
|
|
while j < len(text) and text[j] in " \t\n\r":
|
|
j += 1
|
|
i = j
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
out.append(text[i])
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
def _flush_think_buffer(self) -> None:
|
|
"""On stream end, flush text held back waiting for a possible open tag."""
|
|
if self._think_buffer and not self._in_think_block:
|
|
self._append_accumulated(self._strip_orphan_close_tags(self._think_buffer))
|
|
self._think_buffer = ""
|
|
|
|
async def run(self) -> None:
|
|
"""Async task that drains the queue and edits the platform message."""
|
|
# Length function and limit resolve PER-CHAT (a relay adapter fronting
|
|
# N platforms has different caps per chat) using the platform's unit
|
|
# (e.g. utf16 for Telegram). isinstance gate: MagicMock auto-attributes
|
|
# aren't callables, so test doubles fall back to len.
|
|
_len_fn: "Callable[[str], int]" = (
|
|
self.adapter.message_len_fn_for_chat(self.chat_id)
|
|
if isinstance(self.adapter, _BasePlatformAdapter)
|
|
else len
|
|
)
|
|
_raw_limit = self._raw_message_limit()
|
|
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
|
|
|
|
# Native streaming wins over draft: adapters that declare it (WeCom)
|
|
# cannot edit at all. Send an empty seed frame now so the user sees
|
|
# "typing" before the first token; on seed failure fall back to the
|
|
# edit path (which the gateway's fallback send then handles).
|
|
self._use_native_streaming = self._resolve_native_streaming()
|
|
if self._use_native_streaming:
|
|
logger.debug(
|
|
"Stream consumer using native-stream transport (chat=%s)",
|
|
self.chat_id,
|
|
)
|
|
try:
|
|
seed_ok = await self.adapter.send_stream_frame(
|
|
"",
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
if seed_ok:
|
|
self._native_stream_opened = True
|
|
except Exception:
|
|
logger.debug(
|
|
"Native streaming seed frame raised; disabling native",
|
|
exc_info=True,
|
|
)
|
|
seed_ok = False
|
|
if not seed_ok:
|
|
self._use_native_streaming = False
|
|
|
|
# Drafts and native streaming target the same first-frame slot.
|
|
if self._use_native_streaming:
|
|
self._use_draft_streaming = False
|
|
else:
|
|
self._use_draft_streaming = self._resolve_draft_streaming()
|
|
if self._use_draft_streaming:
|
|
type(self)._draft_id_counter += 1
|
|
self._draft_id = type(self)._draft_id_counter
|
|
logger.debug(
|
|
"Stream consumer using native-draft transport (chat=%s draft_id=%s)",
|
|
self.chat_id, self._draft_id,
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
# Session reset (/new, /stop): abandon rather than deliver stale deltas.
|
|
if not self._run_still_current():
|
|
await self._abandon_native_stream()
|
|
return
|
|
|
|
got_done = False
|
|
got_segment_break = False
|
|
got_flush = False
|
|
flush_event = None
|
|
got_approval_boundary = False
|
|
got_reopen_seed = False
|
|
approval_boundary_future = None
|
|
approval_boundary_cancelled = None
|
|
commentary_text = None
|
|
while True:
|
|
try:
|
|
item = self._queue.get_nowait()
|
|
if item is _DONE:
|
|
got_done = True
|
|
break
|
|
if item is _NEW_SEGMENT:
|
|
got_segment_break = True
|
|
break
|
|
if isinstance(item, tuple) and len(item) == 2 and item[0] is _FINAL_TEXT:
|
|
# Adopt the authoritative final (see finish()) as the
|
|
# finalize content — only if this consumer streamed
|
|
# something (a no-stream turn keeps the gateway's
|
|
# normal final-send ownership).
|
|
_streamed_something = bool(
|
|
self._accumulated
|
|
or self._message_id
|
|
or self._last_sent_text
|
|
)
|
|
if _streamed_something and not self._turn_split_delivery:
|
|
_final_payload = self._clean_for_display(item[1])
|
|
_visible = self._clean_for_display(self._accumulated)
|
|
if _final_payload and _final_payload != _visible:
|
|
self._accumulated = item[1]
|
|
self._stream_ledger = item[1]
|
|
elif _streamed_something and self._turn_split_delivery:
|
|
# Split delivery: wholesale adoption would repeat
|
|
# sealed heads inside the tail, but refusing
|
|
# entirely makes the gateway resend the ENTIRE
|
|
# body+footer. If the final strictly
|
|
# prefix-extends the ledger, append only the
|
|
# missing suffix; non-prefix rewrites keep the
|
|
# full-resend fallback.
|
|
_final_raw = item[1]
|
|
_ledger = self._stream_ledger
|
|
if (
|
|
_ledger
|
|
and _final_raw.startswith(_ledger)
|
|
and len(_final_raw) > len(_ledger)
|
|
):
|
|
_suffix = _final_raw[len(_ledger):]
|
|
self._accumulated += _suffix
|
|
self._stream_ledger = _final_raw
|
|
continue
|
|
if item is _REOPEN_SEED:
|
|
got_reopen_seed = True
|
|
break
|
|
if isinstance(item, tuple) and len(item) == 3 and item[0] is _APPROVAL_BOUNDARY:
|
|
got_approval_boundary = True
|
|
approval_boundary_future = item[1]
|
|
approval_boundary_cancelled = item[2]
|
|
break
|
|
if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY:
|
|
commentary_text = item[1]
|
|
break
|
|
if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH:
|
|
# Barrier: finalize like a tool boundary, signal below.
|
|
got_flush = True
|
|
got_segment_break = True
|
|
flush_event = item[1]
|
|
break
|
|
if isinstance(item, tuple) and len(item) == 2 and item[0] is _TOOL_PROGRESS:
|
|
if self._use_native_streaming:
|
|
self._tool_progress_lines.append(item[1])
|
|
self._tool_progress_active = True
|
|
continue # continue draining to batch simultaneous progress lines
|
|
self._filter_and_accumulate(item)
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Boundary produces its own finalize and resets state, so it
|
|
# must run before got_done/segment_break processing.
|
|
if got_approval_boundary:
|
|
await self._handle_approval_boundary(
|
|
approval_boundary_future, approval_boundary_cancelled
|
|
)
|
|
continue
|
|
|
|
# Eager re-seed after a clarify answer. Re-check the gate
|
|
# (state may have advanced between put and dequeue). Trade-off:
|
|
# WeCom's ~6-minute stream limit (errcode 846608, counted from
|
|
# the FIRST frame) now starts at the reply instant rather than
|
|
# the first post-answer delta; if it expires, send_stream_frame
|
|
# returns False and we degrade to send() — only animation is lost.
|
|
if got_reopen_seed:
|
|
if (
|
|
self._use_native_streaming
|
|
and self._awaiting_reopen_after_boundary
|
|
and not self._native_stream_opened
|
|
):
|
|
try:
|
|
seed_ok = await self.adapter.send_stream_frame(
|
|
"",
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Eager reopen seed raised, disabling native: %s", e,
|
|
)
|
|
seed_ok = False
|
|
if seed_ok:
|
|
self._native_stream_opened = True
|
|
self._native_last_pushed_len = 0
|
|
self._awaiting_reopen_after_boundary = False
|
|
self._reopen_seeded_eagerly = True
|
|
logger.info(
|
|
"[latency] Eager re-seed after clarify answer "
|
|
"(typing bubble reopened immediately, turn=%s)",
|
|
self._turn_id,
|
|
)
|
|
else:
|
|
# Degrade to a single buffered send() (one bubble,
|
|
# not per-tick fragments), like the approval path.
|
|
self._use_native_streaming = False
|
|
self._native_stream_opened = False
|
|
self._native_last_pushed_len = 0
|
|
self.cfg.buffer_only = True
|
|
continue
|
|
|
|
if got_done:
|
|
self._flush_think_buffer()
|
|
|
|
# A bare intentional-silence marker (NO_REPLY / [SILENT]):
|
|
# the gateway's whole-response filter runs too late for a
|
|
# streamed preview, so retract it here instead of finalizing.
|
|
if _is_intentional_silence_response(
|
|
self._clean_for_display(self._accumulated)
|
|
):
|
|
await self._suppress_silence_marker()
|
|
return
|
|
|
|
# Decide whether to flush an edit
|
|
now = time.monotonic()
|
|
elapsed = now - self._last_edit_time
|
|
should_edit = (
|
|
got_done
|
|
or got_segment_break
|
|
or commentary_text is not None
|
|
)
|
|
if not self.cfg.buffer_only:
|
|
if self._use_native_streaming:
|
|
# No platform edit-rate limit: push every delta immediately.
|
|
should_edit = should_edit or bool(self._accumulated) or self._tool_progress_active
|
|
else:
|
|
should_edit = should_edit or (
|
|
(elapsed >= self._current_edit_interval
|
|
and self._accumulated)
|
|
# buffer_threshold is a codepoint debounce heuristic,
|
|
# not a platform-limit check (_len_fn is for overflow).
|
|
or len(self._accumulated) >= self.cfg.buffer_threshold
|
|
)
|
|
|
|
current_update_visible = False
|
|
# got_done update went out as a FRESH send via the draft
|
|
# transport (drafts have no message id) already carrying
|
|
# finalize=True — unlike an EDIT during draft streaming, which
|
|
# still needs the explicit finalize pass for
|
|
# REQUIRES_EDIT_FINALIZE adapters.
|
|
draft_final_fresh_send = False
|
|
# Defer mid-stream edits while the buffer could still resolve
|
|
# to a silence marker ("NO"→"NO_REPLY") so it never flashes on
|
|
# screen; got_done always resolves the buffer, so marker-like
|
|
# prose is never lost.
|
|
if (
|
|
should_edit
|
|
and not got_done
|
|
and not got_segment_break
|
|
and commentary_text is None
|
|
and _is_partial_silence_marker(
|
|
self._clean_for_display(self._accumulated)
|
|
)
|
|
):
|
|
should_edit = False
|
|
if should_edit and (self._accumulated or (self._use_native_streaming and self._tool_progress_active)):
|
|
# Overflow split. Native streaming bypasses this: the
|
|
# adapter truncates against the stream protocol's own limit.
|
|
if (
|
|
not self._use_native_streaming
|
|
and _len_fn(self._accumulated) > _safe_limit
|
|
and self._message_id is None
|
|
):
|
|
# No message to edit yet: seal only the overflowing head
|
|
# chunks, keep the tail in _accumulated so it becomes the
|
|
# active preview that later deltas edit in place.
|
|
chunks = self._truncate_for_stream(
|
|
self._accumulated, _safe_limit, _len_fn,
|
|
)
|
|
if len(chunks) <= 1:
|
|
# Malformed/legacy adapter result must still be splittable.
|
|
chunks = self._split_text_chunks(
|
|
self._accumulated, _safe_limit, _len_fn,
|
|
)
|
|
chunks_delivered = False
|
|
reply_to = self._initial_reply_to_id
|
|
all_heads_delivered = len(chunks) > 1
|
|
for chunk in chunks[:-1]:
|
|
new_id = await self._send_new_chunk(
|
|
chunk,
|
|
reply_to,
|
|
final=got_done,
|
|
)
|
|
if new_id is None or new_id == reply_to:
|
|
# Keep the full text intact for the gateway fallback.
|
|
all_heads_delivered = False
|
|
chunks_delivered = False
|
|
break
|
|
chunks_delivered = True
|
|
reply_to = new_id
|
|
|
|
if all_heads_delivered:
|
|
self._accumulated = chunks[-1]
|
|
# Heads are sealed (or a later head failed): never edit a
|
|
# sealed message with the unsplit payload — the tail is
|
|
# sent fresh, or the fallback path retries.
|
|
self._message_id = None
|
|
self._message_created_ts = None
|
|
self._last_sent_text = ""
|
|
|
|
if chunks_delivered:
|
|
# Flag BEFORE the tail send: fresh-final replaces
|
|
# every tracked preview with one message, which is
|
|
# only valid while the active message holds the whole
|
|
# answer — deleting sealed heads drops delivered text.
|
|
self._turn_split_delivery = True
|
|
|
|
self._last_edit_time = time.monotonic()
|
|
if got_done:
|
|
tail_delivered = True
|
|
if self._accumulated:
|
|
tail_delivered = await self._send_or_edit(
|
|
self._accumulated, finalize=True,
|
|
)
|
|
# ``_already_sent`` may be True from prior
|
|
# progress/fallback state — only heads + tail count.
|
|
self._final_response_sent = chunks_delivered and tail_delivered
|
|
if self._final_response_sent:
|
|
self._final_content_delivered = True
|
|
self._turn_split_delivery = True
|
|
self._record_turn_final_payload(self._accumulated)
|
|
return
|
|
if got_segment_break:
|
|
self._message_id = None
|
|
self._fallback_final_send = False
|
|
self._fallback_prefix = ""
|
|
if not self._accumulated:
|
|
continue
|
|
|
|
# Early `continue` skips the bottom-of-loop flush signal.
|
|
if got_flush:
|
|
self._signal_flush(flush_event)
|
|
continue
|
|
# Existing message: seal it with the head, start a new
|
|
# message for the remainder.
|
|
while (
|
|
_len_fn(self._accumulated) > _safe_limit
|
|
and self._message_id is not None
|
|
and self._edit_supported
|
|
):
|
|
_cp_budget = _custom_unit_to_cp(
|
|
self._accumulated, _safe_limit, _len_fn,
|
|
)
|
|
split_at = self._accumulated.rfind("\n", 0, _cp_budget)
|
|
if split_at < _cp_budget // 2:
|
|
split_at = _cp_budget
|
|
chunk = self._accumulated[:split_at]
|
|
# finalize=True: this sealed chunk is never edited again,
|
|
# so it needs its rich-text pass now or it renders raw.
|
|
# is_turn_final=False: a split head is not the answer, so
|
|
# fresh-final must not mark the turn delivered on it.
|
|
ok = await self._send_or_edit(
|
|
chunk, finalize=True, is_turn_final=False,
|
|
)
|
|
if self._fallback_final_send or not ok:
|
|
# Keep the full text intact for the fallback final send.
|
|
break
|
|
self._accumulated = self._accumulated[split_at:].lstrip("\n")
|
|
self._message_id = None
|
|
self._last_sent_text = ""
|
|
self._turn_split_delivery = True
|
|
|
|
display_text = self._accumulated
|
|
if not got_done and not got_segment_break and commentary_text is None:
|
|
if self._use_native_streaming:
|
|
display_text = self._compose_frame_content()
|
|
if display_text and self.cfg.cursor:
|
|
display_text += self.cfg.cursor
|
|
else:
|
|
display_text += self.cfg.cursor
|
|
|
|
# Segment break finalizes so platforms needing explicit
|
|
# closure (DingTalk AI Cards) don't leave the previous
|
|
# segment stuck loading; got_done has its own finalize below.
|
|
draft_final_fresh_send = (
|
|
got_done
|
|
and self._use_draft_streaming
|
|
and self._message_id is None
|
|
)
|
|
current_update_visible = await self._send_or_edit(
|
|
display_text,
|
|
finalize=(got_done or got_segment_break),
|
|
# A segment-break finalize closes a preamble, not the answer.
|
|
is_turn_final=got_done,
|
|
)
|
|
self._last_edit_time = time.monotonic()
|
|
# Lines stay in _tool_progress_lines for the next compose;
|
|
# only new progress should trigger another should_edit.
|
|
if self._tool_progress_active:
|
|
self._tool_progress_active = False
|
|
|
|
if got_done:
|
|
if self._accumulated or self._message_id is not None or self._already_sent:
|
|
await self._notify_before_finalize()
|
|
# Final edit without cursor; if progressive editing failed
|
|
# mid-stream, send one continuation here rather than letting
|
|
# the gateway resend the full response.
|
|
if (
|
|
self._awaiting_reopen_after_boundary
|
|
and not self._native_stream_opened
|
|
and not self._accumulated
|
|
):
|
|
# Lazy reopen, no post-prompt content: nothing is open
|
|
# on screen, so don't re-seed just to emit a lone "✅".
|
|
logger.debug(
|
|
"Clarify reopen boundary with no post-prompt content "
|
|
"— skipping lone-placeholder finalize (turn=%s)",
|
|
self._turn_id,
|
|
)
|
|
elif (
|
|
self._reopen_seeded_eagerly
|
|
and self._native_stream_opened
|
|
and not self._accumulated
|
|
and not current_update_visible
|
|
):
|
|
# Eager seed, no content: the typing bubble IS on screen
|
|
# and would hang forever — close it with an empty
|
|
# finalize (not a lone "✅"). Delivery flags untouched.
|
|
try:
|
|
await self.adapter.send_stream_frame(
|
|
"",
|
|
finalize=True,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Eager-seed empty finalize failed: %s", e,
|
|
)
|
|
self._native_stream_opened = False
|
|
self._native_last_pushed_len = 0
|
|
self._reopen_seeded_eagerly = False
|
|
logger.debug(
|
|
"Eager reopen seed but no post-answer content — "
|
|
"closed empty typing bubble (turn=%s)",
|
|
self._turn_id,
|
|
)
|
|
elif self._use_native_streaming:
|
|
# Native streams MUST close with finish=true even when
|
|
# empty (tool-only turns) — placeholder if needed.
|
|
if not current_update_visible:
|
|
close_text = self._accumulated or "✅"
|
|
self._final_response_sent = await self._send_or_edit(
|
|
close_text, finalize=True,
|
|
)
|
|
if self._final_response_sent:
|
|
self._final_content_delivered = True
|
|
else:
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
elif self._accumulated:
|
|
if self._fallback_final_send:
|
|
await self._send_fallback_final(self._accumulated)
|
|
elif self._final_response_sent:
|
|
# Fresh-final already delivered above; a second
|
|
# finalize would duplicate / re-delete.
|
|
self._final_content_delivered = True
|
|
self._record_turn_final_payload(self._accumulated)
|
|
elif (
|
|
current_update_visible
|
|
and (
|
|
not self._adapter_requires_finalize
|
|
or self._last_edit_overflowed
|
|
or draft_final_fresh_send
|
|
)
|
|
):
|
|
# The update above already delivered the final. A
|
|
# second finalize would re-edit an already-final
|
|
# message (Telegram: sendRichMessage followed by
|
|
# editMessageText falls back to the legacy
|
|
# formatter), or overflow-split again into an
|
|
# adopted continuation, duplicating chunks.
|
|
self._mark_skip_redundant_finalize()
|
|
elif self._message_id:
|
|
# No visible update this tick, or the adapter needs
|
|
# explicit finalize=True.
|
|
self._final_response_sent = await self._send_or_edit(
|
|
self._accumulated, finalize=True,
|
|
)
|
|
if self._final_response_sent:
|
|
self._final_content_delivered = True
|
|
self._record_turn_final_payload(self._accumulated)
|
|
elif self._fallback_final_send:
|
|
# This edit may have exhausted flood strikes and
|
|
# promoted fallback mode; send only the unsent
|
|
# tail now so the gateway doesn't duplicate the
|
|
# visible prefix.
|
|
await self._send_fallback_final(self._accumulated)
|
|
elif not self._already_sent:
|
|
# Retry after the finalize tick failed. finalize=True
|
|
# keeps stream-is-the-message adapters out of the
|
|
# draft-frame branch, whose dedupe against the last
|
|
# UNSEALED frame would report success with no
|
|
# transport call (silent loss).
|
|
self._final_response_sent = await self._send_or_edit(
|
|
self._accumulated, finalize=True,
|
|
)
|
|
if self._final_response_sent:
|
|
self._final_content_delivered = True
|
|
self._record_turn_final_payload(self._accumulated)
|
|
return
|
|
|
|
if commentary_text is not None:
|
|
# Cumulative transports (stream-is-the-message drafts, WeCom
|
|
# native): commentary posts as its own message and the
|
|
# stream continues — resetting _accumulated would break the
|
|
# append-only invariant / lose pre-commentary text.
|
|
if (
|
|
self._stream_is_message() and self._use_draft_streaming
|
|
) or self._use_native_streaming:
|
|
await self._send_commentary(commentary_text)
|
|
self._last_edit_time = time.monotonic()
|
|
else:
|
|
self._reset_segment_state()
|
|
await self._send_commentary(commentary_text)
|
|
self._last_edit_time = time.monotonic()
|
|
self._reset_segment_state()
|
|
|
|
# Tool boundary: edit-based transports reset so the next chunk
|
|
# is a fresh message below tool progress. Cumulative transports
|
|
# (stream-is-the-message drafts, WeCom native) must NOT reset —
|
|
# clearing _accumulated makes the next frame a non-prefix
|
|
# snapshot and the connector re-appends the whole answer.
|
|
# preserve_no_edit: "__no_edit__" (platform never returned a
|
|
# real id — Signal, github_comment webhook) must keep its
|
|
# sentinel or every tool boundary posts a new message (155
|
|
# comments on one PR); the continuation goes out once via
|
|
# _send_fallback_final. A real id from a flood-failed edit
|
|
# still resets as intended.
|
|
if got_segment_break:
|
|
if (
|
|
self._stream_is_message()
|
|
and self._use_draft_streaming
|
|
) or self._use_native_streaming:
|
|
pass
|
|
else:
|
|
# If the segment-break edit didn't land (flood control /
|
|
# fallback mode), _accumulated holds unseen pre-boundary
|
|
# text — flush it before the reset wipes it.
|
|
if (
|
|
self._accumulated
|
|
and not current_update_visible
|
|
and self._message_id
|
|
and self._message_id != "__no_edit__"
|
|
):
|
|
await self._flush_segment_tail_on_edit_failure()
|
|
self._reset_segment_state(preserve_no_edit=True)
|
|
|
|
# Done last so the waiter unblocks only once everything queued
|
|
# before the barrier is on screen.
|
|
if got_flush:
|
|
self._signal_flush(flush_event)
|
|
|
|
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
|
|
|
except asyncio.CancelledError:
|
|
# Best-effort final edit. finalize=True so REQUIRES_EDIT_FINALIZE
|
|
# platforms apply formatting (the flags below suppress the gateway's
|
|
# formatted re-send); is_turn_final=False because this handler owns
|
|
# the flags, not _try_fresh_final.
|
|
_best_effort_ok = False
|
|
if self._accumulated and self._message_id:
|
|
with contextlib.suppress(Exception):
|
|
_best_effort_ok = bool(
|
|
await self._send_or_edit(
|
|
self._accumulated, finalize=True, is_turn_final=False,
|
|
)
|
|
)
|
|
elif self._message_id is None:
|
|
# Draft path keeps _message_id=None, so the edit above never
|
|
# runs for it; seal in place (else the stream stays visibly
|
|
# live and the adapter keeps armed interception state for the
|
|
# next turn). Sets no delivery flags.
|
|
await self._abandon_native_stream()
|
|
# Only a successful best-effort edit confirms delivery — a partial
|
|
# send (already_sent) may be just "Let me search…", not the answer.
|
|
if _best_effort_ok and not self._final_response_sent:
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
self._record_turn_final_payload(self._accumulated)
|
|
except Exception as e:
|
|
logger.error("Stream consumer error: %s", e)
|
|
finally:
|
|
# Wake any still-queued _FLUSH waiters so a consumer dying mid-flush
|
|
# doesn't stall flush_pending_sync() for its full timeout.
|
|
try:
|
|
while True:
|
|
item = self._queue.get_nowait()
|
|
if (
|
|
isinstance(item, tuple)
|
|
and len(item) == 2
|
|
and item[0] is _FLUSH
|
|
):
|
|
self._signal_flush(item[1])
|
|
except queue.Empty:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Shared with the non-streaming path so a MEDIA tag is treated identically
|
|
# either way (only deliverable-extension paths are stripped).
|
|
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
|
|
|
|
@staticmethod
|
|
def _clean_for_display(text: str) -> str:
|
|
"""Hide MEDIA:<path> / [[audio_as_voice]] directives; media is delivered after the stream."""
|
|
return _BasePlatformAdapter.strip_media_directives_for_display(text)
|
|
|
|
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 result.success and result.message_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)
|
|
else:
|
|
self._edit_supported = False
|
|
return reply_to_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):
|
|
return final_text[len(prefix):].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(chunk, str) for chunk 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.
|
|
|
|
Retries each chunk once on flood-control failures with a short delay.
|
|
"""
|
|
final_text = self._clean_for_display(text)
|
|
# 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(final_text)
|
|
continuation = self._continuation_text(final_text)
|
|
self._fallback_final_send = False
|
|
if not continuation.strip():
|
|
# 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 == self._visible_prefix()
|
|
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
|
|
self._already_sent = True
|
|
self._fallback_prefix = ""
|
|
self._fallback_preserve_partial_messages = False
|
|
if delivery in {"ambiguous", "preview"}:
|
|
# Timeout: Telegram may have accepted the send. Flood
|
|
# rejection: the complete ACKed preview is authoritative.
|
|
# Keep duplicate suppression in both cases.
|
|
self._final_content_delivered = True
|
|
if delivery == "preview":
|
|
# Preview already shows the full final (checked above);
|
|
# record it so the gateway doesn't re-send next to it.
|
|
self._record_turn_final_payload(final_text)
|
|
else:
|
|
self._delivery_ambiguous = True
|
|
else:
|
|
# Confirmed failure: gateway performs its normal final send.
|
|
self._final_response_sent = False
|
|
self._final_content_delivered = False
|
|
return
|
|
# 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 != self._visible_prefix():
|
|
continuation = final_text
|
|
else:
|
|
# Best-effort strip of a cursor left stuck by the edit failure
|
|
# that entered fallback mode.
|
|
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)]
|
|
try:
|
|
result = await self._edit_message(
|
|
message_id=self._message_id,
|
|
content=clean_text,
|
|
)
|
|
if result.success:
|
|
self._last_sent_text = clean_text
|
|
except Exception:
|
|
pass
|
|
self._already_sent = True
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
# Recorder substitutes the full ledger on a split turn.
|
|
self._record_turn_final_payload(final_text)
|
|
return
|
|
|
|
raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
|
_len_fn: "Callable[[str], int]" = (
|
|
self.adapter.message_len_fn
|
|
if isinstance(self.adapter, _BasePlatformAdapter)
|
|
else len
|
|
)
|
|
# Per-chat cap/unit (relay adapter fronting N platforms).
|
|
if isinstance(self.adapter, _BasePlatformAdapter):
|
|
try:
|
|
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)
|
|
safe_limit = max(500, raw_limit - 100)
|
|
chunks = self._split_text_chunks(continuation, safe_limit, 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 = None
|
|
for attempt in range(2):
|
|
result = await self.adapter.send(
|
|
chat_id=self.chat_id,
|
|
content=chunk,
|
|
metadata=self._metadata_for_send(final=True),
|
|
)
|
|
if result.success:
|
|
break
|
|
retry_delay = self._fallback_flood_retry_delay(result)
|
|
if attempt == 0 and retry_delay is not None:
|
|
logger.debug(
|
|
"Flood control on fallback send, retrying in %.1fs",
|
|
retry_delay,
|
|
)
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
break # non-flood error, long flood wait, or second failure
|
|
|
|
if not result or not result.success:
|
|
if sent_any_chunk:
|
|
# Partial continuation landed: do NOT set _final_response_sent
|
|
# (gateway must still deliver the full answer); _already_sent
|
|
# only prevents a duplicate of the partial.
|
|
self._already_sent = True
|
|
self._message_id = last_message_id
|
|
self._last_sent_text = last_successful_chunk
|
|
self._fallback_prefix = ""
|
|
return
|
|
# Nothing landed — let the gateway final send try once more.
|
|
self._already_sent = False
|
|
self._message_id = None
|
|
self._last_sent_text = ""
|
|
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
|
|
):
|
|
delete_fn = getattr(self.adapter, "delete_message", None)
|
|
if delete_fn is not None:
|
|
try:
|
|
await delete_fn(self.chat_id, stale_message_id)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Fallback partial cleanup failed (%s): %s",
|
|
stale_message_id, e,
|
|
)
|
|
|
|
self._message_id = last_message_id
|
|
self._already_sent = True
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
# Recorder substitutes the unsplit ledger on a split turn.
|
|
self._record_turn_final_payload(final_text)
|
|
self._last_sent_text = chunks[-1]
|
|
self._fallback_prefix = ""
|
|
self._fallback_preserve_partial_messages = False
|
|
|
|
async def _send_empty_fallback_final(self, final_text: str) -> str:
|
|
"""Commit a completed answer after Telegram finalization fails.
|
|
|
|
Returns "delivered", "failed" (gateway may retry), "ambiguous" (a
|
|
timeout may have reached the platform), or "preview" (flood control
|
|
leaves the complete streamed preview authoritative).
|
|
"""
|
|
# Segment-scoped only: never delete an earlier finalized preamble.
|
|
stale_ids = set(self._segment_preview_message_ids)
|
|
if self._message_id and self._message_id != "__no_edit__":
|
|
stale_ids.add(str(self._message_id))
|
|
|
|
result = None
|
|
for attempt in range(2):
|
|
try:
|
|
result = await self.adapter.send(
|
|
chat_id=self.chat_id,
|
|
content=final_text,
|
|
reply_to=self._initial_reply_to_id,
|
|
metadata=self._metadata_for_send(final=True),
|
|
)
|
|
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 getattr(result, "success", False):
|
|
break
|
|
retry_delay = self._fallback_flood_retry_delay(result)
|
|
if attempt == 0 and retry_delay is not None:
|
|
logger.debug(
|
|
"Flood control on empty fallback final send; retrying in %.1fs",
|
|
retry_delay,
|
|
)
|
|
await asyncio.sleep(retry_delay)
|
|
continue
|
|
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)
|
|
delete_fn = getattr(self.adapter, "delete_message", None)
|
|
if delete_fn is not None:
|
|
for stale_id in stale_ids:
|
|
if not stale_id or stale_id == new_message_id:
|
|
continue
|
|
try:
|
|
deleted = await delete_fn(self.chat_id, stale_id)
|
|
if deleted is False:
|
|
# Telegram reports delete failure by returning False;
|
|
# the flood window that broke the finalize can reject
|
|
# this too. One bounded retry, then best-effort.
|
|
await asyncio.sleep(1.0)
|
|
await delete_fn(self.chat_id, stale_id)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Empty fallback preview cleanup failed (%s): %s",
|
|
stale_id,
|
|
exc,
|
|
)
|
|
|
|
self._segment_preview_message_ids = set()
|
|
self._message_id = new_message_id or "__no_edit__"
|
|
self._already_sent = True
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
# Record VERBATIM, not via _record_turn_final_payload: the sealed
|
|
# previews were just deleted, so the ledger (which still holds sealed
|
|
# heads) would claim delivery for text this path removed.
|
|
self._delivered_final_text = ensure_closed_code_fences(
|
|
self._clean_for_display(final_text or "")
|
|
).strip()
|
|
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 = getattr(result, "error", "") or ""
|
|
err_lower = err.lower()
|
|
return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower
|
|
|
|
def _resolve_draft_streaming(self) -> bool:
|
|
"""Whether this run should use draft streaming per ``cfg.transport``.
|
|
|
|
"edit"/"off" → False. "draft"/"auto" → the adapter's
|
|
supports_draft_streaming probe (chat type, platform-version gates);
|
|
"draft" logs the downgrade when unsupported.
|
|
"""
|
|
transport = (self.cfg.transport or "edit").lower()
|
|
if transport in ("edit", "off"):
|
|
return False
|
|
# MagicMock test adapters default to edit.
|
|
if not isinstance(self.adapter, _BasePlatformAdapter):
|
|
return False
|
|
try:
|
|
try:
|
|
# Per-chat probe (relay adapters resolve through the CHAT's
|
|
# descriptor); older adapters without the kwarg keep the legacy probe.
|
|
supported = self.adapter.supports_draft_streaming(
|
|
chat_type=self.cfg.chat_type or None,
|
|
metadata=self.metadata,
|
|
chat_id=self.chat_id,
|
|
)
|
|
except TypeError:
|
|
supported = self.adapter.supports_draft_streaming(
|
|
chat_type=self.cfg.chat_type or None,
|
|
metadata=self.metadata,
|
|
)
|
|
except Exception:
|
|
logger.debug("supports_draft_streaming probe raised", exc_info=True)
|
|
supported = False
|
|
if not supported:
|
|
if transport == "draft":
|
|
logger.debug(
|
|
"Draft streaming requested but unsupported (chat=%s, type=%r) — "
|
|
"falling back to edit",
|
|
self.chat_id, self.cfg.chat_type,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def _resolve_native_streaming(self) -> bool:
|
|
"""Whether to use native streaming (adapter.send_stream_frame for ALL frames).
|
|
|
|
Requires a BasePlatformAdapter subclass with class-level
|
|
SUPPORTS_NATIVE_STREAMING and a truthy supports_native_streaming probe.
|
|
"""
|
|
if not isinstance(self.adapter, _BasePlatformAdapter):
|
|
return False
|
|
if not getattr(type(self.adapter), "SUPPORTS_NATIVE_STREAMING", False):
|
|
return False
|
|
probe = getattr(self.adapter, "supports_native_streaming", None)
|
|
if probe is None:
|
|
return False
|
|
try:
|
|
supported = probe(
|
|
chat_type=self.cfg.chat_type or None,
|
|
metadata=self.metadata,
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"supports_native_streaming probe raised", exc_info=True,
|
|
)
|
|
return False
|
|
return bool(supported)
|
|
|
|
async def _send_draft_frame(self, text: str) -> bool:
|
|
"""Emit one draft frame; any failure permanently disables drafts for this run.
|
|
|
|
Drafts have no message_id and clear on the client when the final
|
|
sendMessage lands.
|
|
"""
|
|
if self._draft_id is None:
|
|
# Should never happen (set in tandem with _use_draft_streaming in run()).
|
|
self._use_draft_streaming = False
|
|
return False
|
|
# Every frame must carry the same reply_to_message_id the final send
|
|
# gets from _metadata_for_send: the relay adapter keys draft/seal state
|
|
# on it, else the final can't find the open stream (flat DMs have no
|
|
# thread metadata and would key on the bare chat).
|
|
_md = dict(self.metadata) if self.metadata else {}
|
|
if self._initial_reply_to_id:
|
|
_md.setdefault("reply_to_message_id", self._initial_reply_to_id)
|
|
try:
|
|
result = await self.adapter.send_draft(
|
|
chat_id=self.chat_id,
|
|
draft_id=self._draft_id,
|
|
content=text,
|
|
metadata=_md or None,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"send_draft raised, disabling draft transport for this run: %s", e,
|
|
)
|
|
self._draft_failures += 1
|
|
self._use_draft_streaming = False
|
|
return False
|
|
if not getattr(result, "success", False):
|
|
logger.debug(
|
|
"send_draft returned success=False, disabling draft transport: %s",
|
|
getattr(result, "error", "unknown"),
|
|
)
|
|
self._draft_failures += 1
|
|
self._use_draft_streaming = False
|
|
return False
|
|
self._last_sent_text = text # parity with the edit-based no-op skip
|
|
return True
|
|
|
|
async def _abandon_native_stream(self) -> None:
|
|
"""Seal an orphaned draft stream in place on turn death (stale exit / cancel).
|
|
|
|
Otherwise the message keeps its live indicator forever and the
|
|
adapter's armed interception state leaks into the next turn. Never
|
|
sets delivery flags — the gateway's normal paths own what happens next.
|
|
"""
|
|
if not self._use_draft_streaming:
|
|
return
|
|
abandon = getattr(type(self.adapter), "abandon_open_draft", None)
|
|
if abandon is None:
|
|
return
|
|
try:
|
|
_md = dict(self.metadata) if self.metadata else {}
|
|
if self._initial_reply_to_id:
|
|
_md.setdefault("reply_to_message_id", self._initial_reply_to_id)
|
|
await self.adapter.abandon_open_draft(
|
|
self.chat_id,
|
|
self._last_sent_text or self._clean_for_display(self._accumulated),
|
|
metadata=_md or None,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("abandon_open_draft failed (best-effort): %s", e)
|
|
|
|
async def _flush_segment_tail_on_edit_failure(self) -> None:
|
|
"""Send the unseen tail after the delivered prefix as a new message before a segment reset.
|
|
|
|
Also best-effort strips the stuck cursor from the partial message.
|
|
"""
|
|
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."""
|
|
if not self._message_id or self._message_id == "__no_edit__":
|
|
return
|
|
prefix = self._visible_prefix()
|
|
if not prefix or not prefix.strip():
|
|
return
|
|
try:
|
|
result = await self._edit_message(
|
|
message_id=self._message_id,
|
|
content=prefix,
|
|
)
|
|
if getattr(result, "success", False):
|
|
self._last_sent_text = prefix
|
|
except Exception:
|
|
pass # best-effort — don't let this block the fallback path
|
|
|
|
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.
|
|
self._delivered_commentary_texts.append(text)
|
|
return result.success
|
|
except Exception as e:
|
|
logger.error("Commentary send error: %s", e)
|
|
return False
|
|
|
|
def _should_send_fresh_final(self) -> bool:
|
|
"""True when fresh-final is enabled and a real preview has been visible ≥ threshold."""
|
|
threshold = getattr(self.cfg, "fresh_final_after_seconds", 0.0) or 0.0
|
|
if threshold <= 0:
|
|
return False
|
|
if not self._message_id or self._message_id == "__no_edit__":
|
|
return False
|
|
if self._message_created_ts is None:
|
|
return False
|
|
age = time.monotonic() - self._message_created_ts
|
|
return age >= threshold
|
|
|
|
def _raw_message_limit(self) -> int:
|
|
"""Per-chat length budget (adapter ``message_len_fn`` units) before overflow splits.
|
|
|
|
Rich-capable 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
|
|
|
|
def _track_preview_id(self, message_id: Optional[str]) -> None:
|
|
"""Record a real preview message id for finalization cleanup."""
|
|
if message_id and message_id != "__no_edit__":
|
|
message_id = str(message_id)
|
|
self._preview_message_ids.add(message_id)
|
|
self._segment_preview_message_ids.add(message_id)
|
|
|
|
def _track_preview_ids_from_result(self, result: Any) -> None:
|
|
"""Record the primary id plus any continuation ids from an oversized split."""
|
|
self._track_preview_id(getattr(result, "message_id", None))
|
|
for mid in (getattr(result, "continuation_message_ids", None) or ()):
|
|
self._track_preview_id(mid)
|
|
raw = getattr(result, "raw_response", None) or {}
|
|
if isinstance(raw, dict):
|
|
for mid in (raw.get("message_ids") or ()):
|
|
self._track_preview_id(mid)
|
|
|
|
def _adapter_prefers_fresh_final(self, text: str) -> bool:
|
|
"""Adapter's prefers_fresh_final_streaming hook (e.g. Telegram's richer send path).
|
|
|
|
False when there's no real preview, no hook, or on any error.
|
|
"""
|
|
if not self._message_id or self._message_id == "__no_edit__":
|
|
return False
|
|
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
|
|
if fn is None:
|
|
return False
|
|
try:
|
|
try:
|
|
# chat_id lets relay adapters decide via THIS chat's platform;
|
|
# otherwise a Slack-primary relay misroutes fronted chats
|
|
# through the fresh-send lane (duplicates: no delete op).
|
|
result = fn(text, metadata=self.metadata, chat_id=self.chat_id)
|
|
except TypeError:
|
|
try:
|
|
result = fn(text, metadata=self.metadata) # single-platform signature
|
|
except TypeError:
|
|
result = fn(text) # test doubles without the metadata kwarg
|
|
except Exception as e:
|
|
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
|
|
return False
|
|
# ``is True`` keeps MagicMock auto-children from enabling fresh-final.
|
|
return result is True
|
|
|
|
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
|
"""Send ``text`` as a fresh message and best-effort delete the preview(s).
|
|
|
|
Returns False on any failure so the caller falls back to edit.
|
|
``is_turn_final=False`` (interim segment at a tool boundary) leaves the
|
|
final-delivery flag unset so the gateway still delivers the real answer.
|
|
"""
|
|
# Replacing every tracked preview is only sound while ``text`` holds
|
|
# the whole answer; after a split, deleting sealed heads would erase
|
|
# delivered text — take the edit path instead.
|
|
if self._turn_split_delivery:
|
|
return False
|
|
stale_ids = set(self._preview_message_ids)
|
|
if self._message_id and self._message_id != "__no_edit__":
|
|
stale_ids.add(self._message_id)
|
|
try:
|
|
result = await self.adapter.send(
|
|
chat_id=self.chat_id,
|
|
content=text,
|
|
metadata=self._metadata_for_send(final=True),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
|
|
return False
|
|
if not getattr(result, "success", False):
|
|
return False
|
|
new_message_id = getattr(result, "message_id", None)
|
|
# Best-effort preview cleanup; never delete the message just sent.
|
|
delete_fn = getattr(self.adapter, "delete_message", None)
|
|
if delete_fn is not None:
|
|
for stale_id in stale_ids:
|
|
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
|
|
continue
|
|
try:
|
|
await delete_fn(self.chat_id, stale_id)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Fresh-final preview cleanup failed (%s): %s",
|
|
stale_id, e,
|
|
)
|
|
self._preview_message_ids = set()
|
|
if new_message_id:
|
|
self._message_id = new_message_id
|
|
self._message_created_ts = time.monotonic()
|
|
else:
|
|
# No id returned: sentinel so we never try to edit it.
|
|
self._message_id = "__no_edit__"
|
|
self._message_created_ts = None
|
|
self._already_sent = True
|
|
self._last_sent_text = text
|
|
if is_turn_final:
|
|
self._final_response_sent = True
|
|
self._record_turn_final_payload(text)
|
|
return True
|
|
|
|
async def _suppress_silence_marker(self) -> None:
|
|
"""Retract any streamed preview when the final reply is a bare silence marker.
|
|
|
|
Delivery flags and ``_already_sent`` are left False: nothing was
|
|
delivered, and the gateway's whole-response filter turns the marker
|
|
into "" so no fallback send happens either.
|
|
"""
|
|
# 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:
|
|
try:
|
|
await self.adapter.send_stream_frame(
|
|
"",
|
|
finalize=True,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Silence-marker native stream close failed: %s", e,
|
|
)
|
|
self._native_stream_opened = False
|
|
self._native_last_pushed_len = 0
|
|
self._reopen_seeded_eagerly = False
|
|
|
|
stale_ids = set(self._preview_message_ids)
|
|
if self._message_id and self._message_id != "__no_edit__":
|
|
stale_ids.add(self._message_id)
|
|
delete_fn = getattr(self.adapter, "delete_message", None)
|
|
if delete_fn is not None:
|
|
for stale_id in stale_ids:
|
|
if not stale_id or stale_id == "__no_edit__":
|
|
continue
|
|
try:
|
|
await delete_fn(self.chat_id, stale_id)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Silence-marker preview cleanup failed (%s): %s",
|
|
stale_id, e,
|
|
)
|
|
self._preview_message_ids = set()
|
|
self._message_id = None
|
|
self._accumulated = ""
|
|
self._stream_ledger = ""
|
|
self._last_sent_text = ""
|
|
self._already_sent = False
|
|
self._final_response_sent = False
|
|
self._final_content_delivered = False
|
|
self._delivered_final_text = None
|
|
self._delivery_ambiguous = False
|
|
self._turn_split_delivery = False
|
|
logger.info(
|
|
"Suppressed streamed intentional-silence marker (chat=%s)",
|
|
self.chat_id,
|
|
)
|
|
|
|
async def _send_or_edit(
|
|
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
|
|
) -> bool:
|
|
"""Send or edit the streaming message; True if delivered.
|
|
|
|
``finalize`` marks the last edit of a streaming sequence. Callers such
|
|
as the overflow split loop use the result to decide whether to advance.
|
|
"""
|
|
text = self._clean_for_display(text)
|
|
# Stream-is-the-message draft frames must stay prefix-stable: a closing
|
|
# ``` appended to a mid-code-block frame makes frame N not a prefix of
|
|
# N+1 and the connector re-appends the whole snapshot. The final
|
|
# message is still fence-closed below.
|
|
_pre_fence_text = text
|
|
text = ensure_closed_code_fences(text)
|
|
# A bare cursor renders as a stray tofu box on some clients.
|
|
visible_without_cursor = text
|
|
if self.cfg.cursor:
|
|
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
|
|
_visible_stripped = visible_without_cursor.strip()
|
|
if not _visible_stripped:
|
|
# Native streams MUST still get a finalize frame (placeholder) to
|
|
# close the thinking bubble, e.g. for a MEDIA-only response.
|
|
if finalize and self._use_native_streaming and self._native_stream_opened:
|
|
try:
|
|
ok = await self.adapter.send_stream_frame(
|
|
"✅",
|
|
finalize=True,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
if ok:
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
except Exception as e:
|
|
logger.debug("Finalize empty stream failed: %s", e)
|
|
return True # cursor-only / whitespace-only update
|
|
if not text.strip():
|
|
return True # nothing to send is "success"
|
|
# Don't open a new message for 1-2 tokens + cursor (rapid tool-calling):
|
|
# if the cursor-strip edit is then rate-limited, "X ▉" stays forever.
|
|
# Only first sends are gated.
|
|
_MIN_NEW_MSG_CHARS = 4
|
|
if (self._message_id is None
|
|
and self.cfg.cursor
|
|
and self.cfg.cursor in text
|
|
and len(_visible_stripped) < _MIN_NEW_MSG_CHARS):
|
|
return True # too short for a standalone message — accumulate more
|
|
|
|
# Native streaming: every frame goes through send_stream_frame(); the
|
|
# adapter's send/edit paths are not touched in this mode. Lazy re-seed
|
|
# here after a boundary closed the stream.
|
|
if self._use_native_streaming and not self._native_stream_opened and text:
|
|
try:
|
|
seed_ok = await self.adapter.send_stream_frame(
|
|
"",
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
if seed_ok:
|
|
self._native_stream_opened = True
|
|
self._awaiting_reopen_after_boundary = False
|
|
# Paired with the boundary-finalize INFO: typing-reappear latency.
|
|
logger.info(
|
|
"[latency] Re-opened native stream after boundary "
|
|
"(turn=%s, waited for first delta)",
|
|
self._turn_id,
|
|
)
|
|
else:
|
|
self._use_native_streaming = False
|
|
except Exception as e:
|
|
logger.debug("Re-seed failed, disabling native streaming: %s", e)
|
|
self._use_native_streaming = False
|
|
|
|
if self._use_native_streaming:
|
|
# WeCom renders each finalize as a separate bubble: only the
|
|
# turn-final and boundaries close the stream, not segment breaks.
|
|
if finalize and not is_turn_final:
|
|
finalize = False
|
|
|
|
if not finalize and text == self._last_sent_text:
|
|
return True # unchanged — skip
|
|
|
|
# Mark a finalize frame delivered OPTIMISTICALLY, before the ack
|
|
# wait: the bytes hit the wire (and WeCom renders them) before the
|
|
# ack, so a gateway join-cancel during the ack wait must not strand
|
|
# final_content_delivered=False and cause a duplicate normal send
|
|
# (docs/rca-wecom-stream-final-ack-timeout-duplicate.md). A
|
|
# definitive dispatch failure rolls the mark back below. Residual
|
|
# window (cancel between mark and wire write, sub-ms) is accepted.
|
|
_optimistic_finalize = bool(finalize)
|
|
if _optimistic_finalize:
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
# Recorded so a stale/partial frame can't suppress the corrective send.
|
|
self._record_turn_final_payload(text)
|
|
|
|
ok = False
|
|
try:
|
|
ok = await self.adapter.send_stream_frame(
|
|
text,
|
|
finalize=finalize,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"send_stream_frame raised, disabling native streaming: %s", e,
|
|
)
|
|
ok = False
|
|
|
|
if ok:
|
|
self._already_sent = True
|
|
self._last_sent_text = text
|
|
self._native_last_pushed_len = len(text)
|
|
if finalize:
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
return True
|
|
|
|
# Definitive failure: roll back the optimistic mark so the
|
|
# edit/send fallback delivers exactly once.
|
|
if _optimistic_finalize:
|
|
self._final_response_sent = False
|
|
self._final_content_delivered = False
|
|
self._delivered_final_text = None
|
|
|
|
# Subsequent frames take the edit/send fallback; the adapter marks
|
|
# the chat expired so it doesn't retry the dead stream.
|
|
self._use_native_streaming = False
|
|
|
|
# Best-effort close of an opened bubble (seed frame has zero length
|
|
# but still opens it — hence _native_stream_opened, not pushed_len).
|
|
if self._native_stream_opened:
|
|
try:
|
|
await self.adapter.send_stream_frame(
|
|
text,
|
|
finalize=True,
|
|
chat_id=self.chat_id,
|
|
reply_to=self._initial_reply_to_id,
|
|
turn_id=self._turn_id,
|
|
)
|
|
logger.debug("Native fallback: finalized stream (best-effort close)")
|
|
# DO NOT mark delivered: the frame closes the bubble but
|
|
# WeCom may not render the content (errcode 6000 race).
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Native fallback: failed to finalize stream: %s", e,
|
|
)
|
|
# Fall through so accumulated text still reaches the user via edit/send.
|
|
|
|
# Drafts have no message_id: the final answer goes through the regular
|
|
# send below (which clears the draft client-side). Skip drafts when
|
|
# finalizing or when an edit path is already established. Exception:
|
|
# stream-is-the-message adapters keep ONE stream per turn, so a
|
|
# segment-break finalize must NOT become a real send (seal interception
|
|
# would seal the stream at every tool boundary); only got_done seals.
|
|
_stream_is_msg = self._stream_is_message()
|
|
if (
|
|
self._use_draft_streaming
|
|
and self._message_id is None
|
|
and (not finalize or (_stream_is_msg and not is_turn_final))
|
|
):
|
|
_frame_text = _pre_fence_text if _stream_is_msg else text
|
|
# Strip the cursor: native streams render their own indicator, and
|
|
# "...text▉" is never a prefix of "...text more▉", which forces the
|
|
# connector's whole-text re-append on EVERY tick (stacked copies).
|
|
if self.cfg.cursor and _frame_text.endswith(self.cfg.cursor):
|
|
_frame_text = _frame_text[: -len(self.cfg.cursor)]
|
|
if _frame_text == self._last_sent_text:
|
|
return True
|
|
ok = await self._send_draft_frame(_frame_text)
|
|
if ok:
|
|
# Deliberately NOT _already_sent: the gateway's fallback final
|
|
# send must still fire so the user gets a real message.
|
|
return True
|
|
# Failure disabled drafts; fall through to edit/send.
|
|
self._last_edit_overflowed = False
|
|
try:
|
|
if self._message_id is not None:
|
|
if self._edit_supported:
|
|
# REQUIRES_EDIT_FINALIZE adapters need the finalize=True
|
|
# edit even when unchanged; everyone else short-circuits.
|
|
if text == self._last_sent_text and not (
|
|
finalize and self._adapter_requires_finalize
|
|
):
|
|
return True
|
|
# Fresh-final: replace a long-lived preview with a fresh
|
|
# message (timestamp reflects completion), or whenever the
|
|
# adapter prefers it (Telegram's send path renders richer
|
|
# markdown than its edit path). An explicit hook returning
|
|
# False must NOT be overridden by the time threshold — on
|
|
# Telegram both messages would stay on screen since the
|
|
# delete is best-effort. Check the CLASS (MagicMock
|
|
# auto-creates attrs) plus instance __dict__ (test doubles
|
|
# assign the hook explicitly).
|
|
_has_prefers_hook = (
|
|
hasattr(type(self.adapter),
|
|
"prefers_fresh_final_streaming")
|
|
or "prefers_fresh_final_streaming"
|
|
in getattr(self.adapter, "__dict__", {})
|
|
)
|
|
_prefers_fresh = self._adapter_prefers_fresh_final(text)
|
|
if (
|
|
finalize
|
|
and (
|
|
_prefers_fresh
|
|
or (
|
|
not _has_prefers_hook
|
|
and self._should_send_fresh_final()
|
|
)
|
|
)
|
|
and await self._try_fresh_final(
|
|
text, is_turn_final=is_turn_final,
|
|
)
|
|
):
|
|
return True
|
|
# Edit existing message
|
|
result = await self._edit_message(
|
|
message_id=self._message_id,
|
|
content=text,
|
|
finalize=finalize,
|
|
)
|
|
if result.success:
|
|
self._already_sent = True
|
|
self._track_preview_ids_from_result(result)
|
|
# Oversized edit split across continuations: message_id
|
|
# is now the LAST continuation, which holds only the
|
|
# final chunk — retarget edits and reset skip-if-same.
|
|
# getattr keeps SimpleNamespace test mocks working.
|
|
_continuation_ids = getattr(result, "continuation_message_ids", ()) or ()
|
|
if (
|
|
_continuation_ids
|
|
and result.message_id
|
|
and result.message_id != self._message_id
|
|
):
|
|
self._last_edit_overflowed = True
|
|
self._turn_split_delivery = True
|
|
self._message_id = str(result.message_id)
|
|
self._message_created_ts = time.monotonic()
|
|
self._last_sent_text = ""
|
|
self._notify_new_message()
|
|
else:
|
|
self._last_sent_text = text
|
|
self._flood_strikes = 0
|
|
return True
|
|
else:
|
|
immediate_final_fallback = False
|
|
if (
|
|
finalize
|
|
and is_turn_final
|
|
and self.cfg.cursor
|
|
and self._last_sent_text.endswith(self.cfg.cursor)
|
|
and self._visible_prefix() == text
|
|
):
|
|
# Cosmetic final edit was rate-limited but the full
|
|
# answer is already on screen (cursor stuck): mark
|
|
# delivered so the gateway doesn't send it twice,
|
|
# and record the on-screen payload.
|
|
self._final_content_delivered = True
|
|
self._record_turn_final_payload(text)
|
|
raw_response = getattr(result, "raw_response", None)
|
|
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
|
|
# Some overflow chunks landed but not the whole
|
|
# response: preserve the visible prefix so got_done
|
|
# sends the missing tail.
|
|
self._message_id = str(
|
|
raw_response.get("last_message_id")
|
|
or result.message_id
|
|
or self._message_id
|
|
)
|
|
delivered_prefix = raw_response.get("delivered_prefix")
|
|
if isinstance(delivered_prefix, str) and delivered_prefix:
|
|
self._last_sent_text = delivered_prefix
|
|
self._fallback_prefix = delivered_prefix
|
|
self._fallback_preserve_partial_messages = text.startswith(
|
|
delivered_prefix
|
|
)
|
|
else:
|
|
self._fallback_prefix = self._visible_prefix()
|
|
self._fallback_preserve_partial_messages = False
|
|
self._fallback_final_send = True
|
|
self._edit_supported = False
|
|
self._already_sent = True
|
|
if getattr(result, "continuation_message_ids", ()):
|
|
self._notify_new_message()
|
|
return False
|
|
|
|
# Flood control: adaptive backoff (double the interval);
|
|
# disable edits only after _MAX_FLOOD_STRIKES in a row.
|
|
if self._is_flood_error(result):
|
|
self._flood_strikes += 1
|
|
self._current_edit_interval = min(
|
|
self._current_edit_interval * 2, 10.0,
|
|
)
|
|
logger.debug(
|
|
"Flood control on edit (strike %d/%d), "
|
|
"backoff interval → %.1fs",
|
|
self._flood_strikes,
|
|
self._MAX_FLOOD_STRIKES,
|
|
self._current_edit_interval,
|
|
)
|
|
immediate_final_fallback = (
|
|
finalize
|
|
and is_turn_final
|
|
and getattr(
|
|
self.adapter,
|
|
"FALLBACK_ON_FINAL_EDIT_FLOOD",
|
|
False,
|
|
) is True
|
|
)
|
|
if (
|
|
self._flood_strikes < self._MAX_FLOOD_STRIKES
|
|
and not immediate_final_fallback
|
|
):
|
|
self._last_edit_time = time.monotonic() # honor the new interval
|
|
return False
|
|
|
|
if immediate_final_fallback:
|
|
logger.debug(
|
|
"Turn-final edit hit flood control; "
|
|
"entering fallback immediately"
|
|
)
|
|
|
|
# Fallback mode: send only the missing tail at got_done.
|
|
logger.debug(
|
|
"Edit failed (strikes=%d), entering fallback mode",
|
|
self._flood_strikes,
|
|
)
|
|
self._fallback_prefix = self._visible_prefix()
|
|
self._fallback_final_send = True
|
|
self._edit_supported = False
|
|
self._already_sent = True
|
|
# A turn-final flood skips the cosmetic cursor strip: it
|
|
# would burn the same flood budget and delay the answer.
|
|
if not immediate_final_fallback:
|
|
await self._try_strip_cursor()
|
|
return False
|
|
else:
|
|
return False # edits unsupported; fallback path sends the final
|
|
else:
|
|
# First send, threaded to the user's message (correct topic/thread).
|
|
result = await self.adapter.send(
|
|
chat_id=self.chat_id,
|
|
content=text,
|
|
reply_to=self._initial_reply_to_id,
|
|
metadata=self._metadata_for_send(
|
|
final=finalize,
|
|
expect_edits=not finalize,
|
|
),
|
|
)
|
|
if result.success:
|
|
if result.message_id:
|
|
self._message_id = result.message_id
|
|
self._message_created_ts = time.monotonic()
|
|
self._track_preview_ids_from_result(result)
|
|
else:
|
|
self._edit_supported = False
|
|
self._already_sent = True
|
|
self._last_sent_text = text
|
|
if not result.message_id:
|
|
self._fallback_prefix = self._visible_prefix()
|
|
self._fallback_final_send = True
|
|
# Sentinel: no editable id, don't re-enter first-send
|
|
# on every delta/tool boundary.
|
|
self._message_id = "__no_edit__"
|
|
self._notify_new_message()
|
|
return True
|
|
else:
|
|
self._edit_supported = False
|
|
return False
|
|
except Exception as e:
|
|
logger.error("Stream send/edit error: %s", e)
|
|
return False
|