hermes-agent/gateway/profile_routing.py
Burgunthy f58e4622cf 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>
2026-07-15 09:50:05 -07:00

166 lines
5.5 KiB
Python

"""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 14
2. platform + chat_id (channel route) — specificity 6
3. platform + guild_id (guild/server route) — specificity 2
4. No match → default profile
Parent-chain matching:
For Discord threads and forum posts, ``parent_chat_id`` carries the
direct parent (the channel for a thread, the forum channel for a post).
Routes keyed on a channel match both direct messages and messages in
any thread/post whose parent is that channel.
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 dataclasses import dataclass
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
@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.
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
if self.platform != platform:
return False
if self.thread_id and self.thread_id != thread_id:
return False
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
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. Lazy import avoids a
# circular dependency at module load time.
try:
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
)
profile = normalize_profile_name(profile)
validate_profile_name(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