feat(gateway): add profile-based routing for inbound messages

Adds gateway.profile_routes config that routes specific Discord
guilds/channels/threads (and other platforms) to different profiles.
The routing engine uses hierarchical specificity matching
(thread > channel > guild) with bounded LRU caching for forum post
resolution.

Routing result is stamped on source.profile by BasePlatformAdapter
.build_source() at inbound time. When gateway.multiplex_profiles is on,
the existing _profile_runtime_scope machinery picks up source.profile
and runs the whole turn inside the profile's HERMES_HOME — so memory,
skills, config, and secrets all resolve to that profile automatically.
No new isolation code is added; this PR only adds the routing decision
layer on top of the existing multiplexing infrastructure.

Configuration:

    gateway:
      multiplex_profiles: true
      profile_routes:
        - name: server-default
          platform: discord
          guild_id: "GUILD_ID"
          profile: server-profile
        - name: special-channel
          platform: discord
          guild_id: "GUILD_ID"
          chat_id: "CHANNEL_ID"
          profile: channel-profile

When multiplex_profiles is off, profile_routes is ignored (no behavior
change for single-profile gateways).

Tests: 29 unit tests covering specificity scoring, hierarchical
matching, path-traversal validation, and config parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Burgunthy 2026-06-27 17:46:18 +09:00 committed by Teknium
parent 6efec39ecf
commit 5e65f6d79f
6 changed files with 568 additions and 5 deletions

View file

@ -721,6 +721,11 @@ class GatewayConfig:
# fresh session exactly as if the reset policy had fired. 0 = disabled.
session_store_max_age_days: int = 90
# Profile-based routing: route specific guilds/channels/threads to
# different profiles. See gateway/profile_routing.py. Each entry is a
# dict with: name, platform, profile, and optional guild_id/chat_id/thread_id.
profile_routes: list = field(default_factory=list)
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@ -827,6 +832,7 @@ 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,
}
@classmethod
@ -919,6 +925,10 @@ class GatewayConfig:
except (TypeError, ValueError):
session_store_max_age_days = 90
# Parse profile routes (validated by gateway.profile_routing)
from gateway.profile_routing import parse_profile_routes
profile_routes = parse_profile_routes(data.get("profile_routes") or [])
return cls(
platforms=platforms,
default_reset_policy=default_policy,
@ -941,6 +951,7 @@ class GatewayConfig:
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
profile_routes=profile_routes,
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@ -1053,6 +1064,17 @@ def load_gateway_config() -> GatewayConfig:
if "multiplex_profiles" in yaml_cfg:
gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"]
# Profile-based routing rules: accept either top-level
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
# (matching the multiplex_profiles parity above).
_pr = yaml_cfg.get("profile_routes")
if _pr is None:
_gw_section = yaml_cfg.get("gateway")
if isinstance(_gw_section, dict):
_pr = _gw_section.get("profile_routes")
if isinstance(_pr, list):
gw_data["profile_routes"] = _pr
gateway_section = yaml_cfg.get("gateway")
if isinstance(gateway_section, dict):
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:

View file

@ -5507,10 +5507,47 @@ class BasePlatformAdapter(ABC):
auto_thread_created: bool = False,
auto_thread_initial_name: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
"""Helper to build a SessionSource for this platform.
When ``gateway.profile_routes`` is configured, the routing engine
resolves the matching profile from guild/chat/thread and stamps it on
``source.profile``. Downstream code (``_resolve_profile_home_for_source``
in run.py) reads that field to enter ``_profile_runtime_scope`` for
per-profile HERMES_HOME isolation.
"""
# Normalize empty topic to None
if chat_topic is not None and not chat_topic.strip():
chat_topic = None
# Resolve profile from configured routes (None when no match / no routes)
profile = None
runner = getattr(self, "gateway_runner", None)
if runner is not None:
try:
profile = runner._profile_name_for_source(
SessionSource(
platform=self.platform,
chat_id=str(chat_id),
chat_name=chat_name,
chat_type=chat_type,
user_id=str(user_id) if user_id else None,
user_name=user_name,
thread_id=str(thread_id) if thread_id else None,
chat_topic=chat_topic.strip() if chat_topic else None,
user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt,
is_bot=is_bot,
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
)
)
except Exception:
logger.warning(
"Profile resolution failed for %s/%s, defaulting to active profile",
self.platform, chat_id, exc_info=True,
)
return SessionSource(
platform=self.platform,
chat_id=str(chat_id),
@ -5527,6 +5564,7 @@ class BasePlatformAdapter(ABC):
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
profile=profile,
role_authorized=role_authorized,
auto_thread_created=auto_thread_created,
auto_thread_initial_name=auto_thread_initial_name,

196
gateway/profile_routing.py Normal file
View file

@ -0,0 +1,196 @@
"""Profile-based routing for the gateway with hierarchical matching.
Allows a single Hermes instance to route specific Discord guilds/channels/threads
to different profiles each with their own model, tools, memory, and persona.
Matching priority (most specific first):
1. platform + chat_id + thread_id (exact thread) specificity 8
2. platform + chat_id (channel route) specificity 4
3. platform + guild_id (guild/server route) specificity 2
4. No match default profile
Hierarchical matching:
For Discord forum channels, checks the full parent chain:
- Forum channel Forum post Comment
- Matches if any level of the hierarchy matches a configured route
Configuration (config.yaml):
gateway:
profile_routes:
- name: server-default
platform: discord
guild_id: "YOUR_GUILD_ID"
profile: server-profile
- name: special-channel
platform: discord
guild_id: "YOUR_GUILD_ID"
chat_id: "YOUR_CHANNEL_ID"
profile: channel-profile
- name: thread-route
platform: discord
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
"""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set
import logging
logger = logging.getLogger(__name__)
# Bounded LRU cache for forum post to channel mappings.
# OrderedDict evicts least-recently-used entries when full.
_MAX_FORUM_CACHE = 10000
_forum_post_cache: OrderedDict[str, str] = OrderedDict() # post_id -> channel_id
def register_forum_post(post_id: str, channel_id: str) -> None:
"""Register a forum post's parent channel for hierarchical matching."""
_forum_post_cache[post_id] = channel_id
_forum_post_cache.move_to_end(post_id)
while len(_forum_post_cache) > _MAX_FORUM_CACHE:
_forum_post_cache.popitem(last=False)
logger.debug("Registered forum post %s -> channel %s", post_id, channel_id)
def resolve_forum_channel(post_id: str) -> Optional[str]:
"""Get the parent channel ID for a forum post, if cached."""
return _forum_post_cache.get(post_id)
@dataclass(frozen=True)
class ProfileRoute:
"""A single routing rule that maps a platform scope to a profile."""
name: str
platform: str
profile: str
guild_id: Optional[str] = None
chat_id: Optional[str] = None
thread_id: Optional[str] = None
enabled: bool = True
@property
def specificity(self) -> int:
"""Higher value = more specific match."""
s = 0
if self.guild_id:
s += 2
if self.chat_id:
s += 4
if self.thread_id:
s += 8
return s
def matches(
self,
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> bool:
"""Return True if this route matches the given source fields.
Supports hierarchical matching for Discord forums:
- Direct channel match: chat_id == route.chat_id
- Thread in channel: parent_chat_id == route.chat_id
- Forum post: parent_chat_id is the forum post, check if post belongs to route's channel
- Comment on forum post: parent_chat_id is the forum post, check hierarchy
"""
if not self.enabled:
return False
if self.platform != platform:
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
# Forum post hierarchy: check if parent_chat_id is a forum post
# that belongs to this channel
if parent_chat_id:
parent_channel = resolve_forum_channel(parent_chat_id)
if parent_channel and self.chat_id == parent_channel:
return True
# If chat_id was specified but didn't match any level, fail
if self.chat_id:
return False
if self.guild_id and self.guild_id != guild_id:
return False
return True
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
"""Parse profile_routes from config.yaml into ProfileRoute objects.
Returns routes sorted by specificity (most specific first).
"""
if not raw:
return []
routes: List[ProfileRoute] = []
for entry in raw:
if not isinstance(entry, dict):
continue
name = entry.get("name", "")
platform = entry.get("platform", "")
profile = entry.get("profile", "")
if not platform or not profile:
logger.warning(
"Skipping profile route %s: missing platform or profile",
name,
)
continue
# Validate profile name to prevent path traversal
try:
from hermes_constants import validate_profile_name as _validate
profile = _validate(profile)
except (ValueError, ImportError):
logger.warning("Skipping profile route %s: invalid profile name %r", name, profile)
continue
routes.append(
ProfileRoute(
name=name,
platform=platform,
profile=profile,
guild_id=entry.get("guild_id"),
chat_id=entry.get("chat_id"),
thread_id=entry.get("thread_id"),
enabled=entry.get("enabled", True),
)
)
# Sort: most specific first so the first match wins.
routes.sort(key=lambda r: r.specificity, reverse=True)
logger.debug("Loaded %d profile routes (most-specific-first)", len(routes))
return routes
def match_profile_route(
routes: List[ProfileRoute],
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> Optional[ProfileRoute]:
"""Return the best-matching route, or None for no match."""
for route in routes:
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
return route
return None

View file

@ -17293,16 +17293,62 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
persist_user_timestamp=persist_user_timestamp,
)
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.
"""
config = getattr(self, "config", None)
routes = getattr(config, "profile_routes", None)
if not routes:
return None
from gateway.profile_routing import match_profile_route
try:
matched = match_profile_route(
routes,
platform=source.platform.value,
guild_id=getattr(source, "guild_id", None),
chat_id=source.chat_id,
thread_id=getattr(source, "thread_id", None),
parent_chat_id=getattr(source, "parent_chat_id", None),
)
except Exception:
logger.warning(
"Profile route matching failed for %s/%s, falling back to default",
source.platform, source.chat_id, exc_info=True,
)
return None
if matched:
return matched.profile
logger.info(
"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),
)
return None
def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path":
"""Resolve which profile's HERMES_HOME should serve this inbound source.
Prefers the profile the source was routed to (``source.profile`` set
by the /p/<profile>/ URL prefix or a per-credential adapter), falling
back to the active profile (the multiplexer's own home).
Resolution order:
1. ``source.profile`` set by /p/<profile>/ URL prefix, per-credential
adapter ownership, OR profile_routes matching at ``build_source`` time.
2. ``_profile_name_for_source`` re-run routing here as a defensive
fallback for sources that bypass ``build_source``.
3. The active profile (the multiplexer's own home).
"""
from hermes_cli.profiles import get_active_profile_name, get_profile_dir
try:
name = (source.profile or "").strip() or get_active_profile_name() or "default"
name = (source.profile or "").strip()
if not name:
name = self._profile_name_for_source(source)
if not name:
name = get_active_profile_name() or "default"
return get_profile_dir(name)
except Exception:
from hermes_constants import get_hermes_home

View file

@ -10,6 +10,7 @@ import stat
import sys
import sysconfig
from contextvars import ContextVar, Token
import re
from pathlib import Path
@ -1217,3 +1218,46 @@ FINISH_REASON_LENGTH = "length"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
# ─── Profile Normalization ────────────────────────────────────────────────
# Standard (non-isolated) profile names. All three are treated identically
# for gating and filtering purposes. Named profiles (e.g. "ai-expert")
# are anything *not* in this tuple.
STANDARD_PROFILES: tuple[str, ...] = ("main", "default")
_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def normalize_profile(name: str | None) -> str:
"""Canonicalize a profile name. Never raises.
Returns ``"main"`` for all standard/empty profiles so that downstream
code only needs to compare against a single value. Named profiles
are returned as-is (lowercased, stripped).
"""
if not name or name.strip().lower() in STANDARD_PROFILES:
return "main"
return name.strip().lower()
def validate_profile_name(name: str | None) -> str:
"""Validate and canonicalize. Raises ValueError for invalid names.
Use at config parse boundaries. normalize_profile() is the safe
runtime version that never raises.
"""
result = normalize_profile(name)
if result == "main":
return result
if not _VALID_PROFILE_RE.match(result):
raise ValueError(
f"Invalid profile name: {name!r} (must match [a-z0-9][a-z0-9_-]*)"
)
return result
def is_standard_profile(name: str | None) -> bool:
"""Return True for default/main/None/empty — the unscoped profile."""
return not name or name.strip().lower() in STANDARD_PROFILES

View file

@ -0,0 +1,217 @@
"""Tests for gateway/profile_routing.py — profile-based routing."""
import pytest
from gateway.profile_routing import (
ProfileRoute,
parse_profile_routes,
match_profile_route,
)
class TestProfileRoute:
def test_specificity_thread(self):
r = ProfileRoute(name="t", platform="discord", profile="p",
guild_id="g", chat_id="c", thread_id="t")
assert r.specificity == 14 # 2 + 4 + 8
def test_specificity_channel(self):
r = ProfileRoute(name="c", platform="discord", profile="p",
guild_id="g", chat_id="c")
assert r.specificity == 6 # 2 + 4
def test_specificity_guild(self):
r = ProfileRoute(name="g", platform="discord", profile="p",
guild_id="g")
assert r.specificity == 2
def test_specificity_minimal(self):
r = ProfileRoute(name="m", platform="telegram", profile="p")
assert r.specificity == 0
def test_frozen(self):
r = ProfileRoute(name="x", platform="discord", profile="p")
with pytest.raises(AttributeError):
r.name = "y"
class TestProfileRouteMatching:
def test_exact_thread_match(self):
r = ProfileRoute(name="t", platform="discord", profile="trader",
guild_id="111", chat_id="222", thread_id="333")
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
assert not r.matches("discord", guild_id="111", chat_id="222", thread_id="444")
def test_channel_match(self):
r = ProfileRoute(name="c", platform="discord", profile="helper",
chat_id="222")
assert r.matches("discord", chat_id="222")
assert not r.matches("discord", chat_id="333")
assert not r.matches("telegram", chat_id="222")
def test_guild_match(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111")
assert not r.matches("discord", guild_id="222")
def test_disabled_route_no_match(self):
r = ProfileRoute(name="d", platform="discord", profile="off",
guild_id="111", enabled=False)
assert not r.matches("discord", guild_id="111")
def test_guild_route_matches_any_channel_in_guild(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="222")
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
def test_extra_fields_ignored(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="any")
class TestParseProfileRoutes:
def test_empty(self):
assert parse_profile_routes(None) == []
assert parse_profile_routes([]) == []
def test_valid_routes_sorted_by_specificity(self):
raw = [
{"name": "guild", "platform": "discord", "profile": "p", "guild_id": "1"},
{"name": "thread", "platform": "discord", "profile": "p",
"guild_id": "1", "chat_id": "2", "thread_id": "3"},
{"name": "channel", "platform": "discord", "profile": "p", "chat_id": "2"},
]
routes = parse_profile_routes(raw)
names = [r.name for r in routes]
assert names == ["thread", "channel", "guild"]
def test_skips_invalid(self):
raw = [
{"platform": "discord"},
{"profile": "p"},
"not a dict",
{"name": "ok", "platform": "telegram", "profile": "p"},
]
routes = parse_profile_routes(raw)
assert len(routes) == 1
assert routes[0].name == "ok"
def test_enabled_flag(self):
raw = [
{"name": "off", "platform": "discord", "profile": "p",
"guild_id": "1", "enabled": False},
{"name": "on", "platform": "discord", "profile": "p", "guild_id": "1"},
]
routes = parse_profile_routes(raw)
assert not routes[0].enabled
assert routes[1].enabled
class TestMatchProfileRoute:
def test_no_routes(self):
assert match_profile_route([], "discord") is None
def test_returns_first_match(self):
routes = [
ProfileRoute(name="thread", platform="discord", profile="trader",
guild_id="1", chat_id="2", thread_id="3"),
ProfileRoute(name="channel", platform="discord", profile="helper",
chat_id="2"),
]
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2", thread_id="3")
assert m is not None
assert m.profile == "trader"
def test_falls_through_to_channel(self):
routes = [
ProfileRoute(name="thread", platform="discord", profile="trader",
guild_id="1", chat_id="2", thread_id="3"),
ProfileRoute(name="channel", platform="discord", profile="helper",
chat_id="2"),
]
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2")
assert m is not None
assert m.profile == "helper"
def test_no_match_returns_none(self):
routes = [
ProfileRoute(name="r", platform="telegram", profile="p"),
]
assert match_profile_route(routes, "discord") is None
class TestSessionKeyIntegration:
def test_default_profile_key(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key = build_session_key(src)
assert key.startswith("agent:main:")
def test_custom_profile_key(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key = build_session_key(src, profile="trader")
assert key.startswith("agent:trader:")
assert key == "agent:trader:discord:channel:123:456"
def test_isolated_sessions(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key_default = build_session_key(src)
key_trader = build_session_key(src, profile="trader")
assert key_default != key_trader
def test_dm_profile_scoped(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="999",
chat_type="dm", user_id="111")
key = build_session_key(src, profile="bot2")
assert key == "agent:bot2:discord:dm:999"
class TestParentChatIdMatching:
"""Thread messages carry thread_id as chat_id; parent_chat_id is the channel."""
def test_channel_route_matches_via_parent_chat_id(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert r.matches("discord", chat_id="333", parent_chat_id="222")
def test_channel_route_no_match_wrong_parent(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert not r.matches("discord", chat_id="333", parent_chat_id="444")
def test_match_profile_route_with_parent_chat_id(self):
routes = [
ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222"),
]
m = match_profile_route(routes, "discord", chat_id="333", parent_chat_id="222")
assert m is not None
assert m.profile == "trader"
def test_thread_id_does_not_match_parent_chat_id(self):
"""thread_id only matches the actual thread_id, never parent_chat_id.
Discord snowflakes are globally unique, so thread_id != channel_id."""
r = ProfileRoute(name="th", platform="discord", profile="helper",
thread_id="555")
assert r.matches("discord", thread_id="555")
assert not r.matches("discord", parent_chat_id="555")
def test_no_parent_chat_id_still_works(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert r.matches("discord", chat_id="222")
def test_guild_route_matches_with_parent_chat_id(self):
"""Guild routes should match regardless of chat_id or parent_chat_id."""
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="444")