Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/relay-connector-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ JSON object. Source of truth: `gateway/relay/descriptor.py`.
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
| `supports_context` | bool | no | Whether the connector can supply surrounding channel/group **context** for an addressed turn on this platform (Model A on-demand history fetch — Discord/Slack/Matrix; Model B passive buffer — Telegram/Signal/WhatsApp). Default false ⇒ no `context` is attached to inbound events. See §3. |
| `supported_ops` | string[] | no | Op-level capability discovery: the outbound op names the connector's sender for this platform actually implements (e.g. `["send", "edit", "typing", "follow_up", "get_chat_info"]`). Absent/empty ⇒ the connector predates the field and the gateway assumes the legacy op set (`send`/`edit`/`typing`/`follow_up`); a NEW op is used only when explicitly advertised. |

Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
Expand Down
46 changes: 46 additions & 0 deletions gateway/relay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,44 @@ def relay_wake_url() -> Optional[str]:
return value.rstrip("/") or None


def relay_display_name() -> Optional[str]:
"""The human-facing agent display name, forwarded at provision (Phase 1 parity).

The PRIMARY source for the connector's multi-agent reply-attribution prefix
(gateway-gateway #171): in a multi-agent scope the shared bot prepends
``**<displayName>:** `` to this instance's replies. Gateway-asserted but
safely scoped exactly like ``relay_instance_id()`` / ``relay_wake_url()`` —
the tenant stays token-verified, so a dishonest gateway can only label its
OWN instance. Absent -> the connector stores null and attribution falls
back to the instance's linked-owner identity, else skips the prefix.

Env first (Docker/NAS stamps ``GATEWAY_RELAY_DISPLAY_NAME``), then the
skin's branded agent name (``get_branding("agent_name")`` — the same value
the CLI banner shows), so a self-hosted rename via skin config propagates
on the next boot's re-provision (the connector rotates on change, same as
a wake-url move).
"""
value = os.environ.get("GATEWAY_RELAY_DISPLAY_NAME", "").strip()
if not value:
try:
from hermes_cli.skin_engine import get_active_skin # late import: boot-safe

value = str(
get_active_skin().get_branding("agent_name", "") or ""
).strip()
except Exception: # noqa: BLE001 - branding absence must never crash boot
value = ""
# The stock brand name is IDENTICAL on every default install, so in a
# multi-agent scope it would prefix every reply "**Hermes Agent:**" —
# shadowing the connector's linked-owner fallback, which actually
# disambiguates. Only a deliberately customized name is forwarded.
if value == "Hermes Agent":
value = ""
# Mirror the connector's ingest sanitization (trim + 64-char cap) so what
# we send is what gets stored.
return value[:64] or None


def _provision_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/provision`` POST URL."""
raw = relay_dial_url.rstrip("/")
Expand Down Expand Up @@ -384,6 +422,7 @@ def _post_provision(
route_keys: list[str],
instance_id: Optional[str] = None,
wake_url: Optional[str] = None,
display_name: Optional[str] = None,
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/provision`` and return the JSON body.
Expand Down Expand Up @@ -413,6 +452,11 @@ def _post_provision(
# stores null and simply can't wake this instance (buffering still works).
if wake_url:
body["wakeUrl"] = wake_url
# Same for the display name (Phase 1 parity, gg#171): omit when absent so
# the connector stores null and attribution falls back to the linked-owner
# identity.
if display_name:
body["displayName"] = display_name
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
provision_url,
Expand Down Expand Up @@ -591,6 +635,7 @@ def self_provision_relay() -> bool:
route_keys = relay_route_keys()
instance_id = relay_instance_id()
wake_url = relay_wake_url()
display_name = relay_display_name()

# Phase 1.5 (D-Q1.5c): provision EACH fronted platform under the SAME
# gatewayId + the SAME (platform-less) per-gateway secret. The connector's
Expand All @@ -615,6 +660,7 @@ def self_provision_relay() -> bool:
route_keys=route_keys,
instance_id=instance_id,
wake_url=wake_url,
display_name=display_name,
)
except RuntimeError as exc:
logger.warning(
Expand Down
7 changes: 6 additions & 1 deletion gateway/relay/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,12 @@ async def stop_typing(

async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
# Proxied to the connector (it owns the platform connection / cache).
if self._transport is None:
# Gated on op-level capability discovery: a connector that doesn't
# advertise get_chat_info in supported_ops (including every legacy
# connector, where supported_ops is empty and the LEGACY_OPS set
# applies) would only return "unsupported op", so skip the round trip
# and answer with the same local fallback the error path produced.
if self._transport is None or not self.descriptor.supports_op("get_chat_info"):
return {"name": chat_id, "type": "dm"}
return await self._transport.get_chat_info(chat_id)

Expand Down
39 changes: 39 additions & 0 deletions gateway/relay/descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ class CapabilityDescriptor:
# "no context" — additive within contract_version 1. from_json filters
# unknown keys, so a connector sending this to an older gateway is safe too.
supports_context: bool = False
# Op-level capability discovery (Phase 1 parity): the outbound op names the
# connector's sender for this platform actually implements (e.g.
# ["send", "edit", "typing", "follow_up", "get_chat_info"]). Empty tuple =
# the connector predates the field; callers MUST treat that as "legacy op
# set" (send/edit/typing/follow_up) rather than "nothing supported", so an
# old connector keeps working unchanged. Additive within contract_version 1.
# Stored as a tuple so the frozen dataclass stays hashable/immutable.
supported_ops: tuple = ()

# The op set every connector supported before ``supported_ops`` existed.
# Used as the assumed capability set when a legacy connector sends no list.
LEGACY_OPS = ("send", "edit", "typing", "follow_up")

def supports_op(self, op: str) -> bool:
"""Whether the connector advertises the outbound op ``op``.

Fail-open for legacy connectors: an empty ``supported_ops`` means the
connector predates op discovery, so assume the legacy op set (the four
ops every connector implemented before the field existed). A NEW op
(e.g. ``get_chat_info``) is therefore only True when explicitly
advertised — exactly the discovery semantics Phase 1 needs: the gateway
can probe capability without trying the op and parsing an error.
"""
if not self.supported_ops:
return op in self.LEGACY_OPS
return op in self.supported_ops

def to_json(self) -> str:
"""Serialize to a compact, stable JSON string for the handshake frame."""
Expand Down Expand Up @@ -93,6 +119,19 @@ def from_json(cls, data: str) -> "CapabilityDescriptor":
filtered["max_message_length"] = 4096
except (TypeError, ValueError):
filtered["max_message_length"] = 4096
# Normalize supported_ops at the trust boundary: JSON carries a list;
# the frozen dataclass stores a tuple. Non-list/malformed values (or a
# list holding non-strings) degrade to () — the legacy-op-set fallback —
# rather than raising, matching the "malformed input never breaks the
# handshake" posture above.
if "supported_ops" in filtered:
raw_ops = filtered["supported_ops"]
if isinstance(raw_ops, (list, tuple)):
filtered["supported_ops"] = tuple(
str(op) for op in raw_ops if isinstance(op, str) and op
)
else:
filtered["supported_ops"] = ()
return cls(**filtered)

@classmethod
Expand Down
12 changes: 11 additions & 1 deletion gateway/relay/ws_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,17 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
chat_type=src.get("chat_type", "dm"),
chat_name=src.get("chat_name"),
user_id=src.get("user_id"),
user_name=src.get("user_name"),
# Native adapters surface the human-facing DISPLAY name as user_name
# (e.g. Discord `message.author.display_name`); the connector sends the
# raw platform username as user_name plus optional user_display_name /
# user_handle enrichments (contract §3). Prefer the display name for
# parity with native lanes — session keys derive from user_id, never
# user_name, so this is presentation-only and key-stable.
user_name=(
src.get("user_display_name")
or src.get("user_name")
or src.get("user_handle")
),
thread_id=src.get("thread_id"),
chat_topic=src.get("chat_topic"),
user_id_alt=src.get("user_id_alt"),
Expand Down
25 changes: 17 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8793,12 +8793,18 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None:
except (ValueError, KeyError):
raise RuntimeError(f"unknown platform '{platform_name}'")

# Adapter must be live
adapter = self.adapters.get(platform)
if not adapter:
# Adapter must be live. A relay-fronted gateway registers ONE adapter
# under Platform.RELAY that fronts N logical platforms — so a literal
# adapters.get(discord) misses even though "discord" is deliverable.
# resolve_delivery_transport is the shared alias-aware resolver (native
# adapter wins; relay eligible only when its authenticated transport
# advertises it fronts the logical platform).
transport = resolve_delivery_transport(platform, self.config, self.adapters)
if not transport:
raise RuntimeError(
f"platform '{platform_name}' is not active in this gateway"
)
adapter = transport.adapter

# Home channel must be configured
home = self.config.get_home_channel(platform)
Expand Down Expand Up @@ -8938,15 +8944,18 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None:

# Send the agent's reply to the destination. Route to the new
# thread if we created one; otherwise the configured home channel
# (which may itself carry a thread_id).
# (which may itself carry a thread_id). Send through the resolved
# transport (not adapter.send directly) so a relay-fronted logical
# platform is stamped on the outbound frame (send_for_platform).
send_metadata: Dict[str, Any] = {}
if effective_thread_id:
send_metadata["thread_id"] = effective_thread_id
try:
result = await adapter.send(
chat_id=str(home.chat_id),
content=response_text,
metadata=send_metadata or None,
result = await transport.send(
platform,
str(home.chat_id),
response_text,
send_metadata or None,
)
except Exception as exc:
raise RuntimeError(f"adapter.send failed: {exc}") from exc
Expand Down
22 changes: 20 additions & 2 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,8 +569,26 @@ def _handle_handoff_command(self, cmd_original: str) -> bool:

pcfg = gw_config.platforms.get(platform)
if not pcfg or not pcfg.enabled:
_cprint(f" Platform '{platform_name}' is not configured/enabled in the gateway.")
return True
# Relay aliasing: a relay-fronted gateway has no per-platform
# config block for the logical platform ("discord" etc.) — only a
# RELAY entry — yet /handoff discord is deliverable when the relay
# fronts it. The fronted set is deploy config
# (GATEWAY_RELAY_PLATFORMS), readable here without the live
# adapter; the gateway watcher re-checks against the authenticated
# transport (resolve_delivery_transport) before dispatch, so this
# is a UX pre-check, not the security gate.
relay_fronts = False
try:
from gateway.relay import relay_platform_identities
relay_cfg = gw_config.platforms.get(Platform.RELAY)
if relay_cfg and relay_cfg.enabled:
fronted = {p for p, _ in relay_platform_identities()}
relay_fronts = platform_name in fronted
except Exception:
relay_fronts = False
if not relay_fronts:
_cprint(f" Platform '{platform_name}' is not configured/enabled in the gateway.")
return True

home = gw_config.get_home_channel(platform)
if not home or not home.chat_id:
Expand Down
42 changes: 42 additions & 0 deletions tests/gateway/relay/test_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,45 @@ def test_module_is_marked_experimental():
import gateway.relay.descriptor as m

assert "EXPERIMENTAL" in (m.__doc__ or "")


# ─────────────── supported_ops (op-level capability discovery, Phase 1) ───────────────

def test_supported_ops_roundtrips_json():
d = _telegram_descriptor(supported_ops=("send", "edit", "typing"))
restored = CapabilityDescriptor.from_json(d.to_json())
assert restored.supported_ops == ("send", "edit", "typing")
assert restored == d


def test_supports_op_advertised_list_is_authoritative():
d = _telegram_descriptor(supported_ops=("send", "typing", "get_chat_info"))
assert d.supports_op("send") is True
assert d.supports_op("get_chat_info") is True
# An advertised list EXCLUDES what it omits — even a legacy op.
assert d.supports_op("edit") is False


def test_supports_op_legacy_connector_assumes_legacy_set():
"""An empty supported_ops means the connector predates op discovery: the
legacy four ops are assumed supported (old connectors keep working), while
NEW ops are not (discovery semantics — never probe by trying)."""
d = _telegram_descriptor() # no supported_ops
for op in ("send", "edit", "typing", "follow_up"):
assert d.supports_op(op) is True, op
assert d.supports_op("get_chat_info") is False


def test_from_json_normalizes_malformed_supported_ops():
"""Non-list shapes and non-string members degrade to the legacy fallback
(empty tuple), never raise — malformed input can't break the handshake."""
base = (
'{"contract_version": 1, "platform": "x", "label": "X", '
'"max_message_length": 2000, "supports_draft_streaming": false, '
'"supports_edit": true, "supports_threads": false, '
'"markdown_dialect": "plain", "len_unit": "chars", '
)
d = CapabilityDescriptor.from_json(base + '"supported_ops": "send"}')
assert d.supported_ops == ()
d = CapabilityDescriptor.from_json(base + '"supported_ops": ["send", 7, null, ""]}')
assert d.supported_ops == ("send",)
Loading
Loading