mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
refactor(relay): purge platform-specific scope terminology from the relay adapter (D-Q2.5c) (#56016)
The gateway HALF of the D-Q2.5c cleanup (connector half: gateway-gateway #92). Scope is STRICTLY the relay adapter (gateway/relay/) — session.py and every native platform adapter are untouched (SessionSource.guild_id remains for their use; it is NOT relay-only). Within gateway/relay/, drop the D-Q2.5 wire dual-write/dual-read alias AND genericize all platform-specific (Discord "guild") scope terminology: - ws_transport._event_from_wire: read scope_id only (drop the ?? guild_id fallback). - adapter._with_scope: emit scope_id only on outbound metadata (drop the guild_id dual-write); genericize the "GUILD reply" docstring to "SCOPED reply". - adapter._capture_scope: read source.scope_id only; rename the local `guild` var to `scope`; genericize the docstring + the _scope_by_chat/_dm_user_by_chat field comments ("guild_id (Discord)" -> "scope_id (server/workspace scope)"). - __init__.relay_route_keys docstring: "guild_ids" -> "scope_ids". - The ONE real Discord `guild_id` kept: the raw inbound interaction payload field (payload.get("guild_id")), which is Discord's own wire field, mapped straight into the generic scope_id slot — unchanged. Contract doc (docs/relay-connector-contract.md): reframe the `guild_id` row as a legacy alias the connector no longer reads (session.py's agent-wide to_dict() still emits it for non-relay persistence, so it stays documented + wire-present but ignored) — accurate, and keeps the to_dict()-vs-doc conformance test green. Tests (relay only): migrate the wire-key writes + assertions guild_id -> scope_id across test_relay_adapter / _ws_transport / _passthrough / _roundtrip / _roundtrip_telegram / _multiplatform; keep raw Discord `type:2` interaction payloads' guild_id (real Discord field) and the conformance test's guild_id parametrize (validates the kept legacy field stays wire-reachable). Gate: 156 relay tests pass, ruff clean. Cross-repo E2E — all 14 drivers pass BOTH ways: connector#92 (scope_id-only) x agent-main (still dual-reads) AND connector#92 x this worktree (scope_id-only). Deploy-order-safe either way.
This commit is contained in:
parent
a653bb0cbe
commit
729bbb7a30
10 changed files with 70 additions and 75 deletions
|
|
@ -129,7 +129,7 @@ class _CaptureTransport:
|
|||
return {"success": True, "message_id": "m1"}
|
||||
|
||||
|
||||
def _make_event(chat_id="chan-1", guild_id="guild-9"):
|
||||
def _make_event(chat_id="chan-1", scope_id="scope-9"):
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
|
@ -137,13 +137,13 @@ def _make_event(chat_id="chan-1", guild_id="guild-9"):
|
|||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="channel",
|
||||
guild_id=guild_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
def _make_dm_event(chat_id="dm-1", user_id="user-42"):
|
||||
"""An inbound DM: no guild_id, carries the authentic author user_id."""
|
||||
"""An inbound DM: no scope_id, carries the authentic author user_id."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
|
@ -151,53 +151,53 @@ def _make_dm_event(chat_id="dm-1", user_id="user-42"):
|
|||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm",
|
||||
guild_id=None,
|
||||
scope_id=None,
|
||||
user_id=user_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reattaches_guild_id_from_inbound_scope():
|
||||
async def test_send_reattaches_scope_id_from_inbound_scope():
|
||||
"""The connector's egress guard resolves the owning tenant from
|
||||
metadata.guild_id; the gateway's generic delivery path drops it, so the
|
||||
relay adapter must re-attach the guild scope learned from the inbound event.
|
||||
Regression for live 'discord egress declined: target not routed to an
|
||||
metadata.scope_id; the gateway's generic delivery path drops it, so the
|
||||
relay adapter must re-attach the scope learned from the inbound event.
|
||||
Regression for live 'egress declined: target not routed to an
|
||||
onboarded tenant'."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
# Simulate the connector delivering an inbound message in guild-9 / chan-1,
|
||||
# Simulate the connector delivering an inbound message in scope-9 / chan-1,
|
||||
# but don't run the full handle_message pipeline — just the scope capture.
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
|
||||
await a.send("chan-1", "the reply")
|
||||
|
||||
assert t.sent["metadata"].get("guild_id") == "guild-9"
|
||||
assert t.sent["metadata"].get("scope_id") == "scope-9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_without_known_scope_omits_guild_id():
|
||||
"""A chat we never saw inbound (e.g. a DM) gets no guild_id — no-op, never
|
||||
async def test_send_without_known_scope_omits_scope_id():
|
||||
"""A chat we never saw inbound (e.g. a DM) gets no scope_id — no-op, never
|
||||
invents a scope."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
await a.send("unknown-chat", "hi")
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_preserves_explicit_guild_id():
|
||||
"""An explicitly-provided metadata.guild_id is never overwritten."""
|
||||
async def test_send_preserves_explicit_scope_id():
|
||||
"""An explicitly-provided metadata.scope_id is never overwritten."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"})
|
||||
assert t.sent["metadata"]["guild_id"] == "explicit-1"
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
await a.send("chan-1", "hi", metadata={"scope_id": "explicit-1"})
|
||||
assert t.sent["metadata"]["scope_id"] == "explicit-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reattaches_dm_user_id_from_inbound_scope():
|
||||
"""A DM reply has no guild_id, so the connector resolves the tenant from the
|
||||
"""A DM reply has no scope_id, so the connector resolves the tenant from the
|
||||
recipient's author binding — it needs metadata.user_id. The adapter must
|
||||
re-attach the authentic author id learned from the inbound DM. Regression for
|
||||
live 'discord egress declined: target not routed to an onboarded tenant' on
|
||||
|
|
@ -209,8 +209,8 @@ async def test_send_reattaches_dm_user_id_from_inbound_scope():
|
|||
await a.send("dm-1", "the reply")
|
||||
|
||||
assert t.sent["metadata"].get("user_id") == "user-42"
|
||||
# A DM carries no guild_id — only the author discriminator.
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
# A DM carries no scope_id — only the author discriminator.
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -220,7 +220,7 @@ async def test_send_dm_does_not_invent_user_id_for_unknown_chat():
|
|||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
await a.send("unknown-dm", "hi")
|
||||
assert "user_id" not in t.sent["metadata"]
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -234,15 +234,15 @@ async def test_send_preserves_explicit_user_id():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guild_reply_does_not_carry_user_id():
|
||||
"""A guild reply resolves by guild_id and must NOT carry a DM user_id even if
|
||||
the same chat_id was somehow seen — guild capture wins and user_id stays out
|
||||
(guild_id is the discriminator; user_id is the DM-only fallback)."""
|
||||
async def test_scoped_reply_does_not_carry_user_id():
|
||||
"""A scoped reply resolves by scope_id and must NOT carry a DM user_id even if
|
||||
the same chat_id was somehow seen — scope capture wins and user_id stays out
|
||||
(scope_id is the discriminator; user_id is the DM-only fallback)."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
await a.send("chan-1", "hi")
|
||||
assert t.sent["metadata"].get("guild_id") == "guild-9"
|
||||
assert t.sent["metadata"].get("scope_id") == "scope-9"
|
||||
assert "user_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async def test_adapter_stamps_per_frame_platform_from_inbound(monkeypatch):
|
|||
MessageEvent(
|
||||
text="yo",
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", guild_id="g-1"),
|
||||
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", scope_id="g-1"),
|
||||
)
|
||||
)
|
||||
await adapter.send("dc-1", "a discord reply")
|
||||
|
|
|
|||
|
|
@ -120,10 +120,10 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey
|
|||
ev = seen[0]
|
||||
assert ev.text == "summarize"
|
||||
assert ev.source.chat_id == "chan-9"
|
||||
assert ev.source.guild_id == "guild-7"
|
||||
assert ev.source.scope_id == "guild-7"
|
||||
assert ev.source.user_id == "user-3"
|
||||
assert ev.source.chat_type == "channel"
|
||||
# Scope captured so the agent's reply re-asserts guild_id for egress.
|
||||
# Scope captured so the agent's reply re-asserts scope_id for egress.
|
||||
assert adapter._scope_by_chat.get("chan-9") == "guild-7"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Proves the gateway side of the relay works with no real connector:
|
||||
- connect() registers the inbound handler,
|
||||
- a connector-delivered MessageEvent reaches the adapter's message path,
|
||||
- SessionSource discriminators (guild_id) drive build_session_key isolation,
|
||||
- SessionSource discriminators (scope_id) drive build_session_key isolation,
|
||||
- an outbound send round-trips through the transport.
|
||||
|
||||
These target the transport contract + session-key derivation (Task 1.2's gate),
|
||||
|
|
@ -40,14 +40,14 @@ def _discord_descriptor() -> CapabilityDescriptor:
|
|||
)
|
||||
|
||||
|
||||
def _discord_event(guild_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
|
||||
def _discord_event(scope_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
|
||||
"""Synthetic inbound the connector would build from a discord.js message."""
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=channel_id,
|
||||
chat_type="group",
|
||||
user_id=user_id,
|
||||
guild_id=guild_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
|
||||
|
||||
|
|
@ -79,18 +79,18 @@ async def test_inbound_event_reaches_adapter(wired, monkeypatch):
|
|||
await stub.push_inbound(ev)
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "hello"
|
||||
assert captured[0].source.guild_id == "guildA"
|
||||
assert captured[0].source.scope_id == "guildA"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_guilds_isolate_into_distinct_session_keys(wired):
|
||||
async def test_two_scopes_isolate_into_distinct_session_keys(wired):
|
||||
adapter, _ = wired
|
||||
ev_a = _discord_event("guildA", "chan1", "userX", "hi from A")
|
||||
ev_b = _discord_event("guildB", "chan2", "userX", "hi from B")
|
||||
key_a = build_session_key(ev_a.source)
|
||||
key_b = build_session_key(ev_b.source)
|
||||
assert key_a != key_b
|
||||
# Same guild + channel + user collapses to one session.
|
||||
# Same scope + channel + user collapses to one session.
|
||||
ev_a2 = _discord_event("guildA", "chan1", "userX", "again")
|
||||
assert build_session_key(ev_a2.source) == key_a
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ descriptors to round-trip and their inbound ``MessageEvent``s to drive
|
|||
``build_session_key()`` correctly.
|
||||
|
||||
Telegram's discriminator profile differs from Discord's, which is the point:
|
||||
- No ``guild_id``; isolation between chats comes from ``chat_id`` alone.
|
||||
- No ``scope_id``; isolation between chats comes from ``chat_id`` alone.
|
||||
- Forum topics live inside ONE ``chat_id`` and isolate by ``thread_id`` (the
|
||||
Telegram analog of Discord's per-guild isolation).
|
||||
Telegram analog of Discord's per-scope isolation).
|
||||
- Forum/thread sessions are shared across participants by default
|
||||
(``thread_sessions_per_user=False``) — user_id is NOT appended in a thread.
|
||||
- ``len_unit="utf16"`` (Telegram counts UTF-16 code units) and
|
||||
|
|
@ -51,7 +51,7 @@ def _tg_group_event(chat_id: str, user_id: str, text: str, thread_id: str | None
|
|||
"""Synthetic inbound the connector would build from a Telegram update.
|
||||
|
||||
A plain group message has no thread_id; a forum-topic message carries the
|
||||
topic id as thread_id (no guild_id — Telegram has no guild concept).
|
||||
topic id as thread_id (no scope_id — Telegram has no scope concept).
|
||||
"""
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
|
|
@ -105,13 +105,13 @@ async def test_inbound_telegram_event_reaches_adapter(wired, monkeypatch):
|
|||
assert len(captured) == 1
|
||||
assert captured[0].text == "hello"
|
||||
assert captured[0].source.platform == Platform.TELEGRAM
|
||||
assert captured[0].source.guild_id is None # Telegram has no guild
|
||||
assert captured[0].source.scope_id is None # Telegram has no scope
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_telegram_chats_isolate_by_chat_id(wired):
|
||||
"""No guild_id on Telegram — two distinct chats must still isolate, keyed
|
||||
on chat_id alone (the Discord-guild role is played by chat_id here)."""
|
||||
"""No scope_id on Telegram — two distinct chats must still isolate, keyed
|
||||
on chat_id alone (the Discord-scope role is played by chat_id here)."""
|
||||
ev_a = _tg_group_event("chat-A", "userX", "hi A")
|
||||
ev_b = _tg_group_event("chat-B", "userX", "hi B")
|
||||
key_a = build_session_key(ev_a.source)
|
||||
|
|
@ -125,7 +125,7 @@ async def test_two_telegram_chats_isolate_by_chat_id(wired):
|
|||
@pytest.mark.asyncio
|
||||
async def test_forum_topics_isolate_by_thread_id_within_one_chat(wired):
|
||||
"""Telegram forum topics share a single chat_id and isolate by thread_id —
|
||||
the Telegram analog of Discord per-guild isolation. Two topics in the same
|
||||
the Telegram analog of Discord per-scope isolation. Two topics in the same
|
||||
forum must NOT collide, and (threads shared across participants by default)
|
||||
a second user in the same topic shares the session."""
|
||||
topic1 = _tg_group_event("forum-1", "userX", "in topic 1", thread_id="t-1")
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ async def test_inbound_frame_reaches_handler(server):
|
|||
"event": {
|
||||
"text": "hello from connector",
|
||||
"message_type": "text",
|
||||
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
|
||||
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "scope_id": "guildA"},
|
||||
},
|
||||
"bufferId": "buf-1",
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ async def test_inbound_frame_reaches_handler(server):
|
|||
await asyncio.sleep(0.05)
|
||||
assert len(received) == 1
|
||||
assert received[0].text == "hello from connector"
|
||||
assert received[0].source.guild_id == "guildA"
|
||||
assert received[0].source.scope_id == "guildA"
|
||||
finally:
|
||||
await t.disconnect()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue