1289 lines
59 KiB
Python
1289 lines
59 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.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,
|
|
)
|
|
from gateway.stream_consumer_fences import ( # noqa: F401 (re-exported)
|
|
ensure_closed_code_fences,
|
|
escape_code_fences_for_display,
|
|
)
|
|
from gateway.stream_consumer_transport import StreamTransportMixin
|
|
from gateway.stream_consumer_fallback import StreamFallbackMixin
|
|
from gateway.stream_consumer_think import StreamThinkFilterMixin
|
|
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 = "⏸ 等待审批中..."
|
|
|
|
@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 = ""
|
|
|
|
@dataclass
|
|
class _Tick:
|
|
"""Everything one drain of the queue decided (flag locals of the old run() loop)."""
|
|
got_done: bool = False
|
|
got_segment_break: bool = False
|
|
got_flush: bool = False
|
|
flush_event: Any = None
|
|
got_reopen_seed: bool = False
|
|
approval_boundary: Optional[tuple] = None # (future, cancelled_flag)
|
|
commentary_text: Optional[str] = None
|
|
# Set by _push_update for _finalize_turn / _end_segment.
|
|
update_visible: bool = False
|
|
draft_final_fresh_send: bool = False
|
|
|
|
@property
|
|
def is_interim(self) -> bool:
|
|
"""Mid-stream tick: not finalizing, not a segment break, no commentary."""
|
|
return not self.got_done and not self.got_segment_break and self.commentary_text is None
|
|
|
|
class GatewayStreamConsumer(
|
|
StreamTransportMixin,
|
|
StreamFallbackMixin,
|
|
StreamThinkFilterMixin,
|
|
):
|
|
"""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
|
|
|
|
# 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
|
|
# Fired whenever a fresh content bubble is created (first send,
|
|
# commentary, overflow chunk, fallback continuation) so the gateway
|
|
# opens the next tool-progress bubble BELOW it. Exceptions swallowed.
|
|
self._on_new_message = on_new_message
|
|
# Fired once on entering finalization 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 for adapter.send_stream_frame() so concurrent consumers
|
|
# (/background, parallel subagents) don't interfere.
|
|
import uuid
|
|
self._turn_id = str(uuid.uuid4())
|
|
# Returns False after /new or /stop; run() then abandons the stream.
|
|
self._run_still_current = run_still_current or (lambda: True)
|
|
# Only platforms needing an explicit finalize call (DingTalk AI Cards)
|
|
# force a redundant final edit; ``is True`` keeps MagicMock adapters out.
|
|
self._adapter_requires_finalize: bool = (
|
|
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
|
|
)
|
|
# 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._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
|
|
# monotonic() when ``_message_id`` was first assigned (fresh-final age).
|
|
self._message_created_ts: Optional[float] = None
|
|
# Every real preview id on screen this response (first send +
|
|
# continuations): fresh-final deletes them all so a reply split at the
|
|
# edit limit leaves no stale fragments above the final. The segment
|
|
# set holds only the active text segment: failure recovery must never
|
|
# delete an earlier finalized preamble/commentary message.
|
|
self._preview_message_ids: "set[str]" = set()
|
|
self._segment_preview_message_ids: "set[str]" = set()
|
|
self._already_sent = False
|
|
self._edit_supported = True # False once progressive edits stop working
|
|
self._last_edit_time = 0.0
|
|
self._last_sent_text = "" # skip redundant edits
|
|
# 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 = ""
|
|
# 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
|
|
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 to 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). 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 per segment, so has_delivered_text still
|
|
# matches after _reset_segment_state clears _last_sent_text.
|
|
self._delivered_segment_texts: list[str] = []
|
|
# Think-block filter state (mirrors CLI's _stream_delta tag suppression).
|
|
self._in_think_block = False
|
|
self._think_buffer = ""
|
|
self._before_finalize_notified = False
|
|
|
|
# Transports, resolved at the start of run(). Draft: animated frames
|
|
# via adapter.send_draft instead of edits; the final still uses the
|
|
# first-send path (drafts have no message_id); the first draft failure
|
|
# disables drafts for the response. Native (WeCom msgtype "stream"):
|
|
# the ONLY delivery channel — seed, cumulative updates and finish=true
|
|
# all go through send_stream_frame(); any failure falls back to edit/send.
|
|
self._use_draft_streaming = False
|
|
self._draft_id: Optional[int] = None
|
|
self._draft_failures = 0
|
|
self._use_native_streaming = False
|
|
# Seed frame sent (zero visible content but the bubble is open); the
|
|
# fallback decides from this whether the stream must be finalized first.
|
|
self._native_stream_opened = False
|
|
# Visible chars last pushed; throttles under WeCom's 30 frames/min.
|
|
self._native_last_pushed_len = 0
|
|
# Boundary state from close_for_approval_prompt(); race-free because
|
|
# boundaries are processed serially. ``_boundary_reopen`` keeps native
|
|
# 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
|
|
# Reopen requested but nothing re-seeded yet: got_done must not open a
|
|
# stream just to emit a lone "✅". An EAGER re-seed (_REOPEN_SEED)
|
|
# already opened a fresh bubble before any content: got_done must
|
|
# actively finalize it or a blank typing bubble hangs forever.
|
|
self._awaiting_reopen_after_boundary = False
|
|
self._reopen_seeded_eagerly = False
|
|
# Tool-progress overlay (native only): shown in the bubble until 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
|
|
|
|
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._mark_final_delivered()
|
|
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 _mark_final_delivered(self, record: Optional[str] = None) -> None:
|
|
"""Set both turn-final flags; ``record`` also records the delivered payload."""
|
|
self._final_response_sent = True
|
|
self._final_content_delivered = True
|
|
if record is not None:
|
|
self._record_turn_final_payload(record)
|
|
|
|
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"
|
|
try:
|
|
boundary_ok = True
|
|
if self._native_stream_opened:
|
|
boundary_ok = await self._finalize_boundary_stream(_reason)
|
|
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._close_native_state()
|
|
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 one send() at got_done.
|
|
self._degrade_native_to_buffered_send()
|
|
self._reset_segment_state()
|
|
except Exception as e:
|
|
logger.warning("%s boundary processing failed: %s", _reason, e)
|
|
boundary_ok = False
|
|
finally:
|
|
if isinstance(boundary_future, (asyncio.Future, concurrent.futures.Future)):
|
|
with contextlib.suppress(Exception):
|
|
if not boundary_future.done():
|
|
boundary_future.set_result(boundary_ok)
|
|
|
|
async def _finalize_boundary_stream(self, _reason: str) -> bool:
|
|
"""Close the open native stream at a boundary; send() the pre-prompt text if that fails.
|
|
|
|
Returns False only when both finalize and the fallback send failed
|
|
(pre-prompt text may not have been delivered).
|
|
"""
|
|
finalize_text = self._accumulated or self._boundary_placeholder
|
|
finalize_ok = False
|
|
try:
|
|
finalize_ok = bool(await self._send_frame(finalize_text, finalize=True))
|
|
except Exception as e:
|
|
logger.warning("%s boundary: finalize failed: %s", _reason, e)
|
|
if finalize_ok:
|
|
logger.debug(
|
|
"%s boundary: finalized stream (chat=%s, turn=%s)",
|
|
_reason, self.chat_id, self._turn_id,
|
|
)
|
|
return True
|
|
# 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,
|
|
)
|
|
return fallback_ok
|
|
|
|
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)
|
|
|
|
async def run(self) -> None:
|
|
"""Async task that drains the queue and edits the platform message."""
|
|
self._len_fn, self._safe_limit = self._resolve_length_budget()
|
|
await self._start_transports()
|
|
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
|
|
tick = self._drain_queue()
|
|
|
|
# Boundary produces its own finalize and resets state, so it
|
|
# must run before got_done/segment_break processing.
|
|
if tick.approval_boundary is not None:
|
|
await self._handle_approval_boundary(*tick.approval_boundary)
|
|
continue
|
|
if tick.got_reopen_seed:
|
|
await self._eager_reopen_seed()
|
|
continue
|
|
|
|
if tick.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
|
|
|
|
if self._should_edit(tick) 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 self._len_fn(self._accumulated) > self._safe_limit
|
|
and self._message_id is None
|
|
):
|
|
verdict = await self._split_first_send(tick)
|
|
if verdict == "return":
|
|
return
|
|
continue
|
|
await self._seal_overflow_heads()
|
|
await self._push_update(tick)
|
|
|
|
if tick.got_done:
|
|
await self._finalize_turn(tick)
|
|
return
|
|
|
|
if tick.commentary_text is not None:
|
|
await self._deliver_commentary(tick.commentary_text)
|
|
if tick.got_segment_break:
|
|
await self._end_segment(tick)
|
|
|
|
# Done last so the waiter unblocks only once everything queued
|
|
# before the barrier is on screen.
|
|
if tick.got_flush:
|
|
self._signal_flush(tick.flush_event)
|
|
|
|
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
|
|
|
except asyncio.CancelledError:
|
|
await self._on_cancelled()
|
|
except Exception as e:
|
|
logger.error("Stream consumer error: %s", e)
|
|
finally:
|
|
self._wake_flush_waiters()
|
|
|
|
# ── run() collaborators ─────────────────────────────────────────────
|
|
|
|
def _resolve_length_budget(self) -> "tuple[Callable[[str], int], int]":
|
|
"""Per-chat length function + overflow budget.
|
|
|
|
A relay adapter fronting N platforms has different caps per chat, in
|
|
the platform's unit (e.g. utf16 for Telegram). isinstance gate:
|
|
MagicMock auto-attributes aren't callables, so test doubles use 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()
|
|
return len_fn, max(500, raw_limit - len_fn(self.cfg.cursor) - 100)
|
|
|
|
async def _start_transports(self) -> None:
|
|
"""Resolve native/draft transport; native wins (adapters declaring it can't edit).
|
|
|
|
Sends an empty seed frame so the user sees "typing" before the first
|
|
token; on seed failure fall back to the edit path (the gateway's
|
|
fallback send then handles it). Drafts and native streaming target
|
|
the same first-frame slot.
|
|
"""
|
|
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._send_seed_frame()
|
|
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
|
|
|
|
if self._use_native_streaming:
|
|
self._use_draft_streaming = False
|
|
return
|
|
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,
|
|
)
|
|
|
|
def _drain_queue(self) -> "_Tick":
|
|
"""Drain everything queued so far into one tick.
|
|
|
|
Control sentinels stop the drain (they take effect this tick);
|
|
_FINAL_TEXT / _TOOL_PROGRESS / text deltas fold into state and keep
|
|
draining so simultaneous items batch.
|
|
"""
|
|
tick = _Tick()
|
|
while True:
|
|
try:
|
|
item = self._queue.get_nowait()
|
|
except queue.Empty:
|
|
return tick
|
|
if item is _DONE:
|
|
tick.got_done = True
|
|
return tick
|
|
if item is _NEW_SEGMENT:
|
|
tick.got_segment_break = True
|
|
return tick
|
|
if item is _REOPEN_SEED:
|
|
tick.got_reopen_seed = True
|
|
return tick
|
|
if not isinstance(item, tuple):
|
|
self._filter_and_accumulate(item)
|
|
continue
|
|
try:
|
|
handler = self._QUEUE_TUPLE_HANDLERS.get((item[0], len(item)))
|
|
except TypeError: # unhashable head: not one of ours
|
|
handler = None
|
|
if handler is None:
|
|
self._filter_and_accumulate(item)
|
|
continue
|
|
if handler(self, tick, item):
|
|
return tick
|
|
|
|
def _on_final_text(self, tick: "_Tick", item: tuple) -> bool:
|
|
self._adopt_final_text(item[1])
|
|
return False
|
|
|
|
def _on_approval_boundary(self, tick: "_Tick", item: tuple) -> bool:
|
|
tick.approval_boundary = (item[1], item[2])
|
|
return True
|
|
|
|
def _on_commentary(self, tick: "_Tick", item: tuple) -> bool:
|
|
tick.commentary_text = item[1]
|
|
return True
|
|
|
|
def _on_flush(self, tick: "_Tick", item: tuple) -> bool:
|
|
# Barrier: finalize like a tool boundary, signal at the end of the tick.
|
|
tick.got_flush = True
|
|
tick.got_segment_break = True
|
|
tick.flush_event = item[1]
|
|
return True
|
|
|
|
def _on_tool_progress(self, tick: "_Tick", item: tuple) -> bool:
|
|
if self._use_native_streaming:
|
|
self._tool_progress_lines.append(item[1])
|
|
self._tool_progress_active = True
|
|
return False # keep draining to batch simultaneous progress lines
|
|
|
|
def _adopt_final_text(self, final_raw: str) -> None:
|
|
"""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). 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.
|
|
"""
|
|
streamed_something = bool(
|
|
self._accumulated or self._message_id or self._last_sent_text
|
|
)
|
|
if not streamed_something:
|
|
return
|
|
if not self._turn_split_delivery:
|
|
final_payload = self._clean_for_display(final_raw)
|
|
visible = self._clean_for_display(self._accumulated)
|
|
if final_payload and final_payload != visible:
|
|
self._accumulated = final_raw
|
|
self._stream_ledger = final_raw
|
|
return
|
|
ledger = self._stream_ledger
|
|
if ledger and final_raw.startswith(ledger) and len(final_raw) > len(ledger):
|
|
self._accumulated += final_raw[len(ledger):]
|
|
self._stream_ledger = final_raw
|
|
|
|
async def _eager_reopen_seed(self) -> None:
|
|
"""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 not (
|
|
self._use_native_streaming
|
|
and self._awaiting_reopen_after_boundary
|
|
and not self._native_stream_opened
|
|
):
|
|
return
|
|
try:
|
|
seed_ok = await self._send_seed_frame()
|
|
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._degrade_native_to_buffered_send()
|
|
|
|
def _should_edit(self, tick: "_Tick") -> bool:
|
|
"""Decide whether this tick flushes an edit/frame."""
|
|
should_edit = tick.got_done or tick.got_segment_break or tick.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:
|
|
elapsed = time.monotonic() - self._last_edit_time
|
|
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
|
|
)
|
|
# 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 tick.is_interim
|
|
and _is_partial_silence_marker(self._clean_for_display(self._accumulated))
|
|
):
|
|
return False
|
|
return should_edit
|
|
|
|
async def _split_first_send(self, tick: "_Tick") -> str:
|
|
"""No message to edit yet and the buffer overflows: seal only the head chunks.
|
|
|
|
The tail stays in _accumulated so it becomes the active preview later
|
|
deltas edit in place. Returns "return" (turn finished) or "continue".
|
|
"""
|
|
chunks = self._truncate_for_stream(self._accumulated, self._safe_limit, self._len_fn)
|
|
if len(chunks) <= 1:
|
|
# Malformed/legacy adapter result must still be splittable.
|
|
chunks = self._split_text_chunks(self._accumulated, self._safe_limit, self._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=tick.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 tick.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 "return"
|
|
if tick.got_segment_break:
|
|
self._message_id = None
|
|
self._fallback_final_send = False
|
|
self._fallback_prefix = ""
|
|
if not self._accumulated:
|
|
return "continue"
|
|
# Early `continue` skips the bottom-of-loop flush signal.
|
|
if tick.got_flush:
|
|
self._signal_flush(tick.flush_event)
|
|
return "continue"
|
|
|
|
async def _seal_overflow_heads(self) -> None:
|
|
"""Existing message overflowing: seal it with the head, start a new message for the rest."""
|
|
while (
|
|
self._len_fn(self._accumulated) > self._safe_limit
|
|
and self._message_id is not None
|
|
and self._edit_supported
|
|
):
|
|
cp_budget = _custom_unit_to_cp(self._accumulated, self._safe_limit, self._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
|
|
|
|
async def _push_update(self, tick: "_Tick") -> None:
|
|
"""Send/edit this tick's visible text (cursor-suffixed unless finalizing)."""
|
|
display_text = self._accumulated
|
|
if tick.is_interim:
|
|
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
|
|
|
|
# got_done update going out as a FRESH send via the draft transport
|
|
# (drafts have no message id) already carries finalize=True — unlike an
|
|
# EDIT during draft streaming, which still needs the explicit finalize
|
|
# pass for REQUIRES_EDIT_FINALIZE adapters.
|
|
tick.draft_final_fresh_send = (
|
|
tick.got_done and self._use_draft_streaming and self._message_id is None
|
|
)
|
|
# 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 in _finalize_turn.
|
|
tick.update_visible = await self._send_or_edit(
|
|
display_text,
|
|
finalize=(tick.got_done or tick.got_segment_break),
|
|
# A segment-break finalize closes a preamble, not the answer.
|
|
is_turn_final=tick.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
|
|
|
|
async def _finalize_turn(self, tick: "_Tick") -> None:
|
|
"""got_done: final edit without cursor, or one continuation send if edits failed mid-stream."""
|
|
if self._accumulated or self._message_id is not None or self._already_sent:
|
|
await self._notify_before_finalize()
|
|
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 tick.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._send_frame("", finalize=True)
|
|
except Exception as e:
|
|
logger.debug("Eager-seed empty finalize failed: %s", e)
|
|
self._close_native_state()
|
|
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 tick.update_visible:
|
|
await self._finalize_edit(self._accumulated or "✅", record=False)
|
|
else:
|
|
self._mark_final_delivered()
|
|
elif self._accumulated:
|
|
await self._finalize_edit_path(tick)
|
|
|
|
async def _finalize_edit_path(self, tick: "_Tick") -> None:
|
|
"""Edit-transport finalize (the non-native got_done branches)."""
|
|
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 tick.update_visible and (
|
|
not self._adapter_requires_finalize
|
|
or self._last_edit_overflowed
|
|
or tick.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.
|
|
ok = await self._finalize_edit(self._accumulated)
|
|
if not ok and 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).
|
|
await self._finalize_edit(self._accumulated)
|
|
|
|
async def _finalize_edit(self, text: str, *, record: bool = True) -> bool:
|
|
"""finalize=True send_or_edit; on success mark the turn delivered (and record the payload)."""
|
|
self._final_response_sent = await self._send_or_edit(text, finalize=True)
|
|
if self._final_response_sent:
|
|
self._mark_final_delivered(record=text if record else None)
|
|
return self._final_response_sent
|
|
|
|
def _cumulative_transport(self) -> bool:
|
|
"""Stream-is-the-message drafts and WeCom native: one append-only stream per turn."""
|
|
return (
|
|
self._stream_is_message() and self._use_draft_streaming
|
|
) or self._use_native_streaming
|
|
|
|
async def _deliver_commentary(self, commentary_text: str) -> None:
|
|
"""Cumulative transports post commentary as its own message and the
|
|
stream continues — resetting _accumulated would break the append-only
|
|
invariant / lose pre-commentary text."""
|
|
if self._cumulative_transport():
|
|
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()
|
|
|
|
async def _end_segment(self, tick: "_Tick") -> None:
|
|
"""Tool boundary: edit-based transports reset so the next chunk is a fresh message below tool progress.
|
|
|
|
Cumulative transports 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 self._cumulative_transport():
|
|
return
|
|
# 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 tick.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)
|
|
|
|
async def _on_cancelled(self) -> None:
|
|
"""Best-effort final edit on task cancel.
|
|
|
|
finalize=True so REQUIRES_EDIT_FINALIZE platforms apply formatting
|
|
(the flags set here suppress the gateway's formatted re-send);
|
|
is_turn_final=False because this handler owns the flags, not
|
|
_try_fresh_final. Only a successful best-effort edit confirms
|
|
delivery — a partial send (already_sent) may be just "Let me
|
|
search…", not the answer.
|
|
"""
|
|
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()
|
|
if best_effort_ok and not self._final_response_sent:
|
|
self._mark_final_delivered(record=self._accumulated)
|
|
|
|
def _wake_flush_waiters(self) -> None:
|
|
"""Wake 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
|
|
|
|
# Tuple-shaped queue items keyed on (sentinel, arity); handler returns True
|
|
# to stop draining. Order-insensitive: each sentinel is a distinct object.
|
|
_QUEUE_TUPLE_HANDLERS = {
|
|
(_FINAL_TEXT, 2): _on_final_text,
|
|
(_APPROVAL_BOUNDARY, 3): _on_approval_boundary,
|
|
(_COMMENTARY, 2): _on_commentary,
|
|
(_FLUSH, 2): _on_flush,
|
|
(_TOOL_PROGRESS, 2): _on_tool_progress,
|
|
}
|
|
|
|
@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)
|
|
|