mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
* fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL.
201 lines
7.2 KiB
Python
201 lines
7.2 KiB
Python
"""WebSocketRelayTransport against a real in-process WebSocket server.
|
|
|
|
Exercises the production transport over an actual ``websockets`` server (no
|
|
mock socket): handshake (hello -> descriptor), inbound frame -> handler,
|
|
outbound request/response correlation, and follow_up routing. Proves the wire
|
|
framing (newline-delimited JSON) and the request/response future plumbing work
|
|
end to end on a live socket.
|
|
|
|
Skipped cleanly if the optional ``websockets`` dependency is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE
|
|
|
|
pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
|
|
|
|
if WEBSOCKETS_AVAILABLE:
|
|
import websockets
|
|
|
|
|
|
DESCRIPTOR = {
|
|
"contract_version": 1,
|
|
"platform": "discord",
|
|
"label": "Discord",
|
|
"max_message_length": 2000,
|
|
"supports_draft_streaming": False,
|
|
"supports_edit": True,
|
|
"supports_threads": True,
|
|
"markdown_dialect": "discord",
|
|
"len_unit": "chars",
|
|
}
|
|
|
|
|
|
class _StubConnectorServer:
|
|
"""Minimal connector: answers hello with a descriptor, echoes outbound."""
|
|
|
|
def __init__(self):
|
|
self.received: list[dict] = []
|
|
self._server = None
|
|
self.url = ""
|
|
# Push channel: tests set this to a frame dict to deliver inbound.
|
|
self._to_push: list[dict] = []
|
|
|
|
async def start(self):
|
|
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
|
|
sock = next(iter(self._server.sockets))
|
|
port = sock.getsockname()[1]
|
|
self.url = f"ws://127.0.0.1:{port}"
|
|
|
|
async def stop(self):
|
|
if self._server is not None:
|
|
self._server.close()
|
|
await self._server.wait_closed()
|
|
|
|
async def _handle(self, ws):
|
|
async for raw in ws:
|
|
for line in str(raw).split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
frame = json.loads(line)
|
|
self.received.append(frame)
|
|
await self._on_frame(ws, frame)
|
|
|
|
async def _on_frame(self, ws, frame):
|
|
ftype = frame.get("type")
|
|
if ftype == "hello":
|
|
await ws.send(json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n")
|
|
# Deliver any queued inbound frames right after handshake.
|
|
for f in self._to_push:
|
|
await ws.send(json.dumps(f) + "\n")
|
|
elif ftype == "outbound":
|
|
action = frame.get("action", {})
|
|
# Echo a successful result correlated by requestId.
|
|
result = {"success": True, "message_id": f"srv-{action.get('op')}"}
|
|
await ws.send(
|
|
json.dumps({"type": "outbound_result", "requestId": frame["requestId"], "result": result})
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def server():
|
|
srv = _StubConnectorServer()
|
|
await srv.start()
|
|
yield srv
|
|
await srv.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handshake_negotiates_descriptor(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
desc = await t.handshake()
|
|
assert desc.platform == "discord"
|
|
assert desc.max_message_length == 2000
|
|
# The hello carried the platform + botId.
|
|
hello = next(f for f in server.received if f["type"] == "hello")
|
|
assert hello["platform"] == "discord"
|
|
assert hello["botId"] == "appShared"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_frame_reaches_handler(server):
|
|
server._to_push = [
|
|
{
|
|
"type": "inbound",
|
|
"event": {
|
|
"text": "hello from connector",
|
|
"message_type": "text",
|
|
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
|
|
},
|
|
"bufferId": "buf-1",
|
|
}
|
|
]
|
|
received = []
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
t.set_inbound_handler(lambda ev: received.append(ev) or asyncio.sleep(0))
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
# Give the reader a tick to deliver the pushed inbound frame.
|
|
await asyncio.sleep(0.05)
|
|
assert len(received) == 1
|
|
assert received[0].text == "hello from connector"
|
|
assert received[0].source.guild_id == "guildA"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_round_trips_with_correlation(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
result = await t.send_outbound({"op": "send", "chat_id": "chan1", "content": "hi"})
|
|
assert result["success"] is True
|
|
assert result["message_id"] == "srv-send"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_follow_up_round_trips(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
result = await t.send_follow_up(
|
|
{"op": "follow_up", "session_key": "s1", "kind": "discord.interaction_token", "content": "fu"}
|
|
)
|
|
assert result["success"] is True
|
|
assert result["message_id"] == "srv-follow_up"
|
|
# The follow_up rode an outbound frame the connector saw.
|
|
outbound = [f for f in server.received if f["type"] == "outbound"]
|
|
assert any(f["action"]["op"] == "follow_up" for f in outbound)
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_fails_pending_waiters_cleanly(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared", outbound_timeout_s=5)
|
|
await t.connect()
|
|
await t.handshake()
|
|
await t.disconnect()
|
|
# After disconnect, an outbound returns a structured failure rather than hanging.
|
|
result = await t.send_outbound({"op": "send", "chat_id": "c", "content": "x"})
|
|
assert result["success"] is False
|
|
|
|
|
|
def test_https_url_normalized_to_wss():
|
|
"""The relay URL is configured once as the http(s):// BASE (for the provision
|
|
POST), but websockets.connect needs ws(s):// and the connector mounts its WS
|
|
server at /relay. The transport must convert scheme AND ensure the /relay
|
|
path. Regression for the live staging failures 'scheme isn't ws or wss' then
|
|
'server rejected WebSocket connection: HTTP 400' (wrong path)."""
|
|
t = WebSocketRelayTransport("https://connector.example", "discord", "b")
|
|
assert t._url == "wss://connector.example/relay"
|
|
t2 = WebSocketRelayTransport("http://connector.local:8080", "discord", "b")
|
|
assert t2._url == "ws://connector.local:8080/relay"
|
|
|
|
|
|
def test_ws_dial_url_idempotent_with_scheme_and_path():
|
|
# Already ws(s):// and/or already ending in /relay -> unchanged (no double append).
|
|
t = WebSocketRelayTransport("wss://connector.example/relay", "discord", "b")
|
|
assert t._url == "wss://connector.example/relay"
|
|
t2 = WebSocketRelayTransport("https://connector.example/relay/", "discord", "b")
|
|
assert t2._url == "wss://connector.example/relay"
|
|
t3 = WebSocketRelayTransport("ws://127.0.0.1:9", "discord", "b")
|
|
assert t3._url == "ws://127.0.0.1:9/relay"
|