mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters
Follow-ups on the salvaged #20096 profile-routing feature: - _profile_name_for_source now returns None unless gateway.multiplex_profiles is on. Routing stamps source.profile, which namespaces session/batch keys, but the profile-scoped agent run only activates under multiplexing — without the gate, configured routes with multiplexing off split batch/session keys into agent:<profile> while the agent still ran from agent:main. - Widen the profile-aware _text_batch_key fix from Discord to every adapter that builds batch keys via build_session_key (telegram, whatsapp, matrix, feishu, wecom, weixin) — routing is platform-generic, so the batch-key namespace fix must be too. - Downgrade the no-route-matched log from INFO to DEBUG (fired on every unrouted inbound message). - GatewayConfig.to_dict(): serialize profile_routes as plain dicts (ProfileRoute dataclasses are not JSON-safe). - Docs: correct the 'independent of multiplexing' claim in docs/profile-routing.md (routing requires multiplexing), fix the platform-only specificity row (0, not 1), and document profile_routes in website/docs/user-guide/multi-profile-gateways.md. - Tests: pin the multiplex gate (routes ignored when off, active when on, build_source end-to-end stays in agent:main when off).
This commit is contained in:
parent
c29ab7b9fe
commit
647520f83e
11 changed files with 128 additions and 14 deletions
|
|
@ -87,7 +87,7 @@ When multiple routes match, the **most specific** one wins. Specificity is addit
|
|||
| `thread_id` | 8 |
|
||||
| `chat_id` | 4 |
|
||||
| `guild_id` | 2 |
|
||||
| (platform only) | 1 |
|
||||
| (platform only) | 0 |
|
||||
|
||||
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.
|
||||
|
|
@ -108,8 +108,10 @@ If no route matches, the message uses the default/active profile.
|
|||
Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),
|
||||
every platform goes through this path — not just Discord.
|
||||
|
||||
## Migration / coexistence with multiplexing
|
||||
## Relationship to 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.
|
||||
`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what
|
||||
activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and
|
||||
profile-namespaced session keys); routing is the decision layer that picks *which*
|
||||
profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes`
|
||||
is ignored entirely — behavior is byte-identical to a single-profile gateway.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
from enum import Enum
|
||||
|
||||
|
|
@ -832,7 +832,10 @@ 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,
|
||||
"profile_routes": [
|
||||
asdict(r) if is_dataclass(r) and not isinstance(r, type) else r
|
||||
for r in self.profile_routes
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1515,6 +1515,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
|||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
|
|
|
|||
|
|
@ -17312,14 +17312,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
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.
|
||||
Returns ``None`` when multiplexing is off, 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.
|
||||
|
||||
Gated on ``gateway.multiplex_profiles``: routing stamps
|
||||
``source.profile``, which selects the session-key namespace and batch
|
||||
keys — but the profile-scoped agent run only activates under
|
||||
multiplexing. Without this gate, a configured route with multiplexing
|
||||
off would namespace batch/session keys by profile while the agent
|
||||
still runs in ``agent:main``, splitting the two out of agreement.
|
||||
"""
|
||||
config = getattr(self, "config", None)
|
||||
if not getattr(config, "multiplex_profiles", False):
|
||||
return None
|
||||
routes = getattr(config, "profile_routes", None)
|
||||
if not routes:
|
||||
return None
|
||||
|
|
@ -17341,7 +17350,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return None
|
||||
if matched:
|
||||
return matched.profile
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"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),
|
||||
|
|
|
|||
|
|
@ -3633,6 +3633,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -3455,6 +3455,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
thread_sessions_per_user=self.config.extra.get(
|
||||
"thread_sessions_per_user", False
|
||||
),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
|
|
|
|||
|
|
@ -7925,6 +7925,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
|
|
|
|||
|
|
@ -578,6 +578,7 @@ class WeComAdapter(BasePlatformAdapter):
|
|||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
|
|
|
|||
|
|
@ -1278,6 +1278,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
profile=event.source.profile,
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
|
|
|
|||
|
|
@ -419,3 +419,53 @@ class TestAdapterToSessionKeyIntegration:
|
|||
assert source.profile is None
|
||||
key = build_session_key(source, profile=source.profile)
|
||||
assert key.startswith("agent:main:"), key
|
||||
|
||||
|
||||
class TestMultiplexGate:
|
||||
"""``profile_routes`` only activates under ``gateway.multiplex_profiles``.
|
||||
|
||||
Routing stamps ``source.profile``, which namespaces session/batch keys —
|
||||
but the profile-scoped agent run (``_profile_runtime_scope``) only engages
|
||||
when multiplexing is on. Without the gate, a configured route with
|
||||
multiplexing off would split batch/session keys into ``agent:<profile>``
|
||||
while the agent still served the turn from ``agent:main``'s home.
|
||||
"""
|
||||
|
||||
def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):
|
||||
mock_runner.config.multiplex_profiles = False
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(name="dc", platform="discord", profile="coder",
|
||||
guild_id="789", chat_id="123456"),
|
||||
]
|
||||
discord_source.profile = None
|
||||
|
||||
assert mock_runner._profile_name_for_source(discord_source) is None
|
||||
|
||||
def test_routes_active_when_multiplex_on(self, mock_runner, discord_source):
|
||||
mock_runner.config.multiplex_profiles = True
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(name="dc", platform="discord", profile="coder",
|
||||
guild_id="789", chat_id="123456"),
|
||||
]
|
||||
discord_source.profile = None
|
||||
|
||||
assert mock_runner._profile_name_for_source(discord_source) == "coder"
|
||||
|
||||
def test_build_source_leaves_profile_none_when_multiplex_off(self, mock_runner):
|
||||
"""End-to-end through the real adapter ``build_source``: with routes
|
||||
configured but multiplexing off, no profile is stamped and the session
|
||||
key stays in the legacy ``agent:main`` namespace — byte-identical to a
|
||||
gateway with no routes at all."""
|
||||
mock_runner.config.multiplex_profiles = False
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(name="dc", platform="discord", profile="coder",
|
||||
guild_id="111", chat_id="222"),
|
||||
]
|
||||
adapter = _stub_adapter(Platform.DISCORD, mock_runner)
|
||||
|
||||
source = adapter.build_source(
|
||||
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
|
||||
)
|
||||
assert source.profile is None
|
||||
key = build_session_key(source, profile=source.profile)
|
||||
assert key.startswith("agent:main:"), key
|
||||
|
|
|
|||
|
|
@ -189,6 +189,50 @@ Kanban workers only ever see their own profile's secrets). Kanban,
|
|||
profile-scoped skills/memory/SOUL, and model routing all behave per-profile
|
||||
exactly as they do with separate gateways.
|
||||
|
||||
### Routing shared-bot chats to profiles (`profile_routes`)
|
||||
|
||||
Multiplexing selects a profile per **credential** (each profile's own bot
|
||||
token) or per **URL prefix** (`/p/<profile>/` for HTTP platforms). When several
|
||||
communities share **one** bot token — for example one Discord bot serving many
|
||||
guilds — you can additionally route specific guilds/channels/threads to
|
||||
different profiles with `gateway.profile_routes`:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
multiplex_profiles: true
|
||||
profile_routes:
|
||||
# An entire Discord server → one profile
|
||||
- name: acme-server
|
||||
platform: discord
|
||||
guild_id: "1234567890"
|
||||
profile: acme
|
||||
|
||||
# One channel in that server → a different profile
|
||||
- name: acme-support
|
||||
platform: discord
|
||||
guild_id: "1234567890"
|
||||
chat_id: "9876543210"
|
||||
profile: acme-support
|
||||
|
||||
# A Telegram group (no guild concept — chat_id only)
|
||||
- name: tg-group
|
||||
platform: telegram
|
||||
chat_id: "-1001234567890"
|
||||
profile: tg-profile
|
||||
```
|
||||
|
||||
Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`),
|
||||
all declared fields must hold (AND), and a route keyed on a channel also
|
||||
matches threads/forum posts whose parent is that channel. Messages that match
|
||||
no route stay on the default/active profile. The routed profile gets the full
|
||||
per-profile isolation described above (config, skills, memory, credentials,
|
||||
session namespace). Routing works on every platform adapter, not just Discord.
|
||||
|
||||
`profile_routes` requires `gateway.multiplex_profiles: true`; with
|
||||
multiplexing off the routes are ignored. If a route names a profile that does
|
||||
not exist on disk, the gateway logs a warning naming the profile and source and
|
||||
falls back to the default home.
|
||||
|
||||
## Start, stop, or restart all gateways at once
|
||||
|
||||
The CLI ships with single-profile lifecycle commands. To act across every
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue