Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs/profile-routing.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 32 additions & 5 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -827,6 +832,7 @@ def to_dict(self) -> Dict[str, Any]:
"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
Expand Down Expand Up @@ -919,6 +925,10 @@ def from_dict(cls, data: Dict[str, Any]) -> "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,
Expand All @@ -941,6 +951,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "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:
Expand Down Expand Up @@ -1047,11 +1058,27 @@ def load_gateway_config() -> GatewayConfig:
if "thread_sessions_per_user" in yaml_cfg:
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]

# Multiplexing flag: accept both the top-level key and the nested
# gateway.multiplex_profiles form (written by
# ``hermes config set gateway.multiplex_profiles true``).
if "multiplex_profiles" in yaml_cfg:
gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"]
# Multiplexing flag: accept either top-level ``multiplex_profiles``
# or the nested ``gateway.multiplex_profiles`` form (the latter is
# what ``hermes config set gateway.multiplex_profiles true`` writes).
_mp = yaml_cfg.get("multiplex_profiles")
if _mp is None:
_gw_section = yaml_cfg.get("gateway")
if isinstance(_gw_section, dict):
_mp = _gw_section.get("multiplex_profiles")
if _mp is not None:
gw_data["multiplex_profiles"] = _mp

# 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):
Expand Down
50 changes: 49 additions & 1 deletion gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,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
Expand Down Expand Up @@ -5471,10 +5481,47 @@ def build_source(
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This selector is unavailable for adapters that do not declare gateway_runner: the common factory injects that reference only when hasattr(adapter, "gateway_runner") is true. Slack, Matrix, and Telegram do not declare it, so they will leave source.profile unset. Wire the selector through the base adapter/factory and add a non-Discord integration test.

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),
Expand All @@ -5490,6 +5537,7 @@ def build_source(
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,
Expand Down
Loading