Ctrl K
ComunidadGuías…

butler/gateway/outbound_bridge.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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",
]