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
104 changes: 96 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4574,6 +4574,78 @@ def _active_profile_name(self) -> str:
except Exception:
return "default"

def _channel_profile_routes(self, platform: Optional[Platform] = None) -> dict[str, str]:
"""Return configured per-channel profile routes.

Supports both the global shape:

gateway:
channel_profiles: {"<channel_id>": "profile"}

and the platform-local shape:

discord:
channel_profiles: {"<channel_id>": "profile"}
"""
cfg = _load_gateway_config()
routes: dict[str, str] = {}
for block in (cfg.get("gateway"), cfg):
if isinstance(block, dict):
raw = block.get("channel_profiles")
if isinstance(raw, dict):
routes.update({str(k): str(v) for k, v in raw.items() if v})
platform_key = platform.value if platform else None
if platform_key and isinstance(cfg.get(platform_key), dict):
raw = cfg[platform_key].get("channel_profiles")
if isinstance(raw, dict):
routes.update({str(k): str(v) for k, v in raw.items() if v})
return routes

def _effective_profile_name_for_source(self, source: Optional[SessionSource]) -> str:
"""Resolve the profile that should handle a source.

Discord/Slack threads can use their own chat IDs, so parent channel
routes are considered after direct chat/thread candidates.
"""
active = self._active_profile_name()
if source is None:
return active
routes = self._channel_profile_routes(source.platform)

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.

Current multiplex routing does not derive a profile from channel IDs: secondary adapters stamp event.source.profile before dispatch (gateway/run.py:8629-8637 on main), and the agent resolves that field. This new mapping can make slash commands select a profile that the actual turn will not use; derive from source.profile instead.

candidates = [
getattr(source, "chat_id", None),
getattr(source, "thread_id", None),
getattr(source, "parent_chat_id", None),
]
for candidate in candidates:
if candidate is None:
continue
profile = routes.get(str(candidate))
if profile:
return profile
return active

def _load_profile_config(self, profile_name: str) -> dict:
"""Load config.yaml for a named profile without mutating HERMES_HOME."""
profile_name = (profile_name or "default").strip() or "default"
if profile_name == "default":
return _load_gateway_config()
try:
import yaml
from hermes_cli.profiles import resolve_profile_env

profile_home = Path(resolve_profile_env(profile_name))
cfg_path = profile_home / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception as exc:
logger.warning("Could not load routed profile %s config: %s", profile_name, exc)
return _load_gateway_config()

def _effective_user_config_for_source(self, source: Optional[SessionSource]) -> tuple[dict, str]:
profile_name = self._effective_profile_name_for_source(source)
return self._load_profile_config(profile_name), profile_name

async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None:
"""Poll ``kanban_notify_subs`` and deliver terminal events to users.

Expand Down Expand Up @@ -9218,17 +9290,31 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
return EphemeralReply(f"{header}{_tip_line}")

async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile — show active profile name and home directory."""
"""Handle /profile — show the effective profile for this chat.

The gateway process usually runs under one profile, but individual
Discord channels/threads can be routed to named profiles. Report the
effective chat profile instead of only the process profile.
"""
from hermes_constants import display_hermes_home
from hermes_cli.profiles import get_active_profile_name
from hermes_cli.profiles import resolve_profile_env

display = display_hermes_home()
profile_name = get_active_profile_name()
process_profile = self._active_profile_name()
effective_profile = self._effective_profile_name_for_source(event.source)
if effective_profile == "default":
display = display_hermes_home()
else:
try:
display = resolve_profile_env(effective_profile)
except Exception:
display = display_hermes_home()

lines = [
t("gateway.profile.header", profile=profile_name),
t("gateway.profile.header", profile=effective_profile),
t("gateway.profile.home", home=display),
]
if effective_profile != process_profile:
lines.append(f"**Gateway process profile:** `{process_profile}`")

return "\n".join(lines)

Expand Down Expand Up @@ -9964,7 +10050,10 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
# Parse --provider and --global flags
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)

# Read current model/provider from config
# Read current model/provider from the effective chat profile config.
# The gateway process itself may run as `default`, while Discord
# channels can be routed to profile-specific agents.
source = event.source
current_model = ""
current_provider = "openrouter"
current_base_url = ""
Expand All @@ -9973,7 +10062,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
custom_provs = None
config_path = _hermes_home / "config.yaml"
try:
cfg = _load_gateway_config()
cfg, _effective_profile = self._effective_user_config_for_source(source)

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 fixes only the display-time config read. config_path remains _hermes_home / "config.yaml" immediately above, so the typed/picker global persistence paths still save to the gateway process profile. Scope the full command's read/write path to the same routed profile.

if cfg:
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
Expand All @@ -9990,7 +10079,6 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
pass

# Check for session override
source = event.source
session_key = self._session_key_for_source(source)
override = self._session_model_overrides.get(session_key, {})
if override:
Expand Down
157 changes: 157 additions & 0 deletions tests/gateway/test_channel_profile_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Regression tests for gateway channel/thread profile routing.

The gateway process can run under the default profile while individual Discord
channels are routed to profile-specific agents. Slash commands such as
``/profile`` and ``/model`` must use the effective chat profile, not only the
process profile.
"""

import asyncio
from types import SimpleNamespace
from typing import Any, cast

import gateway.run as gateway_run
from gateway.config import Platform
from gateway.session import SessionSource


def _make_runner():
runner = cast(Any, object.__new__(gateway_run.GatewayRunner))
runner.adapters = {}
runner.session_store = None
runner.config = SimpleNamespace(group_sessions_per_user=True, thread_sessions_per_user=False)
runner._session_model_overrides = {}
return runner


def test_effective_profile_uses_discord_channel_route(monkeypatch):
monkeypatch.setattr(
gateway_run,
"_load_gateway_config",
lambda: {
"discord": {
"channel_profiles": {
"channel-builder": "local-builder",
"channel-research": "local-research",
}
}
},
)
runner = _make_runner()

source = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-research",
chat_type="channel",
)

assert runner._effective_profile_name_for_source(source) == "local-research"


def test_effective_profile_inherits_parent_channel_route_for_thread(monkeypatch):
monkeypatch.setattr(
gateway_run,
"_load_gateway_config",
lambda: {
"discord": {
"channel_profiles": {
"parent-channel": "local-builder",
}
}
},
)
runner = _make_runner()

source = SessionSource(
platform=Platform.DISCORD,
chat_id="thread-id",
thread_id="thread-id",
parent_chat_id="parent-channel",
chat_type="thread",
)

assert runner._effective_profile_name_for_source(source) == "local-builder"


def test_profile_command_reports_effective_profile_not_process_profile(monkeypatch):
monkeypatch.setattr(
gateway_run,
"_load_gateway_config",
lambda: {
"discord": {
"channel_profiles": {
"channel-research": "local-research",
}
}
},
)

import hermes_cli.profiles as profiles

monkeypatch.setattr(
profiles,
"resolve_profile_env",
lambda profile: f"/tmp/hermes/profiles/{profile}",
)

runner = _make_runner()
source = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-research",
chat_type="channel",
)
event = cast(Any, SimpleNamespace(source=source))

response = asyncio.run(runner._handle_profile_command(event))

assert "local-research" in response
assert "/tmp/hermes/profiles/local-research" in response
assert "Gateway process profile" in response
assert "default" in response


def test_model_command_reads_effective_profile_config_for_routed_channel(monkeypatch):
monkeypatch.setattr(
gateway_run,
"_load_gateway_config",
lambda: {
"model": {"default": "gpt-5.5", "provider": "openai-codex"},
"discord": {
"channel_profiles": {
"channel-research": "local-research",
}
},
},
)
monkeypatch.setattr(
gateway_run.GatewayRunner,
"_load_profile_config",
lambda self, profile_name: {
"model": {"default": "aeon-ultimate", "provider": "custom:local"},
"custom_providers": [
{
"name": "local",
"base_url": "http://127.0.0.1:8000/v1",
"models": {"aeon-ultimate": 262144},
}
],
} if profile_name == "local-research" else {},
)

import hermes_cli.model_switch as model_switch

monkeypatch.setattr(model_switch, "list_authenticated_providers", lambda **kwargs: [])

runner = _make_runner()
source = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-research",
chat_type="channel",
)
event = cast(Any, SimpleNamespace(source=source, get_command_args=lambda: ""))

response = asyncio.run(runner._handle_model_command(event))

assert response is not None
assert "aeon-ultimate" in response
assert "gpt-5.5" not in response