diff --git a/gateway/run.py b/gateway/run.py index d4bb5cd044bd..35ad44624d4a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10613,6 +10613,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) _msg_config_ctx = None _msg_cfg = None + _msg_custom_providers = [] try: _msg_cfg = _load_gateway_config() _msg_model_cfg = _msg_cfg.get("model", {}) @@ -10620,6 +10621,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _msg_raw_ctx = _msg_model_cfg.get("context_length") if _msg_raw_ctx is not None: _msg_config_ctx = int(_msg_raw_ctx) + try: + from hermes_cli.config import get_compatible_custom_providers + + _msg_custom_providers = get_compatible_custom_providers(_msg_cfg) + except Exception: + _msg_custom_providers = _msg_cfg.get("custom_providers") or [] except Exception: pass # Resolve the session's actual model/provider/base_url the @@ -10640,6 +10647,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew api_key=_msg_runtime.get("api_key") or "", config_context_length=_msg_config_ctx, provider=_msg_runtime.get("provider") or "", + custom_providers=_msg_custom_providers, ) _ctx_result = await preprocess_context_references_async( message_text, @@ -10663,6 +10671,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return message_text + async def _prepare_profile_scoped_inbound_message_text( + self, + *, + event: MessageEvent, + source: SessionSource, + history: List[Dict[str, Any]], + session_key: Optional[str] = None, + ) -> Optional[str]: + """Run inbound preprocessing under the routed profile when multiplexed.""" + if getattr(getattr(self, "config", None), "multiplex_profiles", False): + with _profile_runtime_scope(self._resolve_profile_home_for_source(source)): + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + def _consume_pending_native_image_paths(self, session_key: str) -> List[str]: pending_native = getattr(self, "_pending_native_image_paths_by_session", None) if not pending_native: @@ -11505,7 +11537,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # attachments (documents, audio, etc.) are not sent to the vision # tool even when they appear in the same message. # ----------------------------------------------------------------- - message_text = await self._prepare_inbound_message_text( + message_text = await self._prepare_profile_scoped_inbound_message_text( event=event, source=source, history=history, diff --git a/scripts/release.py b/scripts/release.py index 4d50af4e84c5..dc42b1308b0f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "iganapolsky@gmail.com": "IgorGanapolsky", # PR #62125 salvage (compaction anti-thrash threshold verification) + "tturney1@gmail.com": "TheTom", # PR #62696 salvage (gateway: expand @ context references under runtime/session model resolution) "wilsonkinyuam@gmail.com": "WilsonKinyua", # PR #62052 (tui: persist unflushed conversations on disconnect/restart) "humphreysun98@gmail.com": "HumphreySun98", # PR #61142 salvage (web: null web/backend config value guards) "sonxi@nous.local": "17324393074", # PR #53196 salvage (tools_config: known_plugin_toolsets null guard; commit under unlinked local identity) diff --git a/tests/gateway/test_context_ref_expansion_runtime.py b/tests/gateway/test_context_ref_expansion_runtime.py index 1b9fd94b4bad..41dbe7705a99 100644 --- a/tests/gateway/test_context_ref_expansion_runtime.py +++ b/tests/gateway/test_context_ref_expansion_runtime.py @@ -18,6 +18,7 @@ the hygiene-compression block already uses) and must actually reach """ import logging import threading +from contextlib import contextmanager import pytest @@ -185,3 +186,94 @@ async def test_at_reference_resolves_model_via_session_runtime(monkeypatch): assert captured_runtime_call.get("model") == "openai/gpt-4.1-mini" assert captured_runtime_call.get("base_url") == "https://api.openai.com/v1" assert captured_runtime_call.get("provider") == "openai" + + +@pytest.mark.asyncio +async def test_at_reference_uses_routed_profile_scope_when_multiplexed(monkeypatch, tmp_path): + """Secondary-profile preprocessing must resolve inside that profile scope.""" + runner = _make_runner() + runner.config.multiplex_profiles = True + source = _source() + source.profile = "secondary" + profile_home = tmp_path / "profiles" / "secondary" + seen = [] + + @contextmanager + def _scope(home): + seen.append(("enter", home)) + try: + yield + finally: + seen.append(("exit", home)) + + async def _prepared(**kwargs): + seen.append(("prepared", kwargs["source"].profile)) + return "expanded" + + monkeypatch.setattr(gateway_run, "_profile_runtime_scope", _scope) + monkeypatch.setattr(runner, "_resolve_profile_home_for_source", lambda _source: profile_home) + monkeypatch.setattr(runner, "_prepare_inbound_message_text", _prepared) + + result = await runner._prepare_profile_scoped_inbound_message_text( + event=MessageEvent(text="@file:note", source=source), + source=source, + history=[], + session_key="agent:secondary:telegram:dm:123", + ) + + assert result == "expanded" + assert seen == [ + ("enter", profile_home), + ("prepared", "secondary"), + ("exit", profile_home), + ] + + +@pytest.mark.asyncio +async def test_at_reference_passes_compatible_custom_provider_context(monkeypatch): + """Per-model custom-provider limits must bound context-reference injection.""" + runner = _make_runner() + source = _source() + captured = {} + custom_providers = [{ + "name": "private", + "base_url": "https://private.example/v1", + "models": {"private/model": {"context_length": 32768}}, + }] + + monkeypatch.setattr( + gateway_run, + "_load_gateway_config", + lambda: {"model": {"default": "private/model"}, "custom_providers": custom_providers}, + ) + monkeypatch.setattr( + gateway_run, + "_resolve_gateway_model", + lambda _cfg=None: "private/model", + ) + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: {"provider": "custom:private", "api_key": "test", "base_url": "https://private.example/v1"}, + ) + + import hermes_cli.config as config_mod + import agent.model_metadata as model_meta_mod + import agent.context_references as ctx_mod + + monkeypatch.setattr(config_mod, "get_compatible_custom_providers", lambda _cfg: custom_providers) + + async def _fake_get_context(_model, **kwargs): + captured["custom_providers"] = kwargs["custom_providers"] + return 32768 + + async def _passthrough(message, **_kwargs): + return ContextReferenceResult(message=message, original_message=message) + + monkeypatch.setattr(model_meta_mod, "get_model_context_length_async", _fake_get_context) + monkeypatch.setattr(ctx_mod, "preprocess_context_references_async", _passthrough) + + await runner._prepare_inbound_message_text( + event=MessageEvent(text="@file:note", source=source), source=source, history=[] + ) + assert captured["custom_providers"] == custom_providers