-
Notifications
You must be signed in to change notification settings - Fork 54.1k
fix(gateway): respect routed profile in slash commands #31102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
tensorbit89-netizen
wants to merge
1
commit into
NousResearch:main
from
tensorbit89-netizen:fix-gateway-routed-profile-commands
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| 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. | ||
|
|
||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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 = "" | ||
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes only the display-time config read. |
||
| if cfg: | ||
| model_cfg = cfg.get("model", {}) | ||
| if isinstance(model_cfg, dict): | ||
|
|
@@ -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: | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.profilebefore dispatch (gateway/run.py:8629-8637on 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 fromsource.profileinstead.