mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Three follow-ups to the initial routing PR after code review: 1. Remove dead forum-post hierarchy cache. The `_forum_post_cache`, `register_forum_post()`, and `resolve_forum_channel()` were never wired up — no caller in the codebase. Discord's adapter already sets `parent_chat_id` to the immediate parent (forum channel for a forum post), so the existing `self.chat_id == parent_chat_id` branch in `matches()` handles forum posts correctly without a cache. The hierarchical-resolution branch in `matches()` and the bounded-LRU infrastructure are removed. 2. Fix docstring specificity numbers (8 → 14, 4 → 6) and rewrite the "Hierarchical matching" section to describe the actual one-level parent_chat_id behavior. Removed unused `OrderedDict` and `Set` imports. 3. Warn loudly when a routed profile doesn't exist on disk. Previously, a typo in `profile_routes` (e.g. `crypto-tradr`) silently fell back to the global HERMES_HOME, causing the message to read the default profile's memory/credentials with no signal to the operator. Now emits a `logger.warning` with the profile name, source identifier, and the fallback reason. Bare-exception path also gets `exc_info`. Tests: - test_profile_routing.py: +2 tests verifying forum post matching via direct parent_chat_id (covers the case the removed cache was meant for). 31 total, all pass. - test_profile_resolution.py: NEW, 12 tests covering resolution order (source.profile > routing > active > default), missing-profile warning, exception handling, and routing consultation. All pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
169 lines
5.4 KiB
Python
169 lines
5.4 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.
|
|
|
|
Supports hierarchical matching for Discord forums:
|
|
- Direct channel match: chat_id == route.chat_id
|
|
- Thread in channel: parent_chat_id == route.chat_id
|
|
"""
|
|
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
|
|
|
|
# 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
|