"""Thread-safe WeChat (and native gateway) outbound UX: typing, ack, milestones.""" from __future__ import annotations import asyncio import logging import os import threading import time from dataclasses import dataclass, field from typing import Any, Callable, Optional, Protocol, cast from butler.contracts.completion_ports import OutboundCompletionHooks from butler.contracts.completion_registry import get_completion_hooks from butler.core.best_effort import async_safe_best_effort, safe_best_effort from butler.defaults.env_defaults import ( GATEWAY_PROGRESS_MAX_ACK_MESSAGES, GATEWAY_STREAM_PREVIEW_DEFAULT, ) from butler.utilities.env_parse import env_truthy, float_env, int_env from butler.gateway.completion_policy import ( max_supplementary_per_turn, suppress_completion_after_main_enabled, ) from butler.gateway.item_event_sink import record_thread_item from butler.gateway.outbound_bridge_ops import ( run_milestone_timer_tick, run_typing_refresh_loop, schedule_coro_threadsafe, send_adapter_message, send_progress_ack, ) from butler.gateway.outbound_events import delegate_event, stream_event, workflow_event from butler.gateway.pii_scrub import scrub_outbound_text from butler.gateway.progressive_stream import maybe_schedule_progressive_reply from butler.gateway.task_milestone import task_milestone_enabled logger = logging.getLogger(__name__) _thread_bridge = threading.local() MILESTONE_TOOLS = frozenset( {"delegate_task", "run_workflow", "write_file", "patch", "terminal"} ) class _TypingAdapter(Protocol): async def send_typing(self, chat_id: str, metadata: Optional[dict[str, Any]] = None) -> None: ... async def stop_typing(self, chat_id: str) -> None: ... async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, ) -> Any: ... def _env_bool(name: str, default: bool) -> bool: return cast(bool, env_truthy(name, default=default)) def _env_float(name: str, default: float) -> float: return cast(float, float_env(name, default)) def set_current_bridge(bridge: GatewayOutboundBridge | None) -> None: _thread_bridge.bridge = bridge def get_current_bridge() -> GatewayOutboundBridge | None: return getattr(_thread_bridge, "bridge", None) def get_gateway_bridge_optional() -> GatewayOutboundBridge | None: return get_current_bridge() def _env_int(name: str, default: int, *, min_value: int = 0) -> int: return cast(int, int_env(name, default, min=min_value)) def _completion_hooks() -> OutboundCompletionHooks | None: return get_completion_hooks() @dataclass class GatewayOutboundBridge: """Coordinates typing refresh and at-most-one ack per inbound turn.""" adapter: _TypingAdapter chat_id: str loop: asyncio.AbstractEventLoop typing_enabled: bool = field(default_factory=lambda: _env_bool("BUTLER_GATEWAY_TYPING_ENABLED", True)) typing_refresh_seconds: float = field( default_factory=lambda: _env_float("BUTLER_GATEWAY_TYPING_REFRESH_SECONDS", 4.0) ) ack_enabled: bool = field(default_factory=lambda: _env_bool("BUTLER_GATEWAY_PROGRESS_ACK_ENABLED", True)) ack_seconds: float = field( default_factory=lambda: _env_float("BUTLER_GATEWAY_PROGRESS_ACK_SECONDS", 30.0) ) max_ack_messages: int = field(default_factory=lambda: int( os.getenv("BUTLER_GATEWAY_PROGRESS_MAX_ACK_MESSAGES", str(GATEWAY_PROGRESS_MAX_ACK_MESSAGES)) or str(GATEWAY_PROGRESS_MAX_ACK_MESSAGES) )) _started_at: float = field(default=0.0, init=False) _final_sent: bool = field(default=False, init=False) _ack_sent: bool = field(default=False, init=False) _completion_push_sent: bool = field(default=False, init=False) _delegate_push_count: int = field(default=0, init=False) _pending_delegate_report: Any = field(default=None, init=False) _timeout_notified: bool = field(default=False, init=False) _typing_task: asyncio.Task[None] | None = field(default=None, init=False) _ack_task: asyncio.Task[None] | None = field(default=None, init=False) _closed: bool = field(default=False, init=False) _task_milestone_sent: bool = field(default=False, init=False) _task_milestone_count: int = field(default=0, init=False) _milestone_task: asyncio.Task[None] | None = field(default=None, init=False) delegate_role: str = field(default="", init=False) workflow_name: str = field(default="", init=False) workflow_step: str = field(default="", init=False) workflow_step_index: int = field(default=0, init=False) workflow_step_total: int = field(default=0, init=False) last_tool_name: str = field(default="", init=False) _last_turn_elapsed: float = field(default=0.0, init=False) _main_reply_chars: int = field(default=0, init=False) _supplementary_scheduled: int = field(default=0, init=False) _delegate_push_inflight: int = field(default=0, init=False) _outbound_events: list[Any] = field(default_factory=list, init=False) _stream_preview: str = field(default="", init=False) _stream_chars: int = field(default=0, init=False) workflow_step_phase: str = field(default="", init=False) slash_single_bubble: bool = field(default=False, init=False) scrape_urls_seen: set[str] = field(default_factory=set, init=False) inbound_user_text: str = field(default="", init=False) network_search_web_used: bool = field(default=False, init=False) firecrawl_search_count: int = field(default=0, init=False) firecrawl_agent_count: int = field(default=0, init=False) firecrawl_feedback_count: int = field(default=0, init=False) web_search_empty_count: int = field(default=0, init=False) @property def turn_started_at(self) -> float: return self._started_at @property def ack_sent(self) -> bool: return self._ack_sent @property def completion_push_sent(self) -> bool: return self._completion_push_sent @property def delegate_push_count(self) -> int: return self._delegate_push_count @property def timeout_notified(self) -> bool: return self._timeout_notified def set_pending_delegate_report(self, report: Any) -> None: self._pending_delegate_report = report def take_pending_delegate_report(self) -> Any: report = self._pending_delegate_report self._pending_delegate_report = None return report @classmethod def for_event( cls, adapter: Any, *, chat_id: str, loop: asyncio.AbstractEventLoop, ensure_typing: Callable[[], Any] | None = None, ) -> GatewayOutboundBridge: bridge = cls(adapter=adapter, chat_id=chat_id, loop=loop) bridge._ensure_typing = ensure_typing # type: ignore[attr-defined] return bridge async def start_turn(self) -> None: self._started_at = time.monotonic() self._final_sent = False self._ack_sent = False self._completion_push_sent = False self._delegate_push_count = 0 self._pending_delegate_report = None self._timeout_notified = False self._closed = False self._task_milestone_sent = False self._task_milestone_count = 0 self.delegate_role = "" self.workflow_name = "" self._main_reply_chars = 0 self._supplementary_scheduled = 0 self._delegate_push_inflight = 0 self.scrape_urls_seen = set() self.inbound_user_text = "" self.network_search_web_used = False self.firecrawl_search_count = 0 self.firecrawl_agent_count = 0 self.firecrawl_feedback_count = 0 self.web_search_empty_count = 0 ensure = getattr(self, "_ensure_typing", None) if callable(ensure): await async_safe_best_effort( ensure, label="outbound_bridge.ensure_typing", default=None, ) if self.typing_enabled: await self._safe_send_typing() self._typing_task = asyncio.create_task( self._typing_refresh_loop(), name=f"butler-typing-{self.chat_id[:8]}", ) if self.ack_enabled and self.ack_seconds > 0 and self.max_ack_messages > 0: self._ack_task = asyncio.create_task( self._ack_timer(), name=f"butler-ack-{self.chat_id[:8]}", ) def _start_milestone_timer() -> None: if task_milestone_enabled(): self._milestone_task = asyncio.create_task( self._milestone_timer(), name=f"butler-milestone-{self.chat_id[:8]}", ) safe_best_effort( _start_milestone_timer, label="outbound_bridge.milestone_timer", default=None, ) async def end_turn(self) -> None: if self._closed: return self._closed = True for task in (self._typing_task, self._ack_task, self._milestone_task): if task and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass self._typing_task = None self._ack_task = None self._milestone_task = None if self.typing_enabled: await self._safe_stop_typing() def mark_final_sent(self, *, main_reply_chars: int = 0) -> None: self._final_sent = True self._main_reply_chars = max(0, int(main_reply_chars or 0)) if self._ack_task and not self._ack_task.done(): self._ack_task.cancel() self._ack_task = None def _should_suppress_redundant_completion(self, kind: str) -> bool: if not suppress_completion_after_main_enabled(): return False if not self._final_sent or self._main_reply_chars <= 0: return False return kind in ("turn", "delegate", "workflow") def _reserve_supplementary_slot(self) -> bool: cap = max_supplementary_per_turn() if cap <= 0: return False if self._supplementary_scheduled >= cap: return False self._supplementary_scheduled += 1 return True def _release_supplementary_slot(self) -> None: if self._supplementary_scheduled > 0: self._supplementary_scheduled -= 1 def emit_threadsafe(self, fn: Callable[[], None]) -> None: if self._closed: return try: self.loop.call_soon_threadsafe(fn) except RuntimeError: logger.debug("outbound bridge loop closed; drop event") def record_outbound_event(self, event: Any) -> None: payload = safe_best_effort( lambda: event.to_dict() if hasattr(event, "to_dict") else dict(event), label="outbound_bridge.event_payload", default=None, ) if payload is None: return self._outbound_events.append(payload) if len(self._outbound_events) > 24: self._outbound_events.pop(0) if payload.get("kind") == "thread_item": def _record_thread_item() -> None: record_thread_item(payload) safe_best_effort( _record_thread_item, label="outbound_bridge.record_thread_item", default=None, ) def recent_outbound_events(self) -> list[dict[str, Any]]: return list(self._outbound_events) def append_stream_preview(self, delta: str) -> None: if self._closed or not delta: return if not env_truthy("BUTLER_GATEWAY_STREAM_PREVIEW", default=GATEWAY_STREAM_PREVIEW_DEFAULT): return def _apply() -> None: self._stream_chars += len(delta) combined = (self._stream_preview + delta)[-400:] self._stream_preview = combined self.record_outbound_event(stream_event(phase="delta", chars=len(delta))) maybe_schedule_progressive_reply(self, combined) self.emit_threadsafe(_apply) def notify_delegate_start(self, role: str, *, preview: str = "") -> None: role = str(role or "").strip() if not role: return self.flush_pending_delegate_completion() self.delegate_role = role self.record_outbound_event(delegate_event(role, phase="start", preview=preview[:80])) logger.info("Gateway milestone delegate_start role=%s preview=%r", role, preview[:40]) def notify_workflow_step( self, workflow_name: str, step_id: str, *, step_index: int = 0, step_total: int = 0, phase: str = "start", error: str = "", ) -> None: def _apply() -> None: self.workflow_name = str(workflow_name or "").strip() self.workflow_step = str(step_id or "").strip() self.workflow_step_index = int(step_index) self.workflow_step_total = int(step_total) self.workflow_step_phase = str(phase or "start").strip() self.record_outbound_event( workflow_event( self.workflow_name, self.workflow_step, phase=self.workflow_step_phase, step_index=self.workflow_step_index, step_total=self.workflow_step_total, error=error, ) ) logger.info( "Gateway milestone workflow_step wf=%s step=%s phase=%s (%s/%s)", self.workflow_name, self.workflow_step, self.workflow_step_phase, self.workflow_step_index, self.workflow_step_total, ) self.emit_threadsafe(_apply) def on_tool_start(self, name: str, args: dict[str, Any]) -> None: name = str(name or "").strip() if name not in MILESTONE_TOOLS: return def _apply() -> None: self.last_tool_name = name if name == "delegate_task": role = str((args or {}).get("role") or "").strip() if role: self.delegate_role = role elif name == "run_workflow": wf = str((args or {}).get("workflow_name") or (args or {}).get("name") or "").strip() if wf: self.workflow_name = wf self.emit_threadsafe(_apply) def on_tool_complete(self, name: str, result: str) -> None: del name, result def flush_pending_delegate_completion(self) -> None: hooks = _completion_hooks() if hooks is not None: hooks.flush_pending_delegate_completion(self) def notify_delegate_finished(self, report: Any) -> None: """Push or defer delegate AgentReport (see delegate_completion_mode).""" hooks = _completion_hooks() if hooks is None: return elapsed = time.monotonic() - self._started_at if self._started_at else 0.0 hooks.try_push_agent_report( report, kind="delegate", bridge=self, elapsed_turn_seconds=elapsed, ) def notify_turn_timeout(self, *, timeout_seconds: float) -> None: hooks = _completion_hooks() if hooks is None: return elapsed = time.monotonic() - self._started_at if self._started_at else 0.0 hooks.try_push_turn_timeout( self, timeout_seconds=timeout_seconds, elapsed_seconds=elapsed, ) def notify_workflow_finished(self, report: Any) -> None: hooks = _completion_hooks() if hooks is None: return elapsed = time.monotonic() - self._started_at if self._started_at else 0.0 hooks.try_push_agent_report( report, kind="workflow", bridge=self, elapsed_turn_seconds=elapsed, ) def record_turn_elapsed(self, elapsed_seconds: float) -> None: self._last_turn_elapsed = max(0.0, float(elapsed_seconds)) def maybe_notify_turn_complete_after_reply(self) -> None: """Call after the main WeChat reply was sent (see platforms/base.py).""" self.flush_pending_delegate_completion() hooks = _completion_hooks() if hooks is None: return elapsed = self._last_turn_elapsed if elapsed <= 0 and self._started_at: elapsed = time.monotonic() - self._started_at hooks.try_push_turn_complete(self, elapsed_seconds=elapsed) def schedule_supplementary_reply(self, text: str, *, kind: str = "queued") -> bool: """Send an extra user-visible message (e.g. drained inbound queue).""" if self._closed or not (text or "").strip(): return False if self._final_sent and kind in ("queued", "progressive_stream"): return False if not self._reserve_supplementary_slot(): logger.info( "Gateway supplementary reply capped kind=%s chat_id=%s", kind, self.chat_id[:12], ) return False body = (text or "").strip() body = scrub_outbound_text(body) if len(body) > 4000: body = body[:3997] + "..." async def _send() -> None: ok = await send_adapter_message( self.adapter, self.chat_id, body, label="outbound_bridge.supplementary_send", ) if ok: logger.info( "Gateway supplementary reply sent kind=%s chat_id=%s len=%d", kind, self.chat_id[:12], len(body), ) else: self._release_supplementary_slot() if schedule_coro_threadsafe( self.loop, _send, label="outbound_bridge.supplementary_schedule", ): return True self._release_supplementary_slot() return False def schedule_completion_push(self, text: str, *, kind: str) -> bool: """Thread-safe: send an extra completion message.""" if self._closed or not (text or "").strip(): return False if self._should_suppress_redundant_completion(kind): if kind == "delegate": self.take_pending_delegate_report() logger.info( "Gateway completion push suppressed after main reply kind=%s", kind, ) return False if kind in ("turn", "workflow") and self._completion_push_sent: return False if kind == "timeout" and (self._timeout_notified or self._completion_push_sent): return False if kind == "delegate": hooks = _completion_hooks() mode = hooks.delegate_completion_mode() if hooks is not None else "last" if mode == "each": max_each = hooks.delegate_completion_max_each() if hooks is not None else 1 inflight = self._delegate_push_count + self._delegate_push_inflight if inflight >= max_each: return False self._delegate_push_inflight += 1 if not self._reserve_supplementary_slot(): if kind == "delegate" and self._delegate_push_inflight > 0: self._delegate_push_inflight -= 1 logger.info( "Gateway completion push capped kind=%s chat_id=%s", kind, self.chat_id[:12], ) return False body = (text or "").strip() body = scrub_outbound_text(body) if len(body) > 4000: body = body[:3997] + "..." async def _send() -> None: hooks = _completion_hooks() ok = False if hooks is not None: ok = bool( await hooks.deliver_completion_push( self.adapter, self.chat_id, body, kind=kind, ) ) if ok: if kind == "delegate": self._delegate_push_count += 1 else: self._completion_push_sent = True if kind == "timeout": self._timeout_notified = True logger.info( "Gateway completion push sent kind=%s chat_id=%s len=%d", kind, self.chat_id[:12], len(body), ) else: self._release_supplementary_slot() if kind == "delegate" and self._delegate_push_inflight > 0: self._delegate_push_inflight -= 1 if schedule_coro_threadsafe( self.loop, _send, label="outbound_bridge.completion_schedule", ): return True self._release_supplementary_slot() if kind == "delegate" and self._delegate_push_inflight > 0: self._delegate_push_inflight -= 1 return False async def maybe_send_ack(self) -> None: if self._closed or self._final_sent or self._ack_sent: return if self.max_ack_messages <= 0: return elapsed = int(time.monotonic() - self._started_at) text = self._build_ack_text(elapsed) if not text: return text = scrub_outbound_text(text) await send_progress_ack(self, text=text, elapsed=elapsed) async def _milestone_timer(self) -> None: try: await run_milestone_timer_tick(self) except asyncio.CancelledError: raise def _build_ack_text(self, elapsed: int) -> str: eta = self._estimate_eta(elapsed) if self.delegate_role: return ( f"正在处理:已委派 {self.delegate_role},完成后回复摘要。" f"(已用 {elapsed}s{eta})可发 /health 查看状态" ) if self.workflow_name: step_part = "" if self.workflow_step: if self.workflow_step_total > 0: step_part = f"({self.workflow_step},{self.workflow_step_index}/{self.workflow_step_total})" else: step_part = f"({self.workflow_step})" return ( f"正在处理:工作流 {self.workflow_name} 执行中{step_part}。" f"(已用 {elapsed}s{eta})可发 /health 查看状态" ) base = ( f"正在处理,请稍候(已用 {elapsed}s{eta})。" "完成后回复摘要;可发 /health 查看状态。" ) preview = (self._stream_preview or "").strip() if preview: snippet = preview[-120:].replace("\n", " ") return f"{base}\n…{snippet}" return base @staticmethod def _estimate_eta(elapsed: int) -> str: if elapsed < 10: return ",预计 15-30s" if elapsed < 30: return ",预计还需 10-30s" if elapsed < 60: return ",预计还需 30-60s" return "" async def _typing_refresh_loop(self) -> None: try: await run_typing_refresh_loop(self) except asyncio.CancelledError: raise async def _ack_timer(self) -> None: try: await asyncio.sleep(self.ack_seconds) if not self._closed and not self._final_sent: await self.maybe_send_ack() except asyncio.CancelledError: raise async def _safe_send_typing(self) -> None: async def _run() -> None: await self.adapter.send_typing(self.chat_id) await async_safe_best_effort( _run, label="outbound_bridge.send_typing", default=None, ) async def _safe_stop_typing(self) -> None: async def _run() -> None: await self.adapter.stop_typing(self.chat_id) await async_safe_best_effort( _run, label="outbound_bridge.stop_typing", default=None, ) # Backward-compatible re-export of ``merge_loop_callbacks``. # # The implementation moved to :mod:`butler.core.loop_callbacks_merge` to fix # audit R1-2 (core → gateway layering violation). This re-export preserves the # historical import path ``from butler.gateway.outbound_bridge import # merge_loop_callbacks`` for existing callers. from butler.core.loop_callbacks_merge import merge_loop_callbacks # noqa: E402,F401 __all__ = [ "GatewayOutboundBridge", "get_current_bridge", "get_gateway_bridge_optional", "max_supplementary_per_turn", "merge_loop_callbacks", "set_current_bridge", "suppress_completion_after_main_enabled", ]