Files
hermes-agent/gateway/profile_routing.py
T
Teknium 3c7069bdcb refactor(gateway): dead-code removal, helper unification, defensive-layer collapse and rationale-preserving comment compaction across 40 modules
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control,
hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__,
relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state,
streaming_tts_consumer, turn_lease and small modules.

- HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table
- WebSocketRelayTransport._handle_frame -> frame-handler table
- GatewayAuthorizationMixin: unified adapter setting/flag/extra readers
- dead symbols removed (verified zero references): RoomLinkProbe/select_room_link,
  relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead,
  BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/
  _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env,
  _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind,
  _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests.
- lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
2026-09-02 13:30:50 -07:00

203 lines
7.7 KiB
Python

"""Profile-based routing for the gateway with hierarchical matching.
Lets a single Hermes instance 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, so a route keyed on a channel also matches messages
in any thread/post under it.
Configuration (config.yaml)::
gateway:
profile_routes:
- name: server-default
platform: discord
guild_id: "YOUR_GUILD_ID"
profile: server-profile
- name: thread-route
platform: discord
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
- name: owner-whatsapp
platform: whatsapp
chat_id: "15551234567" # phone, JID, or LID — all equivalent
profile: owner
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
# Baileys and Cloud share phone/JID/LID identity rules. Other platforms keep
# exact string compare so Telegram numeric ids and Discord snowflakes stay unchanged.
_WHATSAPP_IDENTITY_PLATFORMS = {"whatsapp", "whatsapp_cloud"}
_WHATSAPP_NON_USER_SUFFIXES = ("@g.us", "@broadcast", "@newsletter")
def _is_whatsapp_non_user_chat(chat_id: Optional[str]) -> bool:
"""True for group / broadcast / newsletter JIDs — not a sender identity."""
if not chat_id:
return False
cid = str(chat_id).strip().lower()
return any(cid.endswith(suffix) for suffix in _WHATSAPP_NON_USER_SUFFIXES)
def _whatsapp_user_chat_ids_match(platform: str, left: Optional[str], right: Optional[str]) -> bool:
"""True when two WhatsApp *user* chat_ids refer to the same person.
Uses ``expand_whatsapp_aliases`` (same helper as session keys and adapter
allowlists) so a bare number, ``@s.whatsapp.net`` JID and ``@lid`` LID
collapse to one identity. Group/broadcast JIDs are chats, not senders,
and non-WhatsApp platforms always return False (exact match only).
"""
if (platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS:
return False
if not left or not right:
return False
if _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right):
return False
from gateway.whatsapp_identity import expand_whatsapp_aliases
left_aliases = expand_whatsapp_aliases(str(left))
return bool(left_aliases and left_aliases & expand_whatsapp_aliases(str(right)))
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
@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."""
return 2 * bool(self.guild_id) + 4 * bool(self.chat_id) + 8 * bool(self.thread_id)
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 every discriminator the route declares holds (AND).
``chat_id`` matches the channel directly or as the parent of a
thread/forum post; a route with both ``guild_id`` and ``chat_id``
requires both. WhatsApp ``chat_id`` also matches across user-identity
forms (number/JID/LID) after the exact check; groups/broadcasts stay
exact-only.
"""
if not self.enabled or 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 not in (chat_id, parent_chat_id)
and not _whatsapp_user_chat_ids_match(platform, self.chat_id, chat_id)
and not _whatsapp_user_chat_ids_match(platform, self.chat_id, parent_chat_id)
):
return False
return not (self.guild_id and self.guild_id != guild_id)
def _coerce_route_id(value: Any) -> Optional[str]:
"""Normalize a route discriminator to str for strict equality matching.
PyYAML loads unquoted numeric IDs as ``int`` while inbound ``SessionSource``
fields are always ``str``, so ints must be coerced or ``matches()`` fails
silently. Only ``int`` (not ``bool``) is coerced; floats and other types
stringify to something (``"123.0"``) that can never match, so they pass
through with a load-time warning instead of being silently "fixed".
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
logger.warning(
"Profile route discriminator %r (type %s) can never match an inbound "
"id — quote it in config.yaml (e.g. chat_id: \"%s\").",
value, type(value).__name__, value,
)
return str(value)
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
"""Parse profile_routes from config.yaml, sorted 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=_coerce_route_id(entry.get("guild_id")),
chat_id=_coerce_route_id(entry.get("chat_id")),
thread_id=_coerce_route_id(entry.get("thread_id")),
enabled=entry.get("enabled", True),
)
)
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 first (most specific) matching route, or None."""
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