diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py new file mode 100644 index 00000000000..1917ba1a3cf --- /dev/null +++ b/gateway/relay/__init__.py @@ -0,0 +1,11 @@ +"""Relay/connector support package for the Hermes gateway. + +EXPERIMENTAL. This package implements the gateway side of the "Gateway Gateway" +relay design: a generic ``RelayAdapter`` plus the wire-serializable +``CapabilityDescriptor`` the connector hands it at handshake time. The public +API (module names, descriptor field set, transport protocol) MAY CHANGE without +a deprecation cycle until at least two real Class-1 platforms (Discord + +Telegram) have shaken out the schema. + +See ``docs/relay-connector-contract.md`` for the formal cross-repo interface. +""" diff --git a/gateway/relay/descriptor.py b/gateway/relay/descriptor.py new file mode 100644 index 00000000000..2a6263e28a3 --- /dev/null +++ b/gateway/relay/descriptor.py @@ -0,0 +1,77 @@ +"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL. + +The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter`` +at handshake time; it tells the adapter which platform it is fronting and which +capabilities to advertise to the ``GatewayStreamConsumer`` (char limit, +draft-streaming, edit/threading support, markdown dialect, length unit). It is +the linchpin of the generalization: one gateway adapter serves Discord, +Telegram, Matrix, Signal, ... without per-platform branching. + +EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least +two real Class-1 platforms have validated it. Evolution during the experimental +phase is additive-only, gated by ``contract_version`` (see +docs/relay-connector-contract.md). + +Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus +the per-instance capability methods on ``BasePlatformAdapter``): + +- ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter + ``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer). +- ``len_unit`` -> selects which ``message_len_fn`` the adapter installs + ("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting). +- ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe. +- ``supports_edit`` -> whether edit-based streaming is possible (Discord/ + Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment). +- ``supports_threads`` -> ``create_handoff_thread`` capability flag. +- ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord"). +- ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the + same name. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass + +# Bump additively (never reinterpret an existing field) during the experimental +# phase; a breaking change requires updating both repos in lockstep. +CONTRACT_VERSION = 1 + + +@dataclass(frozen=True) +class CapabilityDescriptor: + """Immutable capability descriptor negotiated at relay handshake. + + Frozen so a descriptor cannot be mutated after handshake — the adapter + advertises a fixed capability profile for the life of the connection. + """ + + contract_version: int + platform: str + label: str + max_message_length: int + supports_draft_streaming: bool + supports_edit: bool + supports_threads: bool + markdown_dialect: str + len_unit: str # "chars" | "utf16" + emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default) + platform_hint: str = "" + pii_safe: bool = False + + def to_json(self) -> str: + """Serialize to a compact, stable JSON string for the handshake frame.""" + return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False) + + @classmethod + def from_json(cls, data: str) -> "CapabilityDescriptor": + """Deserialize from a handshake JSON string. + + Unknown keys are ignored (forward-compat: a newer connector may send + fields this gateway does not know yet); missing optional keys fall back + to dataclass defaults. + """ + raw = json.loads(data) + known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined] + filtered = {k: v for k, v in raw.items() if k in known} + return cls(**filtered) diff --git a/tests/gateway/relay/__init__.py b/tests/gateway/relay/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/gateway/relay/test_descriptor.py b/tests/gateway/relay/test_descriptor.py new file mode 100644 index 00000000000..10471a2c1a7 --- /dev/null +++ b/tests/gateway/relay/test_descriptor.py @@ -0,0 +1,66 @@ +"""Tests for the experimental CapabilityDescriptor (relay Phase 0, Task 0.2).""" + +from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor + + +def _telegram_descriptor(**overrides) -> CapabilityDescriptor: + base = dict( + contract_version=CONTRACT_VERSION, + platform="telegram", + label="Telegram", + max_message_length=4096, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=True, + markdown_dialect="markdown_v2", + len_unit="utf16", + emoji="\u2708\ufe0f", + platform_hint="You are on Telegram.", + pii_safe=False, + ) + base.update(overrides) + return CapabilityDescriptor(**base) + + +def test_descriptor_roundtrips_json(): + d = _telegram_descriptor() + assert CapabilityDescriptor.from_json(d.to_json()) == d + + +def test_descriptor_is_frozen(): + d = _telegram_descriptor() + try: + d.max_message_length = 1 # type: ignore[misc] + except Exception as exc: # FrozenInstanceError + assert "cannot assign" in str(exc) or "frozen" in str(exc).lower() + else: # pragma: no cover + raise AssertionError("descriptor should be immutable (frozen)") + + +def test_from_json_ignores_unknown_keys(): + """A newer connector may send fields this gateway doesn't know — those are + dropped, not fatal (forward-compat during the experimental phase).""" + d = _telegram_descriptor() + raw = d.to_json()[:-1] + ', "future_field": "ignored"}' + restored = CapabilityDescriptor.from_json(raw) + assert restored == d + + +def test_from_json_fills_optional_defaults(): + """Optional fields (emoji/platform_hint/pii_safe) fall back to defaults.""" + minimal = ( + '{"contract_version": 1, "platform": "x", "label": "X", ' + '"max_message_length": 2000, "supports_draft_streaming": false, ' + '"supports_edit": false, "supports_threads": false, ' + '"markdown_dialect": "plain", "len_unit": "chars"}' + ) + d = CapabilityDescriptor.from_json(minimal) + assert d.pii_safe is False + assert d.platform_hint == "" + assert d.emoji == "\U0001f50c" + + +def test_module_is_marked_experimental(): + import gateway.relay.descriptor as m + + assert "EXPERIMENTAL" in (m.__doc__ or "")