The late-failure watch treated every non-"sent" outcome as definitive: a relay lost-ack (raw_response.ambiguous=True, the card may well have posted) arriving after SEND_ACK_WINDOW tore down the registration and returned the delivery notice, so the user's button tap on a card that WAS rendered found no pending entry and was lost. That breaks the invariant _abort_for_outcome (and main) keeps for an immediate ambiguous outcome. _on_card_done now returns early on "ambiguous" and lets the bounded wait's own timeout cover a card that truly never arrived. Also while here: - a late DECLINE releases with UNDELIVERED_DECLINED, matching the immediate path, instead of the generic notice (_release carries the outcome); - when the text fallback cannot even be scheduled (fallback() -> None) the card's "failed" verdict stands instead of re-classifying None, which logged a misleading "no scheduling future (loop unavailable)"; - test_card_failing_after_the_ack_window_... now asserts the text prompt was sent exactly once and the wait released within ack window + 2 s, and the helper answers only after observing the prompt — with fallback/late-watch disabled it stayed green before (the helper answered on a 10 s deadline); - telegram.md: clarify_timeout default is 3600 s (resolve_clarify_timeout, configuration.md), not 600. Part of #112684
183 lines
9.0 KiB
Python
183 lines
9.0 KiB
Python
"""Clarify prompt delivery on messaging platforms: send disposition, plain-text fallback, bounded wait.
|
|
|
|
A native clarify card (Telegram inline keyboard, Slack blocks, …) can fail to render in three ways
|
|
the user never sees (#112684): the platform rejects it at once, the send outruns the 15 s
|
|
disposition window and only then fails (pool / connect timeouts, a stale-thread retry), or there
|
|
is no chat surface at all. Each used to end as a full ``clarify_timeout`` of silence reported as
|
|
"the user did not respond". Here every definitive failure of the native card falls back to the
|
|
adapter's plain-text ``send_clarify`` (numbered list + text capture), and a card that fails AFTER
|
|
the window releases the wait with the delivery notice instead of the inactivity one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
UNDELIVERED = "[clarify prompt could not be delivered]"
|
|
UNDELIVERED_DECLINED = "[clarify prompt could not be delivered: destination refused]"
|
|
# No status adapter (a run whose chat surface is gone): shares the ``[clarify prompt could not be
|
|
# delivered`` prefix every consumer already treats as a non-answer
|
|
# (agent/context_compressor.py::_CLARIFY_NON_RESPONSE_PREFIXES).
|
|
UNDELIVERED_NO_SURFACE = "[clarify prompt could not be delivered: no chat surface]"
|
|
|
|
# Seconds a scheduled card send may take before it is classified ``ambiguous`` (possibly posted).
|
|
SEND_ACK_WINDOW = 15
|
|
|
|
|
|
def text_fallback_coro(adapter, **send_kwargs):
|
|
"""The base numbered-text ``send_clarify`` as a fresh coroutine, or ``None`` when the adapter has
|
|
no native override — its ``send_clarify`` already IS the text path, so retrying it would only
|
|
repeat the failure."""
|
|
from gateway.platforms.base import BasePlatformAdapter
|
|
|
|
if not isinstance(adapter, BasePlatformAdapter) \
|
|
or type(adapter).send_clarify is BasePlatformAdapter.send_clarify:
|
|
return None
|
|
return BasePlatformAdapter.send_clarify(adapter, **send_kwargs)
|
|
|
|
|
|
def _abort_for_outcome(outcome: str, *, session_key: str, clarify_mod) -> Optional[str]:
|
|
"""Map a send outcome to the abort sentinel (registration torn down) or ``None`` (proceed to wait).
|
|
|
|
Only a DEFINITIVE failure tears down the registration; ``ambiguous`` (card may have posted) stays armed
|
|
and proceeds to the bounded wait, whose response timeout covers a lost card."""
|
|
if outcome == "declined":
|
|
# P5(b): a connector DECLINE is MORE definitive than a failure — the
|
|
# destination was authorized and refused, so the card cannot arrive and
|
|
# no late reply can resolve it. Without this branch `declined` fell
|
|
# through to the bounded wait and the agent blocked until
|
|
# clarify_timeout (indefinitely when that is configured non-positive).
|
|
logger.warning(
|
|
"Clarify prompt DECLINED by the connector's egress guard; "
|
|
"clearing registration"
|
|
)
|
|
clarify_mod.clear_session(session_key)
|
|
return UNDELIVERED_DECLINED
|
|
if outcome == "failed":
|
|
# Undeliverable: clear the registration and return the sentinel so the agent falls back, not hangs.
|
|
logger.warning("Clarify send failed definitively; clearing registration")
|
|
clarify_mod.clear_session(session_key)
|
|
return UNDELIVERED
|
|
if outcome == "ambiguous":
|
|
logger.warning(
|
|
"Clarify prompt send timed out — treating as possibly-delivered "
|
|
"(no teardown; the registration stays armed for a late reply)")
|
|
return None
|
|
|
|
|
|
def _clarify_send_disposition(fut, *, session_key: str, clarify_mod) -> Optional[str]:
|
|
"""Decide whether a clarify prompt send aborts the wait; returns the abort sentinel or ``None``."""
|
|
from gateway.run import _approval_send_outcome
|
|
|
|
return _abort_for_outcome(
|
|
_approval_send_outcome(fut, timeout=SEND_ACK_WINDOW), session_key=session_key, clarify_mod=clarify_mod)
|
|
|
|
|
|
def _clarify_send_then_wait(fut, *, clarify_id: str, session_key: str, clarify_mod,
|
|
fallback: Optional[Callable[[], Any]] = None) -> tuple[str, bool]:
|
|
"""Resolve a clarify prompt: send disposition, plain-text fallback, then the bounded wait.
|
|
|
|
``fallback()`` schedules the plain-text ``send_clarify`` and returns its future (or ``None``);
|
|
it runs when the native card failed definitively — at once, or late, after the ack window —
|
|
and never on a connector DECLINE (re-sending refused content as text is the exfiltration the
|
|
egress guard exists to stop). A card that fails after the window releases the wait with the
|
|
delivery notice, not the inactivity one.
|
|
|
|
Returns ``(response, answered)``. ``answered`` is the only signal that a user reply arrived;
|
|
callers must not infer it from the text (a real answer may start with '[' like a sentinel)."""
|
|
from gateway.run import _approval_send_outcome
|
|
|
|
outcome = _approval_send_outcome(fut, timeout=SEND_ACK_WINDOW)
|
|
if outcome == "failed" and fallback is not None:
|
|
# The text prompt is the last resort: a late failure of ITS send has nothing to retry.
|
|
fut, fallback = fallback(), None
|
|
# ``None`` = the fallback could not even be scheduled; the card failure already stands,
|
|
# so re-classifying would only log a misleading "no scheduling future".
|
|
if fut is not None:
|
|
outcome = _approval_send_outcome(fut, timeout=SEND_ACK_WINDOW)
|
|
if outcome == "sent":
|
|
logger.info("Clarify card undeliverable; plain-text prompt sent instead (id=%s)", clarify_id)
|
|
abort = _abort_for_outcome(outcome, session_key=session_key, clarify_mod=clarify_mod)
|
|
if abort is not None:
|
|
return abort, False
|
|
late = _LateFailureWatch(fut, clarify_id=clarify_id, session_key=session_key,
|
|
clarify_mod=clarify_mod, fallback=fallback)
|
|
timeout = clarify_mod.get_clarify_timeout()
|
|
response = clarify_mod.wait_for_response(clarify_id, timeout=float(timeout))
|
|
late.disarm()
|
|
if late.undeliverable:
|
|
return late.undeliverable, False
|
|
if response is None or response == "":
|
|
return f"[user did not respond within {int(timeout / 60)}m]", False
|
|
return response, True
|
|
|
|
|
|
class _LateFailureWatch:
|
|
"""Watch a possibly-delivered card send: when it resolves as a definitive failure after the
|
|
ack window, try the text fallback once, then release the waiter with the delivery notice.
|
|
|
|
Callbacks run on the gateway loop thread (the send future completes there); ``clear_session``
|
|
wakes the agent thread blocked in ``wait_for_response`` and ``undeliverable`` (the delivery
|
|
sentinel, or ``None`` while nothing definitive happened) tells it why.
|
|
Armed only while the future is still pending: a sent card needs no watch."""
|
|
|
|
def __init__(self, fut, *, clarify_id: str, session_key: str, clarify_mod, fallback) -> None:
|
|
self.undeliverable: Optional[str] = None
|
|
self._armed = False
|
|
self._clarify_id = clarify_id
|
|
self._session_key = session_key
|
|
self._clarify_mod = clarify_mod
|
|
self._fallback = fallback
|
|
# Test doubles hand the runner a bare ``.result()`` object; only a real pending
|
|
# concurrent future can still resolve late.
|
|
done = getattr(fut, "done", None)
|
|
if callable(done) and not done():
|
|
self._armed = True
|
|
fut.add_done_callback(self._on_card_done)
|
|
|
|
def disarm(self) -> None:
|
|
self._armed = False
|
|
|
|
@staticmethod
|
|
def _outcome(fut) -> str:
|
|
from gateway.run import _approval_send_outcome
|
|
return _approval_send_outcome(fut, timeout=0)
|
|
|
|
def _on_card_done(self, fut) -> None:
|
|
if not self._armed:
|
|
return
|
|
outcome = self._outcome(fut)
|
|
if outcome == "sent":
|
|
return
|
|
logger.warning("Clarify card send resolved %s after the ack window (id=%s)", outcome, self._clarify_id)
|
|
if outcome == "ambiguous":
|
|
# Lost ack (``raw_response.ambiguous``): the card may well have posted. Same invariant as
|
|
# ``_abort_for_outcome`` — stay armed for the late button tap, never re-send, never
|
|
# release; the bounded wait's own timeout covers a card that truly never arrived.
|
|
return
|
|
if outcome == "declined":
|
|
# Refused destination: no text retry (see ``_clarify_send_then_wait``), and the notice
|
|
# says so rather than the generic delivery failure.
|
|
self._release(UNDELIVERED_DECLINED)
|
|
return
|
|
fallback_fut = self._fallback() if self._fallback is not None else None
|
|
if fallback_fut is None:
|
|
self._release()
|
|
return
|
|
fallback_fut.add_done_callback(self._on_fallback_done)
|
|
|
|
def _on_fallback_done(self, fut) -> None:
|
|
if not self._armed:
|
|
return
|
|
if self._outcome(fut) == "sent":
|
|
logger.info("Clarify card undeliverable; plain-text prompt sent instead (id=%s)", self._clarify_id)
|
|
return
|
|
self._release()
|
|
|
|
def _release(self, notice: str = UNDELIVERED) -> None:
|
|
self.undeliverable = notice
|
|
self._clarify_mod.clear_session(self._session_key)
|