Files
hermes-agent/gateway/relay/adapter.py
T
Ben Barclay 866332bfb5 fix(relay): authorize send_message targets and surface egress declines (P5) (#99220)
* fix(relay): authorize send_message targets and surface egress declines

P5 of the relay egress-authorization workstream. The relay path
authenticated the SENDER but never authorized the DESTINATION, and the
gateway compounded it from both ends.

(a) send_message could silently name an arbitrary relay target. Its
`target` parameter is free-form ('platform:chat_id'), so a model could
name ANY chat id and the gateway would emit an outbound frame for it.
gateway/relay/egress.py adds an attestation floor: a relay-routed
destination must have a provenance this gateway can show -- the
operator's home channel, the channel directory, or its own gateway
session origins. Anything else is refused HERE, with a visible tool
error naming the target, before a frame is written. Non-relay platforms
and platforms served by a live native adapter in this process are
untouched (same precedence resolve_delivery_transport applies).

(b) Connector declines were swallowed into apparent successes. The
connector's egress floor answers an unauthorized destination with a
DEFINITE failure whose text is deliberately uniform (F-005). Several
relay lanes degrade a *transport drop* by design and were degrading an
*authorization refusal* the same way:

  - _send_media returned None, sending the caller into
    BasePlatformAdapter's text fallback -- a DIFFERENT op re-addressed at
    the very chat the connector had just refused.
  - _send_prompt returned None, so exec-approval / slash-confirm /
    clarify reported "relay prompt op unavailable" (a wrong reason) and
    ran their numbered-text fallbacks into the refused chat.
  - task_card_stop discarded the error entirely.
  - typing / delete / react / thread ops degraded silently at debug.

is_egress_decline() classifies THAT a decline happened (never why --
the uniform text is not parsed for reasons) and requires a definite,
non-ambiguous failure, so a lost-ack retry is still a transport
outcome. Lanes with an error-carrying contract now report the decline
verbatim; cosmetic bool/None lanes still degrade but log it at WARNING.

Advisory progress drops that legitimately degrade are unchanged: the
task_card send lane, the draft ambiguous/except branches, and every
transport-exception path keep their existing fail-open behaviour.

Tests: 21 mutations of the production source, all KILLED.

* fix(relay): authorize the RESOLVED target; declines must not fall back

Review round 1 (independently confirmed by a second reviewer) found three
blockers. Two are fixed here; the third (B-2, Telegram @username) is a policy
decision left open deliberately.

B-1 — THE FIX CAUSED THE OUTAGE IT PREVENTED (tools/send_message_tool.py)

The P5(a) guard ran ABOVE Slack user->DM resolution, so it authorized the
internal pseudo-id `_parse_target_ref` emits (`user_name:ben`, `user:U...`).
Provenances only ever hold RESOLVED conversation ids, so a fully attested DM
was compared as a handle against a set of `D...` ids and refused:

  base  slack:@ben  SENT        head(before)  slack:@ben  REFUSED

Every Slack DM by handle was broken. Moved the guard below resolution; it now
authorizes the destination that is actually sent to, and the refusal names the
resolved id. Position is load-bearing, so it is commented as such and pinned:
reverting the move turns exactly the four new cases red.

B-3 — A DECLINE IS NOT A LANE FAILURE (gateway/run.py)

`_approval_send_outcome` had only sent/failed/ambiguous, so a connector
decline collapsed into `failed` — which is the cue to run the plain-text
fallback into the chat the connector had just refused. The adapter fix in the
previous commit improved the error STRING while user-visible behaviour stayed
identical to base; the commit message overstated it. Fixed properly:

  - new `declined` verdict, recognised via the shared `is_egress_decline`
    contract (not string sniffing at the call site)
  - exec-approval returns without the text fallback
  - slash-confirm suppresses the text reply AND clears the registration, so a
    card that never rendered cannot capture the user's next message

`send_clarify` was already correct (returns early inside the adapter).

MUTATIONS (production source; both directions)
  classifier never returns 'declined'        -> KILLED (4 cases)
  ALL failures classified as 'declined'      -> KILLED (2 cases)
  guard moved back above Slack resolution    -> KILLED (4 cases)
  decline CODE changed (review M05)          -> KILLED
  marker match made case-sensitive (M10)     -> KILLED

M05 was a tautology: the test asserted the imported constant against itself,
so changing the constant could not fail it. The wire contract is now pinned as
a literal, because the connector stamps that exact string and a one-sided
change is a silent cross-repo break.

REGRESSION CHECK: the 12 failures + 1 collection error in this test selection
are PRE-EXISTING cross-test contamination — the identical set fails at
7cf86188ac. Verified by diffing the failing sets: no new failures, 363 -> 374
passed.

NOT FIXED (deliberate): B-2, Telegram `@username`. The Bot API resolves handles
at send time, so there is no id to compare and no canonicalization exists yet.
That is a policy decision, not a code move.

* fix(relay): fail CLOSED on guard faults; classify the structured decline

Third independent review. Two more blockers, both reproduced before fixing.

1. THE GUARD ITSELF FAILED OPEN (tools/send_message_tool.py:158)

`_authorize_relay_target` wrapped BOTH the import and the call in one
`except Exception: return None` — and None means AUTHORIZED at every call site.
So any runtime bug inside the guard silently switched the entire P5(a) boundary
off. Reproduced: with the guard raising, an unattested target sent.

The docstring already stated the correct intent ("must not fail closed on its
own IMPORT error") and the code did something broader. The two failures are not
the same: a missing gateway package means there is no relay egress to
authorize; a fault inside the guard means authorization did not happen. The
import is tolerated, the call is not — a guard that cannot answer refuses.

2. THE STRUCTURED DECLINE WAS THROWN AWAY (gateway/run.py)

The adapter preserves the connector's dict in `SendResult.raw_response`. My
previous commit rebuilt a dict from the error STRING, which loses two
contracts:

  * a decline carrying `code: egress_declined` and NO text renders as
    "relay egress declined" — no marker colon — so it classified as `failed`,
    which is exactly the cue to run the fallback into the refused chat;
  * `ambiguous: True` (lost ack) was flattened into a DEFINITE failure,
    re-sending a card that may already be on the user's screen. That is the
    duplicate-card bug the ambiguous verdict exists to prevent, reintroduced
    by the fix meant to harden the same path.

Both call sites now classify `raw_response` when present, ambiguity first, and
fall back to the wire sentence only for connectors that send no structured
response.

I had fixed the text-marker path and tested only the text-marker path. Worth
naming: the review's probe was a shape my tests never produced.

MUTATIONS (production source)
  guard fault returns None (fail open again)   -> KILLED
  classifier ignores raw_response              -> KILLED (3 cases)
  ambiguous treated as a definite failure      -> KILLED (2 cases)

40 focused tests pass. Regression check vs be321faf27: identical 13-item
failing set (pre-existing cross-test contamination), no new failures.

STILL OPEN: B-2 / finding 3, Telegram `@username`. The reviewer is right that
this is a REGRESSION of an existing contract (#53573 added Bot API username
support), not merely an unspecified input, since relay provenance stores the
numeric chat id. Fixing it means resolving the handle before authorization, or
explicitly revoking the contract. That is a policy decision, not a code move,
and it is Ben's call.

* test(relay): pin M21 and M25, the survivors whose comments called them load-bearing

Round-2 review reported six unpinned survivors from round 1. Two guard real
behaviour and are now covered; the other four are cosmetic-lane warnings and
fail-open branches I am leaving documented rather than pretending to close.

M25 — thread-qualified session ids. `_session_ids` adds BOTH "chat:thread" and
the bare chat, because the connector authorizes the CHAT. Without the split a
gateway whose session origin is `-100999:77` cannot send to `-100999`, the chat
it is demonstrably already talking in. KILLED.

M21 — the generic `relay` plane must union every fronted platform, since a
relay session is filed under its LOGICAL platform. KILLED.

MY FIRST M21 TEST WAS THE DEFECT IT WAS TESTING FOR. I patched `_relay_fronted`
— the very function the mutation empties — so emptying it changed nothing the
test could see, and the mutation SURVIVED against a green test. Rewritten to
drive the real `relay_fronted_platforms()` through its env source
(`GATEWAY_RELAY_PLATFORMS`), which is how production learns it.

That is the same "the test verifies my stand-in" failure I have spent this
workstream removing from the connector harnesses, reproduced here in three
lines of Python. The tell was identical: a mutation that survives a test
written specifically to kill it.

334 tests pass.

NOT PINNED, deliberately: M03 (success-guard on a malformed dict), M24
(empty-target allowance — the one fail-open branch, reachable only when the
bare-platform path already resolved a home channel), M35/M36 (decline WARNINGs
on cosmetic lanes). All four are observability or defence-in-depth rather than
authorization, and the review agrees they are non-blocking.

* fix(relay): defer Telegram @username authorization to the connector (B-2)

Closes the last blocker. Two reviewers independently called this a REGRESSION
of the public-channel username support added in #53573, not an unspecified
input, and they were right: provenance stores RESOLVED numeric chat ids, so
comparing `@channel` against them could only ever refuse.

WHY THE GATEWAY CANNOT ANSWER IT. The guard fires only when there is no live
native adapter — i.e. relay-fronted deployments — and on exactly those the
CONNECTOR holds the bot token, not this process. There is no local way to turn
a handle into the numeric id. Refusing here is not "fail closed", it is "fail
always".

WHY DEFERRING IS SAFE. The destination is still authorized one layer out: the
connector's Telegram egress floor (gg#238, merged 743a7c2) classifies and
refuses unauthorized destinations after ITS resolution — the layer that closed
the reported vulnerability in the first place. Handles go from two guards to
one, the authoritative one, not to zero.

The carve-out is deliberately narrow and its EDGES are pinned, because the
failure mode of an exemption is silent widening:

  telegram `@handle`        -> deferred            (the regression case)
  telegram numeric id       -> still guarded
  matrix `@user:server`     -> still guarded       (telegram-only)
  bare name, no `@`         -> still guarded
  attested handle           -> normal path, attestation still consulted

MUTATIONS
  carve-out widened to all platforms   -> KILLED
  carve-out widened to every target    -> KILLED
  carve-out removed (regression back)  -> KILLED
  carve-out checked BEFORE attestation -> KILLED

THE ORDERING MUTANT SURVIVED MY FIRST TEST. Both orderings return None, so
asserting the verdict could not tell them apart — the test asserted the claim
instead of the mechanism. Rewritten to observe that attestation is actually
consulted. Same defect class as the M21 test earlier in this branch: a
mutation surviving a test written specifically to kill it means the test is
measuring the wrong thing.

341 tests pass.

FOLLOW-UP (option 2, Ben's call, deliberately NOT done here): resolve the
handle before authorizing so BOTH layers apply. That needs a resolution
round-trip through the connector — new wire surface — so it belongs in its own
phase rather than bolted onto this one. Recorded in the code comment at the
carve-out, not just here.

* fix(relay): close two fail-open boundaries; test the code-only decline for real

Both blockers from review, each REPRODUCED before fixing.

1. STRUCTURED DECLINE HAD NO GUARD. Deleting `raw_response=result` from both
   `_send_prompt` return branches left all 34 tests green — a surviving,
   non-equivalent security mutant. The `code` field is the documented
   PREFERRED signal precisely because a connector may send no prose, and a
   caller rebuilding `{"success": False, "error": ...}` cannot see it.

   Cause: every existing case declines with marker TEXT. The evidence for the
   code-only path was a hand-built SimpleNamespace in a different file — a
   stand-in for the adapter, so it verified my fixture instead of production.

   Fixed with a CodeOnlyDecliningConnector driving the real
   `send_exec_approval` -> `_send_prompt`, feeding the REAL SendResult to the
   REAL `_approval_send_outcome`, plus the same shape on the media lane.
       drop raw_response  SURVIVED (34 passed) -> KILLED

2. TWO FAIL-OPEN BOUNDARIES, both "absence" and "fault" sharing a return.

   `_relay_fronted` swallowed EVERY exception and returned an empty set, which
   `relay_routed_platform` reads as "not relay-routed" — skipping the guard.
   Probe, with a positive control in the same run:
       positive_control_denied   = True
       discovery_fault_denied    = False   <- unattested target AUTHORIZED

   `_authorize_relay_target` caught every exception during IMPORT as "no
   gateway package". A module that exists and fails to initialize is a fault,
   not an absence, and returning None there means authorized.

   Now: ImportError alone is absence; anything else raises RelayRouteUnknown
   and `authorize_relay_target` converts it to a REFUSAL STRING (not a raised
   exception — every caller treats the return value as the verdict, so raising
   would trade a fail-open for a crash).

   Kept the converse under test so "fail closed" does not silently become
   "refuse everything in CLI/cron", which is the outage the broad except
   existed to prevent.
       discovery fault -> empty set        KILLED
       RelayRouteUnknown -> authorized     KILLED
       import fault -> authorized          KILLED

397 passed (was 392, +5 new cases), zero failures.

* fix(relay): close all seven review-round-3 blockers

Every finding reproduced before fixing; every fix mutation-checked after.

CONTENT LEAKS (the decline was laundered into a different op, same chat)

#1 A declined DRAFT SEAL replayed as a plain send. On stream-is-the-message
   platforms the turn-final becomes draft(final=True); `_seal_open_draft`
   dropped the structured body, so `_absorb_into_open_draft` read a REFUSAL as
   a lane failure and fell through. Probe, Slack descriptor:
       before: draft(partial) -> draft(final,SECRET) -> send(SECRET)
       after:  draft(partial) -> draft(final,SECRET)
   My first probe of this used a discord descriptor and showed no seal at all —
   the leak is real, my probe was wrong (streams only arm for Slack).

#6 Task-card PROGRESS had the same defect one lane over: a bare failed
   SendResult reads as "card lane unavailable", and TurnRunner then sends the
   task text to the same chat. Both card methods now carry raw_response and
   the caller suppresses the fallback on a decline.

AUTHORIZATION BYPASSES

#2 `except ImportError` was NOT the fix I claimed last round. ImportError also
   covers a broken dependency inside an INSTALLED gateway; review probed
   `ImportError.name = "gateway.relay.dependency"` and got an authorized
   verdict. Now only a name identifying the gateway relay module itself is
   absence. An ImportError with NO name stays absence — refusing on a fault we
   cannot attribute would trade an unidentifiable bug for a real CLI/cron
   outage, and an existing test caught exactly that when I first got it wrong.

#3 `relay_routed_platform` lowercases the requested platform; `_relay_fronted`
   returned configured names verbatim. A platform configured as "Discord"
   missed the membership test, looked native, and skipped the guard:
       'discord' => refused    'Discord' => ALLOWED    'DISCORD' => ALLOWED
   An attestation bypass on a string comparison.

UNDELIVERABLE PROMPTS THAT HUNG

#4 `_clarify_send_disposition` handled `failed` and `ambiguous` but not
   `declined`, so a REFUSED clarify card fell through to wait_for_response and
   blocked until clarify_timeout — indefinitely when configured non-positive.
   A decline is more definitive than a failure, not less.

#5 The exec-approval decline branch returned quietly, which suppressed the text
   fallback (right) but left the CENTRAL approval entry pending (wrong) — the
   dangerous command stayed blocked until the approval timeout. My comment
   claimed the registration was torn down; only RelayAdapter's private map was.
   It now raises `_ExecApprovalDeclined`, which propagates to
   `_await_gateway_decision`'s existing notify-failure path (drops the entry,
   unblocks the tool). A dedicated type, re-raised past the local
   `except Exception` that would otherwise have restored the leak.

#7 THE GAP THAT LET ALL OF THIS SHIP. Both caller-level suppressions were
   unfalsifiable: deleting either branch left 36/38 tests green. The suites
   drove `_approval_send_outcome` and `RelayAdapter` but never the real
   TurnRunner / busy-session callers, so nothing observed whether a text send
   FOLLOWED a decline — which is the whole property.
   tests/gateway/test_decline_fallback_suppression.py drives both real callers
   and records every send. Each decline case is paired with an ordinary-FAILURE
   control, because without one a caller that never falls back would also pass.

MUTATIONS (all on production source, anchors count-checked, restored after)

  #1  seal decline -> plain send                KILLED
  #1b seal drops raw_response                   KILLED
  #2  nested ImportError -> authorized          KILLED
  #3  fronted set not normalized                KILLED
  #4  clarify declined branch removed           KILLED
  #5  approval decline returns not raises       KILLED
  #6  task_card drops raw_response              KILLED
  #7  slash-confirm suppression removed         KILLED

#7's two were the reviewer's SURVIVORS (36/38 passing); both now die.

425 passed, zero failures.

* fix(relay): close the three round-4 blockers

Round 4 confirmed six of seven round-3 fixes and found three more. Each
reproduced before fixing, each mutation-checked after.

1. A NAMELESS ImportError still authorized. Last round I admitted it as
   "absence" to protect the CLI/cron path. That reasoning was WRONG and the
   interpreter says so:

       import gateway.relay.nope  -> ModuleNotFoundError, name="gateway.relay.nope"
       import totally_absent_pkg  -> ModuleNotFoundError, name="totally_absent_pkg"

   Genuine absence is ALWAYS ModuleNotFoundError with `.name` set, so the
   CLI/cron path never produces a bare ImportError and nothing legitimate was
   being protected. A plain or nameless ImportError comes from an import hook
   or a module that failed while initializing — an unattributable FAULT.
   Now: absence is ModuleNotFoundError naming gateway / gateway.relay /
   gateway.relay.egress; everything else refuses. Two existing tests raised a
   bare ImportError to simulate absence and were corrected to the real shape.

2. SESSION ATTESTATION INVENTED IDS. `_session_ids` split every id on the first
   colon to recover "chat" from "chat:thread". Matrix ids contain a colon
   natively, so `!room:server.org` attested a bare `!room` — the guard
   vouching for a destination on its own fabrication. The split now applies
   only to platforms whose ids genuinely carry a `:thread` suffix (allow-list;
   unknown platforms are treated as un-splittable, which can only refuse more).
   Kept a Slack control: dropping the split entirely would refuse legitimate
   thread replies, which is the outage the split exists to prevent.

3. THE TASK-CARD FIX WAS UNFALSIFIABLE — my own round-3 mistake, and the same
   one round 3 caught me making. I added the production branch AND a test, but
   the test stopped at RelayAdapter: it proved `raw_response` is carried and
   never called `TurnRunner._task_card_publish`, which owns the property.
   Deleting the real branch left 30 tests green. Now driven through the real
   caller, with an ordinary-failure control.

   The lesson generalises: proving the DATA reaches the boundary is not proving
   the CALLER acts on it. Every one of these decline fixes has two halves and
   the second half is where the security lives.

Also closed the round-4 non-blocking finding: `gateway/relay/egress.py` has its
OWN import boundary, and the existing test intercepted the earlier import in
tools/send_message_tool.py, so it was never exercised. Mutating that classifier
to treat every ImportError as absence now dies.

MUTATIONS (production source, anchors count-checked, restored after)

  R4-1 nameless ImportError -> authorized        KILLED
  R4-2 session split unconditional               KILLED
  R4-3 task-card caller branch removed           KILLED  (was SURVIVED)
  egress classifier: any ImportError = absence   KILLED

Also probed and found NOT a leak: a refused OPENING draft frame disarms the
stream and the turn-final goes out via `send`. That send is itself guarded and
the connector refuses it too, so no content is delivered — unlike the seal case
(round 3, #1) where the seal was the only check on that path.

452 passed, zero failures.

* fix(relay): recover the thread parent from thread_id, not a colon split

Round 4 blocker 2 was closed with an allow-list of platforms whose ids have no
native colon. Reviewing my own fix while round 5 ran, the allow-list is the
wrong mechanism: it NARROWS a guess instead of removing it, and it still gets
Matrix wrong the moment a Matrix session is thread-qualified
(`!room:server.org:$thr` -> split yields `!room`).

The structured field was there all along. `_session_entry_id` composes the id
as f"{chat_id}:{thread_id}" and the entry still carries `thread_id`
separately, so the parent is knowable EXACTLY: strip the known suffix, or add
nothing. No platform list, no guessing, correct for ids that contain colons.

Mutations:
  back to splitting on the first colon        KILLED
  thread parent never recovered (over-refuse) KILLED

Both directions matter: the first invents attestations, the second refuses
legitimate thread replies.

One existing test (M25) asserted the right PROPERTY with a fixture that omitted
`thread_id` — a shape real entries never have. Fixture corrected, assertions
untouched.

453 passed.

* fix(relay): close the four round-5 blockers

Each reproduced before fixing, each mutation-checked after.

R5-1 A DISABLED NATIVE ADAPTER BYPASSED AUTHORIZATION. `_has_live_native_adapter`
     treated any entry in the adapter map as native; `resolve_delivery_transport`
     ignores a native adapter whose config is disabled and routes over Relay.
     Two independent routing classifiers, disagreeing:
         guard says native: True   delivery routes relay: True
     So the guard skipped authorization for a send that went over the relay.
     The guard now applies the router's enabled-state rule; probed both
     configurations and they agree.

R5-2 THREAD IDS WERE NEVER AUTHORIZED. The parser splits chat_id and thread_id;
     only chat_id reached the guard. On Discord the thread IS the destination —
     `POST /channels/{thread_id}/messages` — so an attested parent channel
     authorized an arbitrary caller-supplied thread. `authorize_relay_target`
     now takes thread_id and requires its own attestation (bare id or the
     `chat:thread` form a session origin produces); both call sites forward it.

R5-3 A DECLINED **INITIAL** DRAFT WAS RETRIED AS A PLAIN SEND. Round 3 fixed the
     declined SEAL; the declined OPEN was a different path. `send_draft`
     returned a bare failure, so the stream consumer read "draft transport
     unusable", disabled drafts and fell through to `_first_send`. Measured
     through the real adapter and real StreamTransportMixin:
         before: ops ['draft', 'send']      after: ops ['draft']
     send_draft now carries raw_response; a decline is terminal for the run and
     the guard sits in `_first_send`, where every fallback path converges.

R5-4 MY ROUND-4 TASK-CARD FIX SUPPRESSED EXACTLY ONE UPDATE. It set
     `native_failed`, which the entry gate already uses for an ordinary broken
     lane, so the next progress event skipped the decline branch and went
     straight to the text fallback:
         after first publish: []      after second: ['send']
     Terminal declines are now a separate `egress_declined` state checked at the
     entry gate. A refusal does not expire after one tick.

MUTATIONS

  R5-1  disabled native counts as native        KILLED
  R5-2  thread_id not authorized                KILLED
  R5-2b tool does not forward thread_id         KILLED (was SURVIVED)
  R5-3  initial-draft decline not terminal      KILLED
  R5-3b _first_send guard removed               KILLED
  R5-4  declined state not persistent           KILLED

R5-2b is the same gap that produced findings 3 and 4 of the last two rounds, a
third time: every test called `authorize_relay_target` directly, so dropping the
argument from the TOOL WRAPPER changed nothing. Testing the callee never proves
the caller uses it — now pinned explicitly.

Each fix ships with an ordinary-failure control, because every one of these
makes the guard refuse MORE, and over-refusal is now the larger risk.

474 passed, zero failures.

* refactor(relay): declare the terminal-decline state where it lives

Both terminal-decline flags were set dynamically. They worked (neither class is
frozen or slotted) but an undeclared attribute hides the state from anyone
reading the class, and this one is security-relevant.

  _TaskCardState.egress_declined  — declared dataclass field
  StreamConsumer._egress_declined — initialised in __init__

Lifetime verified while checking whether a refusal can leak ACROSS turns and
mute a healthy destination: it cannot. _TaskCardState is constructed per
progress-drain (run_turn_runner.py:420) and the consumer's flags per run
(stream_consumer.py:163), so both are fresh each turn.

Also verified the guard's blast radius after adding thread authorization: the
ONLY callers of authorize_relay_target are the two model-facing send_message
call sites. Gateway-internal sends — notably the handoff path, which creates a
thread and immediately posts to it with no session provenance yet — go through
transport.adapter directly and are unaffected. That was the most plausible
over-refusal, and it does not reach this guard.

461 passed.

* fix(relay): close the four round-6 blockers — the edit lane

R6-1 MY OWN R5-1 FIX REINTRODUCED THE BYPASS IT CLOSED. I wrote
     `except Exception: return True` around the config lookup, so a config read
     fault declared the platform native while the ROUTER, reading the real
     config, sends over the relay:
         guard_has_live_native True   guard_verdict None   router relay
     Routing we cannot determine is UNKNOWN. It now raises RelayRouteUnknown,
     which the outer handler must re-raise rather than flatten to False, and
     `authorize_relay_target` turns into a refusal. This is the second time a
     convenience `except` in this function created a bypass; there is now no
     permissive return left in it.

R6-2/3/4 THE NINTH LANE: `edit`. ONE dropped field, THREE leaks.
     `RelayAdapter.edit_message` discarded the connector response, and three
     independent callers read a bare edit failure as "editing is unavailable"
     and re-send the content as a NEW message to the same chat:

       stream edit fallback   ['edit', 'edit', 'send']  the unseen tail
       queued reconciliation  ['edit', 'send']          the WHOLE response
       task-card fallback     ['edit', 'send']          the task text again

     Fixed at the source (edit_message carries raw_response) plus each caller:
     `_on_edit_failure` — the single funnel for stream edit failures — makes a
     decline terminal for the run, `_send_fallback_final` refuses to deliver a
     continuation after one, the queued reconciler returns instead of sending,
     and the task-card fallback sets the same terminal state R5-4 introduced.

     R5-4 fixed the native task-card op and I did not check its sibling
     fallback path. The pattern across rounds 3-6 is consistent: the fix goes
     where the decline is OBSERVED, and the leak lives wherever someone else
     later decides to retry.

MUTATIONS

  R6-1  config fault -> assume native            KILLED
  R6-1b RelayRouteUnknown swallowed as False     KILLED
  R6-2  edit drops raw_response                  KILLED
  R6-2b edit-failure decline not terminal        KILLED
  R6-3  queued reconcile falls back on decline   KILLED
  R6-4  task-card fallback edit decline          KILLED

Each with an ordinary-failure control: a genuinely un-editable message must
still be delivered, and a broken card lane must still reach the user.

481 passed, zero failures.

* fix(relay): add a terminal-decline latch at the adapter choke point

THE STRUCTURAL FIX, not a twelfth local check.

Rounds 3-6 of review found ONE defect in eleven lanes: the connector refuses an
op, and some caller downstream reads that as 'this lane is unavailable' and
retries the same content through a DIFFERENT op against the SAME chat. Media,
prompt, draft-open, draft-seal, native task card, task-card fallback edit,
slash-confirm, exec-approval, clarify, stream edit, queued reconciliation.

Each was closed by adding a check at one more call site. That approach cannot
converge: gateway/ has ~60 outbound call sites, every one of them a place a
future change can reintroduce this, and four consecutive review rounds each
found another. The reviewer's own count of lanes is the argument against the
per-site design.

Every relay frame from every one of those callers passes through
_transport.send_outbound. One latch there covers them all: once the connector
refuses a chat, this adapter stops emitting CONTENT frames for that chat.

Proven to subsume the local checks: with the stream-edit per-site check
DISABLED, the leak probe still reports blocked=true — the frame never reaches
the wire. The local checks stay as defence in depth and for their better error
messages, but they are no longer the only thing standing between a decline and
a re-addressed send.

Scope is deliberately narrow, and each limit is mutation-pinned:
  per CHAT       - a refusal must not mute other conversations
  CONTENT ops    - typing/delete carry nothing; latching them would leave a
                   stuck typing indicator for no security gain
  self-healing   - cleared when the connector accepts that chat again, so a
                   transient policy change does not need a restart

Mutations:
  latch never set                KILLED
  latch never consulted          KILLED
  latch is global, not per-chat  KILLED
  latch never clears             KILLED

485 passed.

* fix(relay): one route source; the latch already covered round 7's lanes

Round 7 reviewed 573e41e294 — one commit BEFORE the terminal-decline latch —
and independently reached the same conclusion I had: 'The per-call-site
approach is structurally wrong. Use one turn-scoped choke point.' That is the
latch in 6dbc004594.

Its four 'still broken' lanes (tool-progress edit, progress-overflow edit,
long-running heartbeat edit, stale streamed-final reconciliation) all share the
shape edit_message->declined->adapter.send(same chat, same content), and NONE
has a local check. Probed all four against the latch:

  tool_progress      ops ['edit']  blocked
  progress_overflow  ops ['edit']  blocked
  heartbeat          ops ['edit']  blocked
  stale_final        ops ['edit']  blocked

That is the argument for the choke point, measured: lanes nobody patched are
safe anyway. Pinned by a parametrized test named for those four lanes.

R7-1 IS A REAL BYPASS THE LATCH DOES NOT COVER, and it is fixed here. The guard
rebuilt routing from GATEWAY_RELAY_PLATFORMS while resolve_delivery_transport
asks the CONNECTED adapter (fronts_platform, from the handshake identity set).
Different snapshots: with env discovery stale or momentarily empty, the guard
said 'native' and the router sent over the relay, skipping authorization.

  before: guard_relay_routed False / delivery relay
  after:  guard_relay_routed True  / delivery relay / unattested target refused

The guard now asks the live adapter first and falls back to config only when
there is no runner (CLI/cron) — pinned in both directions.

R7-5 (non-blocking, and a fair hit): my stream-fallback test asserted
_egress_declined and never drove _send_fallback_final, so removing that early
return SURVIVED. The test now calls the real fallback and asserts the wire is
untouched; the mutation dies.

Mutations:
  R7-1 guard ignores the live adapter    KILLED (was SURVIVED)
  R7-5 fallback early return removed     KILLED (was SURVIVED)
  latch not consulted                    KILLED

491 passed.

* fix(relay): close three holes found by attacking my own latch

Round 8's brief told the reviewer to attack the latch. I did the same in
parallel and found three real holes in it before the review returned.

1. send_for_platform BYPASSED THE LATCH ENTIRELY. It builds and posts its frame
   directly rather than through _outbound — and it is the delivery resolver's
   OWN entry point, so it is the single most important caller.
       before: ops ['edit', 'send']   after: ops ['edit']
   gateway/AGENTS.md states the rule I had just broken: 'Seal-interception
   exists at BOTH egress doors (send() and send_for_platform()); a new egress
   door needs the same two checks.' The latch is a third such check and I had
   wired it to one door.

2. A COSMETIC SUCCESS CLEARED THE LATCH. Clearing on ANY success meant a
   typing indicator — routinely allowed for a chat whose content is refused —
   re-opened the door for the very next send:
       ops ['edit', 'typing', 'send']
   Only a CONTENT op the connector accepted may clear it now.

3. A THREAD INSIDE A REFUSED CHAT WAS NOT COVERED. A thread lives inside its
   parent, so the same content reached the same conversation one level down:
       ops ['edit', 'send']
   The latch key now strips the thread suffix.

Also normalised int/str chat ids (callers pass both; a type mismatch would
silently unlatch).

MUTATIONS
  send_for_platform not latched          KILLED
  cosmetic success clears the latch      KILLED
  thread suffix not stripped             KILLED
  draft-seal retry not latched           SURVIVED — EQUIVALENT, proven:
       is unreachable while latched (a declined edit before the seal
      produces ZERO seal frames, measured). Kept as defence in depth because it
      posts directly, and documented at the site rather than covered by a
      test that could not fail.

One self-inflicted bug on the way: a blanket replace put 1Password CLI brings 1Password to your terminal.

Turn on the 1Password app integration and sign in to get started. Run
'op signin --help' to learn more.

For more help, read our documentation:
https://www.1password.dev/cli

1Password CLI is built using open-source software. View our credits and
licenses:
https://downloads.1password.com/op/credits/stable/credits.html

Usage:  op [command] [flags]

Management Commands:
  account         Manage your locally configured 1Password accounts
  connect         Manage Connect server instances and tokens in your 1Password account
  document        Perform CRUD operations on Document items in your vaults
  events-api      Manage Events API integrations in your 1Password account
  group           Manage the groups in your 1Password account
  item            Perform CRUD operations on the 1Password items in your vaults
  plugin          Manage the shell plugins you use to authenticate third-party CLIs
  service-account Manage service accounts
  user            Manage users within this 1Password account
  vault           Manage permissions and perform CRUD operations on your 1Password vaults

Commands:
  completion      Generate shell completion information
  inject          Inject secrets into a config file
  read            Read a secret reference
  run             Pass secrets as environment variables to a process
  signin          Sign in to a 1Password account
  signout         Sign out of a 1Password account
  update          Check for and download updates.
  whoami          Get information about a signed-in account

Global Flags:
      --account account    Select the account to execute the command by account shorthand, sign-in address, account ID, or user ID. For a list
                           of available accounts, run 'op account list'. Can be set as the OP_ACCOUNT environment variable.
      --cache              Store and use cached information. Caching is enabled by default on UNIX-like systems. Caching is not available on
                           Windows. Options: true, false. Can also be set with the OP_CACHE environment variable. (default true)
      --config directory   Use this configuration directory.
      --debug              Enable debug mode. Can also be enabled by setting the OP_DEBUG environment variable to true.
      --encoding type      Use this character encoding type. Default: UTF-8. Supported: SHIFT_JIS, gbk.
      --format string      Use this output format. Can be 'human-readable' or 'json'. Can be set as the OP_FORMAT environment variable.
                           (default "human-readable")
  -h, --help               Get help for op.
      --iso-timestamps     Format timestamps according to ISO 8601 / RFC 3339. Can be set as the OP_ISO_TIMESTAMPS environment variable.
      --no-color           Print output without color.
      --session token      Authenticate with this session token. 1Password CLI outputs session tokens for successful 'op signin' commands when
                           1Password app integration is not enabled.
  -v, --version            version for op

Run 'op [command] --help' for more information on the command. into
send_for_platform, which has no such variable. Two existing unfurl tests caught
it — NameError at adapter.py:1407.

504 passed.

* fix(relay): Telegram handle exemption + a turn boundary for the latch

Round 8 blockers. Two of its four were already closed by 93750e351a (it
reviewed the commit before it); these two are real and both are mine.

B1 — THE TELEGRAM @HANDLE EXEMPTION COVERED A NATIVE SEND.

_is_unresolved_handle exempts telegram @handles from attestation because
"the connector resolves and authorizes it". That justification is FALSE
whenever the gateway holds its own token: _send_to_platform calls
_send_telegram(pconfig.token, ...) directly and no connector is involved.
So an unattested @handle went out under the gateway's own credential
while the numeric control was correctly refused.

The exemption now requires that no native credential exists. A probe
fault WITHDRAWS the exemption (falls back to the ordinary attestation
check) rather than granting it.

Shipped with the converse control: relay-only config still exempts
@handles, and numeric targets stay guarded in both modes.

B4 — THE LATCH HAD NO BOUNDARY, SO IT WAS AN OUTAGE MECHANISM.

My own regression, and worse than reported. Removing "clear on cosmetic
success" (correctly) removed the ONLY way the latch could ever clear: a
content op can never reach the connector to succeed, because the latch
blocks it locally first. A refusal at 09:00 muted that chat forever.

A new inbound message for a chat is the generation marker — the natural
teardown point. Suppression still holds for the whole turn.

    same_turn_blocked: true     next_turn_delivered: true

MUTATIONS (all killed)
  handle exemption ignores native credential
  native-credential fault GRANTS the exemption
  no turn boundary (latch never clears)
  teardown clears ALL chats not just this one
  teardown ignores the chat

The last two SURVIVED first: I tested _clear_declined_for_turn directly
and never proved _on_inbound calls it — the caller-level gap that has now
produced four blockers on this branch. Added a test driving the real
inbound entry point.

One self-inflicted bug, caught by my own fault test: the probe imported
load_config, which does not exist (it is load_gateway_config), so it
always threw and returned the fault default. The test that pinned fault
behaviour is what exposed it.

510 passed.

* fix(relay): correct latch identity and boundary; one config snapshot

Round 9, four blockers, all reproduced.

B1+B4 — THE TEARDOWN WAS AT THE WRONG PLACE, twice over.

It sat on the adapter's raw _on_inbound, which runs BEFORE profile
routing, the ignored-channel guard, plugin hooks and user authorization.
An unauthorized or dropped event could therefore clear a refusal
belonging to an active turn, and stale content then went out as a
different op. The same placement missed Discord interaction passthrough,
which builds its own MessageEvent and calls handle_message directly, so
slash commands and modal submits stayed muted after an earlier decline.

Both are one mistake: I picked a lane instead of a boundary. Teardown now
runs immediately after _hm_admit_event, the single admission gate every
entry path shares.

  dropped event  -> latch survives, stale send blocked
  admitted event -> latch clears

B2 — THE LATCH KEY SPLIT ON ':', WHICH IS A MISTAKE I ALREADY FIXED ONCE.

_latch_key did str(chat_id).split(":", 1)[0], so !room:tenant-a and
!room:tenant-b both keyed !room: a decline in one Matrix room muted
another, and inbound from one cleared the other's refusal. egress.py
::_session_ids stopped doing exactly this in round 4 and I reintroduced
it three rounds later.

Parent identity is never recoverable from identifier TEXT. Thread
coverage is now structural: _thread_parent looks the relationship up in
the recorded auto-thread map.

B3 — AUTHORIZATION AND DISPATCH USED DIFFERENT CONFIG SNAPSHOTS.

_handle_send retains one pconfig; the guard independently reloaded
config. Across a transition the authorization snapshot could see a
connector-only setup (exemption granted) while dispatch still held the
native token and sent the unattested @handle itself. The guard now takes
native_token from the SAME snapshot dispatch will use. A caller that
omits it does not silently look like "no token".

NB-1/2/3 also closed: real-object snapshot tests, an exception shield
that faces a real exception, and send_follow_up no longer discards the
connector's verdict (that discard is exactly how the edit lane laundered
declines).

MUTATIONS (all killed)
  latch key splits on colon again
  thread parent lookup disabled
  dispatch token ignored by guard
  tool drops the snapshot token
  admission teardown removed
  teardown moved BEFORE admission
  exception shield removed
  follow_up drops raw_response

"admission teardown removed" SURVIVED first: I had tested the helper, not
_handle_message. Added a test driving production _handle_message with
admission stubbed both ways. Fifth caller-level gap on this branch.

One self-inflicted bug caught before commit: I passed pconfig.token in
_handle_react, which has no pconfig — a NameError on every reaction.

516 passed.

* docs(relay): pin the latch's thread coverage limit as a deliberate trade

_thread_parent only sees connector auto-threads, and that map is capped at
256 entries, so a user-created or evicted thread does not inherit its
parent's latch. Documented at the site and asserted by a test, because the
alternative - deriving parents from identifier text - is exactly what muted
unrelated Matrix rooms in round 9.

The primary control is unaffected: authorize_relay_target takes thread_id as
part of the destination and attests it on every send (6 thread tests).

* refactor(relay): one SendResult decline classifier for all 8 gateway lanes

The extraction found a DEFECT, not just repetition.

Eight gateway lanes each hand-rolled the unwrapping of a decline from a
SendResult, and they did not agree. Six checked only raw_response. Two
also checked the error text. A connector that answers with the uniform
decline SENTENCE and no structured code - the documented contract for
older connectors, per _approval_send_outcome - was therefore classified
as an ordinary failure by those six lanes, so each treated a refusal as
"editing unavailable" and retried through another op.

Measured:

    text-only decline    six-site check False    two-site check True
    structured decline   six-site check True     two-site check True

No content leaked, because the adapter latch classifies the transport
dict directly and catches both shapes (verified: text-only decline still
latches C1 and keeps SECRET off the wire). The cost was wrong verdicts
and futile retries, not disclosure.

declined_send(result) in gateway/relay/egress.py now owns this. It checks
raw_response when structured, else the error text, and preserves the
ambiguous exclusion - an ambiguous result is a transport outcome, so it
must never read as a refusal.

run.py keeps its own shape deliberately: that lane has three verdicts
(ambiguous / declined / failed), so it checks ambiguous first and then
delegates the boolean.

MUTATIONS (all killed)
  helper drops the text-only branch
  helper drops the structured branch
  ambiguous no longer excluded
  draft lane decline check removed
  edit-failure lane decline check removed
  prompt verdict lane check removed
  slash-confirm lane check removed
  draft lane goes terminal on ANY failure   (over-refusal direction)

"draft lane decline check removed" SURVIVED first: _send_draft_frame had
no test driving an unsuccessful send_draft at all. Added one, with an
ordinary-failure control so the fix cannot silently become "one flaky
frame mutes the chat". A non-unique anchor also masked the edit-failure
lane on the first pass - the trap my own skill warns about.

This closes the duplication that caused four of nine rounds of blockers:
a new lane now calls one classifier instead of copying three lines.

519 passed.

* fix(relay): latch identity, new-turn boundary, seal arming, ambiguity

Round 10, four blockers, each reproduced before fixing. Two are my own
regressions from the previous two rounds.

B1 - ADMISSION IS NOT A NEW-TURN BOUNDARY.

Round 9 moved teardown to just after _hm_admit_event. That is only an
ADMISSION gate: an authorized message can be steered into a running
session, answer a pending prompt, run a busy slash command, or be refused
by the pause/drain gates - all without starting a turn. Each of those
cleared the ACTIVE turn's refusal, and a later fallback from that turn
reached the wire (probe: latch emptied, wire ops ['edit', 'send']).

Teardown now runs after _claim_active_session_slot, the first point the
runner OWNS a new turn. The new test drives production _handle_message
through all four non-turn lanes plus the real new-turn path.

B2 - LATCH IDENTITY OMITTED THE LOGICAL PLATFORM.

One relay adapter fronts several platforms, so native ids collide. A
Discord refusal for chat 42 was cleared by clear_egress_latch("telegram",
"42") - the method took a platform and ignored it - and the Discord
fallback then reached the connector. Keyed by normalized platform plus
exact chat id; thread-parent expansion keeps the platform component.

B3 - THE DIRECT DRAFT-SEAL PATH DID NOT ARM THE LATCH.

_seal_open_draft posts through _attempt directly rather than _outbound,
so a definite decline logged and returned but never latched. The
immediate plain-send fallback was suppressed by the caller's own check;
later same-turn sends were not (wire ['draft', 'draft', 'send'], the
third frame carrying refused content).

B4 - MY OWN REFACTOR MADE AMBIGUOUS RESULTS TERMINAL.

send_draft's ambiguous projection discarded raw_response, so
declined_send fell through to the error-text branch - and an ambiguous
result whose text carries the decline marker ("... egress declined: ack
lost") read as a DEFINITE refusal and terminated the run. Ambiguous means
the frame may well have been delivered: a transport outcome, never an
authorization one.

Fixed on both layers: the projection carries the body (and the seal's
ambiguous return is now explicit too), and declined_send's text-only
branch - which cannot see the ambiguous flag - treats ack-lost text as
transport ambiguity. Audited every SendResult projection in adapter.py
for the same shape.

MUTATIONS (all killed)
  latch key drops the platform
  clear_egress_latch ignores platform
  draft seal does not arm the latch
  ambiguous projection drops raw body
  declined_send infers decline from ack-lost text
  teardown back at admission

523 passed.

* refactor(relay): split the terminal-decline latch out of the guard PR

The latch moves to feat/p5-egress-decline-latch (pushed at 3cf45736d7,
which retains the full history) for redesign. This PR keeps the
authorization guard and the per-site decline checks.

WHY. Across eleven review rounds the two halves behaved very differently.
The guard is a PURE FUNCTION of the destination - its blockers were all
"you asked the wrong question" (case sensitivity, nested ImportError,
missing thread_id, config snapshot skew), each a one-line correction that
then stayed fixed. Rounds 7-10 found nothing new in it.

The latch is MUTABLE STATE WITH A LIFETIME living on RelayAdapter - an
object registered once per process that holds the WebSocket and has no
concept of a turn. Nine of its blockers reduce to three questions the
adapter cannot answer: when does it end, who arms it, what is it keyed
on. Every answer so far has been a proxy (a successful op, an inbound
message, an admitted event, a claimed session slot) and every proxy was
wrong in a lane found later.

The per-site checks hold identical information on `st` - a PER-TURN
object - and have produced zero blockers, because the state dies with the
turn and nobody has to decide when it ends.

The no-relaunder property does NOT depend on the latch. Measured on the
real consumer path with the latch absent: a declined draft frame sets
_egress_declined and puts nothing on the wire.

Removal verified structurally rather than by eye: an AST diff of every
symbol between HEAD and this tree reports only latch symbols gone,
nothing added. That check caught two over-deletions my strip made -
_on_inbound (consumed by a "next def" boundary) and _SEEN_INBOUND_MAX
(a class constant inside the removed span). Both restored; 19 failures
went to 0.

ALSO: RESTORED A TEST I WRONGLY REPORTED AS PASSING.

test_tool_guard_forwards_thread_id never made it into the repo - `git log
-S` finds it in no commit - though round 5 recorded its mutant as killed.
Dropping thread_id from the guard call therefore survived the entire
tests/tools suite (146 passed). Written properly this time, driving the
real _handle_send far enough to reach the guard. It now KILLS that
mutant.

MUTATIONS on this tree
  guard fault authorizes instead of refusing      KILLED
  thread_id dropped from the guard call           KILLED  (was SURVIVED)
  handle exemption ignores native credential      KILLED
  draft lane decline check removed                KILLED
  prompt verdict lane check removed               KILLED
  slash-confirm lane check removed                KILLED

503 passed.

* test(relay): close the phantom-coverage gaps the guard audit found

The thread_id test that was reported as killing a round-5 mutant turned
out never to have been committed. That is a reason to distrust the other
claimed kills, so I re-ran every guard mutation against the COMMITTED
tree instead of trusting the earlier reports.

Result: 9 of 11 killed, and the two "SKIPPED" ones had non-unique
anchors hiding SIX separate sites. Mutating those individually found
three real survivors.

CASE NORMALISATION (round 3, finding 3) WAS HALF-COVERED.

test_relay_fronted_matching_is_case_insensitive varies the CONFIGURED
name but always requests lowercase "discord", so it pins _relay_fronted's
normalisation and nothing else. The REQUESTED name's `.lower()` was
covered by nothing at all. Probe with it removed:

    relay_routed("Discord") -> False
    authorize("Discord", unattested) -> AUTHORIZED

which is exactly the bypass round 3 reported, alive again and untested.

Two further sites were untested in the OVER-REFUSAL direction: the
attested store is keyed lowercase, so a mixed-case request missed its own
attested set and refused legitimate traffic. attested_relay_targets' own
normalisation was invisible to every existing test because they all
monkeypatch that function away; it is now asserted against the real
function with only its leaf sources stubbed.

Three tests added. All six case sites now die when mutated.

I also re-did the three fail-closed RelayRouteUnknown mutations properly.
The first pass swapped whole lines and produced IndentationErrors, so
"KILLED" there proved nothing but a syntax error. Neutralising each raise
at correct indentation: all three genuinely KILLED.

FINAL AUDIT ON THIS TREE — 17 mutations, zero survivors
  guard: thread_id dropped at the call site
  guard: react path unguarded
  guard: handle exemption ignores native credential
  guard: 3x fail-closed raise neutralised
  guard: 6x case-normalisation site
  classifier: ambiguous treated as a decline
  classifier: text-only decline branch removed
  lane: draft / stream-edit / prompt / slash-confirm checks removed

511 passed.

* test(relay): make the stream-edit test fail for the right reason

Review of 45835a282d raised one blocking issue and three non-blocking
ones. All four are addressed; none was a production defect.

BLOCKING — the stream-edit test failed on the double, not on a leak.

test_declined_stream_edit_does_not_send_the_unseen_tail implemented only
the GUARDED path in its consumer double. Removing either guard therefore
raised AttributeError inside the fake before any send could be observed:

  guard 1 removed -> AttributeError: no attribute '_is_flood_error'
  guard 2 removed -> AttributeError: no attribute '_clean_for_display'

Red, but for the wrong reason — the test could not have caught the leak
it is named for. My own docstring claimed it drove the fallback and
checked the wire; it did neither.

The double now implements everything the UNGUARDED path reaches
(_is_flood_error, _flood_strikes, _current_edit_interval, _last_edit_time,
_notify_new_message, _try_strip_cursor, _clean_for_display,
_fallback_prefix, _metadata_for_send). Both mutations now fail on real
assertions:

  guard 1 removed -> assert consumer._egress_declined is True
  guard 2 removed -> AssertionError: the unseen tail reached the wire:
                     ['send']

NON-BLOCKING 1 — a docstring claimed more than the test exercises.

test_requested_platform_name_is_also_normalised described a mixed-case
send_message(target="Discord:999") bypass. That entry point cannot reach
it: _resolve_tool_target lowercases the platform at
tools/send_message_tool.py:47 before the guard runs. The test still pins
a real contract — the helpers must not assume a lowercased argument, for
the gateway lanes and any future non-normalising caller — so the claim is
narrowed to that rather than the test removed.

NON-BLOCKING 2 — the module docstring said "every lane drives the REAL
RelayAdapter". The stream tests drive mixin doubles by design, because
the behaviour under test belongs to the adapter's CALLER. Docstring now
distinguishes the two kinds.

NON-BLOCKING 3 — latch-deletion residue in gateway/relay/adapter.py:418:

      return None
      return latched if surface_declines else None

The second line was unreachable and referenced a name deleted with the
latch. Removed, along with the 20-line comment block describing the latch
as "the structural fix" — that mechanism now lives on
feat/p5-egress-decline-latch, not here.

The reviewer independently confirmed the large deletion: an AST census
between 3cf45736d7 and f57a2298fa reports only latch symbols removed and
nothing added.

511 passed.

* docs(relay): correct three claims that outran the code

Review of 41ce3cc765 found no new production defect but three overstated
claims, one of them in my own commit message.

1. THE LATCH COMMENTARY WAS STILL THERE. My previous commit message said
   it removed "the 20-line comment block describing the latch as the
   structural fix". It removed only the unreachable statement. Twenty
   lines at adapter.py:361-380 still described a per-chat latch, a choke
   point and its scope rules - none of which exist on this branch. In a
   refusal-sensitive module that reads as coverage this branch does not
   have. Now removed for real.

   This is the same defect class as the tests: a claim that outran what
   the code does. I made it while fixing that class.

2. THE STREAM-TEST DOCSTRING OVERSTATED BOTH MUTANTS. It said the
   mutation "now fails on the assertion that a send reached the wire" -
   true of one guard, not both. Verified separately:

     remove the _on_edit_failure check  -> dies on _egress_declined,
                                           never reaches the fallback
     remove the fallback early return   -> dies on the wire: ['send']

   Both are valid behavioural failures, which is what the blocker asked
   for; they are different observables and the docstring now says so.

3. Duplicate `from types import SimpleNamespace` from an earlier scripted
   insert; imports reordered.

112 tests pass in the four focused files.

* fix(relay): close two authorization defects found in review

Both were reproduced before fixing and both mutants are pinned.

1. A LIVE relay adapter whose fronts_platform() raised degraded into the
   config fallback. `_live_relay_fronted` returned None for every failure,
   and None means "no live adapter, use the config snapshot" — so a faulting
   adapter plus an empty/stale snapshot made the guard conclude "not
   relay-routed" and authorize an unattested destination, while
   resolve_delivery_transport asks that same adapter and still routes over
   the relay. Measured: relay_routed=False, verdict None for chat 999.

   Absence and fault now have separate return values: None only when there
   is no runner or no relay adapter; a live adapter that cannot answer
   raises RelayRouteUnknown. This is the third instance of this bug class in
   this file, and the first two were also mine.

2. An attested chat whose id equalled the requested THREAD id vouched for
   that thread. The `thread in attested` arm proved nothing about parentage.
   Measured: attested {"-100A", "7"} authorized (-100A, thread 7).

   Only the bound `parent:thread` form is accepted now. Nothing legitimate
   needed the bare arm — _session_entry_id records a threaded origin as
   f"{chat_id}:{thread_id}", and a thread addressed as its own channel
   arrives as chat_id and passes the parent check.

The existing test blessed the bare form via parametrize, so it PINNED the
defect. Corrected, plus negative controls for the sibling-chat and
other-parent cases and a positive control proving genuine absence still
takes the config path (otherwise fix 1 would break native-only deploys).

Merged origin/main (was 22 behind). 428 passed via scripts/run_tests.sh;
full 10-row mutation ledger re-killed on the merged tree, none dying on an
exception rather than an assertion.

* fix(relay): only a missing adapter is absence; everything else is a fault

Reviewer BLOCKER, reproduced before fixing. Two more paths where a PRESENT
relay adapter still degraded into the config snapshot:

1. `fronts_platform` may be a property or descriptor, so the ATTRIBUTE
   LOOKUP can raise — and the lookup sat inside the absence handler. Probed
   with a raising property plus an empty snapshot: live=None, routed=False,
   verdict=None, i.e. an unattested target authorized. The previous test made
   an already-retrieved METHOD raise, so it could not reach this.

2. A present adapter with no usable `fronts_platform` returned None for the
   same reason. An adapter that cannot say what it fronts is broken, not
   absent, so it now raises too.

Also found by my own spot-check while the review ran: the nested imports of
`gateway.config` / `gateway.run` inside the live probe shared the broad
handler, so a broken installation degraded to the snapshot as well. Probed
with a healthy-adapter positive control in the same run — healthy refused
the unattested target, faulted authorized it. `_relay_fronted` one function
below already drew this exact distinction for its own import.

The boundary is now: `relay is None` is the ONLY absence. Everything about a
present adapter — attribute access, callability, the call itself, and the
imports needed to reach it — is a fault and raises RelayRouteUnknown.

This is the fourth variant of absence-vs-fault in this file and all four
were mine. The lesson is in the code as a comment rather than in a commit
message nobody re-reads.

Four controls keep genuine absence benign: no runner, no relay adapter in
the runner, a real ModuleNotFoundError naming the gateway package, and the
configured-attested-target-still-sends case.

434 passed via scripts/run_tests.sh; 9-row mutation ledger re-killed
including both new guards, none dying on an exception.

* fix(relay): invert the live probe to fail closed by default

Reviewer BLOCKER round 2, reproduced: reading the adapter registry can also
raise. A runner whose `adapters.get()` raised gave relay_present=True,
live=None, routed=False, verdict=None — unattested discord:999 authorized.

That was the FIFTH boundary in one function with the same defect: the call,
the attribute lookup, a non-callable attribute, the nested imports, and now
the registry lookup. Each round I patched the reported boundary and the
defect moved one statement up. The cause was the shape, not the statements:
the function asked "did something go wrong?" and answered None, and None
MEANS "no live adapter, use the config snapshot" — so every statement was a
new chance to fail open, and every new statement would have been too.

Inverted rather than patched a sixth time. Each `return None` now sits
behind an explicit narrow check that cannot itself be the fault (no runner,
no adapters, no relay key, gateway package genuinely absent), and one outer
handler turns anything else into RelayRouteUnknown. A statement added inside
this function is now fail-CLOSED by default.

Verified all six fault shapes raise (call, attribute, missing method,
registry .get, .adapters property, runner ref) and all five absence shapes
stay benign, plus a liveness control where the config snapshot disagrees
with a healthy adapter and the adapter still wins.

Four new tests, including the two absence controls that keep native-only and
CLI deployments working. 438 passed via scripts/run_tests.sh. Mutation
ledger: 8 killed. One survivor recorded as a proven equivalent mutant —
widening `if not registry` to `or {}` is behaviourally identical because
`{}.get()` returns None, i.e. the same absence; it is a readability guard.
2026-09-08 17:51:35 +10:00

2320 lines
116 KiB
Python

"""RelayAdapter — one generic gateway adapter fronted by the connector. EXPERIMENTAL.
A single ``BasePlatformAdapter`` subclass that receives a ``CapabilityDescriptor`` at
handshake (which platform it fronts, which capabilities to advertise) and delegates
all wire I/O to an injected transport. There is NO per-platform gateway code: only
the connector knows "this chat_id maps to a Discord channel"; the gateway sees an
ordinary ``MessageEvent`` in and calls ``adapter.send`` out. 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 json
import logging
import re
import secrets
import time
from collections import OrderedDict
from typing import Any, Callable, Dict, Optional, Tuple, Union
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter, SendResult,
)
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.egress import (
EGRESS_DECLINE_CODE,
decline_error,
is_egress_decline,
log_decline,
)
from gateway.relay.media import RelayMediaClient
from gateway.relay.transport import RelayTransport
from gateway.session import SessionSource
logger = logging.getLogger(__name__)
# The drain-path going-idle ACK budget must stay strictly under the runner's
# default adapter disconnect timeout (5s) or cancellation fires before
# transport.disconnect() and leaves the websocket open. With transport teardown
# budgets of 1s each for supervisor, reader and ws.close, the drain stays <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 URLs, Slack mrkdwn links
# and markdown links. Permissive on purpose — a false positive costs one fresh
# (non-edited) final; a false negative silently loses the preview.
_URL_RE = re.compile(r"https?://|<https?:|\]\(https?:")
# Already-answered prompt ids to remember so a duplicate answer (double tap or
# connector redelivery) reads as a repeat, not a stale prompt.
_RESOLVED_PROMPT_MEMORY = 256
# Connector promptCodec.decodePromptCallback id alphabet ([A-Za-z0-9_.-], <=32).
_PROMPT_ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,32}$")
_TRUTHY = {"1", "true", "yes", "on"}
_FALSY = {"0", "false", "no", "off"}
_SLACK = Platform.SLACK.value
# Prompt option id -> in-channel ack label (the option set doubles as the choice allowlist).
_EXEC_APPROVAL_LABELS = {
"once": "✅ Approved once",
"session": "✅ Approved for session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}
_SLASH_CONFIRM_LABELS = {"once": "✅ Approved once", "always": "🔒 Always approve", "cancel": "❌ Cancelled"}
def _utf16_len(text: str) -> int:
"""Count UTF-16 code units (Telegram's length unit)."""
return len(text.encode("utf-16-le")) // 2
_LEN_FNS: Dict[str, Callable[[str], int]] = {"chars": len, "utf16": _utf16_len}
def _send_result(result: Dict[str, Any], **extra: Any) -> SendResult:
"""Project a connector ``outbound_result`` dict onto a SendResult."""
return SendResult(
success=bool(result.get("success")), message_id=result.get("message_id"),
error=result.get("error"), **extra,
)
def _event_ids(event) -> Tuple[Optional[str], Optional[str]]:
"""(message_id, chat_id) of an inbound event; message_id lives on the event, falls back to source."""
message_id = getattr(event, "message_id", None) or getattr(event.source, "message_id", None)
return message_id, getattr(event.source, "chat_id", None)
class RelayAdapter(BasePlatformAdapter):
"""Generic relay adapter advertising a connector-negotiated capability profile."""
def __init__(
self,
config: PlatformConfig,
descriptor: CapabilityDescriptor,
transport: Optional[RelayTransport] = None,
) -> None:
# Fronts many platforms but presents to the runner as Platform.RELAY.
super().__init__(config, Platform.RELAY)
self._transport = transport
self._apply_descriptor(descriptor)
# Per-chat egress routing caches learned from inbound events (send() only
# receives a chat_id). The connector's egress guard resolves the owning tenant
# from OUTBOUND metadata.scope_id / user_id, so we re-attach what we saw
# inbound (_capture_scope).
self._scope_by_chat: Dict[str, str] = {}
self._dm_user_by_chat: Dict[str, str] = {}
# chat_id -> chat_type: reproduces native Slack's synthetic DM-thread
# suppression (a raw reply_to becomes a thread_ts connector-side, so a plain
# DM reply would thread under the user).
self._chat_type_by_chat: Dict[str, str] = {}
# chat_id -> last triggering Slack message ts (typing/status lane's
# synthetic thread anchor in thread-per-message mode).
self._last_inbound_ts_by_chat: Dict[str, str] = {}
# chat_id -> UNDERLYING platform ("discord", ...): one adapter fronts N
# platforms on one WS and a reply must egress through the platform the
# inbound came from. Empty for a single-platform gateway (connector default).
self._platform_by_chat: Dict[str, str] = {}
# Chats the connector has refused (see the terminal-decline latch).
# chat_id -> (thread_id, initial_name) of the auto-thread the CONNECTOR
# created for our latest send; read by the semantic thread-rename lane.
self._auto_thread_by_chat: Dict[str, Tuple[str, str]] = {}
# chat_id -> event fired when the entry above lands (wait_for_auto_thread_info).
self._auto_thread_waiters: Dict[str, asyncio.Event] = {}
# Bounded FIFO seen-set for inbound replay dedupe (insertion-ordered dict).
self._seen_inbound: Dict[str, None] = {}
# Live cards: draft_key -> draft_id of the OPEN native stream. Armed by
# send_draft; consumed by send() to convert the turn-final into
# draft(final=true) instead of a duplicate post. Keyed by _draft_key (chat +
# per-turn identity), NOT bare chat: parallel turns in one DM are distinct
# streams (per-chat keying merged three concurrent turns).
self._open_draft_by_chat: Dict[str, int] = {}
# draft_key -> draft_id of the most recently SEALED stream (mirror of the
# connector's sealed-key tombstone): post-seal stragglers must neither
# re-arm interception nor re-open a stream.
self._sealed_draft_by_chat: Dict[str, int] = {}
# Draft keys whose post-seal swallow has been logged once (bounded FIFO).
self._tombstone_swallow_logged: Dict[str, int] = {}
# Strong refs for fire-and-forget lifecycle acks (asyncio holds tasks weakly).
self._lifecycle_ack_tasks: set = set()
# Stream-is-the-message marker read by the stream consumer to keep ONE draft
# stream per turn instead of bumping draft_id at tool boundaries. SLACK-ONLY:
# the base send_draft contract is Telegram-shaped (draft clears, final is a
# separate real send); setting this for any "draft" connector intercepted the
# turn-final into draft(final=true) and no history message was ever posted.
# A future platform with this semantic should advertise it via the descriptor.
self.draft_stream_is_message = str(getattr(descriptor, "platform", "") or "").lower() == "slack"
# Watches the transport for a terminal auth revocation (4401 after a
# successful handshake = operator opted this instance out) and surfaces a
# clean non-retryable "relay disabled" fatal instead of a retry spin.
self._revocation_monitor: Optional[asyncio.Task[None]] = None
# Lazily built client for the connector's /relay/media routes; None when
# dial URL or creds are absent (media lanes degrade to text fallbacks).
self._media_client: Optional["RelayMediaClient"] = None
# prompt_id -> pending-prompt state for the interactive `prompt` op; the
# user's pick comes back as a prompt_response naming this id and resolves the
# waiting primitive like native button callbacks. Expire lazily (_pop_prompt).
self._pending_prompts: Dict[str, Dict[str, Any]] = {}
# Per-process marker prefixed onto every prompt id we mint. WHY: button
# presses ride the passthrough plane, which the connector fans out to EVERY
# live gateway session of the tenant, while _pending_prompts is process-local.
# Without the marker a sibling cannot tell "someone else owns this" from "my
# prompt expired", and the id-shaped text ("/c1") falls through to chat
# dispatch as "Unknown command" — once per sibling (common in a DM).
self._prompt_owner_nonce: str = secrets.token_hex(3)
# Prompt ids this process already resolved, newest last (repeat answers are
# consumed silently instead of treated as stale).
self._resolved_prompts: "OrderedDict[str, float]" = OrderedDict()
# ── capability surface (from descriptor) ─────────────────────────────
@property
def authorization_is_upstream(self) -> bool:
"""The connector enforces authorization (owner-only author-binding before
delivery), so relay users must not be default-denied for lack of a local
``RELAY_ALLOWED_USERS`` allowlist."""
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: Slack's typing
surface is the assistant status line, so run.py's live-status lane may feed
per-tool phrases; other platforms have textless bubbles and must NOT receive
them. Reflects the PRIMARY identity, like the scalar ``descriptor``."""
return self.descriptor.platform == _SLACK
# ── per-chat capability resolution (multi-platform) ──────────────────
def _negotiated_descriptor(self, platform: Optional[str]) -> Optional[CapabilityDescriptor]:
"""The transport's negotiated descriptor for ``platform``, or None (unknown
platform, no transport, or a transport predating ``descriptor_for_platform``).
Never raises — capability lookup must never break a send."""
resolve = getattr(self._transport, "descriptor_for_platform", None) if platform else None
if not callable(resolve):
return None
try:
return resolve(platform)
except Exception: # noqa: BLE001
return None
def _chat_platform(self, chat_id: str) -> Optional[str]:
"""The chat's underlying platform as seen inbound, else the primary's."""
return self._platform_by_chat.get(str(chat_id)) or self.descriptor.platform
def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor:
"""The descriptor governing a specific chat. Platform caps genuinely differ
(Discord 2000 / Telegram 4096 / Slack 39000), so the primary's scalar cap
either fragments needlessly or over-sends into a platform 400. Falls back to
the scalar when the chat's platform is unknown (never saw inbound)."""
per_platform = self._negotiated_descriptor(self._platform_by_chat.get(str(chat_id)))
return per_platform if per_platform is not None else 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:
# Needs BOTH the descriptor flag and an explicit "draft" op: supported_ops is
# fail-open for legacy connectors, but "draft" did not exist pre-contract, so
# it must NOT fail open. Resolved per chat when the caller names one (a
# Telegram primary must not starve a Slack chat).
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; like native
# SlackAdapter, refuse streaming when those knobs are set so chat.postMessage
# can carry them.
platform = self._chat_platform(chat_id) if chat_id is not None else desc.platform
return not self._slack_unfurl_hints(platform)
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``; a
``chat.update`` that INTRODUCES the URL never unfurls. Edit-based streaming
posts its first frame before any URL exists, so a configured ``unfurl_*: true``
can only surface via a fresh final that ``send()`` stamps with the hints. ONLY
when the hints contain an explicit True: false-only hints (fail-closed) ride
the placeholder post fine. Only link-bearing finals qualify — the relay has no
delete op in contract v1, so a linkless fresh final would just be a duplicate.
"""
platform = self._platform_by_chat.get(str(chat_id)) if chat_id is not None else None
# The stream consumer's hook passes (content, metadata=...) only.
if platform is None and isinstance(metadata, dict):
platform = metadata.get("platform")
if platform is None:
platform = self.descriptor.platform
hints = self._slack_unfurl_hints(platform)
return bool(hints) and any(v is True for v in hints.values()) and bool(_URL_RE.search(content or ""))
def stream_is_message_for_chat(self, chat_id: str) -> bool:
"""Per-chat stream-is-the-message semantic (see ``draft_stream_is_message``).
A Slack primary must not impose seal semantics on a Telegram chat (its
turn-final would become draft(final=true) — no history message), nor a
Telegram primary deny a Slack chat native streaming. Platform-name inference
is deliberate; a descriptor field is the eventual contract."""
return str(self._descriptor_for_chat(str(chat_id)).platform or "").lower() == "slack"
# ── Live cards: native draft streaming + task cards ──────────────────
# Additive relay ops within contract v1, emitted when the negotiated descriptor
# advertises them; the connector owns the platform API mechanics and the
# send+edit fallback. Semantic bridge: the base send_draft contract is
# Telegram-shaped (draft clears, final is a separate send()); Slack native
# streaming makes the stream THE message, so the adapter tracks the open draft
# per turn and converts that turn's final send() into draft(final=true).
def supports_native_task_cards(self) -> bool:
"""Explicit advertisement required — same no-fail-open rule as "draft"."""
return "task_card" in (self.descriptor.supported_ops or ())
def native_task_cards_enabled(self) -> bool:
"""TurnRunner opt-in probe (gateway/run.py calls THIS name, same contract as
native Slack); without the alias the card lane silently stays text-mode."""
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`` / ``reply_to_message_id``) over the thread
anchor: two parallel turns inside ONE thread share thread_ts (turn A's final
sealed turn B's stream), and a flat DM with no anchor degraded to the bare
chat id. Anchor is the fallback for placement-only callers; bare chat last."""
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 (per-turn keys); matches the
# connector's tombstone store size.
_DRAFT_STATE_CAP = 512
@classmethod
def _evict_oldest(cls, d: Dict[str, Any], cap: Optional[int] = None) -> None:
"""FIFO-bound an insertion-ordered dict in place (default cap: draft state)."""
while len(d) > (cls._DRAFT_STATE_CAP if cap is None else 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``; 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 carrying a per-turn MESSAGE id never fall back — their
identity is authoritative. Callers without one may absorb into the chat's
single open stream; with several open the send stays plain: a duplicate
message is recoverable, sealing someone else's stream is not."""
key = self._draft_key(str(chat_id), metadata)
if key in self._open_draft_by_chat:
return key
md = metadata or {}
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 decision (the
# prompt-ack-seals-own-stream bug); log it so the next mismatch is a grep.
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 _outbound(self, chat_id: str, action: Dict[str, Any]) -> Dict[str, Any]:
"""Send one outbound frame tagged with the chat's underlying platform.
P5(b): the second frame path (the first is ``_gated_op``). Lanes that
return bool/None by contract — typing, delete, thread create/rename —
come through here, and a silent degrade made an AUTHORIZATION refusal
indistinguishable from "op unsupported" in the logs. The return
contract is unchanged; the refusal is recorded.
"""
op = str(action.get("op", "?"))
result = await self._transport.send_outbound( # type: ignore[union-attr]
action, platform=self._platform_by_chat.get(str(chat_id))
)
if isinstance(result, dict):
if not result.get("success") and is_egress_decline(result):
log_decline(op, chat_id, result)
return result
async def _gated_op(
self,
chat_id: str,
action: Dict[str, Any],
*,
decline_level: Optional[int] = logging.WARNING,
subject: Any = None,
platform: Optional[str] = None,
surface_declines: bool = False,
) -> Optional[Dict[str, Any]]:
"""Emit one best-effort, op-gated frame; None when the caller must fall back.
None covers every unavailability: op not advertised (probe the descriptor
instead of parsing a connector error), no transport, transport raised, or a
structured connector decline (logged at ``decline_level``; None = silent).
"""
op = action["op"]
if self._transport is None or not self.descriptor.supports_op(op):
return None
try:
result = await self._transport.send_outbound(
action, platform=platform or self._platform_by_chat.get(str(chat_id))
)
except Exception: # noqa: BLE001 - transport failure degrades to the caller's fallback
logger.debug("relay %s transport failure", op, exc_info=True)
return None
if not result.get("success"):
# P5(b): an AUTHORIZATION decline is not lane unavailability. This
# helper's `None` means "lane absent — do your fallback", and the
# fallbacks re-address the SAME chat by another route (media -> a
# text notice, prompt -> numbered text). The connector authorized
# that destination and REFUSED it, so degrading launders a security
# decision into "op unsupported" and delivers the content anyway.
# Callers whose fallback would leak pass surface_declines=True and
# turn this into a failed lane; cosmetic ops (typing, card stop)
# keep the old None contract.
if is_egress_decline(result):
# Every lane records an authorization decline — the cosmetic
# ones (typing, delete, thread create/rename, card stop) still
# degrade to None, but a silent degrade made a security refusal
# indistinguishable from "op unsupported" in the logs.
log_decline(op, chat_id if subject is None else subject, result)
if surface_declines:
return result
return None
if decline_level is not None:
logger.log(
decline_level, "relay %s declined for %s: %s",
op, chat_id if subject is None else subject, result.get("error"),
)
return None
return result
def _text_metadata(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Metadata for a text egress frame: format hints + tenant discriminators.
Draft, seal, send and edit are all text lanes — a streamed final can only
render blocks if every frame carries the hint (a hintless seal is the
plain-code-block downgrade)."""
return self._with_scope(chat_id, self._with_format_hints_for_chat(chat_id, metadata))
def _draft_frame(
self, chat_id: str, draft_id: int, content: str, final: bool, metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
"""One ``draft`` op frame (``final=True`` seals the stream)."""
return {
"op": "draft",
"chat_id": chat_id,
"draft_id": draft_id,
"content": content,
"final": final,
"metadata": self._text_metadata(chat_id, dict(metadata or {})),
}
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")
# Arm optimistically BEFORE the transport call (a lossy ack often means
# delivered), but NEVER for a draft_id already sealed on this key: a straggler
# after the seal re-armed interception with no live stream, and the next
# unrelated send was wrongly converted into a seal.
chat_key = self._draft_key(str(chat_id), metadata)
if self._sealed_draft_by_chat.get(chat_key) == draft_id:
# Post-seal straggler: content is already in the sealed message; report
# success, send nothing. Log the FIRST swallow per key — one straggler is
# the normal race, but a burst means something sealed a live stream
# mid-flight (silence here cost a forensic hunt).
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: on a
# Telegram-shaped connector the final MUST go out as a real send.
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._outbound(chat_id, self._draft_frame(chat_id, draft_id, content, False, metadata))
except Exception as e:
# Ambiguous (stale socket, mid-write drop): 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, returned rather than raised): same
# contract as the except branch — keep interception armed.
#
# RAW BODY PRESERVED. Dropping it made `declined_send` fall through
# to the error-text branch, and an ambiguous result whose text
# happens to carry the decline marker ("... egress declined: ack
# lost") then read as a DEFINITE refusal and terminated the run.
# Ambiguous means the frame may well have been delivered, so it is
# a transport outcome, never an authorization one.
return SendResult(
success=False,
error=str(result.get("error") or "draft ack lost"),
raw_response=result,
)
# DEFINITE connector rejection: disarm. The stream consumer falls back to
# edit-based streaming and its turn-final must go out as a REAL send, not a
# seal on a stream the connector just declared unusable.
if self._open_draft_by_chat.get(chat_key) == draft_id:
self._open_draft_by_chat.pop(chat_key, None)
# P5(b): carry the structured body. The stream consumer reads a bare
# draft failure as "draft transport unusable", disables drafts, and
# falls through to a plain send — a second op against the chat the
# connector just refused. Verified end to end with the real
# GatewayStreamConsumer: ops were ['draft', 'send'].
if is_egress_decline(result):
log_decline("draft", chat_id, result)
return SendResult(
success=False,
error=str(result.get("error") or decline_error(result) or "draft failed"),
raw_response=result,
)
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: whatever the ack says, this draft_id
# must never be re-armed by a straggler frame.
self._sealed_draft_by_chat[draft_key] = draft_id
self._evict_oldest(self._sealed_draft_by_chat)
if self._transport is None:
return SendResult(success=False, error="no transport")
seal_frame = self._draft_frame(chat_id, draft_id, content, True, metadata)
_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 Exception as e:
logger.warning("relay seal transport error (ambiguous): %s", e)
return None
if r.get("ambiguous"):
logger.warning("relay seal ack lost (ambiguous): %s", r.get("error"))
return None
return r
# Ambiguous outcomes 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. Two consecutive ack losses on one socket
# almost always mean the transport is down. Cancellation safety: the open
# entry was popped and the tombstone written BEFORE the await; restore both
# before re-raising so the later abandon pass can still seal the stream.
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:
# Same ambiguity contract as send_draft: the retry's ack was lost,
# so the seal may have been applied. Marked explicitly rather than
# left to text inference.
return SendResult(
success=False,
error="draft seal ambiguous after retry (transport ack lost)",
raw_response={"success": False, "ambiguous": True},
)
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)
# P5(b): carry the structured body. Without it the caller cannot tell a
# lane failure (fall through to a plain send, correct) from an
# AUTHORIZATION decline (a plain send re-delivers the very content the
# connector refused, to the same chat).
if is_egress_decline(result):
log_decline("draft_seal", chat_id, result)
return SendResult(
success=False,
error=str(result.get("error") or decline_error(result) or "draft seal failed"),
raw_response=result,
)
async def _absorb_into_open_draft(
self, chat_id: str, content: str, metadata: Dict[str, Any], interim: bool
) -> Optional[SendResult]:
"""Seal an open native stream with this turn-final; None = do a plain send.
An open stream absorbs the turn-final whichever egress door it arrives
through (send / send_for_platform) — otherwise the stream is left frozen
mid-word AND the final posts as a duplicate. A failed seal must NOT swallow
the final: the consumer already disabled the draft transport, so fall through
to a plain send (the orphaned stream is sealed connector-side). Interim sends
(commentary, tail flush, lifecycle acks) never seal.
"""
if interim:
return None
key = self._match_open_draft(str(chat_id), metadata)
if key is None:
return None
seal = await self._seal_open_draft(chat_id, content, metadata, draft_key=key)
if seal.success:
return seal
# An AUTHORIZATION decline is not a lane failure. Falling through here
# re-sends the sealed content as a plain `send` into the destination the
# connector just refused — review demonstrated the leak end to end
# (ops: draft(partial) -> send(SECRET)). Surface the refusal instead.
if is_egress_decline(getattr(seal, "raw_response", None)):
logger.warning(
"relay draft seal DECLINED for %s — not falling back to a plain "
"send (the destination is not approved for this connection)",
chat_id,
)
return seal
logger.warning("relay seal failed (%s); delivering turn-final as plain send", seal.error)
return None
async def _card_frame(
self, chat_id: str, op: str, reply_to: Optional[str], metadata: Dict[str, Any], **fields: Any
) -> Union[SendResult, Dict[str, Any]]:
"""Emit one task-card op: the connector result dict, or a failed SendResult
when the lane is unavailable / the transport raised.
Card frames are advisory and run inside the progress loop / turn-cleanup
path: an escaping exception there skipped final delivery, so transport
errors degrade to the TurnRunner's text fallback instead of raising.
"""
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")
frame = {
"op": op,
"chat_id": chat_id,
"card_id": self._card_key(reply_to, metadata),
**fields,
"metadata": self._with_scope(chat_id, metadata),
}
try:
return await self._outbound(chat_id, frame)
except Exception as e:
return SendResult(success=False, error=f"{op} transport error: {e}")
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 task-card lane: emit one card frame.
SIGNATURE CONTRACT: the TurnRunner calls this with the NATIVE Slack
adapter's keyword contract, not a card_id. ``fallback_text``/``title``
are accepted for parity but not forwarded (the connector's plan-mode
stream renders task chunks; field limits are enforced connector-side).
See #85476.
"""
merged_meta = dict(metadata or {})
if reply_to and "thread_ts" not in merged_meta:
# Slack card streams are thread replies anchored on the trigger.
merged_meta["thread_ts"] = str(reply_to)
result = await self._card_frame(
chat_id, "task_card", reply_to, merged_meta, chunks=[dict(t) for t in tasks]
)
if isinstance(result, SendResult):
return result
if result.get("success"):
return SendResult(success=True)
# P5(b): carry the structured body. The TurnRunner reads a bare failure
# as "card lane unavailable" and sends fallback TEXT to the same chat —
# the same decline-laundering fixed for media and prompt, in a sibling
# content lane.
if is_egress_decline(result):
log_decline("task_card", chat_id, result)
return SendResult(
success=False,
error=str(result.get("error") or decline_error(result) or "task_card failed"),
raw_response=result,
)
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 key derivation as send."""
result = await self._card_frame(chat_id, "task_card_stop", reply_to, dict(metadata or {}))
if isinstance(result, SendResult):
return result
# P5(b): carry the connector's reason. Dropping `error` reported a
# refusal as a bare failure, which reads as "the card lane is broken"
# rather than "this destination was refused".
return SendResult(
success=bool(result.get("success")),
error=result.get("error"),
raw_response=result,
)
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 (/stop, /new, supersede), in
place with ``content`` (the text already on screen) so the seal adds and
claims nothing; otherwise the live indicator stays forever and the NEXT turn
could inherit the armed interception state. Failure is reported, never raised."""
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
# reconnect watcher passes it; refusing the kwarg would break recovery).
# Relay IGNORES it: messages buffered during a gap live in the CONNECTOR's
# durable buffer and replay on re-handshake; routine WS drops are handled by
# the transport's own reconnect supervisor.
if self._transport is None:
# ``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.
# 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). 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.
raise RuntimeError("RelayAdapter has no transport configured")
self._transport.set_inbound_handler(self._on_inbound)
# Interrupts and passthrough-plane forwards (Discord interactions, Twilio, …)
# ride the SAME outbound WS — no inbound HTTP receiver, no public port.
for setter_name, handler in (
("set_interrupt_inbound_handler", self.on_interrupt),
("set_passthrough_handler", self._on_passthrough),
):
setter = getattr(self._transport, setter_name, None)
if callable(setter):
setter(handler)
if not await self._transport.connect():
return False
# Adopt the connector-advertised descriptor in place of the placeholder.
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)
# Only the production WebSocket transport exposes `auth_revoked`.
if hasattr(self._transport, "auth_revoked"):
self._start_revocation_monitor()
return True
def _start_revocation_monitor(self) -> None:
"""Spawn (once) the task turning 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 (a unit test calling connect() via a stub).
self._revocation_monitor = None
async def _watch_for_revocation(self, poll_interval_s: float = 1.0) -> None:
"""Poll for a terminal 4401 revocation (opt-out), then surface a non-retryable
`relay_disabled` fatal so the adapter is cleanly removed rather than queued
for reconnection (the credential is dead until the instance is recreated)."""
transport = self._transport
if transport is None:
return
while not getattr(transport, "auth_revoked", False):
await asyncio.sleep(poll_interval_s)
logger.warning("relay credential revoked (opt-out) — marking the relay adapter disabled")
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);
# class default is False, so only an explicit descriptor bit turns it 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: the relay leg is at-least-once — on WS re-handshake
# the connector replays its durable buffer, and a long turn straddling a
# quiet socket drop got re-run (final answer 2-5x). Platform message identity
# is stable across replays.
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
self._evict_oldest(self._seen_inbound, self._SEEN_INBOUND_MAX)
self._capture_scope(event)
self._stamp_slack_session_thread(event)
# A structured prompt answer resolves its waiting primitive and is CONSUMED —
# never also dispatched as chat.
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). The platform
joins the key because one relay socket can front several platforms whose
numeric ids may collide. None when the event carries no platform message id —
those never dedupe (fail-open: dropping a real message beats rerunning one)."""
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
# Enum value when present, plain string otherwise: both spellings of one
# platform must produce ONE key.
raw_platform = getattr(source, "platform", None)
platform = getattr(raw_platform, "value", raw_platform) or ""
return f"{platform}:{chat_id}:{message_id}"
def _relay_platform_extra(self, platform: str) -> Dict[str, Any]:
"""``platforms.relay.extra.<platform>.*`` — relay-namespaced mirror of a native
platform's knobs (``platforms.<platform>`` keeps meaning native settings).
Legacy fallback: flat keys on the relay extra when no ``<platform>`` object exists."""
extra = getattr(self.config, "extra", None) or {}
sub = extra.get(platform)
return sub if isinstance(sub, dict) else extra
def _relay_slack_extra(self) -> Dict[str, Any]:
return self._relay_platform_extra("slack")
@staticmethod
def _coerce_flag(raw: Any, default: bool) -> bool:
"""Coerce an operator-supplied boolean exactly as native Slack does: a
YAML-quoted ``"false"`` must turn the flag OFF (bare ``bool()`` would read
the non-empty string as True and silently ignore the switch)."""
if raw is None:
return default
return raw if isinstance(raw, bool) else str(raw).strip().lower() in _TRUTHY
def _slack_flag(self, knob: str, default: bool) -> bool:
"""A coerced boolean knob from the relay Slack extra; ``default`` on any config-shape error."""
try:
return self._coerce_flag(self._relay_slack_extra().get(knob), default)
except Exception: # noqa: BLE001 - config shape is operator-owned
return default
def _effective_reply_in_thread(self) -> bool:
"""Resolve the thread-per-message vs flat-DM mode for fronted Slack."""
return self._slack_flag("reply_in_thread", True)
def _dm_top_level_threads_as_sessions(self) -> bool:
"""Native-parity escape hatch: per-message DM sessions on/off. Default True:
in thread-per-message mode each top-level DM message keys its own session.
False keeps threaded PLACEMENT but ONE rolling DM session (legacy steer/queue
posture), decoupled from reply_in_thread."""
return self._slack_flag("dm_top_level_threads_as_sessions", True)
def _slack_unfurl_hints(self, platform: Optional[str]) -> Optional[Dict[str, bool]]:
"""Slack-only outbound link-preview knobs (``unfurl_links``/``unfurl_media``)
from the relay namespace. Only explicitly configured booleans are returned
(omitted keys preserve Slack's default); YAML strings are coerced, junk
dropped. Non-Slack platforms return None so their metadata is never polluted."""
if str(platform or "").lower() != _SLACK:
return None
extra = self._relay_slack_extra()
hints: Dict[str, bool] = {}
for knob in ("unfurl_links", "unfurl_media"):
val = extra.get(knob)
if isinstance(val, bool):
hints[knob] = val
elif isinstance(val, str) and val.strip().lower() in (_TRUTHY | _FALSY):
hints[knob] = val.strip().lower() in _TRUTHY
return hints or None
def _stamp_slack_unfurl(self, platform: Optional[str], metadata: Dict[str, Any]) -> None:
unfurl = self._slack_unfurl_hints(platform)
if unfurl:
metadata.update(unfurl)
def _stamp_slack_session_thread(self, event) -> None:
"""Native session-keying parity for fronted Slack DMs.
Native Slack stamps ``thread_ts = event.thread_ts or ts``, so each TOP-LEVEL
message keys a FRESH session (parallel turns). The connector normalizes
top-level messages with thread_id=null, so without this every top-level DM
collapsed into ONE session and message 2 pre-empted message 1. Only in
thread-per-message mode (flat mode keeps the shared rolling session on
purpose); never overwrites a real thread_id.
"""
try:
src = getattr(event, "source", None)
if not src:
return
platform = getattr(src, "platform", None)
if getattr(platform, "value", platform) != _SLACK:
return
if getattr(src, "thread_id", None):
return # real thread — its session key is already correct
message_id, _chat = _event_ids(event)
if not message_id or 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 — every NATIVE
adapter presents inbound media as LOCAL FILE PATHS (vision/file tools consume
paths). Best-effort per entry: a failed download drops that entry, never the
message; with no client only re-host URLs are dropped (they'd 401 downstream)."""
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: carry (url, mime) PAIRS through the loop or surviving
# attachments inherit a neighbour's type.
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:
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 still has value as a URL; a dead re-host does not.
localized.append((url, mime))
event.media_urls = [u for u, _ in localized]
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: a completion
turn injected right after a restart (durable async-delegation replay) reaches
handle_message with the caches COLD, so its replies egress without
scope_id/user_id and the connector's fail-closed tenant guard declines them."""
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's egress discriminators from an inbound event. Never raises.
scope_id: scoped (guild/channel) message → routing-table resolution. user_id:
authentic author id, captured for EVERY message — sole discriminator for a DM
AND the author-first fallback for a scoped reply whose guild has no route row
(managed agents join guilds dynamically). Without a resolvable discriminator
the connector declines egress as 'target not routed to an onboarded tenant'.
"""
try:
src = getattr(event, "source", None)
chat = getattr(src, "chat_id", None) if src else None
if not chat:
return
chat = str(chat)
# Underlying platform's string VALUE, skipping the generic RELAY
# fallback (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[chat] = str(platform_value)
for attr, cache in (
("user_id", self._dm_user_by_chat), ("scope_id", self._scope_by_chat),
("chat_type", self._chat_type_by_chat),
):
value = getattr(src, attr, None)
if value:
cache[chat] = str(value)
# Triggering message ts for the typing/status lane's synthetic thread anchor.
message_id, _chat = _event_ids(event)
if message_id:
self._last_inbound_ts_by_chat[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]:
"""Outbound metadata carrying the tenant discriminators (see _capture_scope).
Both are attached when known and not already set; the connector tries scope_id
first and only falls back to user_id on a route miss, so carrying both never
overrides routing-table resolution."""
meta: Dict[str, Any] = dict(metadata or {})
for key, cache in (("scope_id", self._scope_by_chat), ("user_id", self._dm_user_by_chat)):
if not meta.get(key):
value = cache.get(str(chat_id))
if value:
meta[key] = value
return meta
def fronts_platform(self, platform: Any) -> bool:
"""Whether the authenticated relay transport advertises ``platform`` — a
restart-safe ownership signal from the identity set sent at handshake, not
from an inbound chat cache."""
platform_value = getattr(platform, "value", platform)
ids = getattr(self._transport, "_identities", None) or ()
return bool(platform_value) and any(p == str(platform_value) for p, _ in ids)
def supports_inchannel_continuable_for_platform(self, platform: Any) -> bool:
"""Whether ONE fronted platform can host the flat continuable cron surface (D6
gate). The scalar bit is the PRIMARY's only, so resolve the platform's own
negotiated descriptor; fall back to the scalar when unavailable."""
per_platform = self._negotiated_descriptor(str(getattr(platform, "value", platform) or ""))
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 per-session interrupt path."""
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. The connector answered the
provider's latency-critical ACK at the edge, verified the signature and vaulted
any shared-identity credential; the agent later acts via the token-less
``send_follow_up`` path. A Discord interaction becomes a normalized
``MessageEvent`` on the SAME agent path as chat; other forwards are logged and
dropped. NEVER raises: a malformed forward must not kill the read loop."""
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)
# A prompt-token component press is consumed (same gate as _on_inbound).
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 for
an unusable body (a PING is answered at the edge and never forwarded). The
session source mirrors the connector's ``interactionSessionSource`` so the
session key matches the one the follow-up capability was bound under."""
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 2 = APPLICATION_COMMAND; 3 = MESSAGE_COMPONENT; 5 = MODAL_SUBMIT.
itype = payload.get("type")
data = payload.get("data") or {}
message_type = MessageType.TEXT
if itype == 2:
# Normalize to a leading-slash command string ("/name arg…"), the
# shape the dispatcher and the connector's Slack slash lane expect.
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 {}
if not isinstance(user, dict):
user = {}
guild_id = payload.get("guild_id")
source = SessionSource(
# The LOGICAL platform, not RELAY: session keys must match the connector's
# capability binding (platform="discord"), /sethome must file under the
# logical platform, and _capture_scope skips the generic "relay".
platform=Platform.DISCORD,
chat_id=str(payload.get("channel_id") or ""),
# "group", not "channel": both the connector's capability binding and the
# native Discord adapter key guild channels as "group".
chat_type="group" if guild_id else "dm",
user_id=str(user["id"]) if user.get("id") else None,
user_name=str(user["username"]) if user.get("username") else None,
scope_id=str(guild_id) if guild_id else None,
message_id=str(payload.get("id")) if payload.get("id") else None,
# Same upstream-trust marker the relay text lane stamps. Set locally, never
# read off the wire (engages /sethome's via_relay guard).
delivered_via_upstream_relay=True,
# Profile routing (multiplex mode), mirroring _event_from_wire.
# 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:
# A component press whose custom_id is a Hermes prompt token
# (hp1:<prompt_id>:<option_id>) becomes a STRUCTURED prompt answer;
# foreign custom_ids keep the 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["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:<prompt_id>:<option_id> callback token, or None (mirrors the connector's promptCodec)."""
parts = (token or "").split(":")
if len(parts) != 3 or parts[0] != "hp1":
return None
if not _PROMPT_ID_RE.match(parts[1]) or not _PROMPT_ID_RE.match(parts[2]):
return None
return parts[1], parts[2]
@staticmethod
def _render_interaction_options(options) -> list:
"""Render Discord interaction options to text parts: scalars contribute their
value (native ``f"/model {name}"`` shape); SUB_COMMAND (1) / SUB_COMMAND_GROUP
(2) contribute their name then recurse into nested options."""
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_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:
# The runner wraps this call in wait_for(adapter disconnect budget). Monitor
# teardown and go_idle eat into the transport's drain time, so measure from
# the top and thread the REMAINDER down — otherwise teardown is cancelled
# mid-drain and the transport's fail-pending loop is skipped (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()
# 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:
# Ask the connector to flip this instance to buffered-only BEFORE tearing
# down the socket, so inbound arriving while asleep buffers durably and
# replays on reconnect. Best-effort: a transport without go_idle (the
# stub) or a failed ack must not block shutdown. transport.disconnect()
# runs in finally so an outer cancellation during go_idle still closes the
# socket/supervisor; 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 = self._transport.disconnect(budget_s=_remaining) # type: ignore[call-arg]
except TypeError:
# Transports without the budget_s keyword (stubs).
_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. Unlike ``disconnect()`` this
keeps the reconnect path armed so the gateway re-dials and drains its backlog
on wake. A transport without ``go_dormant`` (the stub) is a no-op returning
False. Deliberately does NOT stop the revocation monitor — dormancy is not a
teardown."""
go_dormant = getattr(self._transport, "go_dormant", None)
if not callable(go_dormant):
return False
try:
result: Any = go_dormant()
return bool(await result) if asyncio.iscoroutine(result) else 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
def hold_redial(self) -> bool:
"""Park the transport's reconnect supervisor. False if it did not take.
The caller suspends on the strength of this, so a swallowed failure here
would report protection that was never installed.
"""
return self._toggle_transport_redial("hold_redial")
def release_redial(self) -> bool:
"""Undo hold_redial() when the suspend did not land."""
return self._toggle_transport_redial("release_redial")
def _toggle_transport_redial(self, method_name: str) -> bool:
# Still never raises into the idle path, but a stub transport or a
# throwing toggle now reports False instead of passing for success.
method = getattr(self._transport, method_name, None)
if not callable(method):
return False
try:
method()
except Exception: # noqa: BLE001 - never blocks the idle path
logger.debug("relay %s failed", method_name, exc_info=True)
return False
return True
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 delivery resolver calls this only after
``fronts_platform`` succeeds; repeated here fail-closed."""
platform_value = str(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.
_interim = bool(_sfp_metadata.pop("_interim_send", False))
# The delivery resolver calls THIS method directly, bypassing send() — an
# open native stream must absorb the turn-final here too.
seal = await self._absorb_into_open_draft(chat_id, content, _sfp_metadata, _interim)
if seal is not None:
return seal
if self._transport is None:
return SendResult(success=False, error="no transport")
self._stamp_slack_unfurl(platform_value, _sfp_metadata)
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: the cron brief
# must render blocks exactly like an interactive send.
"metadata": self._with_scope(
chat_id, self._with_format_hints_for_platform(platform_value, _sfp_metadata)
),
},
platform=platform_value,
)
if isinstance(result, dict):
if not result.get("success") and is_egress_decline(result):
log_decline("send", chat_id, result)
return _send_result(result, 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. The CONNECTOR
owns the platform API call, so the gateway only signals intent: 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 ``platforms.relay.extra.<platform>.rich_blocks`` /
``markdown_blocks`` (both default OFF, ``_coerce_flag`` semantics).
``descriptor``/``platform`` are the DESTINATION's, never the scalar primary:
gating on the primary both leaked hints onto platforms that never advertised
the bit and suppressed them for ones that did."""
if descriptor is None or not getattr(descriptor, "supports_block_formatting", False):
return None
try:
knob_src = self._relay_platform_extra(str(platform or "").lower())
except Exception: # noqa: BLE001 - config shape is operator-owned
return None
hints = {knob: True for knob in ("rich_blocks", "markdown_blocks") if self._coerce_flag(knob_src.get(knob), False)}
return hints or None
def _stamp_format_hints(
self,
descriptor: Optional[CapabilityDescriptor],
platform: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""``metadata`` with ``format_hints`` for the DESTINATION (descriptor, platform) stamped, if any."""
hints = self._format_hints(descriptor, platform)
if not hints:
return metadata
merged = dict(metadata or {})
merged.setdefault("format_hints", hints)
return merged
def _with_format_hints_for_chat(
self, chat_id: str, metadata: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Hints for a chat-addressed send (chat's platform as seen inbound, else the primary)."""
return self._stamp_format_hints(self._descriptor_for_chat(chat_id), self._chat_platform(chat_id), metadata)
def _with_format_hints_for_platform(
self, platform_value: str, metadata: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Hints for an explicit-platform send (scheduled/persisted-home lane). Falls
back to the scalar descriptor only when it IS that platform's — never stamp
from another platform's capability bit."""
descriptor = self._negotiated_descriptor(str(platform_value))
if descriptor is None and self.descriptor.platform == str(platform_value):
descriptor = self.descriptor
return self._stamp_format_hints(descriptor, str(platform_value), metadata)
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 duplicate).
# Gateway-internal marker; strip before the wire.
_interim = bool(send_metadata.pop("_interim_send", False))
# Seal-interception is checked BEFORE the explicit-platform branch: an open
# stream absorbs the turn-final whichever door it arrives through.
seal = await self._absorb_into_open_draft(chat_id, content, send_metadata, _interim)
if seal is not None:
return seal
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")
effective_reply_to = self._prepare_slack_egress(chat_id, reply_to, send_metadata)
result = await self._outbound(
chat_id,
{
"op": "send",
"chat_id": chat_id,
"content": content,
"reply_to": effective_reply_to,
"metadata": self._text_metadata(chat_id, send_metadata),
},
)
# Auto-thread routing feedback: when the connector's auto-thread policy routed
# this send into a thread it just created, the result carries thread_id (+
# initial name). The conversation was keyed on the PARENT channel, so this is
# the only place the gateway learns where the reply landed.
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: "nowhere new" is an
# answer 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 _send_result(result)
def auto_thread_info_for_chat(self, chat_id: str) -> Optional[Tuple[str, str]]:
"""(thread_id, initial_name) of the connector-created auto-thread for the most
recent send into *chat_id*, if any (semantic thread-rename lane)."""
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 as soon as the session is titled — a whole turn early. Waits for the
next send into this chat, so a reply the connector didn't auto-thread reports
its miss immediately; *timeout* is only a backstop for a turn that never sends."""
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 installed, and only if no later call replaced it; a
# fired event must not make the next turn's wait return instantly.
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.
The stream consumer sends a DM reply with ``reply_to`` = the triggering ts
(its edit anchor); the connector maps a raw reply_to to a Slack thread_ts, so
the reply would thread under the user's message and lose progressive edit
streaming. Native ``_resolve_thread_ts`` drops that anchor only when
``reply_in_thread`` is off; mirror it: Slack DM + no real
``thread_id``/``thread_ts`` + flat mode ⇒ drop. In thread-per-message mode the
triggering ts IS the thread anchor and the final reply's ONLY threading signal
(dropping it unconditionally exiled finals to the DM root while progress
stayed threaded). Removes an anchor, never adds one.
It does NOT: * 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. See #18859.
"""
if reply_to is None:
return None
md = metadata or {}
if (
self._platform_by_chat.get(str(chat_id)) != _SLACK
or self._chat_type_by_chat.get(str(chat_id)) != "dm"
or md.get("thread_id")
or md.get("thread_ts")
):
return reply_to
return reply_to if self._effective_reply_in_thread() else 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 for text (``send``) and media (``_send_media``): (1) mode gate via
``_resolve_reply_to_for_send``; (2) when the anchor is dropped, strip the
mirrored ``metadata.reply_to_message_id`` too, or the connector threads on it;
(3) the connector's Slack sender THREADS ON METADATA ONLY (``threadTs()`` never
reads the frame's ``reply_to``), so a surviving anchor is promoted into
``metadata.thread_id``. ``metadata`` is mutated in place."""
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)) == _SLACK
and not (metadata.get("thread_id") or metadata.get("thread_ts"))
):
metadata["thread_id"] = str(effective_reply_to)
return effective_reply_to
def _prepare_slack_egress(
self, chat_id: str, reply_to: Optional[str], metadata: Dict[str, Any]
) -> Optional[str]:
"""Text/media egress prep: Slack thread anchor (DM replies post flat at the DM
root, native _resolve_thread_ts parity) + unfurl hints; mutates ``metadata``."""
effective_reply_to = self._apply_slack_thread_anchor(chat_id, reply_to, metadata)
self._stamp_slack_unfurl(self._chat_platform(chat_id), metadata)
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 and the typing lane's metadata carries no anchor
for a top-level DM, so synthesize it from the per-chat inbound-ts cache. Shared
by ``send_typing`` and ``stop_typing`` — the clear MUST target the same thread
the heartbeat set or the status sticks until Slack's timeout."""
md = dict(metadata or {})
if (
not (md.get("thread_id") or md.get("thread_ts"))
and self._platform_by_chat.get(str(chat_id)) == _SLACK
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._outbound(
chat_id,
{
"op": "edit",
"chat_id": chat_id,
"message_id": message_id,
"content": content,
"metadata": self._text_metadata(chat_id, metadata),
},
)
# P5(b): carry the structured body. THREE separate callers read a bare
# edit failure as "editing is unavailable" and re-send the content as a
# NEW message to the same chat (stream edit-fallback, queued-response
# reconciliation, task-card fallback edit). The edit lane is the ninth
# place a decline could be laundered into a different op.
return SendResult(
success=bool(result.get("success")), message_id=result.get("message_id") or message_id,
error=result.get("error"),
raw_response=result,
)
async def delete_message(self, chat_id: str, message_id: str) -> bool:
"""Delete a relayed message (the stream consumer's fresh-final cleanup). Gated
on the descriptor advertising ``delete``: older connectors return False so
cleanup degrades to leaving the preview in place."""
if self._transport is None:
return False
if "delete" not in (self._descriptor_for_chat(str(chat_id)).supported_ops or ()):
return False
try:
result = await self._outbound(
chat_id,
{
"op": "delete",
"chat_id": chat_id,
"message_id": message_id,
"metadata": self._with_scope(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 (the base ``_keep_typing`` tick) as the ``typing``
op. Best-effort and one-shot: Discord/Telegram indicators self-expire; Slack
Assistant status persists, so ``stop_typing`` sends an explicit clear for
Slack only."""
if self._transport is None:
return
# Rich status parity: carry run.py's per-tool phrase as the frame's content
# (rendered on assistant.threads.setStatus). Absent => omit content and the
# connector uses its default heartbeat. NEVER send empty-string content here:
# on Slack that is the CLEAR request.
phrase = getattr(self, "_status_text", {}).get(str(chat_id))
await self._typing_frame(chat_id, metadata, str(phrase) if phrase else None, "send_typing")
async def stop_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Forward an explicit typing/status clear (empty ``content``) — Slack only:
other relay senders have one-shot heartbeats, where an empty heartbeat would
re-trigger typing at completion. A connector older than gateway-gateway #154
hardcodes the typing status and would SET it on a clear frame — deploy the
connector first."""
if self._transport is None or self._platform_by_chat.get(str(chat_id)) != _SLACK:
return
await self._typing_frame(chat_id, metadata, "", "stop_typing")
async def _typing_frame(
self, chat_id: str, metadata: Optional[Dict[str, Any]], content: Optional[str], lane: str
) -> None:
"""One ``typing`` frame (``content`` None = omit; "" = Slack clear). Cosmetic: never raises."""
# 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)
frame: Dict[str, Any] = {"op": "typing", "chat_id": chat_id, "metadata": self._with_scope(chat_id, md)}
if content is not None:
frame["content"] = content
try:
await self._outbound(chat_id, frame)
except Exception: # noqa: BLE001 - typing/status is cosmetic, never breaks a turn
logger.debug("relay %s failed for %s", lane, chat_id, exc_info=True)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
# Op-gated so a legacy connector (which would only answer "unsupported op")
# gets the same local fallback without a round trip.
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. The gateway never
holds the credential: it names the session and the capability ``kind``; the
connector resolves the value from its vault and egresses (enforcing tenant)."""
if self._transport is None:
return SendResult(success=False, error="no transport")
# `kind` is platform-prefixed ("discord.interaction_token"): tag the frame
# with that platform when we front it; otherwise the connector's session
# default routes it.
prefix = kind.split(".", 1)[0] if kind and "." in kind else None
follow_up_platform = prefix if prefix and self.fronts_platform(prefix) else None
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,
)
# CARRY THE STRUCTURED DECLINE. This lane is addressed by `session_key`,
# not `chat_id`, so it has no latch identity — the latch is per chat and
# inventing one from a session key would be exactly the text-derived
# identity that blocker 2 was about. What it must NOT do is discard the
# connector's verdict: dropping `raw_response` is precisely how the
# `edit_message` lane laundered declines into a plain send.
if isinstance(result, dict) and not result.get("success") and is_egress_decline(result):
log_decline("follow_up", session_key, result)
return _send_result(result, raw_response=result)
# ── Phase 2 media ─────────────────────────────────────────────────────
def _get_media_client(self) -> Optional[RelayMediaClient]:
"""Lazily build the authenticated /relay/media client from the SAME dial URL and
per-gateway creds the WS uses; None when unavailable (media lanes then degrade
to their pre-media fallbacks)."""
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
a LOCAL path (uploaded to /relay/media first — the connector cannot reach our
filesystem) or an already-public URL (passed through). None when the lane is
unavailable (op not advertised, no transport, upload failed, connector
decline) so each caller falls back to its pre-media behaviour."""
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: media frames go through
# the connector's Slack sender too (threadTs() reads metadata only).
media_metadata: Dict[str, Any] = dict(metadata or {})
effective_reply_to = self._prepare_slack_egress(chat_id, reply_to, media_metadata)
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
# A capacity decline (size cap, platform rejection) is logged and the
# caller's fallback still delivers the caption/notice; an
# AUTHORIZATION decline must NOT degrade that way (surface_declines).
result = await self._gated_op(chat_id, action, surface_declines=True)
if result is None:
return None
if not result.get("success"):
# An AUTHORIZATION refusal, surfaced by _gated_op. Returning None
# would hand the caller back to its fallback — a DIFFERENT op
# against the SAME destination the connector just refused. Report
# a failed lane instead, carrying the decline verbatim.
return SendResult(
success=False, error=decline_error(result), raw_response=result
)
return SendResult(success=True, message_id=result.get("message_id"), raw_response=result)
# Each media override tries the native ``send_media`` lane, then falls back to
# the base adapter's text/URL behaviour when the lane is unavailable.
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:
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,
)
return result if result is not None else 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:
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,
)
return result if result is not None else 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:
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,
)
return result if result is not None else 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:
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,
)
return result if result is not None else 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:
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,
)
return result if result is not None else 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 (``<owner nonce>.<8 hex>``).
Expiry is enforced gateway-side on consumption (_pop_prompt); the wire's
timeout_s is advisory. The nonce marks the minting process so a sibling
gateway receiving the fanned-out answer stays quiet. Both segments use the
connector codec's alphabet ([A-Za-z0-9_.-], <=32)."""
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.
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 without a ``.`` segment
predate the owner nonce (in-flight across an in-place upgrade) and are ours."""
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."""
state = self._pending_prompts.pop(str(prompt_id), None)
if not state or state.get("expires_at", 0) < time.time():
return None
return state
def _note_prompt_resolved(self, prompt_id: str) -> None:
"""Remember that this process answered ``prompt_id`` (bounded FIFO: a repeat is
only interesting while a redelivery/double tap can arrive)."""
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 (the caller falls
back to its numbered-text base behaviour). 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)."""
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, metadata),
"metadata": self._with_scope(chat_id, metadata),
}
if timeout_s is not None:
action["timeout_s"] = int(timeout_s)
result = await self._gated_op(chat_id, action, surface_declines=True)
if result is None:
return None
if not result.get("success"):
# An AUTHORIZATION refusal, surfaced by _gated_op. Returning None
# would hand the caller back to its fallback — a DIFFERENT op
# against the SAME destination the connector just refused. Report
# a failed lane instead, carrying the decline verbatim.
return SendResult(
success=False, error=decline_error(result), raw_response=result
)
return SendResult(success=True, message_id=result.get("message_id"), raw_response=result)
async def _mint_and_send_prompt(
self,
kind: str,
state: Dict[str, Any],
chat_id: str,
*,
prompt_kind: str,
text: str,
options: list,
metadata: Optional[Dict[str, Any]],
) -> Optional[SendResult]:
"""Register + egress a prompt; unregisters and returns None when the lane is unavailable."""
prompt_id = self._mint_prompt(kind, {**state, "chat_id": str(chat_id)})
result = await self._send_prompt(
chat_id, prompt_kind=prompt_kind, text=text, prompt_id=prompt_id, options=options,
metadata=metadata,
)
if result is None or not getattr(result, "success", False):
# P5(b): a DECLINE now returns a failed SendResult rather than
# None, and the registration must come down on that path too. A
# prompt card the connector refused never rendered, so leaving it
# pending lets the user's next unrelated message be captured as the
# answer to a prompt they never saw.
self._pending_prompts.pop(prompt_id, None)
return result
_PROMPT_UNAVAILABLE = SendResult(success=False, error="relay prompt op unavailable")
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 (same choice set as native; the
press resolves via tools.approval.resolve_gateway_approval). When the lane is
unavailable the send FAILS (success=False) so run.py's button→text fallback runs."""
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 = f"⚠️ **Command Approval Required**\n\n```\n{cmd_preview}\n```\nReason: {description}"
if smart_denied:
text += "\n\n**Smart DENY:** owner override applies to this one operation only."
result = await self._mint_and_send_prompt(
"exec_approval", {"session_key": session_key}, chat_id, prompt_kind="approval",
text=text, options=options, metadata=metadata,
)
return result if result is not None else self._PROMPT_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 (resolves via
tools.slash_confirm.resolve; success=False falls back to text-intercept)."""
options = [
{"id": "once", "label": "Approve Once", "style": "primary"},
{"id": "always", "label": "Always Approve"},
{"id": "cancel", "label": "Cancel", "style": "danger"},
]
result = await self._mint_and_send_prompt(
"slash_confirm", {"session_key": session_key, "confirm_id": confirm_id}, chat_id,
prompt_kind="approval", text=f"**{title}**\n\n{message}" if title else message,
options=options, metadata=metadata,
)
return result if result is not None else self._PROMPT_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. A press resolves with the CHOICE TEXT
(never the option id); "Other" flips to text-capture. Option ids are
positional (c0..cN / other) — choice text is arbitrary UTF-8 and would blow
the 64-byte callback budget. Open-ended clarifies and unavailable lanes fall
back to base."""
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)"})
result = await self._mint_and_send_prompt(
"clarify",
{
"session_key": session_key,
"clarify_id": clarify_id,
"choices": [str(c) for c in choices],
},
chat_id,
prompt_kind="clarify",
text=f"❓ {question}",
options=options,
metadata=metadata,
)
if result is not None:
return result
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; True when the
event was a prompt answer (consumed — never dispatched as chat). EVERY prompt
answer is consumed, whoever owns it: a sibling's prompt (the connector fans the
press to every gateway of the tenant; falling through produced a wall of
"Unknown command"), a repeat answer (first one won), or our own expired/unknown
prompt (answered with a short expiry notice — option ids are not commands)."""
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):
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", ""))
handler = _PROMPT_RESOLVERS.get(kind)
try:
if handler is None:
logger.warning("relay prompt_response with unknown kind %r", kind)
else:
# Acks are fire-and-forget: we are ON the read loop here (see
# _send_lifecycle_ack) and awaiting a send would self-deadlock.
await handler(self, state, option_id, chat_id, self._prompt_reply_metadata(event))
except Exception: # noqa: BLE001 - a resolver failure must not kill the reader
logger.warning("relay prompt_response resolution failed", exc_info=True)
return True
async def _resolve_exec_approval(self, state, option_id, chat_id, ack_meta) -> None:
from tools.approval import resolve_gateway_approval
choice = option_id if option_id in _EXEC_APPROVAL_LABELS else "deny"
count = resolve_gateway_approval(str(state.get("session_key") or ""), choice)
label = _EXEC_APPROVAL_LABELS[choice] if count else "⌛ Approval expired — no command was waiting."
# In-channel ack preserves the audit trail the native edit gives (the
# connector's prompt message can't be edited cross-platform yet).
self._send_lifecycle_ack(chat_id, label, ack_meta)
if count:
self.resume_typing_for_chat(chat_id)
async def _resolve_slash_confirm(self, state, option_id, chat_id, ack_meta) -> None:
from tools import slash_confirm as slash_confirm_mod
choice = option_id if option_id in _SLASH_CONFIRM_LABELS else "cancel"
result_text = await slash_confirm_mod.resolve(
str(state.get("session_key") or ""), str(state.get("confirm_id") or ""), choice
)
self._send_lifecycle_ack(chat_id, _SLASH_CONFIRM_LABELS[choice], ack_meta)
if result_text:
self._send_lifecycle_ack(chat_id, str(result_text), ack_meta)
async def _resolve_clarify(self, state, option_id, chat_id, ack_meta) -> None:
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:", ack_meta)
return
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]}", ack_meta)
else:
# Unmappable option: flip to text capture (never dead-end a clarify).
mark_awaiting_text(clarify_id)
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.
_consume_prompt_response executes ON the transport read loop; an ``await
self.send(...)`` there is a SELF-DEADLOCK (send() blocks on an outbound_result
future only the read loop can resolve) — every button tap wedged the transport
for the full outbound timeout. Acks are cosmetic, so they ride a background
task; failures log at debug. The task ref is retained (asyncio holds tasks weakly)."""
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 (owning gateway only, best-effort)."""
chat_id = str(getattr(event.source, "chat_id", "") or "")
if not chat_id:
return
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 metadata so prompt acks land where the prompt lives. Marked INTERIM:
acks fire while the approval turn's OWN draft stream is open and carry only
placement metadata, so send()'s single-open-stream fallback sealed the live
draft with the ack text (frozen stream + duplicate final on every approval turn)."""
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, logged at debug)."""
if not chat_id or not message_id:
return False
result = await self._gated_op(
chat_id,
{
"op": "react",
"chat_id": chat_id,
"message_id": message_id,
"emoji": emoji,
"remove": remove,
"metadata": self._with_scope(chat_id, None),
},
decline_level=None,
)
return result is not None
async def on_processing_start(self, event) -> None:
"""Add the 👀 in-progress reaction (op-gated; silent no-op otherwise)."""
message_id, chat_id = _event_ids(event)
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)."""
message_id, chat_id = _event_ids(event)
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). None on any failure/unavailability so the
handoff watcher falls back to the parent."""
result = await self._gated_op(
str(parent_chat_id),
{
"op": "thread_create",
"chat_id": str(parent_chat_id),
"thread_name": (str(name or "").strip() or "handoff")[:100],
"metadata": self._with_scope(str(parent_chat_id), None),
},
decline_level=logging.INFO,
subject=parent_chat_id,
)
if result is None:
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. Prefer
``prefer_connector_created=True``: the CONNECTOR enforces the no-clobber guard
from its own created-name memory, so the gateway need not reproduce the
initial name byte-for-byte (any normalization drift silently declined every
rename). ``only_if_current_name`` is the legacy string guard for older
connectors. ``parent_chat_id`` defaults to the thread id (Telegram needs the
containing chat; Discord ignores it)."""
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)
result = await self._gated_op(
chat_id,
action,
decline_level=logging.INFO,
subject=thread_id,
platform=self._platform_by_chat.get(chat_id) or self._platform_by_chat.get(str(thread_id)),
)
return result is not None
# prompt kind -> resolver (order-independent: kinds are distinct keys).
_PROMPT_RESOLVERS = {
"exec_approval": RelayAdapter._resolve_exec_approval,
"slash_confirm": RelayAdapter._resolve_slash_confirm,
"clarify": RelayAdapter._resolve_clarify,
}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import cast # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----