Ctrl K
ComunidadGuías…

butler/gateway/completion_notify.py

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

Archivo original ↗Preguntar a KiBot
"""WeChat completion pushes for long gateway turns (outbound bridge, not shell hooks)."""

from __future__ import annotations

import logging
import os
import time
from typing import TYPE_CHECKING, Any, cast

if TYPE_CHECKING:
    from butler.gateway.outbound_bridge import GatewayOutboundBridge

from butler.contracts.completion_registry import set_completion_hooks
from butler.contracts.gateway_registry import get_bridge_access
from butler.defaults.env_defaults import (
    GATEWAY_COMPLETION_NOTIFY_MIN_SECONDS,
    GATEWAY_DELEGATE_COMPLETION_MAX_EACH,
    GATEWAY_DELEGATE_COMPLETION_MODE_DEFAULT,
)
from butler.utilities.env_parse import env_truthy, float_env, int_env
from butler.gateway import delegate_push_dedup as _delegate_push_dedup
from butler.gateway.completion_notify_ops import (
    deliver_completion_push_safe,
    outbox_diagnostic_lines_safe,
)
from butler.gateway.completion_telemetry import (
    completion_push_stats,
    push_queue_pending_count,
    record_completion_push_enqueued,
    record_completion_push_failed,
    record_completion_push_sent,
)
from butler.resilience.durable_outbox import (
    enqueue_outbox_message,
    mark_outbox_failed,
    mark_outbox_sent,
)
from butler.gateway.completion_policy import suppress_completion_after_main_enabled
from butler.gateway.pii_scrub import scrub_outbound_text
from butler.gateway.wechat_text_export import build_delegate_completion_message
from butler.report import AgentReport, cache_report, format_for_wechat, get_last_report
from butler.runtime.notify import (
    mark_wechat_push_sent,
    should_enqueue_wechat_push_failure,
    wait_wechat_push_cooldown,
    wechat_push_cooldown_seconds,
)
from butler.runtime.push_queue import enqueue_failed_push

logger = logging.getLogger(__name__)


def _optional_bridge() -> GatewayOutboundBridge | None:
    access = get_bridge_access()
    if access is not None:
        bridge = access.get_optional_bridge()
        if bridge is not None:
            return cast("GatewayOutboundBridge", bridge)
    from butler.gateway.outbound_bridge import get_gateway_bridge_optional

    return get_gateway_bridge_optional()


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 completion_notify_enabled() -> bool:
    return _env_bool("BUTLER_GATEWAY_COMPLETION_NOTIFY", True)


def min_elapsed_for_push() -> float:
    return max(
        0.0,
        _env_float(
            "BUTLER_GATEWAY_COMPLETION_NOTIFY_MIN_SECONDS",
            GATEWAY_COMPLETION_NOTIFY_MIN_SECONDS,
        ),
    )


def delegate_completion_enabled() -> bool:
    return _env_bool("BUTLER_GATEWAY_DELEGATE_COMPLETION_NOTIFY", True)


def delegate_progress_notify_enabled() -> bool:
    """Mid-flight WeChat progress for long delegates (also enables task milestones)."""
    return _env_bool("BUTLER_GATEWAY_DELEGATE_PROGRESS_NOTIFY", False)


def turn_completion_enabled() -> bool:
    return _env_bool("BUTLER_GATEWAY_TURN_COMPLETION_NOTIFY", True)


def workflow_completion_enabled() -> bool:
    return _env_bool("BUTLER_GATEWAY_WORKFLOW_COMPLETION_NOTIFY", True)


def timeout_completion_enabled() -> bool:
    return _env_bool("BUTLER_GATEWAY_TIMEOUT_COMPLETION_NOTIFY", True)


def delegate_completion_mode() -> str:
    """last: only the final delegate in a turn pushes; each: up to N; once: first only."""
    return (os.getenv("BUTLER_GATEWAY_DELEGATE_COMPLETION_MODE", GATEWAY_DELEGATE_COMPLETION_MODE_DEFAULT) or GATEWAY_DELEGATE_COMPLETION_MODE_DEFAULT).strip().lower()


def delegate_completion_max_each() -> int:

    return cast(
        int,
        int_env(
            "BUTLER_GATEWAY_DELEGATE_COMPLETION_MAX_EACH",
            GATEWAY_DELEGATE_COMPLETION_MAX_EACH,
            min=1,
        ),
    )


def format_outbound_diagnostic_lines(
    session_key: str = "",
    *,
    chat_id: str = "",
) -> list[str]:
    """Lines for ``/诊断`` — completion notify policy + session push stats."""

    mode = delegate_completion_mode()
    flags: list[str] = []
    if completion_notify_enabled():
        flags.append("完成提醒:开")
        flags.append(f"委派={mode}")
        if mode == "each":
            flags.append(f"each≤{delegate_completion_max_each()}")
        flags.append(f"最短{int(min_elapsed_for_push())}s")
        flags.append(f"冷却{int(wechat_push_cooldown_seconds())}s")
        if not delegate_completion_enabled():
            flags.append("委派推:关")
        if not turn_completion_enabled():
            flags.append("整轮推:关")
        if not timeout_completion_enabled():
            flags.append("超时推:关")
    else:
        flags.append("完成提醒:关")
    lines = [f"出站策略: {' · '.join(flags)}"]
    stats = completion_push_stats(session_key or chat_id)
    if any(stats.values()):
        lines.append(
            "出站推送本轮: "
            f"成功{stats['sent']} 失败{stats['failed']} 入队{stats['enqueued']}"
        )
    else:
        lines.append("出站推送本轮: 无记录")
    pending = push_queue_pending_count(chat_id=chat_id)
    if pending:
        lines.append(f"推送队列待发: {pending} 条")

    lines.extend(outbox_diagnostic_lines_safe(chat_id))
    return lines


def format_elapsed(seconds: float) -> str:
    total = max(0, int(seconds))
    if total < 60:
        return f"{total} 秒"
    minutes, secs = divmod(total, 60)
    if minutes < 60:
        return f"{minutes} 分 {secs} 秒" if secs else f"{minutes} 分钟"
    hours, rem = divmod(minutes, 60)
    return f"{hours} 小时 {rem} 分钟"


def build_report_push_text(report: AgentReport, *, prefix: str = "") -> str:
    body = cast(str, format_for_wechat(report))
    if prefix:
        return f"{prefix}\n\n{body}".strip()
    return body


def build_turn_complete_text(*, elapsed_seconds: float) -> str:
    return (
        f"✅ 本轮处理已完成(用时约 {format_elapsed(elapsed_seconds)})\n"
        "完整回复见上一条;发 /详细 可看报告。"
    )


def should_push_delegate_completion(
    bridge: GatewayOutboundBridge,
    elapsed_turn_seconds: float,
) -> bool:
    if not completion_notify_enabled() or not delegate_completion_enabled():
        return False
    mode = delegate_completion_mode()
    if mode == "once" and bridge.delegate_push_count >= 1:
        return False
    if mode == "each" and bridge.delegate_push_count >= delegate_completion_max_each():
        return False
    if bridge.ack_sent:
        return True
    if elapsed_turn_seconds >= min_elapsed_for_push():
        return True
    return _env_bool("BUTLER_GATEWAY_DELEGATE_PUSH_ALWAYS", False)


def should_push_workflow_completion(
    bridge: GatewayOutboundBridge,
    elapsed_turn_seconds: float,
) -> bool:
    if not completion_notify_enabled() or not workflow_completion_enabled():
        return False
    if bridge.completion_push_sent:
        return False
    if bridge.ack_sent:
        return True
    return elapsed_turn_seconds >= min_elapsed_for_push()


def should_push_turn_completion(
    bridge: GatewayOutboundBridge,
    elapsed_turn_seconds: float,
) -> bool:
    if not completion_notify_enabled() or not turn_completion_enabled():
        return False
    if bridge.completion_push_sent or bridge.timeout_notified:
        return False
    if not bridge.ack_sent:
        return False
    return elapsed_turn_seconds >= min_elapsed_for_push()


def should_push_timeout_completion(
    bridge: GatewayOutboundBridge,
    elapsed_turn_seconds: float,
) -> bool:
    if not completion_notify_enabled() or not timeout_completion_enabled():
        return False
    if bridge.timeout_notified or bridge.completion_push_sent:
        return False
    return bridge.ack_sent or elapsed_turn_seconds >= min_elapsed_for_push()


def build_timeout_text(*, timeout_seconds: float, elapsed_seconds: float) -> str:
    limit = int(timeout_seconds)
    return (
        f"⏱ 处理超时(已超过 {limit} 秒,本轮约 {format_elapsed(elapsed_seconds)})\n"
        "请稍后重试,或发 /诊断 查看状态。"
    )


def try_push_turn_timeout(
    bridge: GatewayOutboundBridge | None,
    *,
    timeout_seconds: float,
    elapsed_seconds: float,
) -> bool:
    br = bridge
    if br is None:
        return False
    if not should_push_timeout_completion(br, elapsed_seconds):
        return False
    return cast(
        bool,
        br.schedule_completion_push(
            build_timeout_text(
                timeout_seconds=timeout_seconds,
                elapsed_seconds=elapsed_seconds,
            ),
            kind="timeout",
        ),
    )


def flush_pending_delegate_completion(bridge: GatewayOutboundBridge) -> bool:
    """Push deferred delegate report (``delegate_completion_mode=last``)."""
    report = bridge.take_pending_delegate_report()
    if report is None:
        return False
    tid = str(getattr(report, "task_id", "") or "").strip()
    ok, reason = _delegate_push_dedup.should_deliver_delegate_push(bridge.chat_id, tid)
    if not ok:
        logger.info(
            "pending delegate flush suppressed chat=%s task=%s reason=%s",
            str(bridge.chat_id or "")[:12],
            tid,
            reason,
        )
        return False
    if (
        getattr(bridge, "_final_sent", False)
        and getattr(bridge, "_main_reply_chars", 0) > 0
    ):
        if suppress_completion_after_main_enabled():
            return False
    elapsed = time.monotonic() - bridge.turn_started_at if bridge.turn_started_at else 0.0
    if not should_push_delegate_completion(bridge, elapsed):
        return False

    text = build_delegate_completion_message(
        report,
        prefix="📋 委派阶段完成",
        platform="wechat",
    )
    return cast(bool, bridge.schedule_completion_push(text, kind="delegate"))


def _workflow_push_prefix(report: AgentReport) -> str:
    if report.success:
        return "📋 工作流阶段完成"
    return "⚠️ 工作流未完成"


async def deliver_completion_push(
    adapter: Any,
    chat_id: str,
    body: str,
    *,
    kind: str,
) -> bool:
    """Send completion text with shared WeChat cooldown; enqueue on retryable failure."""
    import asyncio



    telemetry_key = chat_id or ""

    body = scrub_outbound_text(body)
    if kind == "delegate":
        if _delegate_push_dedup.maybe_defer_delegate_push(chat_id, body, kind=kind):
            return True
        ok, reason = _delegate_push_dedup.should_deliver_delegate_push(chat_id, "", body=body)
        if not ok:
            logger.info(
                "Gateway delegate push suppressed chat=%s reason=%s",
                str(chat_id or "")[:12],
                reason,
            )
            return False
    outbox_id = enqueue_outbox_message(chat_id, body, kind=kind)
    await asyncio.to_thread(wait_wechat_push_cooldown)
    title = f"[Butler] {kind}完成提醒"

    async def _on_success() -> None:
        await asyncio.to_thread(mark_wechat_push_sent)
        mark_outbox_sent(outbox_id)
        record_completion_push_sent(session_key=telemetry_key)
        if kind == "delegate":
            _delegate_push_dedup.mark_delegate_push_delivered(chat_id, "", body=body)

    def _on_failure(exc: Exception) -> None:
        mark_outbox_failed(outbox_id, error=str(exc))
        if should_enqueue_wechat_push_failure(str(exc)):
            enqueue_failed_push(title, body, chat_id=chat_id)
            record_completion_push_enqueued(session_key=telemetry_key)
        else:
            record_completion_push_failed(session_key=telemetry_key)

    return cast(
        bool,
        await deliver_completion_push_safe(
            lambda: adapter.send(chat_id, body),
            on_success=_on_success,
            on_failure=_on_failure,
            kind=kind,
        ),
    )


def try_push_workflow_failure(
    bridge: GatewayOutboundBridge | None,
    workflow_name: str,
    error: Exception | str,
    *,
    session_key: str = "",
) -> bool:
    br = bridge
    if br is None:
        return False
    report = get_last_report(session_key)
    if report is None:
        msg = str(error)[:2000]
        report = AgentReport(
            headline=f"工作流 {workflow_name} 失败",
            summary=msg,
            success=False,
            task_preview=f"workflow:{workflow_name}"[:200],
            issues=[msg[:500]],
        )
        cache_report(report, session_key=session_key or "default")
    elapsed = time.monotonic() - br.turn_started_at if br.turn_started_at else 0.0
    if not should_push_workflow_completion(br, elapsed):
        return False
    text = build_report_push_text(report, prefix=_workflow_push_prefix(report))
    return cast(bool, br.schedule_completion_push(text, kind="workflow"))


def try_push_agent_report(
    report: AgentReport,
    *,
    kind: str,
    bridge: GatewayOutboundBridge | None = None,
    elapsed_turn_seconds: float | None = None,
) -> bool:
    """Schedule a WeChat completion message via the outbound bridge."""
    br = bridge or _optional_bridge()
    if br is None:
        return False
    elapsed = (
        float(elapsed_turn_seconds)
        if elapsed_turn_seconds is not None
        else (time.monotonic() - br.turn_started_at if br.turn_started_at else 0.0)
    )
    if kind == "delegate":
        if delegate_completion_mode() == "last":
            br.set_pending_delegate_report(report)
            return True
        if not should_push_delegate_completion(br, elapsed):
            return False
        prefix = "📋 委派阶段完成"
    elif kind == "workflow":
        if not should_push_workflow_completion(br, elapsed):
            return False
        prefix = _workflow_push_prefix(report)
    else:
        return False
    text = build_report_push_text(report, prefix=prefix)
    return cast(bool, br.schedule_completion_push(text, kind=kind))


def try_push_turn_complete(
    bridge: GatewayOutboundBridge | None,
    *,
    elapsed_seconds: float,
) -> bool:
    br = bridge or _optional_bridge()
    if br is None:
        return False
    if not should_push_turn_completion(br, elapsed_seconds):
        return False
    return cast(
        bool,
        br.schedule_completion_push(
            build_turn_complete_text(elapsed_seconds=elapsed_seconds),
            kind="turn",
        ),
    )


class _LiveOutboundCompletionHooks:
    def flush_pending_delegate_completion(self, bridge: Any) -> bool:
        return flush_pending_delegate_completion(cast("GatewayOutboundBridge", bridge))

    def try_push_agent_report(
        self,
        report: Any,
        *,
        kind: str,
        bridge: Any,
        elapsed_turn_seconds: float,
    ) -> bool:
        return try_push_agent_report(
            report,
            kind=kind,
            bridge=cast("GatewayOutboundBridge | None", bridge),
            elapsed_turn_seconds=elapsed_turn_seconds,
        )

    def try_push_turn_timeout(
        self,
        bridge: Any,
        *,
        timeout_seconds: float,
        elapsed_seconds: float,
    ) -> bool:
        return try_push_turn_timeout(
            cast("GatewayOutboundBridge | None", bridge),
            timeout_seconds=timeout_seconds,
            elapsed_seconds=elapsed_seconds,
        )

    def try_push_turn_complete(
        self,
        bridge: Any,
        *,
        elapsed_seconds: float,
    ) -> bool:
        return try_push_turn_complete(
            cast("GatewayOutboundBridge | None", bridge),
            elapsed_seconds=elapsed_seconds,
        )

    async def deliver_completion_push(
        self,
        adapter: Any,
        chat_id: str,
        body: str,
        *,
        kind: str,
    ) -> bool:
        return await deliver_completion_push(adapter, chat_id, body, kind=kind)

    def delegate_completion_mode(self) -> str:
        return delegate_completion_mode()

    def delegate_completion_max_each(self) -> int:
        return delegate_completion_max_each()


def register_default_completion_hooks() -> None:
    set_completion_hooks(_LiveOutboundCompletionHooks())


register_default_completion_hooks()