From 647520f83e1d5c6f1ea4b2abf67a982a0ab4c993 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:37:23 -0700 Subject: [PATCH] fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the salvaged #20096 profile-routing feature: - _profile_name_for_source now returns None unless gateway.multiplex_profiles is on. Routing stamps source.profile, which namespaces session/batch keys, but the profile-scoped agent run only activates under multiplexing — without the gate, configured routes with multiplexing off split batch/session keys into agent: while the agent still ran from agent:main. - Widen the profile-aware _text_batch_key fix from Discord to every adapter that builds batch keys via build_session_key (telegram, whatsapp, matrix, feishu, wecom, weixin) — routing is platform-generic, so the batch-key namespace fix must be too. - Downgrade the no-route-matched log from INFO to DEBUG (fired on every unrouted inbound message). - GatewayConfig.to_dict(): serialize profile_routes as plain dicts (ProfileRoute dataclasses are not JSON-safe). - Docs: correct the 'independent of multiplexing' claim in docs/profile-routing.md (routing requires multiplexing), fix the platform-only specificity row (0, not 1), and document profile_routes in website/docs/user-guide/multi-profile-gateways.md. - Tests: pin the multiplex gate (routes ignored when off, active when on, build_source end-to-end stays in agent:main when off). --- docs/profile-routing.md | 12 +++-- gateway/config.py | 7 ++- gateway/platforms/weixin.py | 1 + gateway/run.py | 23 ++++++--- plugins/platforms/feishu/adapter.py | 1 + plugins/platforms/matrix/adapter.py | 1 + plugins/platforms/telegram/adapter.py | 1 + plugins/platforms/wecom/adapter.py | 1 + plugins/platforms/whatsapp/adapter.py | 1 + tests/gateway/test_profile_resolution.py | 50 +++++++++++++++++++ .../docs/user-guide/multi-profile-gateways.md | 44 ++++++++++++++++ 11 files changed, 128 insertions(+), 14 deletions(-) diff --git a/docs/profile-routing.md b/docs/profile-routing.md index 52c6934b47ab..9b0237f5c6f0 100644 --- a/docs/profile-routing.md +++ b/docs/profile-routing.md @@ -87,7 +87,7 @@ When multiple routes match, the **most specific** one wins. Specificity is addit | `thread_id` | 8 | | `chat_id` | 4 | | `guild_id` | 2 | -| (platform only) | 1 | +| (platform only) | 0 | So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server. If no route matches, the message uses the default/active profile. @@ -108,8 +108,10 @@ If no route matches, the message uses the default/active profile. Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`), every platform goes through this path — not just Discord. -## Migration / coexistence with multiplexing +## Relationship to multiplexing -`profile_routes` is independent of `gateway.multiplex_profiles`. Multiplexing splits the -gateway across model credentials; profile routing splits conversation state across profiles. -They compose: you may multiplex credentials while also routing channels to distinct profiles. +`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what +activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and +profile-namespaced session keys); routing is the decision layer that picks *which* +profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes` +is ignored entirely — behavior is byte-identical to a single-profile gateway. diff --git a/gateway/config.py b/gateway/config.py index 75f878af943d..71e4d1d3fced 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -12,7 +12,7 @@ import logging import os import json from pathlib import Path -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field, is_dataclass from typing import Dict, List, Optional, Any, Callable from enum import Enum @@ -832,7 +832,10 @@ class GatewayConfig: "unauthorized_dm_behavior": self.unauthorized_dm_behavior, "streaming": self.streaming.to_dict(), "session_store_max_age_days": self.session_store_max_age_days, - "profile_routes": self.profile_routes, + "profile_routes": [ + asdict(r) if is_dataclass(r) and not isinstance(r, type) else r + for r in self.profile_routes + ], } @classmethod diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index d78eb4aad6dc..980be11709ec 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1515,6 +1515,7 @@ class WeixinAdapter(BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/gateway/run.py b/gateway/run.py index 658e57efaf7a..0faec35bbebf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17312,14 +17312,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _profile_name_for_source(self, source: SessionSource) -> Optional[str]: """Resolve the profile name for an inbound source via configured routes. - Returns ``None`` when no routes are configured or no route matches. - Callers (``build_source``, ``_resolve_profile_home_for_source``) treat - ``None`` as "use the default/active profile". When - ``gateway.profile_routes`` is configured, the most specific matching - route wins (guild < channel < thread). See :mod:`gateway.profile_routing` - for matching rules. + Returns ``None`` when multiplexing is off, no routes are configured, or + no route matches. Callers (``build_source``, + ``_resolve_profile_home_for_source``) treat ``None`` as "use the + default/active profile". When ``gateway.profile_routes`` is configured, + the most specific matching route wins (guild < channel < thread). See + :mod:`gateway.profile_routing` for matching rules. + + Gated on ``gateway.multiplex_profiles``: routing stamps + ``source.profile``, which selects the session-key namespace and batch + keys — but the profile-scoped agent run only activates under + multiplexing. Without this gate, a configured route with multiplexing + off would namespace batch/session keys by profile while the agent + still runs in ``agent:main``, splitting the two out of agreement. """ config = getattr(self, "config", None) + if not getattr(config, "multiplex_profiles", False): + return None routes = getattr(config, "profile_routes", None) if not routes: return None @@ -17341,7 +17350,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return None if matched: return matched.profile - logger.info( + logger.debug( "No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s", source.platform.value, source.chat_id, getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None), diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 8f0b61685d38..41e087069e85 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -3633,6 +3633,7 @@ class FeishuAdapter(BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) @staticmethod diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 39a8fd818fa7..f653528d067d 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -3455,6 +3455,7 @@ class MatrixAdapter(BasePlatformAdapter): thread_sessions_per_user=self.config.extra.get( "thread_sessions_per_user", False ), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 3d406a58bfa9..75416d3e21d9 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -7925,6 +7925,7 @@ class TelegramAdapter(BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 1a1421b9d711..80ca2ca1439f 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -578,6 +578,7 @@ class WeComAdapter(BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 07f12cd513de..7cf94b7c1e63 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1278,6 +1278,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index 0678dd5c8e8d..799f20c2589c 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -419,3 +419,53 @@ class TestAdapterToSessionKeyIntegration: assert source.profile is None key = build_session_key(source, profile=source.profile) assert key.startswith("agent:main:"), key + + +class TestMultiplexGate: + """``profile_routes`` only activates under ``gateway.multiplex_profiles``. + + Routing stamps ``source.profile``, which namespaces session/batch keys — + but the profile-scoped agent run (``_profile_runtime_scope``) only engages + when multiplexing is on. Without the gate, a configured route with + multiplexing off would split batch/session keys into ``agent:`` + while the agent still served the turn from ``agent:main``'s home. + """ + + def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source): + mock_runner.config.multiplex_profiles = False + mock_runner.config.profile_routes = [ + ProfileRoute(name="dc", platform="discord", profile="coder", + guild_id="789", chat_id="123456"), + ] + discord_source.profile = None + + assert mock_runner._profile_name_for_source(discord_source) is None + + def test_routes_active_when_multiplex_on(self, mock_runner, discord_source): + mock_runner.config.multiplex_profiles = True + mock_runner.config.profile_routes = [ + ProfileRoute(name="dc", platform="discord", profile="coder", + guild_id="789", chat_id="123456"), + ] + discord_source.profile = None + + assert mock_runner._profile_name_for_source(discord_source) == "coder" + + def test_build_source_leaves_profile_none_when_multiplex_off(self, mock_runner): + """End-to-end through the real adapter ``build_source``: with routes + configured but multiplexing off, no profile is stamped and the session + key stays in the legacy ``agent:main`` namespace — byte-identical to a + gateway with no routes at all.""" + mock_runner.config.multiplex_profiles = False + mock_runner.config.profile_routes = [ + ProfileRoute(name="dc", platform="discord", profile="coder", + guild_id="111", chat_id="222"), + ] + adapter = _stub_adapter(Platform.DISCORD, mock_runner) + + source = adapter.build_source( + chat_id="222", chat_type="group", guild_id="111", user_id="u1", + ) + assert source.profile is None + key = build_session_key(source, profile=source.profile) + assert key.startswith("agent:main:"), key diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 533a3d3c7043..92c4f9ef9eda 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -189,6 +189,50 @@ Kanban workers only ever see their own profile's secrets). Kanban, profile-scoped skills/memory/SOUL, and model routing all behave per-profile exactly as they do with separate gateways. +### Routing shared-bot chats to profiles (`profile_routes`) + +Multiplexing selects a profile per **credential** (each profile's own bot +token) or per **URL prefix** (`/p//` for HTTP platforms). When several +communities share **one** bot token — for example one Discord bot serving many +guilds — you can additionally route specific guilds/channels/threads to +different profiles with `gateway.profile_routes`: + +```yaml +gateway: + multiplex_profiles: true + profile_routes: + # An entire Discord server → one profile + - name: acme-server + platform: discord + guild_id: "1234567890" + profile: acme + + # One channel in that server → a different profile + - name: acme-support + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + profile: acme-support + + # A Telegram group (no guild concept — chat_id only) + - name: tg-group + platform: telegram + chat_id: "-1001234567890" + profile: tg-profile +``` + +Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`), +all declared fields must hold (AND), and a route keyed on a channel also +matches threads/forum posts whose parent is that channel. Messages that match +no route stay on the default/active profile. The routed profile gets the full +per-profile isolation described above (config, skills, memory, credentials, +session namespace). Routing works on every platform adapter, not just Discord. + +`profile_routes` requires `gateway.multiplex_profiles: true`; with +multiplexing off the routes are ignored. If a route names a profile that does +not exist on disk, the gateway logs a warning naming the profile and source and +falls back to the default home. + ## Start, stop, or restart all gateways at once The CLI ships with single-profile lifecycle commands. To act across every