"""RelayAdapter — one generic gateway adapter fronted by the connector. EXPERIMENTAL. A single ``BasePlatformAdapter`` subclass that, at handshake, receives a ``CapabilityDescriptor`` from the connector telling it which platform it is fronting and which capabilities to advertise to the ``GatewayStreamConsumer``. It implements the four abstract methods (``connect`` / ``disconnect`` / ``send`` / ``get_chat_info``) plus the capability surface (``MAX_MESSAGE_LENGTH``, ``message_len_fn``, ``supports_draft_streaming``) by delegating wire I/O to an injected transport and reading capabilities off the descriptor. There is NO per-platform gateway code: the connector is the only side that knows "this chat_id maps to a Discord channel, send it via the Discord websocket." The gateway sees an ordinary ``MessageEvent`` in and calls ``adapter.send`` out. EXPERIMENTAL: the transport protocol and descriptor schema may change without a deprecation cycle until >=2 Class-1 platforms validate them. """ from __future__ import annotations import asyncio import logging import re import secrets import time from collections import OrderedDict from typing import Any, Callable, Dict, Optional, Tuple, cast from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult from gateway.relay.descriptor import CapabilityDescriptor from gateway.relay.media import RelayMediaClient from gateway.relay.transport import RelayTransport from gateway.session import SessionSource logger = logging.getLogger(__name__) # Keep the drain-path going-idle ACK budget strictly under the runner's default # adapter disconnect timeout (5s). If go_idle consumes the whole outer budget, # cancellation can fire before transport.disconnect() and leave the websocket # open. Paired with transport teardown budgets of 1s each for supervisor, # reader, and ws.close (~3s), the full drain path stays inside 5s. _RELAY_GO_IDLE_ON_DISCONNECT_TIMEOUT_S = 2.0 _RELAY_REVOCATION_MONITOR_TEARDOWN_TIMEOUT_S = 1.0 # Link detection for the fresh-final unfurl route: raw http(s) URLs, Slack # mrkdwn link syntax (), and markdown links. Cheap and # permissive on purpose — a false positive costs one fresh (non-edited) # final message; a false negative silently loses the preview. _URL_RE = re.compile(r"https?://| int: """Count UTF-16 code units (Telegram's length unit).""" return len(text.encode("utf-16-le")) // 2 # Table-driven length-unit selection from the descriptor's ``len_unit``. _LEN_FNS: Dict[str, Callable[[str], int]] = { "chars": len, "utf16": _utf16_len, } class RelayAdapter(BasePlatformAdapter): """Generic relay adapter advertising a connector-negotiated capability profile.""" def __init__( self, config: PlatformConfig, descriptor: CapabilityDescriptor, transport: Optional[RelayTransport] = None, ) -> None: # The relay adapter fronts many platforms but presents as a single # logical platform to the runner; Platform.RELAY identifies it. super().__init__(config, Platform.RELAY) self.descriptor = descriptor self._transport = transport # Capability surface read by stream_consumer (getattr(..., 4096)). self.MAX_MESSAGE_LENGTH = descriptor.max_message_length # chat_id -> scope_id (server/workspace scope), learned from inbound # events. The connector's egress guard resolves the owning tenant from # the OUTBOUND action's metadata.scope_id; the gateway's generic delivery # path (run.py _thread_metadata_for_source) only carries thread_id, so we # re-attach the scope here from what we saw inbound. Keyed by chat_id # (channel) since that's what send() receives. See routedEgressGuard.ts. self._scope_by_chat: Dict[str, str] = {} # chat_id -> author user_id for DM chats (no scope). A DM reply has # no scope discriminator, so the connector resolves its tenant from the # recipient's author binding; we re-attach this user_id as # metadata.user_id on the outbound action so it can. See _capture_scope. self._dm_user_by_chat: Dict[str, str] = {} # chat_id -> (thread_id, initial_name) of the auto-thread the CONNECTOR # created for our most recent send into that chat (auto-thread routing # feedback off SendResult — see send()). Consumed by the gateway's # semantic thread-rename lane; bounded like the sibling caches. self._auto_thread_by_chat: Dict[str, Tuple[str, str]] = {} # Bounded FIFO seen-set for inbound replay dedupe (finding #3); # dict preserves insertion order, giving cheap oldest-first eviction. self._seen_inbound: Dict[str, None] = {} # chat_id -> draft_id of the currently OPEN native draft stream # (NS-658 live cards). Armed by send_draft on a successful frame; # consumed by send() to convert the turn-final delivery into the # sealing draft(final=true) frame instead of a duplicate post. # Keyed by _draft_key (chat + per-turn identity), NOT bare chat: # parallel turns in one DM are distinct streams (live finding #10 — # per-chat keying collided three concurrent turns: merged task # cards, clobbered seal state, 3x duplicate finals). self._open_draft_by_chat: Dict[str, int] = {} # Strong refs for in-flight fire-and-forget lifecycle acks (asyncio # holds tasks weakly; unreferenced tasks can be GC'd mid-flight). self._lifecycle_ack_tasks: set = set() # Draft keys whose post-seal tombstone swallow has been logged once # (observability for the hijacked-live-stream class; bounded FIFO # like the sibling caches). self._tombstone_swallow_logged: Dict[str, int] = {} # chat_id -> draft_id of the most recently SEALED stream (gateway # mirror of the connector's sealed-key tombstone): post-seal # straggler frames must neither re-arm interception nor re-open a # stream. One entry per turn key; a NEW turn's fresh draft_id # differs, so it arms normally and writes its own tombstone at its # own seal. Bounded like the sibling caches (see send_draft). self._sealed_draft_by_chat: Dict[str, int] = {} # Stream-is-the-message marker (finding #4): the stream consumer # checks this to keep ONE draft stream per turn instead of bumping # draft_id at tool boundaries (which opens a new Slack message per # segment on native streaming — Telegram-shaped adapters want the # bump, we don't). # # SLACK-ONLY semantic, gated on the negotiated descriptor (review # B4): the base send_draft contract is Telegram-shaped — the draft # clears and the final arrives as a separate real send. Setting # this unconditionally made ANY relay connector that advertises # the draft op (e.g. a Telegram connector) intercept the turn-final # into draft(final=true), so no real history message was ever # posted. A future connector platform whose native streaming is # also stream-is-the-message should advertise it explicitly # (descriptor field within the contract) rather than widening this # platform check by guesswork. self.draft_stream_is_message = ( str(getattr(descriptor, "platform", "") or "").lower() == "slack" ) # chat_id -> event fired when the entry above lands, so a consumer that # arrives before the send can wait for it instead of polling. See # wait_for_auto_thread_info. self._auto_thread_waiters: Dict[str, asyncio.Event] = {} # chat_id -> chat_type (e.g. "dm", "channel", "group") learned from the # inbound event. Used to reproduce native Slack's synthetic-DM-thread # suppression on the relay lane: a DM streaming reply carries # reply_to= as its edit anchor, but the connector # maps a raw reply_to to a Slack thread_ts — so a plain DM reply would be # threaded UNDER the user's message (and lose progressive edit streaming) # instead of posting flat at the DM root. Native SlackAdapter drops that # synthetic reply_to in _resolve_thread_ts; the relay lane needs the same # disambiguation, and it needs the chat_type to know a chat is a DM. self._chat_type_by_chat: Dict[str, str] = {} # chat_id -> last triggering message ts (Slack). The typing/status # lane's synthetic thread anchor in thread-per-message mode; # see _capture_scope and send_typing. self._last_inbound_ts_by_chat: Dict[str, str] = {} # chat_id -> the UNDERLYING platform (e.g. "discord", "telegram") this # chat belongs to (Phase 1.5 multi-platform-per-agent). One relay adapter # fronts N platforms on one WS; an outbound reply must egress through the # platform the inbound came from. We remember it per chat_id from the # inbound event's source.platform and stamp it on the OutboundFrame so the # connector dispatches to the right sender. Empty for a single-platform # gateway (the connector falls back to its session default). See # _capture_scope / send. self._platform_by_chat: Dict[str, str] = {} self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain") # Cron flat continuable surface — descriptor-advertised (see # _apply_descriptor; same bit, constructor path). self.supports_inchannel_continuable = bool( getattr(descriptor, "supports_inchannel_continuable", False) ) # Phase 7 Unit 7d-B: watches the transport for a terminal auth revocation # (a 4401 close after a successful handshake = the operator opted this # instance out of the relay). On revocation we surface a clean, # non-retryable "relay disabled" fatal so the dashboard stops showing a # red "retrying" spin against a dead credential. self._revocation_monitor: Optional[asyncio.Task[None]] = None # Phase 2 media: the authenticated client for the connector's # /relay/media routes (upload for send_media source_url; download for # inbound re-hosted attachments → local paths the vision/file tools # consume). Built lazily from the relay dial URL + per-gateway creds; # None when either is absent (media lanes then degrade to the # pre-media text fallbacks). self._media_client: Optional["RelayMediaClient"] = None # Phase 3 interactive: prompt_id -> pending-prompt state. A `prompt` # op renders native options; the user's pick comes back inbound as a # prompt_response naming this id. The registry maps it back to the # waiting primitive (approval / slash-confirm / clarify) so the click # resolves EXACTLY like the native adapters' button callbacks. # Entries expire lazily (see _pop_prompt) so an unanswered prompt # never leaks. Keyed by our own minted ids (see _prompt_owner_nonce). self._pending_prompts: Dict[str, Dict[str, Any]] = {} # Per-process marker prefixed onto every prompt id this adapter mints, # so an answer can be recognized as OURS before it is acted on. # # WHY: a button press arrives on the passthrough plane, which the # connector fans out to EVERY live gateway session of the tenant # (relayServer.routeBusMessage delivers `passthrough` via # sessionsByTenant, unlike `message`, which narrows to the admitted # instance set). The prompt itself went out from exactly one instance, # and _pending_prompts is process-local — so every OTHER instance sees # an answer for a prompt it never minted. Without this marker those # instances cannot tell "a sibling owns this" from "my own prompt # expired", and the id-shaped text ("/c1") falls through to chat # dispatch, where run.py answers "Unknown command `/c1`" — one copy per # sibling gateway. Siblings are the common case in a DM: the connector # resolves the invoker's bindings to DISTINCT TENANTS, so several # instances of one tenant all receive the forward. self._prompt_owner_nonce: str = secrets.token_hex(3) # Prompt ids this process has already resolved, newest last. A repeat # answer for the same id (double tap, or a connector redelivery) is # then consumed silently instead of being treated as a stale prompt. self._resolved_prompts: "OrderedDict[str, float]" = OrderedDict() # ── capability surface (from descriptor) ───────────────────────────── @property def authorization_is_upstream(self) -> bool: """Relay authorization is enforced by the connector, not locally. The connector authenticates this gateway's WS (per-instance secret) and performs owner-only author-binding resolution before delivering, so any inbound relay event was already authorized as THIS instance's bound user (``user_instance_binding``, keyed on the connector-observed author id). The instance therefore must not default-deny relay users for lack of a local ``RELAY_ALLOWED_USERS`` env allowlist. See ``BasePlatformAdapter.authorization_is_upstream``. """ return True @property def message_len_fn(self) -> Callable[[str], int]: return _LEN_FNS.get(self.descriptor.len_unit, len) @property def supports_status_text(self) -> bool: # type: ignore[override] """Whether the fronted platform renders a TEXT status line. Native parity (rich status text): Slack's typing surface is the assistant status line ("Finding answers…" next to the bot name), a text-rendering indicator. When the relay fronts Slack, advertise it so run.py's live-status lane feeds per-tool phrases via ``set_status_text()`` — exactly the wiring the native SlackAdapter gets (``supports_status_text = True``). Other fronted platforms keep textless typing bubbles and must NOT receive phrase traffic. Property (not class attr) because ONE RelayAdapter class fronts many platforms; the answer depends on the handshaked descriptor. On a multi-platform relay this scalar reflects the PRIMARY identity's platform (same convention as the scalar ``descriptor``); per-chat egress paths that need chat-accurate capabilities use ``_descriptor_for_chat`` below. """ return self.descriptor.platform == Platform.SLACK.value # ── per-chat capability resolution (Phase 1.5 multi-platform) ───────── def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor: """The capability descriptor governing a specific chat. A multi-platform gateway fronts N platforms on ONE adapter, but the scalar `descriptor`/`MAX_MESSAGE_LENGTH` surface can only carry one platform's profile (the primary identity's). Platform caps genuinely differ — Discord 2000 / Telegram 4096 / Slack 39000 — so applying the primary's cap to every chat either fragments needlessly (small primary) or over-sends into a platform 400 (large primary; the live bug: 2,543 and 2,641-char sends rejected by Discord). Resolve the chat's platform from what we saw inbound (`_platform_by_chat`, the same map per-frame egress uses) and look up that platform's negotiated descriptor on the transport. Falls back to the scalar descriptor when the chat's platform is unknown (never saw inbound) or the transport predates the map. """ platform = self._platform_by_chat.get(str(chat_id)) if platform and self._transport is not None: resolve = getattr(self._transport, "descriptor_for_platform", None) if callable(resolve): try: per_platform = cast( Optional[CapabilityDescriptor], resolve(platform) ) except Exception: # noqa: BLE001 - capability lookup must never break a send per_platform = None if per_platform is not None: return per_platform return self.descriptor def max_message_length_for_chat(self, chat_id: str) -> int: return self._descriptor_for_chat(chat_id).max_message_length def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]: return _LEN_FNS.get(self._descriptor_for_chat(chat_id).len_unit, len) def supports_draft_streaming( self, chat_type: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, chat_id: Optional[str] = None, ) -> bool: # Native draft streaming needs BOTH the descriptor flag and the # "draft" op. supported_ops is fail-open for legacy connectors # (empty tuple = pre-contract ops only), but "draft" did not exist # pre-contract, so it must NOT fail open: an explicit advertisement # is required. Without it the stream consumer stays on the # edit-based path exactly as today. # # Per-chat resolution (review r2, finding 2): one adapter fronts N # platforms, and the scalar descriptor only reflects the PRIMARY # identity — a Telegram primary must not starve a secondary Slack # chat of native streaming, nor vice versa. When the caller can # name the chat, resolve through its platform's negotiated # descriptor; the scalar remains the fallback (chat unknown, # single-platform gateways: identical behavior). desc = ( self._descriptor_for_chat(str(chat_id)) if chat_id is not None else self.descriptor ) if not ( desc.supports_draft_streaming and "draft" in (desc.supported_ops or ()) ): return False # Slack chat.*Stream has no unfurl_links / unfurl_media. Native # SlackAdapter already refuses streaming when those knobs are set # so chat.postMessage can carry them. Mirror that here or a # configured true never reaches Slack (bot default = no preview). platform = None if chat_id is not None: platform = self._platform_by_chat.get(str(chat_id)) if platform is None: platform = getattr(desc, "platform", None) if self._slack_unfurl_hints(platform): return False return True def prefers_fresh_final_streaming( self, content: str, metadata: Optional[Dict[str, Any]] = None, chat_id: Optional[str] = None, ) -> bool: """Deliver streamed finals as a FRESH send when Slack unfurl is forced on. Slack evaluates link previews exactly once, at ``chat.postMessage`` time (live-probed 2026-08-28: URL at post + ``unfurl_links: true`` unfurls; a ``chat.update`` that INTRODUCES the URL never does, stamps or not). Edit-based streaming posts its first frame before the model has produced any URL — a tool-progress card or an early text frame — so a configured ``unfurl_links/media: true`` can never surface a preview through the edit lane: the only post Slack evaluates has no link in it. Returning True routes the completed reply through the consumer's fresh-final path: one new ``send`` carrying the full content, which ``send()`` stamps with the unfurl hints — URL and flags present at the single moment Slack looks. Scope: ONLY when the hints contain an explicit True. False-only hints (the enterprise fail-closed posture) keep the edit lane — suppression rides the placeholder post and an edit can never add a preview afterwards, so ``false`` inherits correctly with zero streaming-UX cost. """ platform = None if chat_id is not None: platform = self._platform_by_chat.get(str(chat_id)) # The stream consumer's hook call passes (content, metadata=...) only # — no chat_id — so resolve through the turn metadata's platform when # present before falling back to the primary descriptor. if platform is None and isinstance(metadata, dict): platform = metadata.get("platform") if platform is None: platform = getattr(self.descriptor, "platform", None) hints = self._slack_unfurl_hints(platform) if not hints: return False if not any(v is True for v in hints.values()): return False # Only link-bearing finals benefit: without a URL there is nothing to # unfurl, and the relay has no delete op (connector contract v1), so # the streamed preview stays behind the fresh final. Keep the edit # lane for linkless replies to avoid a pointless duplicate message. return bool(_URL_RE.search(content or "")) def stream_is_message_for_chat(self, chat_id: str) -> bool: """Per-chat stream-is-the-message semantic (review r2, finding 2). The class-level ``draft_stream_is_message`` can only reflect the primary identity's platform. On a multi-platform relay, a Slack primary must not impose seal semantics on a Telegram chat (its turn-final would become draft(final=true) — no history message), and a Telegram primary must not deny a secondary Slack chat its native streaming. Resolve through the chat's own negotiated descriptor. Platform-name inference is deliberate for now — a descriptor-level field is the eventual contract (gg follow-up) so a future platform can advertise the semantic explicitly. """ return ( str(self._descriptor_for_chat(str(chat_id)).platform or "").lower() == "slack" ) # ── Live cards: native draft streaming + task cards (NS-658) ───────── # # Additive relay ops within contract v1. The gateway side is dumb: it # emits ops when the negotiated descriptor advertises them; the # connector owns the platform API mechanics (chat.startStream et al.), # per-workspace feature-gate caching, and the send+edit fallback. # # Semantic bridge: the base send_draft contract is Telegram-shaped — # the draft clears and the final answer arrives as a separate send(). # Slack native streaming makes the stream THE message, sealed once. # The adapter tracks the open draft per chat; the turn-final send() # for that chat converts to draft(final=true) so the connector seals # the stream instead of posting a duplicate message. def supports_native_task_cards(self) -> bool: """Descriptor probe for the TurnRunner's task-card lane. Explicit advertisement required — same no-fail-open rule as "draft" (the op did not exist pre-contract). """ return "task_card" in (self.descriptor.supported_ops or ()) def native_task_cards_enabled(self) -> bool: """TurnRunner opt-in probe (gateway/run.py) — the card lane calls THIS name (same contract as the native Slack adapter's opt-in); ``supports_native_task_cards`` is the descriptor-level capability. Live-canary finding: without this alias the lane silently stays text-mode (hasattr probe fails) even though the connector advertises task_card.""" return self.supports_native_task_cards() @staticmethod def _draft_key(chat_id: str, metadata: Optional[Dict[str, Any]]) -> str: """Coordination key for one turn's stream. Prefers a PER-TURN identity — the triggering inbound message id (``message_id`` is stamped by the gateway's Slack thread metadata, ``reply_to_message_id`` by the consumer's send path; both carry the same event id) — over the thread anchor. Finding #10 keyed on the thread anchor alone, which is simultaneously too coarse and too fragile (review B2 + flat-DM concern): - two parallel turns REPLYING INSIDE ONE THREAD share thread_ts, so turn A's final sealed turn B's stream with A's content; - a flat DM whose metadata carries no anchor at all degraded to the bare chat id, re-creating the original #10 collision. The thread anchor remains the fallback for callers that only have placement metadata, and the bare chat is the last resort (single-turn semantics). """ md = metadata or {} turn_id = md.get("message_id") or md.get("reply_to_message_id") if turn_id: return f"{chat_id}:turn:{turn_id}" anchor = md.get("thread_ts") or md.get("thread_id") or "" return f"{chat_id}:{anchor}" # Cap for the draft/seal coordination dicts, matching the sibling # bounded caches (_auto_thread_by_chat). Entries are per-turn keys; # 512 in-flight-or-recent turns per adapter is far beyond any real # concurrency, and matches the connector's tombstone store size. _DRAFT_STATE_CAP = 512 @classmethod def _evict_oldest(cls, d: Dict[str, int]) -> None: """FIFO-bound a coordination dict in place (review M1).""" while len(d) > cls._DRAFT_STATE_CAP: d.pop(next(iter(d)), None) @staticmethod def _card_key( reply_to: Optional[str], metadata: Optional[Dict[str, Any]] ) -> str: """Per-turn task-card identity — same precedence as ``_draft_key``. ``reply_to`` (the triggering message id from the TurnRunner) wins; metadata message ids cover the flat-DM / resolver lanes; the thread anchor is only a fallback because two turns replying inside one thread share ``thread_ts`` and must not share a card (review B2). One derivation for send AND stop, so the stop always hits the stream the send opened. """ md = metadata or {} anchor = ( reply_to or md.get("message_id") or md.get("reply_to_message_id") or md.get("thread_ts") or md.get("thread_id") or "root" ) return f"turn:{anchor}" def _match_open_draft( self, chat_id: str, metadata: Optional[Dict[str, Any]] ) -> Optional[str]: """Resolve which open stream (if any) a turn-final send belongs to. Exact key match first. Callers WITHOUT a per-turn message id fall into two classes (review r2, finding 5): - placement-only metadata (thread_ts/thread_id but no message id — legacy resolver lanes): the thread anchor is placement info, not turn identity, and streams are keyed per turn — an exact match will practically never fire for them. They may still absorb the final via the single-open-stream fallback. - no metadata at all: same fallback. The fallback only fires when the chat has EXACTLY one open stream. With several open, the send stays a plain send: a duplicate message is recoverable, sealing someone else's stream with the wrong content is not (review B2). Callers that DO carry a message id never fall back — their identity is authoritative, and a mismatch means the stream is someone else's. """ key = self._draft_key(str(chat_id), metadata) if key in self._open_draft_by_chat: return key md = metadata or {} # Only a per-turn MESSAGE id is turn identity. Thread anchors are # placement info shared by every turn in the thread — treating # them as identity made the single-open-stream fallback dead for # placement-only callers (probed: plain final beside an open # turn-keyed stream). if md.get("message_id") or md.get("reply_to_message_id"): return None prefix = f"{chat_id}:" candidates = [ k for k in self._open_draft_by_chat if k.startswith(prefix) ] if len(candidates) == 1: # Absorbing a send into a stream is a significant, previously # silent decision — the wrong caller matching here is exactly # the prompt-ack-seals-own-stream bug (rc.4 live finding). Say # it out loud so the next mismatch is a grep, not a hunt. logger.info( "relay: absorbing identity-less send into the single open " "stream %s (single-open-stream fallback)", candidates[0], ) return candidates[0] return None async def send_draft( self, chat_id: str, draft_id: int, content: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: if not self.supports_draft_streaming(chat_id=str(chat_id)): raise NotImplementedError( "connector does not advertise the 'draft' relay op" ) if self._transport is None: return SendResult(success=False, error="no transport") # Audit fix G-D1 + regression fix (2026-08-15): arm optimistically # BEFORE the transport call (lossy ack: a timeout/WS-drop 'failure' # often means delivered), but NEVER for a draft_id that has already # been sealed this chat — the gateway-side mirror of the connector's # sealed-key tombstone. Without this, a straggler frame arriving # after the seal re-armed interception with no live stream, and the # next unrelated send (media follow-up, next-turn text) was wrongly # converted to draft(final=true) on the tombstoned key — clearing # the tombstone, re-opening a stream, and freezing it (the observed # escalating-frozen-prefixes regression). chat_key = self._draft_key(str(chat_id), metadata) if self._sealed_draft_by_chat.get(chat_key) == draft_id: # Post-seal straggler: its content is already in the sealed # message; report success, send nothing, arm nothing. Log the # FIRST swallow per key at WARNING — one straggler is the # normal race this tombstone exists for, but a hijacked live # stream (something else sealed this draft mid-flight) shows # up as a burst of swallows, and silence here cost a full # forensic hunt (rc.4: prompt ack sealed the turn's own draft # and every later append vanished without a line). if chat_key not in self._tombstone_swallow_logged: self._tombstone_swallow_logged[chat_key] = draft_id self._evict_oldest(self._tombstone_swallow_logged) logger.warning( "relay: draft frame for %s swallowed by post-seal " "tombstone (draft_id=%s) — expected for a straggler; " "a live stream freezing NOW means something sealed it " "mid-flight", chat_key, draft_id, ) return SendResult(success=True) # Arm seal-interception ONLY for stream-is-the-message chats # (review B4, per-chat in r2 finding 2): on a Telegram-shaped # connector the draft clears client-side and the final MUST go out # as a separate real send — arming here would intercept that final # into draft(final=true) and no history message would ever be # posted. Resolved per chat: one adapter fronts N platforms. if self.stream_is_message_for_chat(str(chat_id)): self._open_draft_by_chat[chat_key] = draft_id self._evict_oldest(self._open_draft_by_chat) try: result = await self._transport.send_outbound( { "op": "draft", "chat_id": chat_id, "draft_id": draft_id, "content": content, "final": False, # Boundary rule (observed in live relay testing): the draft lane # is a text egress lane like send/edit — a streamed final # can only render blocks if its frames carry the hint. "metadata": self._with_scope( chat_id, self._with_format_hints_for_chat( chat_id, dict(metadata or {}) ), ), }, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception as e: # Ambiguous by definition (stale socket, mid-write drop): the # frame may have been delivered. Keep interception armed. return SendResult(success=False, error=f"draft transport error: {e}") if result.get("success"): return SendResult(success=True) if result.get("ambiguous"): # Ack lost (transport timeout) — the production ws transport # RETURNS this shape rather than raising (PR 85796 review, # round 2): the connector may have applied the frame. Same # contract as the except branch: keep interception armed. return SendResult( success=False, error=str(result.get("error") or "draft ack lost") ) # DEFINITE connector rejection (an explicit non-ambiguous result): # disarm interception for this key. The stream consumer disables # the draft transport on this failure and falls back to edit-based # streaming — its turn-final must go out as a REAL send, not get # converted into a seal on a stream the connector just told us is # unusable. (This restores the disarm-on-failure semantics the # G-D1 optimistic-arming change silently dropped; the ambiguity # that motivated G-D1 lives in the except branch above and the # ambiguous-result branch — both keep the key armed.) if self._open_draft_by_chat.get(chat_key) == draft_id: self._open_draft_by_chat.pop(chat_key, None) return SendResult( success=False, error=str(result.get("error") or "draft failed") ) async def _seal_open_draft( self, chat_id: str, content: str, metadata: Optional[Dict[str, Any]], *, draft_key: Optional[str] = None, ) -> SendResult: """Convert the turn-final send into the sealing draft frame.""" if draft_key is None: draft_key = self._draft_key(str(chat_id), metadata) draft_id = self._open_draft_by_chat.pop(draft_key) # Tombstone BEFORE the transport call (regression fix): whatever the # ack says, this draft_id's stream must never be re-armed by a # straggler frame — the connector-side tombstone handles its half. self._sealed_draft_by_chat[draft_key] = draft_id # Bounded like the sibling caches (review M1): the key embeds a # per-turn identity, so an unbounded dict grows one entry per turn # for the life of the process. FIFO eviction matches the # straggler window this tombstone exists for (seconds, not days); # the connector holds its own 512-entry tombstone store. self._evict_oldest(self._sealed_draft_by_chat) if self._transport is None: return SendResult(success=False, error="no transport") seal_frame = { "op": "draft", "chat_id": chat_id, "draft_id": draft_id, "content": content, "final": True, # Same boundary rule as the interim frame: the SEAL frame is the # one the connector's block reconcile reads — a hintless seal is # exactly the plain-code-block downgrade seen in live relay testing. "metadata": self._with_scope( chat_id, self._with_format_hints_for_chat(chat_id, dict(metadata or {})), ), } _seal_platform = self._platform_by_chat.get(str(chat_id)) _transport = self._transport # narrowed by the None-guard above async def _attempt() -> Optional[Dict[str, Any]]: """One seal attempt; None means ambiguous (exception or lost ack).""" try: r = await _transport.send_outbound( seal_frame, platform=_seal_platform ) except asyncio.CancelledError: raise except Exception as e: logger.warning("relay seal transport error (ambiguous): %s", e) return None if r.get("ambiguous"): # The production ws transport returns this shape on ack # timeout instead of raising (PR 85796 review, round 2): # the connector may have sealed and lost only the ack. logger.warning( "relay seal ack lost (ambiguous): %s", r.get("error") ) return None return r # Ambiguous outcomes (exception OR timeout-shaped result) retry the # SAME idempotent frame once: the connector's sealed-key tombstone # returns the original stream ts for a repeated final and never # opens a second stream, so the retry can turn "unknown" into a # definite answer for free. Only after BOTH attempts stay ambiguous # do we report failure — the caller's fail-open plain send is a # possible duplicate, but a silent loss is worse, and two # consecutive ack losses on one socket almost always mean the # transport is actually down (so the plain send fails too and the # gateway's fallback owns delivery). # # Cancellation safety (review r2, finding 4): the open entry was # popped and the tombstone written BEFORE the await. If the task is # cancelled mid-seal, CancelledError bypasses the failure handling # and the later abandon pass would find nothing to close — the # connector-side stream stays visibly live until eviction. Restore # the open entry (and drop our premature tombstone) before # re-raising so the abandon path can seal it. try: result = await _attempt() if result is None: result = await _attempt() except asyncio.CancelledError: self._open_draft_by_chat[draft_key] = draft_id if self._sealed_draft_by_chat.get(draft_key) == draft_id: self._sealed_draft_by_chat.pop(draft_key, None) raise if result is None: return SendResult( success=False, error="draft seal ambiguous after retry (transport ack lost)", ) if result.get("success"): # The connector returns the stream's ts as the message identity. return SendResult( success=True, message_id=str(result.get("message_id") or "") or None, ) return SendResult( success=False, error=str(result.get("error") or "draft seal failed") ) async def send_native_task_card_progress( self, chat_id: str, tasks: list, *, title: str = "Hermes is working", reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, fallback_text: Optional[str] = None, ) -> SendResult: """Relay leg of the #85476 task-card lane: emit one card frame. SIGNATURE CONTRACT (live-canary finding): the TurnRunner calls this with the NATIVE Slack adapter's keyword contract (tasks/title/ reply_to/metadata/fallback_text) — not a card_id. The card stream key is derived per (chat, reply_to-thread): one card per turn thread, matching the connector's (channel, card_id) keying. ``fallback_text``/``title`` are accepted for contract parity; the connector's plan-mode stream renders task chunks, so they are not forwarded. ``tasks`` are the TurnRunner's normalized task dicts (id/title/ status/details/output); the connector maps them onto its workspace-scoped card stream (task_update chunks, 256-char field limits enforced connector-side where the API lives). """ if not self.supports_native_task_cards(): return SendResult( success=False, error="connector does not advertise task_card" ) if self._transport is None: return SendResult(success=False, error="no transport") # Finding #10 + review B2: one card per TURN. reply_to (the # triggering message id) is already per-turn; when it is absent # (flat DM, resolver lanes) fall back to the same per-turn # identity the draft lane keys on — metadata message ids first, # thread anchor only after that (two turns replying inside one # thread share thread_ts and must not share a card). card_id = self._card_key(reply_to, metadata) merged_meta = dict(metadata or {}) if reply_to and "thread_ts" not in merged_meta: # Slack card streams are thread replies (same rule as draft): # anchor on the triggering message when the runner gave us one. merged_meta["thread_ts"] = str(reply_to) try: result = await self._transport.send_outbound( { "op": "task_card", "chat_id": chat_id, "card_id": card_id, "chunks": [dict(t) for t in tasks], "metadata": self._with_scope(chat_id, merged_meta), }, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception as e: # Progress is advisory: a transport drop must degrade to the # TurnRunner's text fallback (failed SendResult), never raise # into the progress loop / turn-cleanup path (review B7 — an # escaping card exception in cleanup skipped final delivery). return SendResult( success=False, error=f"task_card transport error: {e}" ) if result.get("success"): return SendResult(success=True) return SendResult( success=False, error=str(result.get("error") or "task_card failed") ) async def stop_native_task_card_progress( self, chat_id: str, *, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Seal the card stream at turn end (idempotent connector-side). Same NATIVE-contract signature as send (canary finding above); card key derived identically so the stop hits the open stream. """ if not self.supports_native_task_cards(): return SendResult( success=False, error="connector does not advertise task_card" ) if self._transport is None: return SendResult(success=False, error="no transport") # Same per-turn key derivation as send (shared helper) so the stop # hits the open stream. card_id = self._card_key(reply_to, metadata) try: result = await self._transport.send_outbound( { "op": "task_card_stop", "chat_id": chat_id, "card_id": card_id, "metadata": self._with_scope(chat_id, dict(metadata or {})), }, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception as e: # Best-effort by contract: the stop runs in the progress loop's # finally block on the turn-cleanup path — an escaping transport # exception there skipped final delivery (review B7). The # connector seals orphaned card streams on its own (recycling / # eviction), so a lost stop is cosmetic. return SendResult( success=False, error=f"task_card_stop transport error: {e}" ) return SendResult(success=bool(result.get("success"))) async def abandon_open_draft( self, chat_id: str, content: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Seal an orphaned stream when its turn dies (review B8). A stopped (/stop, /new) or superseded turn previously left its native stream open forever: the Slack message kept the live streaming indicator and the adapter kept armed interception state, which the NEXT turn's key could inherit. Seal in place with ``content`` — the text already on screen (the consumer passes its last delivered frame), so the seal adds nothing and claims nothing: it only ends the stream. Delivery flags are the consumer's business; this never sets any. Best-effort by contract: failure is reported, never raised — the connector reaps truly orphaned streams via recycling/eviction. """ draft_key = self._match_open_draft(str(chat_id), metadata) if draft_key is None: return SendResult(success=True) # nothing armed — no-op try: return await self._seal_open_draft( chat_id, content, metadata, draft_key=draft_key ) except Exception as e: return SendResult( success=False, error=f"abandon seal transport error: {e}" ) # ── abstract methods (delegated to the transport) ──────────────────── async def connect(self, *, is_reconnect: bool = False) -> bool: # ``is_reconnect`` is part of the BasePlatformAdapter.connect contract: # the gateway's reconnect watcher (gateway/run.py) re-establishes a # platform after a fatal adapter error by building a fresh adapter and # calling ``connect(is_reconnect=True)``. Relay MUST accept the kwarg or # that recovery path raises TypeError and the relay platform can never # come back through the watcher. # # Relay deliberately IGNORES the flag. The flag exists so adapters with a # server-side update queue (e.g. Telegram's Bot API) preserve that queue # across an outage instead of dropping it (#46621). Relay has no such # gateway-side queue: messages buffered during a gap live in the # CONNECTOR's durable buffer and are replayed when the transport # re-handshakes. Routine WS drops are handled entirely by the transport's # own reconnect supervisor (WebSocketRelayTransport, reconnect=True); # a watcher-driven reconnect builds a fresh transport from scratch (the # fatal-error handler disconnect()s the old adapter first, cancelling its # supervisor), so there is nothing at the adapter layer to preserve. if self._transport is None: raise RuntimeError("RelayAdapter has no transport configured") self._transport.set_inbound_handler(self._on_inbound) # Inbound interrupts (connector -> owning gateway) arrive as # interrupt_inbound frames over the SAME outbound WS; bridge them to the # adapter's interrupt path. WS-only: there is no inbound HTTP receiver. set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None) if callable(set_interrupt): set_interrupt(self.on_interrupt) # Passthrough-plane forwards (Discord interactions, Twilio, …) also ride # the SAME outbound WS (Phase 5 §5.1) — the connector edge-ACKed and # forwards the real request here, so a hosted gateway needs no public # inbound port. Bridge them to the adapter's passthrough handler. set_passthrough = getattr(self._transport, "set_passthrough_handler", None) if callable(set_passthrough): set_passthrough(self._on_passthrough) ok = await self._transport.connect() if not ok: return False # Negotiate the real capability descriptor from the connector and adopt # it — the placeholder passed at construction is replaced by what the # connector advertises for the platform this gateway actually fronts. try: descriptor = await self._transport.handshake() except Exception as exc: # noqa: BLE001 - a failed handshake = a failed connect logger.warning("relay handshake failed: %s", exc) return False self._apply_descriptor(descriptor) # Inbound (messages + interrupts) is delivered over the outbound WS via # the connector's relay bus — there is NO inbound HTTP endpoint (hosted # gateways have no public IP). The transport's reader already dispatches # `inbound` / `interrupt_inbound` frames to the handlers wired above. # Phase 7 Unit 7d-B: start watching for a terminal auth revocation # (opt-out). Only meaningful when the transport exposes `auth_revoked` # (the production WebSocket transport); the test/stub transports don't. if hasattr(self._transport, "auth_revoked"): self._start_revocation_monitor() return True def _start_revocation_monitor(self) -> None: """Spawn (once) the task that turns a transport auth-revocation into a clean non-retryable 'relay disabled' fatal. Idempotent.""" if self._revocation_monitor is not None and not self._revocation_monitor.done(): return try: self._revocation_monitor = asyncio.create_task( self._watch_for_revocation(), name="relay-revocation-monitor" ) except RuntimeError: # No running loop (e.g. a unit test calling connect() synchronously # via a stub) — nothing to monitor. self._revocation_monitor = None async def _watch_for_revocation(self, poll_interval_s: float = 1.0) -> None: """Poll the transport for a terminal 4401 revocation (opt-out). On revocation, surface a non-retryable `relay_disabled` fatal so the dashboard renders a clean 'Relay disabled' state instead of a red 'retrying' spin, and notify the gateway's fatal-error handler so the adapter is cleanly removed (it is NOT queued for reconnection, because the credential is dead until the instance is recreated).""" transport = self._transport try: while True: if transport is None or getattr(transport, "auth_revoked", False): break await asyncio.sleep(poll_interval_s) except asyncio.CancelledError: raise if transport is None or not getattr(transport, "auth_revoked", False): return logger.warning( "relay credential revoked (opt-out) — marking the relay adapter disabled" ) # Non-retryable: a revoked secret never comes back without a recreate, so # _handle_adapter_fatal_error must NOT queue it for reconnection. self._set_fatal_error( "relay_disabled", "Relay disabled (opted out — recreate the instance to re-enable)", retryable=False, ) try: await self._notify_fatal_error() except Exception: # noqa: BLE001 - notification is best-effort logger.debug("relay revocation fatal-error notify failed", exc_info=True) def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None: """Adopt a (re)negotiated descriptor into the live capability surface.""" self.descriptor = descriptor self.MAX_MESSAGE_LENGTH = descriptor.max_message_length self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain") # Cron in_channel continuable surface (D6 gate in cron/scheduler.py): # the scheduler reads this off the adapter; the connector advertises it # per platform at handshake. Class default is False (BasePlatformAdapter), # so only an explicit descriptor bit turns the flat surface on. self.supports_inchannel_continuable = bool( getattr(descriptor, "supports_inchannel_continuable", False) ) async def _on_inbound(self, event) -> None: """Bridge a connector-delivered MessageEvent into the normal adapter path.""" # Inbound replay dedupe (live-canary finding #3, Alice staging): the # relay leg is at-least-once — on WS re-handshake the connector # replays its durable per-instance buffer, and a long multi-tool turn # (60-100s) straddling a quiet socket drop gets its ORIGINAL inbound # replayed after the turn completes, re-running the whole turn (user # saw the final answer 2-5x). Platform message identity (chat_id + # message_id/ts) is stable across replays, so a bounded seen-set # drops them. Consumer-side idempotency; no wire change. dedupe_key = self._inbound_dedupe_key(event) if dedupe_key is not None: if dedupe_key in self._seen_inbound: logger.info( "relay inbound dropped as replay (dedupe key=%s)", dedupe_key ) return self._seen_inbound[dedupe_key] = None while len(self._seen_inbound) > self._SEEN_INBOUND_MAX: self._seen_inbound.pop(next(iter(self._seen_inbound))) self._capture_scope(event) self._stamp_slack_session_thread(event) # Phase 3: a structured prompt answer resolves its waiting primitive # (approval/confirm/clarify) and is CONSUMED — it must not also # dispatch as a chat message. Unknown/expired prompt ids fall through # (the command-shaped text then behaves like a typed reply). if await self._consume_prompt_response(event): return await self._localize_inbound_media(event) await self.handle_message(event) _SEEN_INBOUND_MAX = 512 def _inbound_dedupe_key(self, event) -> Optional[str]: """Stable replay identity: (platform, chat, platform message id). Chat identity lives on ``event.source`` (MessageEvent has no top-level ``chat_id``), and this adapter can front SEVERAL platforms over one relay socket (Phase 1.5 multiplex), so the underlying platform joins the key — two platforms' numeric chat/message ids must never collide into one identity. Returns None when the event carries no platform message id (synthetic events, some prompt responses) — those never dedupe, fail-open by design: dropping a real user message is strictly worse than rerunning one, so only dedupe when identity is certain. """ source = getattr(event, "source", None) message_id = getattr(event, "message_id", None) chat_id = getattr(source, "chat_id", None) if not message_id or not chat_id: return None # Normalize the platform component: production wire decoding always # yields a Platform enum (unknowns canonicalize to Platform.RELAY), # but alternate constructors may carry the plain string. Use the # enum's value when present, the string itself otherwise — both # spellings of one platform must produce ONE key, and two different # string platforms must not collapse into the same empty component. raw_platform = getattr(source, "platform", None) platform = getattr(raw_platform, "value", raw_platform) or "" return f"{platform}:{chat_id}:{message_id}" def _relay_slack_extra(self) -> Dict[str, Any]: """The Slack-behavior subset of the RELAY platform config. Enterprise knob shape (Hermes-config directed, relay-namespaced): platforms: relay: extra: slack: # supported subset of native Slack fields reply_in_thread: true The native ``platforms.slack`` block keeps meaning "native adapter settings"; relay-fronted Slack reads its subset here. Legacy fallback: a flat key on the relay extra (``extra.reply_in_thread``) still wins when no ``slack`` object exists, preserving current staging configs. """ extra = getattr(self.config, "extra", None) or {} sub = extra.get("slack") return sub if isinstance(sub, dict) else extra @staticmethod def _coerce_flag(raw: Any, default: bool) -> bool: """Coerce an operator-supplied boolean exactly as native Slack does. Native SlackAdapter reads its behavior flags with ``str(raw).strip().lower() in {"1","true","yes","on"}``, so a YAML-quoted ``"false"`` — a shape operators write routinely — turns the flag OFF. A bare ``bool()`` would read that same string as True (non-empty string), silently ignoring the off switch. These knobs are documented as native-parity mirrors, so they must coerce identically or the parity claim only holds for unquoted YAML booleans. """ if raw is None: return default if isinstance(raw, bool): return raw return str(raw).strip().lower() in {"1", "true", "yes", "on"} def _effective_reply_in_thread(self) -> bool: """Resolve the thread-per-message vs flat-DM mode for fronted Slack.""" try: return self._coerce_flag( self._relay_slack_extra().get("reply_in_thread"), True ) except Exception: # noqa: BLE001 - config shape is operator-owned return True def _dm_top_level_threads_as_sessions(self) -> bool: """Native-parity escape hatch: per-message DM sessions on/off. Mirrors native SlackAdapter._dm_top_level_threads_as_sessions (platforms.slack.extra.dm_top_level_threads_as_sessions). Default True: in thread-per-message mode each top-level DM message keys its own session (parallel turns). Set platforms.relay.extra.slack.dm_top_level_threads_as_sessions: false to keep threaded reply PLACEMENT but ONE rolling DM session — the legacy steer/queue posture, decoupled from reply_in_thread. """ try: return self._coerce_flag( self._relay_slack_extra().get("dm_top_level_threads_as_sessions"), True, ) except Exception: # noqa: BLE001 - config shape is operator-owned return True def _slack_unfurl_hints(self, platform: Optional[str]) -> Optional[Dict[str, bool]]: """Slack-only outbound link-preview suppression, relay-namespaced. Mirrors the native SlackAdapter's unfurl controls (``platforms.slack.extra.unfurl_links`` / ``unfurl_media``) but reads the relay namespace (``platforms.relay.extra.slack.*``) per the ``reply_in_thread`` seam: relay-fronted Slack reads its subset here; the native ``platforms.slack`` block keeps meaning native-adapter settings. Only explicitly-configured booleans are returned — omitted keys preserve Slack's default unfurling. Non-Slack platforms return None so the metadata is never polluted for other fronted platforms. """ if str(platform or "").lower() != Platform.SLACK.value: return None extra = self._relay_slack_extra() hints: Dict[str, bool] = {} for knob in ("unfurl_links", "unfurl_media"): val = extra.get(knob) if val is None: continue # Railway / `hermes config set` write YAML strings ("true"/"false"). # A Slack bot that omits the fields does NOT get human-default # previews — so a string "true" that we drop looks like # suppression. Coerce the same way as reply_in_thread; still drop # junk (empty, 0, "maybe") so omitted stays omitted. if isinstance(val, bool): hints[knob] = val continue if isinstance(val, str) and val.strip().lower() in { "1", "0", "true", "false", "yes", "no", "on", "off", }: hints[knob] = val.strip().lower() in {"1", "true", "yes", "on"} return hints or None def _stamp_slack_session_thread(self, event) -> None: """Native session-keying parity for fronted Slack DMs. Native SlackAdapter's inbound handler stamps ``thread_ts = event.thread_ts or ts`` — every TOP-LEVEL message carries its own ts as ``source.thread_id``, so build_session_key appends it and each top-level message gets a FRESH session (per-message threads ⇒ per-message sessions; a 2nd message runs parallel instead of steering the in-flight turn). The connector normalizes a top-level message with thread_id=null, so without this stamp every top-level DM collapses into ONE session key and message 2 pre-empts message 1 ("Redirected current run", 2026-07-27 report). Only in thread-per-message mode: flat mode keeps the shared rolling DM session on purpose (steer/queue there is the intended UX). Never overwrites a real thread_id (an in-thread reply must keep resolving to its thread's session). """ try: src = getattr(event, "source", None) if not src: return platform = getattr(src, "platform", None) if getattr(platform, "value", platform) != Platform.SLACK.value: return if getattr(src, "thread_id", None): return # real thread — its session key is already correct message_id = getattr(event, "message_id", None) or getattr( src, "message_id", None ) if not message_id: return if not self._effective_reply_in_thread(): return if not self._dm_top_level_threads_as_sessions(): return # opt-out: threaded replies, one rolling session src.thread_id = str(message_id) except Exception: # noqa: BLE001 - session stamping must never break inbound logger.debug("slack session-thread stamp failed", exc_info=True) async def _localize_inbound_media(self, event) -> None: """Download connector re-hosted attachments to local temp paths. The wire's ``media_urls`` name connector re-hosts (``{connector}/relay/media/{id}``, per-gateway-bearer-authenticated) or public platform CDN URLs (Discord pass-through). Every NATIVE adapter presents inbound media to the agent as LOCAL FILE PATHS (the vision / file tools consume paths, and an authenticated URL would be useless in the agent's context anyway) — so mirror that here: fetch each URL and swap the list entries for temp paths. Best-effort per entry: a failed download drops that entry (never the message); no client ⇒ only re-host URLs are dropped (they'd 401 for every consumer downstream), public URLs stay. """ try: urls = list(getattr(event, "media_urls", None) or []) if not urls: return # media_types is INDEXED IN PARALLEL with media_urls by every # downstream classifier (_event_media_type_at). Any URL we drop or # rewrite here must carry its MIME with it, or the surviving # attachments inherit a neighbour's type and get mis-routed (an # image classified by a PDF's mime is not treated as an image). # Carry (url, mime) as PAIRS through the whole loop. types = list(getattr(event, "media_types", None) or []) pairs = [ (u, types[i] if i < len(types) else "") for i, u in enumerate(urls) ] client = self._get_media_client() localized: list[tuple[str, str]] = [] for url, mime in pairs: if not isinstance(url, str) or not url: continue if client is None: # No authenticated client: keep public URLs, drop re-hosts. if "/relay/media/" not in url: localized.append((url, mime)) continue path = await client.download(url) if path: localized.append((path, mime)) elif "/relay/media/" not in url: # A public URL that failed to download still has value as # a URL (native adapters pass URLs to vision in some # lanes); a dead re-host reference does not. localized.append((url, mime)) event.media_urls = [u for u, _ in localized] # Keep the parallel-array invariant: one mime slot per surviving # url, always. A short/stale media_types would shift entries onto # the wrong url the moment anything indexes or merges them. event.media_types = [m for _, m in localized] except Exception: # noqa: BLE001 - media localization must never break inbound logger.debug("relay inbound media localization failed", exc_info=True) def prime_routing_cache(self, event) -> None: """Warm the per-chat egress routing caches from a SYNTHETIC event. The caches (_scope_by_chat/_dm_user_by_chat/...) are normally warmed only by the inbound path (_on_inbound -> _capture_scope). A synthetic completion turn injected right after a restart (durable async-delegation replay) reaches handle_message with the caches COLD, so every reply it produces egresses without metadata.scope_id / metadata.user_id and is declined by the connector's fail-closed tenant guard ("target not routed to an onboarded tenant" — staging 2026-08-09, defect #4). The synthetic event's session-store origin already carries the discriminators; feed it through the same capture used for real inbound. Never raises. """ if event is None or getattr(event, "source", None) is None: return self._capture_scope(event) def _capture_scope(self, event) -> None: """Remember a chat_id's egress discriminator from an inbound event so our outbound (the agent's reply) can re-assert it for the connector's egress tenant resolution. Never raises — scope tracking must not break inbound. Two discriminators, captured independently (a scoped message has BOTH): - scope_id: for a scoped (guild/channel) message. The connector's primary path resolves the tenant from metadata.scope_id (routing table). - user_id: the authentic author id, captured for EVERY message (DM and scoped alike). The connector resolves the tenant from the recipient's author binding (resolveByUser) when a route lookup misses. This is the sole discriminator for a DM (no scope), AND the author-first FALLBACK for a scoped reply whose guild has no route row — a managed agent joins guilds dynamically, so a provision-time guild route is not guaranteed. Re-attaching user_id on scoped replies too lets the connector's guild-route-miss fallback resolve the tenant the same way inbound already does (SharedSocketRouter targets()). Without a resolvable discriminator the connector's egress guard declines the reply as 'target not routed to an onboarded tenant'. See gateway-gateway routedEgressGuard.ts / discordTenant.ts (makeDiscordTenantOf). """ try: src = getattr(event, "source", None) if not src: return chat = getattr(src, "chat_id", None) if not chat: return # Phase 1.5: remember the underlying platform for this chat so the # reply egresses through the right sender (one relay adapter fronts N # platforms). source.platform is a Platform enum (e.g. Platform.DISCORD, # mapped from the connector's "discord" by ws_transport _frame_to_event); # record its string VALUE, skipping the generic RELAY fallback (a # single-platform connector that didn't tag a concrete platform — the # connector's session default handles egress then). platform = getattr(src, "platform", None) platform_value = getattr(platform, "value", platform) if platform_value and platform_value != "relay": self._platform_by_chat[str(chat)] = str(platform_value) # Author id for outbound author-binding resolution. Captured for BOTH # DM and scoped messages: it's the sole discriminator for a DM and # the guild-route-miss fallback for a scoped reply. (Formerly captured # for DMs only, which left managed-agent guild replies with no # resolvable tenant when the guild had no route row.) user_id = getattr(src, "user_id", None) if user_id: self._dm_user_by_chat[str(chat)] = str(user_id) scope = getattr(src, "scope_id", None) if scope: self._scope_by_chat[str(chat)] = str(scope) # Remember the chat_type so send() can suppress the synthetic-DM # thread anchor on Slack (native _resolve_thread_ts parity). send() # only receives a chat_id, so it needs this per-chat cache to know a # chat is a DM. chat_type = getattr(src, "chat_type", None) if chat_type: self._chat_type_by_chat[str(chat)] = str(chat_type) # Triggering message ts: the typing/status lane's metadata # (base.py _thread_metadata_for_source) carries NO thread anchor # for a top-level DM, but in thread-per-message mode the status # must target the per-message thread (its root = this ts). Cache # it per chat so send_typing can synthesize the anchor, mirroring # native send_typing's _resolve_thread_ts(metadata.message_id). # NOTE: message_id lives on the EVENT (MessageEvent), not the # source — fall back to source for defensive coverage. message_id = getattr(event, "message_id", None) or getattr( src, "message_id", None ) if message_id: self._last_inbound_ts_by_chat[str(chat)] = str(message_id) except Exception: # noqa: BLE001 - scope tracking must never break inbound pass def _with_scope( self, chat_id: str, metadata: Optional[Dict[str, Any]] ) -> Dict[str, Any]: """Ensure the outbound metadata carries the discriminator(s) the connector's egress guard needs to resolve the owning tenant. - scope_id: re-attached for a scoped reply (guild/channel) → routing-table resolution (the primary path). - user_id: the authentic author id we saw inbound, re-attached for EVERY reply we know it for. It is the sole discriminator for a DM (no scope), AND the author-first FALLBACK the connector uses when a scoped reply's guild has no route row (a managed agent joins guilds dynamically — the guild route may not be provisioned). Carrying both on a scoped reply is harmless: the connector tries scope_id first and only falls back to user_id on a route miss. Without a resolvable discriminator egress is declined as 'target not routed to an onboarded tenant'. See gateway-gateway routedEgressGuard.ts / discordTenant.ts. No-op when the relevant value is already present or unknown for this chat. """ meta: Dict[str, Any] = dict(metadata or {}) if not meta.get("scope_id"): scope = self._scope_by_chat.get(str(chat_id)) if scope: meta["scope_id"] = scope # Author-binding discriminator. Attached whenever we know the author for # this chat and it isn't already set — for DMs (the sole discriminator) # AND scoped replies (the connector's guild-route-miss fallback). It is # only consulted by the connector when the scope/route lookup misses, so # carrying it alongside scope_id never overrides routing-table resolution. if not meta.get("user_id"): author = self._dm_user_by_chat.get(str(chat_id)) if author: meta["user_id"] = author return meta def fronts_platform(self, platform: Any) -> bool: """Whether the authenticated relay transport advertises ``platform``. This is the restart-safe delivery ownership signal: it comes from the configured identity set sent during handshake, not from an inbound chat cache learned only after a user sends another message. """ platform_value = getattr(platform, "value", platform) if not platform_value: return False ids = getattr(self._transport, "_identities", None) if not ids: return False return any(p == str(platform_value) for p, _ in ids) def _platform_is_fronted(self, platform: str) -> bool: """Backward-compatible internal alias for follow-up routing.""" return self.fronts_platform(platform) def supports_inchannel_continuable_for_platform(self, platform: Any) -> bool: """Whether ONE fronted logical platform can host the flat continuable cron surface (the D6 gate in cron/scheduler.py). The scalar ``supports_inchannel_continuable`` carries only the PRIMARY identity's bit, but one RelayAdapter fronts N platforms and the connector advertises the capability per platform at handshake. On a multi-platform relay the scalar both leaks the primary's True onto platforms whose own descriptor never advertised it and suppresses a non-primary platform's advertised True. Resolve the platform's own negotiated descriptor off the transport; fall back to the scalar only when the per-platform descriptor is unavailable (single-platform transport, or a transport predating descriptor_for_platform). """ platform_value = str(getattr(platform, "value", platform) or "") if platform_value and self._transport is not None: resolve = getattr(self._transport, "descriptor_for_platform", None) if callable(resolve): try: per_platform = resolve(platform_value) except Exception: # noqa: BLE001 - capability lookup must never break delivery per_platform = None if per_platform is not None: return bool( getattr( per_platform, "supports_inchannel_continuable", False ) ) return bool(self.supports_inchannel_continuable) async def on_interrupt(self, session_key: str, chat_id: str) -> None: """Bridge a connector-delivered /stop into the adapter's interrupt path. The connector forwards a mid-turn interrupt down the socket owned by the gateway instance running ``session_key``; this routes it to the existing per-session interrupt mechanism (sets the ``_active_sessions[session_key]`` Event and clears typing), cancelling the right turn without touching sibling sessions. """ await self.interrupt_session_activity(session_key, chat_id) async def _on_passthrough(self, forward, buffer_id: Optional[str] = None) -> None: """Handle a connector-forwarded passthrough request (Phase 5 §5.1). The passthrough plane (Discord interactions, Twilio webhooks, …) answers the provider's latency-critical ACK at the connector EDGE, then forwards the real, ALREADY-SANITIZED request to this gateway over the outbound WS. The connector is the trust boundary: it verified the provider signature at the edge and stripped any shared-identity credential (e.g. a Discord interaction follow-up token) into its vault — so this body carries no token, and the agent later acts on it via the token-less ``follow_up`` path (``send_follow_up``), never holding the credential. For a Discord interaction we decode the (JSON) body and convert it to a normalized ``MessageEvent`` so it flows through the SAME agent path as a chat message (``handle_message``); the agent's reply egresses over the normal outbound/follow_up path. Non-JSON or non-interaction forwards are logged and dropped for now (Twilio/SMS over the relay is a later unit). NEVER raises: a malformed forward must not kill the read loop. Interaction -> MessageEvent command mapping (formerly flagged here as an open sub-design, now implemented): an APPLICATION_COMMAND interaction is normalized to a leading-slash COMMAND event ("/name arg…", mirroring the connector's Slack slash-command lane, normalizeSlackCommand), so the dispatcher routes it as a command instead of plain chat. Component interactions (custom_id) still surface as best-effort TEXT; the deferred-vs-immediate response UX remains connector-side. """ try: platform = getattr(forward, "platform", "") or "" if platform == "discord": event = self._discord_interaction_to_event(forward) if event is not None: self._capture_scope(event) # Phase 3: a component press carrying a Hermes prompt token # resolves its waiting primitive and is consumed (same # gate as _on_inbound's prompt_response arm). if await self._consume_prompt_response(event): return await self.handle_message(event) return logger.info( "relay passthrough_forward dropped (no handler): platform=%s method=%s path=%s", platform, getattr(forward, "method", "?"), getattr(forward, "path", "?"), ) except Exception: # noqa: BLE001 - a bad forward must never break the reader logger.warning("relay passthrough_forward handling failed", exc_info=True) def _discord_interaction_to_event(self, forward): """Convert a forwarded Discord interaction body to a MessageEvent, or None. Builds the session source the same way the connector does for an interaction (``interactionSessionSource`` on the connector side), so the agent's session key matches the one the connector bound the follow-up capability under. Returns None when the body isn't a usable interaction (e.g. a PING, which the connector already answers at the edge and never forwards). """ import json from gateway.platforms.base import MessageType try: payload = json.loads(bytes(getattr(forward, "body", b"")).decode("utf-8")) except Exception: # noqa: BLE001 return None if not isinstance(payload, dict): return None # type 1 = PING (answered at the edge, never forwarded); 2 = APPLICATION_COMMAND; # 3 = MESSAGE_COMPONENT; 5 = MODAL_SUBMIT. Surface a best-effort text. itype = payload.get("type") data = payload.get("data") or {} message_type = MessageType.TEXT if itype == 2: # Normalize a real slash-command interaction to a leading-slash # command string — the shape the dispatcher (MessageEvent.is_command: # text.startswith("/")) and the native Discord adapter's # _run_simple_slash lane (f"/model {name}".strip()) both expect. # Options render space-separated: scalar options contribute their # value; SUB_COMMAND/SUB_COMMAND_GROUP (types 1/2) contribute their # name then their nested options. Mirrors the connector's Slack # slash lane (normalizeSlackCommand: `${command} ${args}`.trim()). text = ("/" + str(data.get("name") or "")).rstrip("/") or "" if text: parts = [text] + self._render_interaction_options(data.get("options")) text = " ".join(parts).strip() message_type = MessageType.COMMAND elif itype == 3: text = str(data.get("custom_id") or "") else: text = "" member = payload.get("member") or {} user = ( (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {} ) channel_id = str(payload.get("channel_id") or "") guild_id = payload.get("guild_id") # real Discord interaction wire field source = SessionSource( # The LOGICAL platform, not Platform.RELAY. This lane parses a # Discord interaction wire payload, so the underlying platform is # known statically — and it must be stamped for three consumers: # 1. Session keys: the connector binds the interaction's # follow-up capability under buildSessionKey with # platform="discord" (interactionSessionSource); the relay # TEXT lane (ws_transport._event_from_wire) also maps to the # logical platform. RELAY here forked interaction sessions # away from both. # 2. /sethome: with platform=RELAY the handler filed the home # channel under platforms.relay.home_channel (invisible to # cron delivery, which looks up the logical platform) and # mirrored it into the dead RELAY_HOME_CHANNEL env var. # 3. Egress: _capture_scope records _platform_by_chat from this # value and deliberately skips the generic "relay". platform=Platform.DISCORD, chat_id=channel_id, # "group", not "channel": the session key embeds chat_type, and # BOTH the connector's capability binding (interactionSessionSource # → buildSessionKey, chat_type "group") and the native Discord # adapter's channel events key guild channels as "group". A # "channel" slot here forked the interaction session from the # chat's message session AND from the vault key the connector # bound the follow-up capability under. chat_type="group" if guild_id else "dm", user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None, user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None, scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot message_id=str(payload.get("id")) if payload.get("id") else None, # Same upstream-trust marker the relay text lane stamps # (ws_transport._event_from_wire): this interaction arrived over # the per-instance-authenticated relay WS after the connector # verified Discord's edge signature and resolved the tenant. # Without it, authz treated the event as unauthenticated relay # traffic — and /sethome's via_relay guard never engaged, which is # how platform=RELAY home channels slipped through in the first # place. Set locally, never read off the wire. delivered_via_upstream_relay=True, # The HERMES profile this interaction is routed to (multiplex # mode) — mirrors _event_from_wire's profile stamping for plain # relayed messages (#60586). Without this, a Team-Gateway's # Discord slash-command/button/modal always fell back to the # legacy agent:main namespace even when the connector resolved # a specific profile for it. profile=getattr(forward, "profile", None), ) event = MessageEvent(text=text, message_type=message_type, source=source) if itype == 3: # Phase 3: a component press whose custom_id is a Hermes prompt # token (hp1::) becomes a STRUCTURED prompt # answer — _on_inbound's _consume_prompt_response then resolves # the waiting approval/confirm/clarify, replacing the bare- # custom_id-as-text stub. Foreign custom_ids keep the legacy # best-effort TEXT shape. decoded = self._decode_prompt_token(text) if decoded: prompt_id, option_id = decoded msg = payload.get("message") or {} prompt_message_id = ( str(msg.get("id")) if isinstance(msg, dict) and msg.get("id") else None ) event.prompt_response = { "prompt_id": prompt_id, "option_id": option_id, "prompt_message_id": prompt_message_id, } event.text = f"/{option_id}" event.message_type = MessageType.COMMAND return event @staticmethod def _decode_prompt_token(token: str): """Decode an hp1:: callback token, or None. Mirrors the connector's promptCodec.decodePromptCallback (the token alphabet is [A-Za-z0-9_.-], ≤32 per id) so both ends agree on what is — and is not — a Hermes prompt answer. """ import re if not token: return None parts = token.split(":") if len(parts) != 3 or parts[0] != "hp1": return None id_re = re.compile(r"^[A-Za-z0-9_.\-]{1,32}$") if not id_re.match(parts[1]) or not id_re.match(parts[2]): return None return parts[1], parts[2] @staticmethod def _render_interaction_options(options) -> list: """Render Discord interaction options to space-separated text parts. Discord's `data.options` is a list of {name, value, type}. Scalar options (STRING/INTEGER/BOOLEAN/…) contribute just their value — matching the native adapter's `f"/model {name}".strip()` shape, where only the value follows the command. SUB_COMMAND (1) and SUB_COMMAND_GROUP (2) contribute their *name* then recurse into their nested `options` list (one level of nesting per Discord's schema: group -> subcommand -> scalars). """ parts: list = [] if not isinstance(options, list): return parts for opt in options: if not isinstance(opt, dict): continue if opt.get("type") in (1, 2): # SUB_COMMAND / SUB_COMMAND_GROUP sub_name = str(opt.get("name") or "").strip() if sub_name: parts.append(sub_name) parts.extend( RelayAdapter._render_interaction_options(opt.get("options")) ) else: value = opt.get("value") if value is not None and str(value).strip(): parts.append(str(value).strip()) return parts async def disconnect(self) -> None: # Budget accounting: the runner wraps this whole call in # asyncio.wait_for(_adapter_disconnect_timeout_secs()). Everything we # spend on monitor teardown and go_idle below eats into what the # transport can spend on its outbound drain, so measure from the top # and thread the REMAINDER down — otherwise worst-case # monitor(1s) + go_idle(2s) + drain + 3×teardown(1s) can blow the 5s # budget, cancelling teardown mid-drain and skipping the transport's # fail-pending loop (callers then block on _OUTBOUND_TIMEOUT_S). from gateway.relay.ws_transport import _env_disconnect_budget_s _started = time.monotonic() _budget = _env_disconnect_budget_s() # Phase 7 Unit 7d-B: stop the revocation monitor first so it can't fire a # spurious fatal during/after a deliberate teardown. if self._revocation_monitor is not None: self._revocation_monitor.cancel() try: await asyncio.wait_for( self._revocation_monitor, timeout=_RELAY_REVOCATION_MONITOR_TEARDOWN_TIMEOUT_S, ) except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001 - best-effort teardown pass self._revocation_monitor = None if self._transport is not None: # Phase 5 §5.3: emit going_idle as part of the gateway's EXISTING # drain/shutdown transition (the runner calls adapter.disconnect() # when the gateway enters `draining`). Asking the connector to flip # this instance to buffered-only BEFORE we tear down the socket means # inbound that arrives while we're asleep buffers durably and replays # on reconnect, instead of being pushed at a closing socket. The # connector is authoritative (it acks the flip); we stay serving until # the ack (Q-5.3c). Best-effort + guarded: a transport without go_idle # (the stub) or a failed/timed-out ack must not block shutdown — we # proceed to disconnect exactly as before, no regression. # # transport.disconnect() runs in finally so an outer cancellation # during go_idle (runner default adapter budget is 5s) still closes # the socket/supervisor instead of leaking them. shield() keeps the # teardown await itself from being cancelled mid-flight. try: go_idle = getattr(self._transport, "go_idle", None) if callable(go_idle): try: result: Any = go_idle( timeout_s=_RELAY_GO_IDLE_ON_DISCONNECT_TIMEOUT_S ) if asyncio.iscoroutine(result): await result except Exception: # noqa: BLE001 - going-idle is an optimization, never blocks drain logger.debug( "relay going_idle failed during drain", exc_info=True ) finally: try: _remaining = max(0.0, _budget - (time.monotonic() - _started)) try: _td = cast(Any, self._transport).disconnect( budget_s=_remaining ) except TypeError: # Transports without the budget_s keyword (stubs, # older implementations) keep the legacy signature. _td = self._transport.disconnect() await asyncio.shield(_td) except Exception: # noqa: BLE001 - teardown must not block outer cancel propagation logger.debug( "relay transport disconnect failed during drain", exc_info=True, ) async def go_dormant(self) -> bool: """Quiesce the relay for a scale-to-zero suspend (D12 / Phase 0). Unlike ``disconnect()`` (terminal teardown for shutdown/restart), this keeps the adapter's reconnect path armed so the gateway re-dials and drains its buffered backlog when the machine wakes. Delegates to the transport's ``go_dormant()`` when available; a transport without it (the stub) is a no-op that returns False, so callers degrade safely. NOTE: deliberately does NOT stop the revocation monitor — going dormant is not a teardown; the monitor stays live so a real opt-out/revocation during dormancy is still surfaced on wake. """ if self._transport is None: return False go_dormant = getattr(self._transport, "go_dormant", None) if not callable(go_dormant): return False try: result: Any = go_dormant() if asyncio.iscoroutine(result): return bool(await result) return bool(result) except Exception: # noqa: BLE001 - dormancy is best-effort, never blocks the idle path logger.debug("relay go_dormant failed", exc_info=True) return False async def send_for_platform( self, logical_platform: Any, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send to an explicitly advertised logical platform over Relay. Scheduled and persisted-home deliveries have no fresh inbound event to populate ``_platform_by_chat``. The shared delivery resolver calls this method only after ``fronts_platform`` succeeds, and this method repeats that check fail-closed before stamping the outbound frame. """ platform_value = getattr(logical_platform, "value", logical_platform) if not self.fronts_platform(platform_value): return SendResult( success=False, error=f"relay does not front platform {platform_value}", ) _sfp_metadata = dict(metadata or {}) # Gateway-internal interim marker (see send()): strip before the # wire; an interim send through this door also skips interception. _interim = bool(_sfp_metadata.pop("_interim_send", False)) # Finding #7 (live canary): the delivery resolver calls THIS method # directly (gateway/delivery.py), bypassing send() — an open native # stream must absorb the turn-final here too, or the stream is left # unsealed (frozen live indicator) and the final posts as a separate # duplicate message. if not _interim: _sfp_key = self._match_open_draft(str(chat_id), _sfp_metadata) else: _sfp_key = None if _sfp_key is not None: seal = await self._seal_open_draft( chat_id, content, _sfp_metadata, draft_key=_sfp_key ) if seal.success: return seal # Failed seal falls through to the plain send below (review # finding, PR 85796 point 1): never swallow the turn-final. logger.warning( "relay seal failed (%s); delivering turn-final as plain send", seal.error, ) if self._transport is None: return SendResult(success=False, error="no transport") _sfp_unfurl = self._slack_unfurl_hints(str(platform_value)) if _sfp_unfurl: _sfp_metadata.update(_sfp_unfurl) result = await self._transport.send_outbound( { "op": "send", "chat_id": chat_id, "content": content, "reply_to": reply_to, # format_hints on the explicit-platform lane too: this is the # scheduled/cron delivery path — the in_channel brief itself — # and it must render blocks exactly like an interactive send. # Stamps _sfp_metadata (the interim-marker-stripped copy, per # the seal path above), composing both sides of the merge. "metadata": self._with_scope( chat_id, self._with_format_hints_for_platform( str(platform_value), _sfp_metadata ), ), }, platform=str(platform_value), ) return SendResult( success=bool(result.get("success")), message_id=result.get("message_id"), error=result.get("error"), raw_response=result, ) def _format_hints( self, descriptor: Optional[CapabilityDescriptor], platform: Optional[str] ) -> Optional[Dict[str, bool]]: """Block-formatting hints for one outbound text frame, or None. Native Slack reads ``platforms.slack.extra.rich_blocks`` / ``markdown_blocks`` and renders Block Kit locally; on the relay lane the CONNECTOR owns the platform API call, so the gateway can only signal intent. Hints are stamped ONLY when (a) the DESTINATION platform's negotiated descriptor advertises ``supports_block_formatting`` — an old connector never receives dead metadata — and (b) the operator enabled at least one knob under the relay's per-logical-platform sub-block (``platforms.relay.extra..rich_blocks`` / ``markdown_blocks``, same seam and same _coerce_flag semantics as reply_in_thread). Both knobs default OFF, matching native's opt-in posture. ``descriptor``/``platform`` are the DESTINATION's, not the adapter's scalar primary identity: one RelayAdapter fronts N platforms, and gating on the primary descriptor both leaked hints onto platforms that never advertised the bit (Slack-primary, Discord chat) and suppressed them for platforms that did (Discord-primary, Slack chat). Same seam as ``_descriptor_for_chat`` / max_message_length. """ if descriptor is None or not getattr( descriptor, "supports_block_formatting", False ): return None try: extra = getattr(self.config, "extra", None) or {} sub = extra.get(str(platform or "").lower()) knob_src = sub if isinstance(sub, dict) else extra except Exception: # noqa: BLE001 - config shape is operator-owned return None hints: Dict[str, bool] = {} for knob in ("rich_blocks", "markdown_blocks"): if self._coerce_flag(knob_src.get(knob), False): hints[knob] = True return hints or None def _with_format_hints_for_chat( self, chat_id: str, metadata: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """Metadata with ``format_hints`` stamped for a chat-addressed send. Resolves the chat's platform from what we saw inbound (``_platform_by_chat``) and that platform's negotiated descriptor (``_descriptor_for_chat``) — falling back to the primary identity for chats we never saw inbound, matching every other per-chat capability. """ platform = self._platform_by_chat.get(str(chat_id)) or getattr( self.descriptor, "platform", None ) hints = self._format_hints(self._descriptor_for_chat(chat_id), platform) if not hints: return metadata merged = dict(metadata or {}) merged.setdefault("format_hints", hints) return merged def _with_format_hints_for_platform( self, platform_value: str, metadata: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """Metadata with ``format_hints`` stamped for an explicit-platform send. ``send_for_platform`` is the scheduled/persisted-home lane — the cron delivery path, i.e. the flagship consumer of the in_channel brief — and it has no inbound event to populate ``_platform_by_chat``, so the destination platform is the caller-supplied logical platform. Resolves that platform's negotiated descriptor off the transport; falls back to the scalar descriptor only when it IS that platform's (fail closed: never stamp from another platform's capability bit). """ descriptor: Optional[CapabilityDescriptor] = None if self._transport is not None: resolve = getattr(self._transport, "descriptor_for_platform", None) if callable(resolve): try: descriptor = cast( Optional[CapabilityDescriptor], resolve(str(platform_value)) ) except Exception: # noqa: BLE001 - capability lookup must never break a send descriptor = None if descriptor is None and getattr( self.descriptor, "platform", None ) == str(platform_value): descriptor = self.descriptor hints = self._format_hints(descriptor, str(platform_value)) if not hints: return metadata merged = dict(metadata or {}) merged.setdefault("format_hints", hints) return merged async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: send_metadata = dict(metadata or {}) explicit_platform = send_metadata.pop("_relay_logical_platform", None) # Consumer-declared interim send (commentary, tail flush): NOT the # turn-final, so it must never trigger seal-interception — sealing # the live stream with interim text orphans the true final into a # plain-send duplicate (live finding, 2026-08-16 canary). The # marker is gateway-internal; strip before the wire. _interim = bool(send_metadata.pop("_interim_send", False)) # NS-658 seal-interception — checked BEFORE the explicit-platform # branch (finding #7, live canary): the delivery-resolver lane # (follow-up queue, media-accompanied finals, scheduled sends) routes # through send_for_platform, which posted a plain send while the # native stream stayed open — the user got the stream frozen # mid-word (live indicator, never sealed) PLUS the final as a # separate message. An open stream absorbs the turn-final send no # matter which egress door it arrives through; the stream IS the # message. if not _interim: _send_key = self._match_open_draft(str(chat_id), send_metadata) else: _send_key = None if _send_key is not None: seal = await self._seal_open_draft( chat_id, content, send_metadata, draft_key=_send_key ) if seal.success: return seal # Review finding (PR 85796, point 1): a failed seal must NOT # swallow the turn-final — the stream consumer has already # disabled the draft transport, so returning failure here means # the user never gets the answer. Fall through to a plain send # (the orphaned stream is sealed connector-side by recycling / # MAX_OPEN_STREAMS eviction). logger.warning( "relay seal failed (%s); delivering turn-final as plain send", seal.error, ) if explicit_platform: return await self.send_for_platform( explicit_platform, chat_id, content, reply_to=reply_to, metadata=send_metadata or None, ) if self._transport is None: return SendResult(success=False, error="no transport") # Native _resolve_thread_ts parity: a Slack DM reply must post flat at # the DM root, not threaded under the triggering message. One shared # helper resolves the anchor for EVERY egress lane (see # _apply_slack_thread_anchor) so the text and media lanes cannot drift. effective_reply_to = self._apply_slack_thread_anchor( chat_id, reply_to, send_metadata ) _unfurl = self._slack_unfurl_hints( self._platform_by_chat.get(str(chat_id)) or getattr(self.descriptor, "platform", None) ) if _unfurl: send_metadata.update(_unfurl) result = await self._transport.send_outbound( { "op": "send", "chat_id": chat_id, "content": content, "reply_to": effective_reply_to, "metadata": self._with_scope( chat_id, self._with_format_hints_for_chat(chat_id, send_metadata) ), }, platform=self._platform_by_chat.get(str(chat_id)), ) # Auto-thread routing feedback (contract §SendResult): when the # connector's auto-thread egress policy routed this send into a # thread it just created, the result carries thread_id (+ the # initial name). The conversation was keyed on the PARENT channel — # the thread didn't exist at ingest — so this is the only place the # gateway learns where the reply landed. The semantic-rename lane # (auto session title) reads it via auto_thread_info_for_chat. try: _at_thread = result.get("thread_id") _at_name = result.get("auto_thread_name") if _at_thread and _at_name: self._auto_thread_by_chat[str(chat_id)] = ( str(_at_thread), str(_at_name), ) if len(self._auto_thread_by_chat) > 256: self._auto_thread_by_chat.pop( next(iter(self._auto_thread_by_chat)), None ) except Exception: # noqa: BLE001 - feedback capture must never break send pass # Wake the rename lane on EVERY send into this chat, not only the ones # that auto-threaded. It is waiting to learn where this turn's reply # landed, and "nowhere new" is an answer — one it should get now rather # than by outlasting a timeout. waiter = self._auto_thread_waiters.get(str(chat_id)) if waiter is not None: waiter.set() return SendResult( success=bool(result.get("success")), message_id=result.get("message_id"), error=result.get("error"), ) def auto_thread_info_for_chat( self, chat_id: str ) -> Optional[Tuple[str, str]]: """(thread_id, initial_name) of the auto-thread the connector created for the most recent send into *chat_id*, if any. Consumed by the gateway's semantic thread-rename lane (auto session title).""" return self._auto_thread_by_chat.get(str(chat_id)) async def wait_for_auto_thread_info( self, chat_id: str, timeout: float ) -> Optional[Tuple[str, str]]: """``auto_thread_info_for_chat``, but willing to wait for the send. The rename lane asks where the reply landed as soon as the session is titled, and the session is titled from the user's opening message — before the model has answered, let alone before we've sent anything. So the question arrives a whole turn early, and a turn is a one-liner or twenty minutes of tool calls. Waits for the next send into this chat and then answers, so a reply the connector didn't auto-thread reports its miss as soon as it's sent instead of holding until *timeout* — which is only a backstop for a turn that never sends at all. """ info = self.auto_thread_info_for_chat(chat_id) if info is not None: return info key = str(chat_id) waiter = self._auto_thread_waiters.get(key) if waiter is None: waiter = asyncio.Event() self._auto_thread_waiters[key] = waiter try: await asyncio.wait_for(waiter.wait(), timeout) except asyncio.TimeoutError: return None finally: # Only the waiter we may have installed, and only if no later call # replaced it; a fired event must not be left behind to make the # next turn's wait return instantly on stale feedback. if self._auto_thread_waiters.get(key) is waiter: self._auto_thread_waiters.pop(key, None) return self.auto_thread_info_for_chat(chat_id) def _resolve_reply_to_for_send( self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], ) -> Optional[str]: """Suppress the synthetic-DM thread anchor for a Slack DM reply. A DM turn's streaming reply is sent with ``reply_to`` = the triggering message's ts (the stream consumer's ``initial_reply_to_id``, used as the edit anchor and, on threading platforms, the reply target). The connector's slackRestSender maps a raw ``reply_to`` to a Slack ``thread_ts``, so a plain DM reply would be posted THREADED under the user's message instead of flat at the DM root — and a threaded first send loses the progressive edit-streaming the user sees in a real thread (the reported symptom: DM/home replies arrive flat, no progressive edits). Native Slack Hermes already suppresses this synthetic DM thread anchor: ``SlackAdapter._resolve_thread_ts`` returns ``None`` for a top-level / DM message when ``reply_in_thread`` is off. The relay lane has no such disambiguation, so we reproduce it here. run.py already encodes the real-thread decision in ``metadata["thread_id"]`` (it is set only when progress threading is active — a real thread, or channel autoThread); for a DM with no real thread that key is absent. So the rule is: Slack DM + no real ``thread_id`` in metadata ⇒ drop ``reply_to``. This posts the reply flat at the DM root and lets the consumer edit its own first-send ts — streaming works exactly as in a thread. It does NOT: * reintroduce a synthetic DM thread_id (#18859 / the /sethome landmine) — it removes an anchor, never adds one; * regress real-thread streaming — a real thread carries a distinct ``thread_id`` in metadata, so the guard leaves ``reply_to`` alone; * regress channel autoThread — a channel/group top-level reply carries ``thread_id`` (the message's own ts) in metadata when threading is on, so it is left alone; and a non-DM chat is never matched here. """ if reply_to is None: return None if self._platform_by_chat.get(str(chat_id)) != Platform.SLACK.value: return reply_to if self._chat_type_by_chat.get(str(chat_id)) != "dm": return reply_to md = metadata or {} if md.get("thread_id") or md.get("thread_ts"): # A real thread was resolved by run.py — honour it. return reply_to # Mode gate (native _resolve_thread_ts parity). The final-reply lane # (gateway/platforms/base.py) builds metadata from source.thread_id # ONLY — for a top-level DM that is None, so in thread-per-message # mode the triggering-ts reply_to here is the final reply's ONLY # threading signal (run.py's synthetic root feeds just the # progress/status lane). Dropping it unconditionally exiled the final # message to the DM root while progress stayed threaded (2026-07-27 # report, same class as the prompt-placement bug). Native SlackAdapter only # suppresses the anchor when reply_in_thread=false; mirror that. reply_in_thread = self._effective_reply_in_thread() if reply_in_thread: # Thread-per-message: the triggering ts is the thread anchor. return reply_to # Flat mode: synthetic DM self-anchor — post flat at the DM root. return None def _apply_slack_thread_anchor( self, chat_id: str, reply_to: Optional[str], metadata: Dict[str, Any], *, mirror_key: str = "reply_to_message_id", ) -> Optional[str]: """Resolve the outbound Slack thread anchor for ONE egress frame. The single choke point every send lane goes through — text (``send``) and media (``send_media``) alike. It does three things that must always happen together, and previously only happened on the text lane: 1. Mode gate: ``_resolve_reply_to_for_send`` drops the synthetic DM self-anchor in flat mode, keeps it in thread-per-message mode. 2. Mirror strip: when the anchor is dropped, remove the mirrored ``metadata.reply_to_message_id`` too, so the connector cannot thread on the copy we forgot about. 3. Anchor promotion: the connector's Slack sender THREADS ON METADATA ONLY — ``threadTs()`` reads ``metadata.thread_id``/``thread_ts`` and never looks at the frame's ``reply_to``. A surviving anchor is promoted into ``metadata.thread_id`` or the message silently lands in the home channel instead of the per-message thread. ``metadata`` is mutated in place; the effective ``reply_to`` is returned. Non-Slack and non-DM chats are untouched by (1), and (3) is Slack-only, so other fronted platforms keep their existing behaviour. """ effective_reply_to = self._resolve_reply_to_for_send( chat_id, reply_to, metadata ) if effective_reply_to is None and reply_to is not None: metadata.pop(mirror_key, None) if ( effective_reply_to is not None and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and not (metadata.get("thread_id") or metadata.get("thread_ts")) ): metadata["thread_id"] = str(effective_reply_to) return effective_reply_to def _with_status_thread_anchor( self, chat_id: str, metadata: Optional[Dict[str, Any]] ) -> Dict[str, Any]: """Copy ``metadata`` with the typing/status thread anchor applied. Slack's status line is THREAD-scoped: the connector's typing case no-ops without a thread anchor, and the typing lane's metadata (base.py ``_thread_metadata_for_source``) carries none for a top-level DM (``source.thread_id`` is None). Synthesize it from the per-chat inbound-ts cache, exactly as native ``send_typing`` resolves ``thread_ts`` from ``metadata.message_id``. Unconditional across both modes: in flat mode the send lane strips its own anchors (see ``_apply_slack_thread_anchor``), so the status anchor cannot leak into reply placement, and ``setStatus`` clears without leaving a message artifact. Shared by ``send_typing`` and ``stop_typing`` — the clear MUST target the same thread the heartbeat set, or the status line sticks until Slack's own timeout. Keeping one implementation is what guarantees it. """ md = dict(metadata or {}) if ( not (md.get("thread_id") or md.get("thread_ts")) and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value and self._chat_type_by_chat.get(str(chat_id)) == "dm" ): anchor = self._last_inbound_ts_by_chat.get(str(chat_id)) if anchor: md["thread_id"] = anchor return md async def edit_message( self, chat_id: str, message_id: str, content: str, *, finalize: bool = False, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Edit a relayed message through the connector-owned platform API.""" if self._transport is None: return SendResult(success=False, error="no transport") result = await self._transport.send_outbound( { "op": "edit", "chat_id": chat_id, "message_id": message_id, "content": content, # Same format_hints as send: a streamed reply's FINAL edit is # the frame that carries the finished markdown, so the edit # lane must signal block rendering too or streams would seal # as plain text (boundary rule: every text egress lane). "metadata": self._with_scope( chat_id, self._with_format_hints_for_chat(chat_id, metadata) ), }, platform=self._platform_by_chat.get(str(chat_id)), ) return SendResult( success=bool(result.get("success")), message_id=result.get("message_id") or message_id, error=result.get("error"), ) async def delete_message( self, chat_id: str, message_id: str, ) -> bool: """Delete a relayed message through the connector-owned platform API. Consumer: the stream consumer's fresh-final cleanup — on the Slack unfurl force-on route the completed reply is re-delivered as a new stamped post and the sealed streamed preview must go away, or the user sees the answer twice. Gated on the negotiated descriptor advertising the ``delete`` op (additive within contract_version 1): older connectors never receive an op they can't dispatch, and this returns False so the consumer's best-effort cleanup degrades to leaving the preview in place — exactly the pre-delete behavior. """ if self._transport is None: return False desc = self._descriptor_for_chat(str(chat_id)) if "delete" not in (desc.supported_ops or ()): return False try: result = await self._transport.send_outbound( { "op": "delete", "chat_id": chat_id, "message_id": message_id, "metadata": self._with_scope(chat_id, {}), }, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: logger.debug("relay delete_message failed", exc_info=True) return False return bool(result.get("success")) async def send_typing(self, chat_id: str, metadata=None) -> None: """Egress a typing indicator through the connector. The base class spawns ``_keep_typing`` for every adapter (a 2s refresh loop for the life of the turn), but the relay adapter inherited the base no-op ``send_typing`` — so hosted/relay chats never showed "is typing…" even though the wire contract (``OutboundOp "typing"``) and every connector-side sender (Discord ``POST /channels/{id}/typing``, Telegram ``sendChatAction``, Signal ``sendTyping``, Slack assistant status) already implement it. This bridges the loop's tick onto the existing outbound frame. Two details are load-bearing, mirroring ``send()``: - ``_with_scope``: the connector's egress guard wraps ALL ops (routedEgressGuard), so a typing frame without a resolvable tenant discriminator (metadata.scope_id, or user_id for DMs) is declined exactly like a bare send would be. - the per-frame ``platform`` tag (Phase 1.5): a multi-platform gateway must egress typing through the platform the chat lives on. Best-effort: failures are swallowed (``_keep_typing`` already treats send_typing errors as non-fatal, and an older connector that rejects the op just returns an unsuccessful result we ignore). Each call is one-shot — Discord/Telegram indicators self-expire and need no cleanup; Slack Assistant status persists, so ``stop_typing`` below sends an explicit clear for Slack only. """ if self._transport is None: return # Thread anchor for the status surface. Slack's status line # ("is thinking…" in the thread's replies footer — works with plain # chat:write, confirmed on native no-assistant bots) is THREAD-only: # the connector's typing case no-ops without a thread_ts. But the # typing lane's metadata (base.py _thread_metadata_for_source) has no # anchor for a top-level DM — source.thread_id is None — so every # heartbeat was silently dropped. In thread-per-message mode the # turn's thread root IS the triggering message ts (run.py's synthetic # root); synthesize it here from the per-chat inbound cache, exactly # like native send_typing resolves thread_ts from metadata.message_id. # Flat mode (reply_in_thread=false) keeps the no-anchor no-op: there # is no thread and must not be one (#18859). md = self._with_status_thread_anchor(chat_id, metadata) # Rich status parity: run.py's live-status lane stashes the # current per-tool phrase via set_status_text() (base class store). # Carry it as the typing frame's content so the connector's Slack # sender renders it on assistant.threads.setStatus — the same phrase # the native adapter shows ("is running pytest…", "Finding answers…"). # Absent (None/empty) => omit content; the connector falls back to its # default "is typing…" heartbeat, preserving pre-phrase behaviour on # every platform. Never send empty-string content here: on Slack that # is the explicit CLEAR request reserved for stop_typing. frame: Dict[str, Any] = { "op": "typing", "chat_id": chat_id, "metadata": self._with_scope(chat_id, md), } phrase = getattr(self, "_status_text", {}).get(str(chat_id)) if phrase: frame["content"] = str(phrase) try: await self._transport.send_outbound( frame, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: # noqa: BLE001 - typing is cosmetic, never breaks a turn logger.debug("relay send_typing failed for %s", chat_id, exc_info=True) async def stop_typing( self, chat_id: str, metadata: Optional[Dict[str, Any]] = None, ) -> None: """Forward an explicit typing/status clear to the connector. Slack Assistant status persists until explicitly cleared (empty ``content`` on the ``typing`` op). Other relay senders expose only one-shot typing heartbeats; sending an empty heartbeat there would incorrectly re-trigger typing at completion, so this is Slack-gated. NOTE (deploy order): a connector older than gateway-gateway #154 hardcodes ``status: "is typing…"`` for the typing op, so an empty clear frame would SET the status instead of clearing it. Deploy the connector first. Best-effort like ``send_typing``: status clearing is cosmetic and must never break turn completion. """ if self._transport is None: return platform = self._platform_by_chat.get(str(chat_id)) if platform != Platform.SLACK.value: return # Clear must target the SAME thread the heartbeat set, or the clear # frame no-ops threadless and the status line sticks until Slack's own # timeout. Shared helper with send_typing so the two guards cannot drift. md = self._with_status_thread_anchor(chat_id, metadata) try: await self._transport.send_outbound( { "op": "typing", "chat_id": chat_id, "content": "", "metadata": self._with_scope(chat_id, md), }, platform=platform, ) except Exception: # noqa: BLE001 - status clear is cosmetic, never breaks a turn logger.debug("relay stop_typing failed for %s", chat_id, exc_info=True) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: # Proxied to the connector (it owns the platform connection / cache). # Gated on op-level capability discovery: a connector that doesn't # advertise get_chat_info in supported_ops (including every legacy # connector, where supported_ops is empty and the LEGACY_OPS set # applies) would only return "unsupported op", so skip the round trip # and answer with the same local fallback the error path produced. if self._transport is None or not self.descriptor.supports_op("get_chat_info"): return {"name": chat_id, "type": "dm"} return await self._transport.get_chat_info(chat_id) async def send_follow_up( self, session_key: str, kind: str, content: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send via a shared-identity capability bound to a session (A2 outbound). The gateway never holds the credential: it names the session it is already in plus the capability ``kind``, and the connector resolves the real value from its vault and egresses (enforcing the tenant match). Used e.g. to post a Discord interaction follow-up as the shared bot without the token ever reaching the gateway. See RelayTransport.send_follow_up. """ if self._transport is None: return SendResult(success=False, error="no transport") # Phase 1.5: the capability `kind` is platform-prefixed (e.g. # "discord.interaction_token"), so derive the egress platform from it when # it names one we front — that tags the OutboundFrame so a multi-platform # gateway routes the follow-up through the right sender. Falls back to the # session default (connector-side) when the prefix isn't a fronted platform. follow_up_platform = None if kind and "." in kind: prefix = kind.split(".", 1)[0] if self._platform_is_fronted(prefix): follow_up_platform = prefix result = await self._transport.send_follow_up( { "op": "follow_up", "session_key": session_key, "kind": kind, "content": content, "metadata": metadata or {}, }, platform=follow_up_platform, ) return SendResult( success=bool(result.get("success")), message_id=result.get("message_id"), error=result.get("error"), ) # ── Phase 2 media ───────────────────────────────────────────────────── def _get_media_client(self) -> Optional[RelayMediaClient]: """Lazily build the authenticated /relay/media client. Uses the SAME connector base URL the WS dials and the SAME per-gateway (id, secret) the upgrade authenticates with — no new configuration. None when either is unavailable (unenrolled/dev), and every media lane then degrades to its pre-media fallback. """ if self._media_client is not None: return self._media_client try: from gateway.relay import relay_connection_auth, relay_url from gateway.relay.media import media_base_url url = relay_url() gateway_id, secret = relay_connection_auth() if not url: return None client = RelayMediaClient(media_base_url(url), gateway_id, secret) if not client.enabled: return None self._media_client = client return client except Exception: # noqa: BLE001 - media plumbing must never break the adapter logger.debug("relay media client init failed", exc_info=True) return None async def _send_media( self, chat_id: str, *, media_kind: str, source: str, source_is_path: bool, caption: Optional[str] = None, filename: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> Optional[SendResult]: """Egress one media object via the connector's ``send_media`` op. ``source`` is either a LOCAL file path (uploaded to the connector's /relay/media first — the connector cannot reach our filesystem) or an already-public URL (fal.media output etc. — passed through; the connector downloads it directly). Returns None when the lane is unavailable (op not advertised, no transport, upload failed) so each caller can fall back to its pre-media behaviour — media delivery is progressive enhancement, never a regression when the connector predates the op. """ if self._transport is None or not self.descriptor.supports_op("send_media"): return None source_url = source if source_is_path: client = self._get_media_client() if client is None: return None uploaded = await client.upload(source, filename=filename) if not uploaded: return None source_url = uploaded # Same Slack thread-anchor contract as the text lane (send). Media # frames egress through the connector's Slack sender too, so an # unresolved anchor threads an image under the user's DM message in # flat mode, and loses the per-message thread entirely in thread mode # (threadTs() reads metadata only). Route through the shared helper. media_metadata: Dict[str, Any] = dict(metadata or {}) effective_reply_to = self._apply_slack_thread_anchor( chat_id, reply_to, media_metadata ) _media_unfurl = self._slack_unfurl_hints( self._platform_by_chat.get(str(chat_id)) or getattr(self.descriptor, "platform", None) ) if _media_unfurl: media_metadata.update(_media_unfurl) action: Dict[str, Any] = { "op": "send_media", "chat_id": chat_id, "media_kind": media_kind, "source_url": source_url, "content": caption or "", "reply_to": effective_reply_to, "metadata": self._with_scope(chat_id, media_metadata), } if filename: action["filename"] = filename try: result = await self._transport.send_outbound( action, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: # noqa: BLE001 - transport failure degrades to the caller's fallback logger.debug("relay send_media transport failure", exc_info=True) return None if not result.get("success"): # A structured connector decline (size cap, platform rejection). # Surface it as a failed lane so the caller's fallback still # delivers SOMETHING (the caption/notice), mirroring native # adapters' upload-failure paths. logger.warning( "relay send_media declined for %s: %s", chat_id, result.get("error"), ) return None return SendResult( success=True, message_id=result.get("message_id"), raw_response=result, ) async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image (public URL) as a native attachment via the connector.""" result = await self._send_media( chat_id, media_kind="image", source=image_url, source_is_path=False, caption=caption, reply_to=reply_to, metadata=metadata, ) if result is not None: return result return await super().send_image( chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata ) async def send_image_file( self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send a local image file natively (upload → send_media).""" result = await self._send_media( chat_id, media_kind="image", source=image_path, source_is_path=True, caption=caption, reply_to=reply_to, metadata=metadata, ) if result is not None: return result return await super().send_image_file( chat_id, image_path, caption=caption, reply_to=reply_to, metadata=metadata, **kwargs, ) async def send_voice( self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send a local audio file as a native voice message (upload → send_media).""" result = await self._send_media( chat_id, media_kind="voice", source=audio_path, source_is_path=True, caption=caption, reply_to=reply_to, metadata=metadata, ) if result is not None: return result return await super().send_voice( chat_id, audio_path, caption=caption, reply_to=reply_to, metadata=metadata, **kwargs, ) async def send_video( self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send a local video file natively (upload → send_media).""" result = await self._send_media( chat_id, media_kind="video", source=video_path, source_is_path=True, caption=caption, reply_to=reply_to, metadata=metadata, ) if result is not None: return result return await super().send_video( chat_id, video_path, caption=caption, reply_to=reply_to, metadata=metadata, **kwargs, ) async def send_document( self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send a local file as a downloadable attachment (upload → send_media).""" result = await self._send_media( chat_id, media_kind="document", source=file_path, source_is_path=True, caption=caption, filename=file_name, reply_to=reply_to, metadata=metadata, ) if result is not None: return result return await super().send_document( chat_id, file_path, caption=caption, file_name=file_name, reply_to=reply_to, metadata=metadata, **kwargs, ) # ── Phase 3 interactive: prompt + react ────────────────────────────── def _mint_prompt( self, kind: str, state: Dict[str, Any], timeout_s: float = 3600.0 ) -> str: """Register a pending prompt and return its id. ``state`` carries what the resolver needs when the answer comes back (session_key, resolver kind, per-kind extras). Expiry is enforced gateway-side on consumption (_pop_prompt) — the wire's timeout_s is advisory only. The id is ``.<8 hex>``: the nonce marks the minting process so a sibling gateway that receives the same fanned-out answer can tell it is not the owner and stay quiet (see ``_prompt_owner_nonce``). Both segments use the callback alphabet the connector's prompt codec accepts ([A-Za-z0-9_.-], <=32 chars). """ import time prompt_id = f"{self._prompt_owner_nonce}.{secrets.token_hex(4)}" self._pending_prompts[prompt_id] = { **state, "kind": kind, "expires_at": time.time() + timeout_s, } # Opportunistic sweep so abandoned prompts can't accumulate: drop # anything already expired (cheap — dict is small by construction). now = time.time() for stale in [ k for k, v in self._pending_prompts.items() if v.get("expires_at", 0) < now ]: self._pending_prompts.pop(stale, None) return prompt_id def _minted_here(self, prompt_id: str) -> bool: """True when this process minted ``prompt_id``. Ids minted before the owner nonce existed (a prompt still pending across an in-place upgrade) carry no ``.`` segment; treat them as ours so an in-flight prompt from the previous build still resolves. """ head, sep, _ = str(prompt_id).partition(".") return head == self._prompt_owner_nonce if sep else True def _pop_prompt(self, prompt_id: str) -> Optional[Dict[str, Any]]: """Consume a pending prompt: one answer wins, expired entries miss.""" import time state = self._pending_prompts.pop(str(prompt_id), None) if not state: return None if state.get("expires_at", 0) < time.time(): return None return state def _note_prompt_resolved(self, prompt_id: str) -> None: """Remember that this process already answered ``prompt_id``. Bounded FIFO: a repeat answer is only interesting for as long as a redelivery or a double tap can plausibly arrive, so old ids are dropped rather than retained for the process lifetime. """ self._resolved_prompts[str(prompt_id)] = time.time() while len(self._resolved_prompts) > _RESOLVED_PROMPT_MEMORY: self._resolved_prompts.popitem(last=False) async def _send_prompt( self, chat_id: str, *, prompt_kind: str, text: str, prompt_id: str, options: list, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, timeout_s: Optional[int] = None, ) -> Optional[SendResult]: """Egress one `prompt` op; None when the lane is unavailable. Mirrors _send_media's progressive-enhancement posture: op-gated on the descriptor advertising `prompt`, and every failure returns None so the caller falls back to its numbered-text base behaviour (which is the pre-Phase-3 UX, still fully functional via typed replies). """ if self._transport is None or not self.descriptor.supports_op("prompt"): return None # An interactive prompt (approval / clarify / slash-confirm) is emitted # mid-turn in reply to the triggering inbound event, so `metadata` carries # that event's thread context (run.py _thread_metadata_for_source stamps # metadata.thread_id — for a Slack DM the triggering message's own ts, # used only as a session-keying fallback). Forwarding it makes the # connector thread the prompt card UNDER the triggering message instead # of posting it flat at the DM root (the reported bug). Native Slack # Hermes suppresses this synthetic DM thread anchor; drop it here for the # same Slack-DM-with-no-real-thread case, matching _resolve_reply_to_for_send. # Prompt metadata is forwarded VERBATIM. The threading mode is decided # in exactly one place — run.py's _resolve_progress_thread_id (flat mode # suppresses the synthetic self-anchor there; thread mode stamps the # turn's thread). Boundary pinned by test_run_py_suppresses_self_anchor*. prompt_metadata = metadata action: Dict[str, Any] = { "op": "prompt", "chat_id": chat_id, "content": text, "prompt_kind": prompt_kind, "prompt_id": prompt_id, "options": options, "reply_to": self._resolve_reply_to_for_send( chat_id, reply_to, prompt_metadata ), "metadata": self._with_scope(chat_id, prompt_metadata), } if timeout_s is not None: action["timeout_s"] = int(timeout_s) try: result = await self._transport.send_outbound( action, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: # noqa: BLE001 - transport failure degrades to fallback logger.debug("relay prompt transport failure", exc_info=True) return None if not result.get("success"): logger.warning( "relay prompt declined for %s: %s", chat_id, result.get("error") ) return None return SendResult( success=True, message_id=result.get("message_id"), raw_response=result, ) async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, ) -> SendResult: """Native-button exec approval over the relay (Phase 3). Renders the same choice set as the native adapters (Allow Once / Session / Always / Deny, gated by the same flags) through the connector's `prompt` op. The user's press comes back as a prompt_response and resolves via tools.approval.resolve_gateway_approval — the exact mechanism the native button handlers use. When the lane is unavailable the send FAILS (success=False) so gateway/run.py's existing button→text fallback takes over (same contract as a native adapter's failed button send). """ options: list = [{"id": "once", "label": "Allow Once", "style": "primary"}] if not smart_denied and allow_session: options.append({"id": "session", "label": "Allow Session"}) if allow_permanent: options.append({ "id": "always", "label": "Always Allow", }) options.append({"id": "deny", "label": "Deny", "style": "danger"}) cmd_preview = command if len(command) <= 1500 else command[:1500] + "..." text = ( "⚠️ **Command Approval Required**\n\n" f"```\n{cmd_preview}\n```\n" f"Reason: {description}" ) if smart_denied: text += ( "\n\n**Smart DENY:** owner override applies to this one operation only." ) prompt_id = self._mint_prompt( "exec_approval", {"session_key": session_key, "chat_id": str(chat_id)}, ) result = await self._send_prompt( chat_id, prompt_kind="approval", text=text, prompt_id=prompt_id, options=options, metadata=metadata, ) if result is not None: return result # Lane unavailable: unregister and let run.py's text fallback run. self._pending_prompts.pop(prompt_id, None) return SendResult(success=False, error="relay prompt op unavailable") async def send_slash_confirm( self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Three-button slash-command confirmation over the relay (Phase 3). Resolves via tools.slash_confirm.resolve() — the same primitive the native button handlers call. Falls back (success=False) to the gateway's text-intercept flow when the prompt lane is unavailable. """ options = [ {"id": "once", "label": "Approve Once", "style": "primary"}, {"id": "always", "label": "Always Approve"}, {"id": "cancel", "label": "Cancel", "style": "danger"}, ] text = f"**{title}**\n\n{message}" if title else message prompt_id = self._mint_prompt( "slash_confirm", { "session_key": session_key, "confirm_id": confirm_id, "chat_id": str(chat_id), }, ) result = await self._send_prompt( chat_id, prompt_kind="approval", text=text, prompt_id=prompt_id, options=options, metadata=metadata, ) if result is not None: return result self._pending_prompts.pop(prompt_id, None) return SendResult(success=False, error="relay prompt op unavailable") async def send_clarify( self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, session_key: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Native-button clarify over the relay (Phase 3). Multiple-choice clarifies render as a `prompt` op (one button per choice + Other). A press resolves via tools.clarify_gateway.resolve_gateway_clarify with the CHOICE TEXT (never the option id); "Other" flips the clarify to text-capture exactly like the native adapters. Open-ended clarifies and unavailable lanes fall back to the base numbered-text behaviour. Option ids are positional (c0..cN / other) — choice text can be arbitrary UTF-8 and would blow the 64-byte callback budget; the registry maps ids back to the real strings on the answer. """ if choices and self.descriptor.supports_op("prompt"): options = [ {"id": f"c{i}", "label": str(choice)[:75]} for i, choice in enumerate(choices) ] options.append({"id": "other", "label": "✏️ Other (type your answer)"}) prompt_id = self._mint_prompt( "clarify", { "session_key": session_key, "clarify_id": clarify_id, "choices": [str(c) for c in choices], "chat_id": str(chat_id), }, ) result = await self._send_prompt( chat_id, prompt_kind="clarify", text=f"❓ {question}", prompt_id=prompt_id, options=options, metadata=metadata, ) if result is not None: return result self._pending_prompts.pop(prompt_id, None) return await super().send_clarify( chat_id, question, choices, clarify_id, session_key, metadata=metadata ) async def _consume_prompt_response(self, event) -> bool: """Route an inbound prompt_response to its waiting primitive. Returns True when the event was a prompt answer (consumed — do NOT dispatch it as a chat message), False otherwise. A prompt answer is ALWAYS consumed, whoever it belongs to. The three non-resolving cases each stay silent rather than falling through to chat dispatch: * **A sibling's prompt** (``_minted_here`` false). The connector fans a passthrough forward out to every live session of the tenant, so one button press reaches every gateway of that tenant while only the minting one can resolve it. Falling through here is what produced a wall of ``Unknown command `/c1``` — one per sibling — under the single ``✅`` from the real owner. * **A repeat answer** for an id this process already resolved (a double tap, or a redelivered forward). The first answer won; a second must not re-run the resolver or re-ack. * **Our own expired/unknown prompt.** Answer with a short expiry notice instead of a command-not-found error: the option ids a prompt renders ("c1", "once", "other") are not commands, so the text lane cannot do anything useful with them. """ pr = getattr(event, "prompt_response", None) if not isinstance(pr, dict): return False prompt_id = str(pr.get("prompt_id") or "") option_id = str(pr.get("option_id") or "") if not prompt_id or not option_id: return False if not self._minted_here(prompt_id): # A sibling gateway of this tenant owns this prompt and is # resolving it right now. Consume silently — two gateways must # never both answer one press. logger.debug( "relay prompt_response %s (option=%s) belongs to another " "gateway instance — ignoring", prompt_id, option_id, ) return True if prompt_id in self._resolved_prompts: logger.debug( "relay prompt_response %s (option=%s) already resolved — ignoring " "repeat", prompt_id, option_id, ) return True state = self._pop_prompt(prompt_id) if state is None: logger.info( "relay prompt_response for unknown/expired prompt %s (option=%s)", prompt_id, option_id, ) await self._notify_prompt_expired(event) return True self._note_prompt_resolved(prompt_id) kind = state.get("kind") chat_id = str(state.get("chat_id") or getattr(event.source, "chat_id", "")) session_key = str(state.get("session_key") or "") try: if kind == "exec_approval": from tools.approval import resolve_gateway_approval choice = ( option_id if option_id in {"once", "session", "always", "deny"} else "deny" ) count = resolve_gateway_approval(session_key, choice) label = { "once": "✅ Approved once", "session": "✅ Approved for session", "always": "✅ Approved permanently", "deny": "❌ Denied", }.get(choice, "Resolved") if not count: label = "⌛ Approval expired — no command was waiting." # Acknowledge in-channel (the connector's prompt message can't # be edited cross-platform yet — edit support varies; a short # confirmation preserves the audit trail the native edit gives). # Fire-and-forget: we are ON the read loop here (see # _send_lifecycle_ack) — awaiting the send self-deadlocks the # transport for the full outbound timeout. self._send_lifecycle_ack( chat_id, label, self._prompt_reply_metadata(event) ) if count: self.resume_typing_for_chat(chat_id) elif kind == "slash_confirm": from tools import slash_confirm as slash_confirm_mod choice = ( option_id if option_id in {"once", "always", "cancel"} else "cancel" ) result_text = await slash_confirm_mod.resolve( session_key, str(state.get("confirm_id") or ""), choice ) label = { "once": "✅ Approved once", "always": "🔒 Always approve", "cancel": "❌ Cancelled", }.get(choice, "Resolved") # Fire-and-forget (read-loop context — see _send_lifecycle_ack). self._send_lifecycle_ack( chat_id, label, self._prompt_reply_metadata(event) ) if result_text: self._send_lifecycle_ack( chat_id, str(result_text), self._prompt_reply_metadata(event), ) elif kind == "clarify": from tools.clarify_gateway import ( mark_awaiting_text, resolve_gateway_clarify, ) clarify_id = str(state.get("clarify_id") or "") if option_id == "other": mark_awaiting_text(clarify_id) self._send_lifecycle_ack( chat_id, "✏️ Type your answer:", self._prompt_reply_metadata(event), ) else: choices = state.get("choices") or [] try: idx = int(option_id[1:]) if option_id.startswith("c") else -1 except ValueError: idx = -1 if 0 <= idx < len(choices): resolve_gateway_clarify(clarify_id, str(choices[idx])) self._send_lifecycle_ack( chat_id, f"✅ {choices[idx]}", self._prompt_reply_metadata(event), ) else: # Unmappable option: flip to text capture so the user # can answer by typing (never dead-end a clarify). mark_awaiting_text(clarify_id) else: logger.warning("relay prompt_response with unknown kind %r", kind) except Exception: # noqa: BLE001 - a resolver failure must not kill the reader logger.warning("relay prompt_response resolution failed", exc_info=True) return True def _send_lifecycle_ack( self, chat_id: str, text: str, metadata: Dict[str, Any] ) -> None: """Fire-and-forget a prompt-lifecycle ack from read-loop context. Live finding round 2 (rc.4): _consume_prompt_response executes ON the transport read loop (inbound frame -> _handle_frame -> the _inbound handler). ``await self.send(...)`` there is a SELF-DEADLOCK: send() blocks on an outbound_result future that only the read loop can resolve — and the read loop is blocked inside this very handler. Every button tap wedged the transport for the full outbound timeout: draft appends starved (the observed frozen stream right after approving), sibling approval-card sends timed out into 'possibly-delivered' ambiguity, and the turn's seal timed out ambiguous -> plain-send fallback (the duplicate final). Acks are cosmetic by contract (the audit trail), so they ride a background task: the handler returns immediately, the read loop keeps consuming, and the ack's own result frame resolves normally. Failures are logged at debug — an undelivered ack must never break the reader or the turn. The task ref is retained (asyncio only weakly references tasks) and dropped on completion. """ async def _ack() -> None: try: await self.send(chat_id, text, metadata=metadata) except Exception: # noqa: BLE001 - ack is best-effort logger.debug("relay lifecycle ack failed", exc_info=True) task = asyncio.create_task(_ack(), name="relay-lifecycle-ack") self._lifecycle_ack_tasks.add(task) task.add_done_callback(self._lifecycle_ack_tasks.discard) async def _notify_prompt_expired(self, event) -> None: """Tell the presser their prompt is no longer waiting. Only the OWNING gateway reaches this (siblings return earlier), so the user sees exactly one notice. Best-effort: a send failure here must not break the reader. """ chat_id = str(getattr(event.source, "chat_id", "") or "") if not chat_id: return # Fire-and-forget (read-loop context — see _send_lifecycle_ack): # _notify_prompt_expired is called from _consume_prompt_response too. self._send_lifecycle_ack( chat_id, "⌛ That prompt is no longer waiting for an answer. " "Send your reply as a normal message.", self._prompt_reply_metadata(event), ) def _prompt_reply_metadata(self, event) -> Dict[str, Any]: """Thread/topic metadata so prompt acks land where the prompt lives. Marked as an INTERIM send (live finding, rc.4 staging): prompt lifecycle acks ("✅ Approved once", slash-confirm acks, expiry notices) are system messages that fire while the approval turn's OWN draft stream is open. Without the interim marker they carry only placement metadata — no per-turn identity — so send()'s single-open-stream fallback matched them to the live draft and sealed it with the ack text. Every later append then died on the post-seal tombstone (silent by design), freezing the visible stream mid-word, and the real turn-final fell through to a plain send: the observed 100%-reproducible stuck-draft + duplicate-final on approval turns. Interim sends bypass draft matching entirely. """ meta: Dict[str, Any] = {"_interim_send": True} thread_id = getattr(event.source, "thread_id", None) if thread_id: meta["thread_id"] = str(thread_id) return meta # ── Phase 3 ack lifecycle (👀 → ✅/❌) ──────────────────────────────── async def _react( self, chat_id: str, message_id: str, emoji: str, *, remove: bool = False, ) -> bool: """Egress one `react` op; best-effort (False on any failure). Reactions are cosmetic by contract: a failure is logged at debug and never surfaces to the caller's flow (mirrors the native Discord adapter's _add_reaction posture). """ if self._transport is None or not self.descriptor.supports_op("react"): return False if not chat_id or not message_id: return False try: result = await self._transport.send_outbound( { "op": "react", "chat_id": chat_id, "message_id": message_id, "emoji": emoji, "remove": remove, "metadata": self._with_scope(chat_id, None), }, platform=self._platform_by_chat.get(str(chat_id)), ) return bool(result.get("success")) except Exception: # noqa: BLE001 - reactions are cosmetic logger.debug("relay react failed", exc_info=True) return False async def on_processing_start(self, event) -> None: """Add the 👀 in-progress reaction (op-gated; silent no-op otherwise).""" message_id = getattr(event, "message_id", None) or getattr( event.source, "message_id", None ) chat_id = getattr(event.source, "chat_id", None) if message_id and chat_id: await self._react(str(chat_id), str(message_id), "👀") async def on_processing_complete(self, event, outcome) -> None: """Swap 👀 for ✅/❌ per outcome (op-gated; silent no-op otherwise).""" from gateway.platforms.base import ProcessingOutcome message_id = getattr(event, "message_id", None) or getattr( event.source, "message_id", None ) chat_id = getattr(event.source, "chat_id", None) if not (message_id and chat_id): return await self._react(str(chat_id), str(message_id), "👀", remove=True) if outcome == ProcessingOutcome.SUCCESS: await self._react(str(chat_id), str(message_id), "✅") elif outcome == ProcessingOutcome.FAILURE: await self._react(str(chat_id), str(message_id), "❌") # ── Phase 4 thread lifecycle ────────────────────────────────────────── async def create_handoff_thread( self, parent_chat_id: str, name: str, ) -> Optional[str]: """Create a thread/topic under ``parent_chat_id`` via the connector. One `thread_create` op covers Discord (channel thread), Telegram (forum topic), and Slack (named seed root message — threads there are message-anchored). Op-gated on the descriptor advertising `thread_create`; None on any failure/unavailability so the handoff watcher falls back to the parent channel — the same contract as the native adapters' create_handoff_thread. """ if self._transport is None or not self.descriptor.supports_op("thread_create"): return None thread_name = (str(name or "").strip() or "handoff")[:100] try: result = await self._transport.send_outbound( { "op": "thread_create", "chat_id": str(parent_chat_id), "thread_name": thread_name, "metadata": self._with_scope(str(parent_chat_id), None), }, platform=self._platform_by_chat.get(str(parent_chat_id)), ) except Exception: # noqa: BLE001 - handoff falls back to the parent channel logger.debug("relay thread_create transport failure", exc_info=True) return None if not result.get("success"): logger.info( "relay thread_create declined for %s: %s", parent_chat_id, result.get("error"), ) return None thread_id = result.get("thread_id") or result.get("message_id") return str(thread_id) if thread_id else None async def rename_thread( self, thread_id: str, name: str, *, only_if_current_name: Optional[str] = None, prefer_connector_created: bool = False, parent_chat_id: Optional[str] = None, ) -> bool: """Best-effort thread rename via the connector's `thread_rename` op. The relay sibling of the native Discord adapter's rename_thread — called by the SAME semantic-rename lane (run.py _rename_discord_auto_thread_for_session_title), which fires only for sources carrying the connector-stamped auto-thread markers. No-clobber guard: prefer ``prefer_connector_created=True``, which asks the CONNECTOR to enforce the guard from ITS OWN created-name memory (only_if_connector_created) — the gateway no longer has to reproduce the thread's initial name byte-for-byte, which drifted on any normalization difference and silently declined every relay rename. ``only_if_current_name`` is the legacy string guard, kept for the native-marker lane and older connectors. ``parent_chat_id`` is the containing chat where the caller knows it (Telegram needs it); defaults to the thread id itself (Discord ignores chat_id). """ if self._transport is None or not self.descriptor.supports_op("thread_rename"): return False cleaned = " ".join(str(name or "").split()).strip() if not cleaned or not thread_id: return False chat_id = str(parent_chat_id or thread_id) action: Dict[str, Any] = { "op": "thread_rename", "chat_id": chat_id, "message_id": str(thread_id), "thread_name": cleaned[:100], "metadata": self._with_scope(chat_id, None), } if prefer_connector_created: action["only_if_connector_created"] = True elif only_if_current_name is not None: action["only_if_current_name"] = str(only_if_current_name) try: result = await self._transport.send_outbound( action, platform=self._platform_by_chat.get(chat_id) or self._platform_by_chat.get(str(thread_id)), ) except Exception: # noqa: BLE001 - renames are cosmetic logger.debug("relay thread_rename transport failure", exc_info=True) return False if not result.get("success"): logger.info( "relay thread_rename declined for %s: %s", thread_id, result.get("error"), ) return False return True