Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/profile-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ If no route matches, the message uses the default/active profile.
(`source.profile` → active profile → `default`) and the session is scoped per-profile, so
each routed community gets isolated memory and conversation state.

Multiplexed secondary adapters are already bound to the profile that owns their
credential. That credential owner takes precedence over `profile_routes`; the
global routes apply to the primary/shared credential. This keeps several
independent Slack bots in one channel attached to their own profiles instead of
collapsing them all onto the channel's primary route.

Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),
every platform goes through this path — not just Discord.

Expand Down
13 changes: 10 additions & 3 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3002,6 +3002,11 @@ def set_status_text(self, chat_id: str, text: Optional[str]) -> None:
# routing is platform-generic instead of Discord-only.
gateway_runner = None # type: ignore[assignment] # set by gateway/run.py

# Multiplexed secondary adapters are created from one profile's own
# credential. Their inbound messages must stay bound to that profile even
# when a global channel route for the primary/shared credential matches.
owning_profile: Optional[str] = None

def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
Expand Down Expand Up @@ -7051,11 +7056,13 @@ def build_source(
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
# A secondary adapter's credential owner is authoritative. Global
# profile routes are for the primary/shared credential and must not
# collapse several independently identified bots onto one profile.
profile = str(getattr(self, "owning_profile", "") or "").strip() or None
profile_route_rejected = False
runner = getattr(self, "gateway_runner", None)
if runner is not None:
if profile is None and runner is not None:
from gateway.profile_routing import ProfileRouteRejected

try:
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14897,6 +14897,7 @@ def _configure_profile_adapter(
platform: Platform,
) -> None:
"""Install the profile-scoped handlers shared by startup and reconnect."""
adapter.owning_profile = profile_name
adapter.set_message_handler(self._make_profile_message_handler(profile_name))
adapter.set_fatal_error_handler(
self._make_profile_fatal_error_handler(profile_name, platform)
Expand Down
53 changes: 52 additions & 1 deletion tests/gateway/test_profile_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,58 @@ def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner):
# A default-profile key would land in agent:main — must differ.
assert key != build_session_key(source, profile=None)

def test_secondary_adapter_credential_owner_wins_over_global_route(
self, mock_runner
):
"""A unique secondary bot identity must keep its owning profile.

Global routes describe the primary/shared credential. Applying them to
every secondary adapter would make several Slack bots in one channel
all run the same routed profile despite having distinct credentials.
"""
mock_runner.config.profile_routes = [
ProfileRoute(
name="shared-channel",
platform="slack",
profile="chief-of-staff",
chat_id="C_SHARED",
)
]
adapter = _stub_adapter(Platform.SLACK, mock_runner)
adapter.owning_profile = "software-engineer"

source = adapter.build_source(
chat_id="C_SHARED", chat_type="group", user_id="U_OPERATOR"
)

assert source.profile == "software-engineer"
key = build_session_key(source, profile=source.profile)
assert key.startswith("agent:software-engineer:"), key

def test_primary_adapter_without_owner_still_uses_global_route(self, mock_runner):
mock_runner.config.profile_routes = [
ProfileRoute(
name="shared-channel",
platform="slack",
profile="chief-of-staff",
chat_id="C_SHARED",
)
]
adapter = _stub_adapter(Platform.SLACK, mock_runner)

with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[
("default", Path("/profiles/default")),
("chief-of-staff", Path("/profiles/chief-of-staff")),
],
):
source = adapter.build_source(
chat_id="C_SHARED", chat_type="group", user_id="U_OPERATOR"
)

assert source.profile == "chief-of-staff"

@pytest.mark.asyncio
async def test_adapter_drops_rejected_route_before_dispatch(self, mock_runner):
mock_runner.config.multiplex_profile_allowlist = []
Expand Down Expand Up @@ -386,4 +438,3 @@ def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):

assert mock_runner._profile_name_for_source(discord_source) is None


6 changes: 6 additions & 0 deletions website/docs/user-guide/multi-profile-gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ 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`:

The credential binding wins for secondary profile adapters. In other words, a
Slack app/token configured on `software-engineer` always runs that profile,
even in a channel that the primary shared Slack app routes to another profile.
`profile_routes` select profiles for the primary/shared credential; they do not
rewrite the identity of independently credentialed peer bots.

```yaml
gateway:
multiplex_profiles: true
Expand Down
Loading