fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters

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:<profile> 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).
This commit is contained in:
Teknium 2026-07-15 00:37:23 -07:00
parent c29ab7b9fe
commit 647520f83e
11 changed files with 128 additions and 14 deletions

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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),

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:<profile>``
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

View file

@ -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/<profile>/` 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