mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
feat(relay): experimental CapabilityDescriptor schema
Frozen, JSON-serializable handshake payload the connector hands the future RelayAdapter: char limit, draft-streaming/edit/threading flags, markdown dialect, len_unit. Mostly a wire projection of PlatformEntry + the adapter capability methods. contract_version gates additive-only evolution; declared EXPERIMENTAL until >=2 Class-1 platforms validate it. from_json ignores unknown keys (forward-compat) and fills optional defaults. Phase 0, Task 0.2 of the gateway-relay plan.
This commit is contained in:
parent
e9a2ce6585
commit
53d9b98305
4 changed files with 154 additions and 0 deletions
11
gateway/relay/__init__.py
Normal file
11
gateway/relay/__init__.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
77
gateway/relay/descriptor.py
Normal file
77
gateway/relay/descriptor.py
Normal file
|
|
@ -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)
|
||||
0
tests/gateway/relay/__init__.py
Normal file
0
tests/gateway/relay/__init__.py
Normal file
66
tests/gateway/relay/test_descriptor.py
Normal file
66
tests/gateway/relay/test_descriptor.py
Normal file
|
|
@ -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 "")
|
||||
Loading…
Add table
Add a link
Reference in a new issue