fix(gateway): profile routing — conjunctive matching + universal gateway_runner

Addresses hermes-sweeper review on #20096.

Problem 1 (profile_routing.py): route matching returned True on a chat_id
hit before the guild_id constraint was consulted, so a route declaring both
guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND)
semantics — every declared discriminator must hold; hierarchical parent_chat_id
matching is preserved. Added a regression test for the guild+chat case.

Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter
pre-declared the attribute, and only Discord did — so build_source never called
_profile_name_for_source for Telegram/Feishu/Slack/etc., despite the
platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made
the plugin-registry injection unconditional, so profile routing now reaches
every platform. Added non-Discord (Telegram) resolution coverage and an
injection-inheritance test.

Also adds docs/profile-routing.md documenting gateway.profile_routes
(matching rules, specificity, profile isolation) — requested in review.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Burgunthy 2026-07-13 22:57:20 +09:00 committed by Teknium
parent e8b7ce8c19
commit f58e4622cf
6 changed files with 231 additions and 21 deletions

115
docs/profile-routing.md Normal file
View file

@ -0,0 +1,115 @@
# Profile-Based Routing for Inbound Messages
> **Audience:** Gateway operators and contributors
> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py`
> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md`
## Overview
By default a single gateway run uses one profile (memory, persona, tools). **Profile-based
routing** lets one gateway instance serve **multiple isolated profiles**, selecting which
profile handles an inbound message based on *where the message came from* — the platform,
server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`).
This is the inbound counterpart to multiplexing: instead of running N gateways, run one
gateway and route per-community / per-channel / per-thread to a dedicated profile. Each
profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools).
Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every
adapter — not just Discord.
## Configuring routes
Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested
`gateway.profile_routes` forms are accepted (the nested form is what
`hermes config set gateway.profile_routes ...` writes).
```yaml
profile_routes:
# Route an entire Discord server (guild) to one profile.
- name: server-default
platform: discord
guild_id: "1234567890"
profile: server-profile
# Override a specific channel within that server with a different profile.
- name: support-channel
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
profile: support-profile
# Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).
- name: tg-group
platform: telegram
chat_id: "-1001234567890"
profile: tg-profile
# Route a single Discord thread.
- name: standup-thread
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
thread_id: "1111111111"
profile: standup
```
### Fields
| Field | Required | Description |
|---|---|---|
| `name` | yes | Human-readable route identifier (used in logs). |
| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … |
| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/<name>`). |
| `guild_id` | no | Server/guild (Discord). |
| `chat_id` | no | Channel/group/DM id. |
| `thread_id` | no | Thread id within a channel. |
| `enabled` | no | Default `true`; set `false` to disable a route without removing it. |
## Matching rules
A route matches an inbound source when **every discriminator the route declares is satisfied**
(conjunctive / AND). A field the route leaves unset is ignored.
- **`platform`** must equal the source platform exactly.
- **`thread_id`** (if set) must equal the source thread id.
- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a
channel matches the channel's route (hierarchical match for Discord forums/threads).
- **`guild_id`** (if set) must equal the source guild.
> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match
> alone does not satisfy a guild constraint — this is intentional and tested.
When multiple routes match, the **most specific** one wins. Specificity is additive:
| Discriminator | Weight |
|---|---|
| `thread_id` | 8 |
| `chat_id` | 4 |
| `guild_id` | 2 |
| (platform only) | 1 |
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.
## How it works at runtime
1. An inbound message arrives at a platform adapter.
2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every
adapter carries a back-reference to the running `GatewayRunner`
(`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the
target profile via `_profile_name_for_source`.
3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and
stamps `source.profile` with the winning route's profile (or leaves it unset).
4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory
(`source.profile` → active profile → `default`) and the session is scoped per-profile, so
each routed community gets isolated memory and conversation state.
Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),
every platform goes through this path — not just Discord.
## Migration / coexistence with 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.

View file

@ -2353,6 +2353,16 @@ class BasePlatformAdapter(ABC):
# generic seam; Slack is merely the first consumer).
supports_inchannel_continuable: bool = False
# Back-reference to the running ``GatewayRunner``, injected by
# ``gateway/run.py`` after the adapter is created. Adapters consume it via
# ``getattr(self, "gateway_runner", None)`` for cross-platform delivery and
# — critically — for inbound profile routing: ``build_source`` resolves the
# target profile through ``runner._profile_name_for_source(...)``. Declaring
# it on the base (rather than only on adapters that happen to pre-declare
# it) means EVERY platform adapter receives the injection, so profile
# routing is platform-generic instead of Discord-only.
gateway_runner = None # type: ignore[assignment] # set by gateway/run.py
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform

View file

@ -80,10 +80,14 @@ class ProfileRoute:
parent_chat_id: Optional[str] = None,
) -> bool:
"""Return True if this route matches the given source fields.
Supports hierarchical matching for Discord forums:
All configured discriminators are matched conjunctively (AND): every
discriminator that the route declares must hold. ``chat_id`` supports
hierarchical matching for Discord forums/threads:
- Direct channel match: chat_id == route.chat_id
- Thread in channel: parent_chat_id == route.chat_id
A route declaring both ``guild_id`` and ``chat_id`` requires both to
match (a chat match alone does not satisfy a guild constraint).
"""
if not self.enabled:
return False
@ -91,20 +95,8 @@ class ProfileRoute:
return False
if self.thread_id and self.thread_id != thread_id:
return False
# Hierarchical chat_id matching
if self.chat_id:
# Direct match
if self.chat_id == chat_id:
return True
# Parent match (thread or direct child)
if self.chat_id == parent_chat_id:
return True
# If chat_id was specified but didn't match any level, fail
if self.chat_id:
if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id:
return False
if self.guild_id and self.guild_id != guild_id:
return False
return True

View file

@ -8835,12 +8835,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if platform_registry.is_registered(platform.value):
adapter = platform_registry.create_adapter(platform.value, config)
if adapter is not None:
# Adapters that need a back-reference to the gateway runner
# (e.g. for cross-platform admin alerts) declare a
# ``gateway_runner`` attribute. Inject it after creation so
# plugin adapters don't need a custom factory signature.
if hasattr(adapter, "gateway_runner"):
adapter.gateway_runner = self
# Inject a back-reference to the gateway runner so every
# adapter can (a) deliver cross-platform admin alerts and
# (b) resolve inbound profile routing through
# ``runner._profile_name_for_source``. Unconditional:
# ``BasePlatformAdapter`` declares ``gateway_runner``, so
# this reaches ALL platforms (not just the ones that
# pre-declared it), making profile routing platform-generic.
adapter.gateway_runner = self
return adapter
# Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms).

View file

@ -8,6 +8,7 @@ import pytest
from gateway.session import SessionSource
from gateway.run import GatewayRunner
from gateway.profile_routing import ProfileRoute
@pytest.fixture
@ -33,6 +34,22 @@ def discord_source():
)
@pytest.fixture
def telegram_source():
"""Create a basic Telegram SessionSource for testing.
Telegram (like Slack/Feishu/etc.) has no ``guild_id`` only ``chat_id``.
Used to prove profile routing is platform-generic, not Discord-only.
"""
return SessionSource(
platform=MagicMock(value="telegram"),
chat_id="-1001234567890",
guild_id=None,
thread_id=None,
parent_chat_id=None,
)
class TestResolutionOrder:
"""Tests that profile resolution follows the correct priority order."""
@ -255,3 +272,62 @@ class TestRoutingConsultation:
# Should NOT have called routing
mock_runner._profile_name_for_source.assert_not_called()
class TestNonDiscordProfileRouting:
"""Profile routing must be platform-generic, not Discord-only.
Regression coverage for the ``gateway_runner`` injection gap: previously
only Discord's adapter pre-declared ``gateway_runner``, so only Discord
ever had ``build_source`` call ``_profile_name_for_source``. Telegram /
Feishu / Slack / etc. silently fell through to the default profile. These
tests pin the resolution half for a non-Discord platform (Telegram).
"""
def test_telegram_route_resolves(self, mock_runner, telegram_source):
"""A configured Telegram route resolves to its profile via the real
``_profile_name_for_source`` (bound onto the mock runner)."""
mock_runner.config.profile_routes = [
ProfileRoute(name="tg", platform="telegram", profile="tg-profile",
chat_id="-1001234567890"),
]
telegram_source.profile = None
assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile"
def test_telegram_no_route_returns_none(self, mock_runner, telegram_source):
"""With no matching Telegram route, resolution returns None (caller
falls back to the default/active profile)."""
mock_runner.config.profile_routes = [
ProfileRoute(name="dc", platform="discord", profile="dc-profile",
chat_id="123456"),
]
telegram_source.profile = None
assert mock_runner._profile_name_for_source(telegram_source) is None
class TestGatewayRunnerInjection:
"""``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's
unconditional injection reaches every platform adapter the foundation
that makes the routing in TestNonDiscordProfileRouting reachable at runtime.
"""
def test_base_adapter_declares_gateway_runner(self):
from gateway.platforms.base import BasePlatformAdapter
# Class-level attribute exists and defaults to None.
assert hasattr(BasePlatformAdapter, "gateway_runner")
assert BasePlatformAdapter.gateway_runner is None
def test_subclass_inherits_gateway_runner(self):
from gateway.platforms.base import BasePlatformAdapter
class _ToyAdapter(BasePlatformAdapter):
pass
# No manual declaration — yet the attribute is inherited from the base,
# so the gateway's ``adapter.gateway_runner = self`` injection reaches
# every adapter, not just the ones that pre-declared it (Discord).
assert hasattr(_ToyAdapter, "gateway_runner")
assert _ToyAdapter.gateway_runner is None

View file

@ -70,6 +70,21 @@ class TestProfileRouteMatching:
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="any")
def test_guild_and_chat_are_conjunctive(self):
# A route declaring BOTH guild_id and chat_id requires both to match.
# Regression guard: previously chat_id was checked first and returned
# True before guild_id was ever consulted.
r = ProfileRoute(name="gc", platform="discord", profile="scoped",
guild_id="111", chat_id="222")
# Both match (direct channel) -> match
assert r.matches("discord", guild_id="111", chat_id="222")
# Both match via parent (thread inside the channel) -> match
assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="222")
# chat matches but guild differs -> NO match (the bug this guards)
assert not r.matches("discord", guild_id="999", chat_id="222")
# guild matches but chat differs -> NO match
assert not r.matches("discord", guild_id="111", chat_id="333")
class TestParseProfileRoutes:
def test_empty(self):