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
126 changes: 125 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2290,6 +2290,102 @@ def _load_gateway_runtime_config() -> dict:
return expanded if isinstance(expanded, dict) else {}


def _gateway_profile_route_candidate(routes: Any, *, chat_id: str, thread_id: str) -> Optional[str]:
"""Return a routed profile name from a mapping/list route config.

Supported shapes are intentionally small and platform-neutral:
``{"<chat_id>": "profile"}``, ``{"<chat_id>:<thread_id>":
"profile"}``, or ``[{chats: [...], threads: [...], profile: "profile"}]``.
"""
if not chat_id:
return None

if isinstance(routes, dict):
candidates: list[Any] = []
if thread_id:
candidates.append(f"{chat_id}:{thread_id}")
candidates.append(chat_id)
if chat_id.lstrip("-").isdigit():
try:
candidates.append(int(chat_id))
except ValueError:
pass
for candidate in candidates:
routed = routes.get(candidate)
if routed:
return str(routed).strip() or None
return None

if isinstance(routes, list):
for item in routes:
if not isinstance(item, dict):
continue
chats = item.get("chats") or item.get("chat_ids") or item.get("channels") or []
if isinstance(chats, (str, int)):
chats = [chats]
if chat_id not in {str(chat) for chat in chats}:
continue
threads = item.get("threads") or item.get("thread_ids")
if threads is not None:
if isinstance(threads, (str, int)):
threads = [threads]
if not thread_id or thread_id not in {str(thread) for thread in threads}:
continue
routed = item.get("profile") or item.get("name")
if routed:
return str(routed).strip() or None
return None


def _resolve_shared_credential_profile_route(config: dict, source: Any) -> Optional[str]:
"""Resolve optional chat/topic → profile routing for a shared adapter.

This is the companion to ``gateway.multiplex_profiles`` for operators who
want one inbound bot token to serve multiple profile homes. The adapter
remains owned by the active/default gateway profile; only the agent turn is
routed by stamping ``SessionSource.profile`` before session-key generation.
"""
if not isinstance(config, dict):
return None
platform = getattr(getattr(source, "platform", None), "value", None) or str(getattr(source, "platform", "") or "")
platform_cfg = config.get(platform) or {}
if not isinstance(platform_cfg, dict):
return None

chat_id = str(getattr(source, "chat_id", "") or "")
thread_id = str(getattr(source, "thread_id", "") or "")
raw_profile = None
for key in ("profile_routes", "chat_profiles", "channel_profiles"):
raw_profile = _gateway_profile_route_candidate(
platform_cfg.get(key),
chat_id=chat_id,
thread_id=thread_id,
)
if raw_profile:
break
if not raw_profile:
return None

try:
from hermes_cli.profiles import normalize_profile_name, profile_exists
profile_name = normalize_profile_name(str(raw_profile))
if profile_name == "default":
return None
if not profile_exists(profile_name):
logger.warning(
"Gateway profile route for %s chat=%s thread=%s points to missing profile %r",
platform,
chat_id,
thread_id,
profile_name,
)
return None
return profile_name
except Exception as exc:
logger.warning("Gateway profile route resolution failed: %s", exc)
return None


def _resolve_gateway_model(config: dict | None = None) -> str:
"""Read model from config.yaml — single source of truth.

Expand Down Expand Up @@ -8676,7 +8772,35 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Record rate limit so subsequent messages are silently ignored
self.pairing_store._record_rate_limit(platform_name, source.user_id)
return None


# Optional shared-credential profile routing. This lets one adapter/bot
# token receive many chats and route selected chats/topics into named
# profile homes. Authorization above intentionally used the owning
# adapter/default profile; from here on, session keys and agent runtime
# use the routed profile.
if (
not is_internal
and not getattr(source, "profile", None)
and getattr(getattr(self, "config", None), "multiplex_profiles", False)
):
_route_profile = _resolve_shared_credential_profile_route(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This lookup is after _is_user_authorized(source). In multiplex mode the authorization mixin selects the pairing store from source.profile, so this admits users under the default profile's policy and only then switches them into the target profile. Resolve and stamp the route before authorization.

_load_gateway_runtime_config(),
source,
)
if _route_profile:
event = dataclasses.replace(
event,
source=dataclasses.replace(source, profile=_route_profile),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This stamp is too late for adapter-owned state: BasePlatformAdapter.handle_message() builds its active-session key before it invokes this handler. That key is still agent:main:...; route the source at build_source() time and pass its profile through all adapter session/batch-key calls.

)
source = event.source
logger.info(
"Gateway shared-credential profile route active: platform=%s chat=%s thread=%s profile=%s",
source.platform.value if source.platform else "unknown",
source.chat_id,
source.thread_id,
_route_profile,
)

# Intercept messages that are responses to a pending /update prompt.
# The update process (detached) wrote .update_prompt.json; the watcher
# forwarded it to the user; now the user's reply goes back via
Expand Down
78 changes: 78 additions & 0 deletions tests/gateway/test_multiplex_shared_profile_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Shared-credential chat/topic routes for multiplexed gateways."""
from unittest.mock import patch

from gateway.config import Platform
from gateway.session import SessionSource, build_session_key
from gateway.run import _resolve_shared_credential_profile_route


def _src(**kw) -> SessionSource:
kw.setdefault("platform", Platform.TELEGRAM)
kw.setdefault("chat_id", "-100111")
kw.setdefault("chat_type", "group")
return SessionSource(**kw)


def _resolve(config, source):
with patch("hermes_cli.profiles.profile_exists", return_value=True):
return _resolve_shared_credential_profile_route(config, source)


def test_mapping_routes_chat_to_profile():
profile = _resolve(
{"telegram": {"profile_routes": {"-100111": "research"}}},
_src(),
)
assert profile == "research"


def test_mapping_prefers_topic_specific_route():
profile = _resolve(
{
"telegram": {
"profile_routes": {
"-100111": "general",
"-100111:42": "support",
}
}
},
_src(thread_id="42"),
)
assert profile == "support"


def test_list_routes_can_scope_threads():
profile = _resolve(
{
"telegram": {
"profile_routes": [
{"chats": ["-100111"], "threads": ["42"], "profile": "support"},
{"chats": ["-100111"], "profile": "fallback"},
]
}
},
_src(thread_id="42"),
)
assert profile == "support"


def test_aliases_are_accepted():
profile = _resolve(
{"telegram": {"chat_profiles": {"-100111": "writer"}}},
_src(),
)
assert profile == "writer"


def test_missing_profile_is_ignored():
with patch("hermes_cli.profiles.profile_exists", return_value=False):
profile = _resolve_shared_credential_profile_route(
{"telegram": {"profile_routes": {"-100111": "ghost"}}},
_src(),
)
assert profile is None


def test_routed_source_uses_namespaced_session_key():
source = _src(profile="research")
assert build_session_key(source, profile=source.profile) == "agent:research:telegram:group:-100111"
45 changes: 40 additions & 5 deletions website/docs/user-guide/multi-profile-gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,46 @@ its `/p/<profile>/` prefix.
#### 3. Per-credential platforms still need their own token per profile

Polling/connection platforms (Telegram, Discord, Slack, Matrix, Signal, …) work
fine multiplexed, but each profile that enables one must supply its **own** bot
token — the same token cannot be polled by two profiles at once. If two profiles
configure the same `(platform, token)`, startup fails fast naming both profiles
(see [Token-conflict safety](#token-conflict-safety) — the rule is unchanged,
it's just enforced inside the one process now).
fine multiplexed, but each profile that enables its own adapter must supply its
**own** bot token — the same token cannot be polled by two adapters at once. If
two profiles configure the same `(platform, token)` as separate adapters, startup
fails fast naming both profiles (see [Token-conflict safety](#token-conflict-safety)
— the rule is unchanged, it's just enforced inside the one process now).

#### 3a. Shared-token chat routes

Sometimes you want a different tradeoff: **one bot/account, many profile homes**.
For example, a single Telegram bot may be invited to several groups, where each
group should have isolated skills, memory, cron state, and sessions — but you do
not want to create a BotFather bot for every profile.

In multiplex mode, keep the polling platform enabled only on the default profile
and route selected chats/topics into named profiles:

```yaml
gateway:
multiplex_profiles: true

telegram:
profile_routes:
"-1001111111111": research
"-1002222222222:42": support

# Equivalent aliases are accepted:
# telegram.chat_profiles: {...}
# telegram.channel_profiles: {...}
```

The default gateway owns the inbound credential and authorization gate. After the
message is accepted, Hermes stamps the message source with the routed profile, so
the agent turn uses that profile's `config.yaml`, `.env`, SOUL, skills, memory,
and namespaced session key. The routed profile does **not** start its own Telegram
adapter, so there is no duplicate polling and no token-lock conflict.

Use shared-token routes when identity continuity matters (one public bot name,
existing group membership, one Slack app) and process-level isolation is less
important than profile-scoped state. Use separate tokens/adapters when each
profile should be independently reachable and restartable.

#### 4. Session keys are namespaced by profile

Expand Down
Loading