From d45bc39667c13c7a47a63f0e4ef5c709c3671456 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:39:47 -0700 Subject: [PATCH] fix(gateway): resolve the ephemeral personality prompt per turn from the scoped profile GatewayRunner.__init__ snapshotted _ephemeral_system_prompt once from the launch profile's config and _get_system_prompt_for_channel returned that string for every source, so under multiplex a routed profile's display.personality / agent.system_prompt never injected (#89161), and /personality from any chat rewrote the one process-global attribute for everyone. Drop the snapshot: _get_system_prompt_for_channel now calls _load_ephemeral_system_prompt() (env var, then resolve_ephemeral_system_prompt_from_config(_load_gateway_runtime_config())) on each call. Its caller run_sync already runs inside _profile_runtime_scope, so the routed profile's config.yaml is what gets read; single-profile hot-edits of the personality also take effect on the next turn instead of requiring a restart. /personality only persists via persist_personality() (get_hermes_home()/config.yaml = the routed profile) and no longer touches in-memory state. Fixes #89161 Co-authored-by: worlldz <101180447+worlldz@users.noreply.github.com> --- gateway/run.py | 11 +++-- gateway/slash_commands.py | 14 +++--- tests/cli/test_personality_none.py | 8 ++-- .../test_personality_routed_profile.py | 45 +++++++++++++++++++ 4 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 tests/gateway/test_personality_routed_profile.py diff --git a/gateway/run.py b/gateway/run.py index 6809f0346b..730280f838 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7523,7 +7523,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Load ephemeral config from config.yaml / env vars. # Both are injected at API-call time only and never persisted. self._prefill_messages = self._load_prefill_messages() - self._ephemeral_system_prompt = self._load_ephemeral_system_prompt() self._reasoning_config = self._load_reasoning_config() self._service_tier = self._load_service_tier() self._show_reasoning = self._load_show_reasoning() @@ -10283,7 +10282,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) -> str: """Ephemeral system prompt for this channel/thread. - Uses ``channel_overrides`` when set, else the global gateway prompt. + Uses ``channel_overrides`` when set, else the gateway prompt resolved + from the CURRENT profile's config on every call. Callers run inside + ``_profile_runtime_scope`` (``run_sync`` under ``_run_agent``), so a + routed multiplex profile gets its own ``display.personality`` / + ``agent.system_prompt`` instead of a boot-time snapshot of the launch + profile's (#89161); ``/personality`` edits take effect on the next + turn for the same reason. Legacy ``channel_prompts`` are applied separately via ``event.channel_prompt`` in ``run_sync`` (adapter ``resolve_channel_prompt``), so they are not duplicated here. @@ -10299,7 +10304,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) if override and override.system_prompt: return (override.system_prompt or "").strip() - return getattr(self, "_ephemeral_system_prompt", None) or "" + return self._load_ephemeral_system_prompt() @staticmethod def _load_reasoning_config(model: str = "") -> dict | None: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 184754b069..000cd757a4 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2592,7 +2592,6 @@ class GatewaySlashCommandsMixin: available_personalities, describe_personality, persist_personality, - prompt_text, resolve_personality, ) @@ -2621,24 +2620,21 @@ class GatewaySlashCommandsMixin: return "\n".join(lines) try: - name, new_prompt = resolve_personality(args, config) + name, _new_prompt = resolve_personality(args, config) except ValueError: available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) return t("gateway.personality.unknown", name=args.lower(), available=available) # Persist the selection only — hermes_cli.personality never writes - # agent.system_prompt (user-owned manual overlay). + # agent.system_prompt (user-owned manual overlay). persist_personality + # writes get_hermes_home()/config.yaml, i.e. the routed profile under + # multiplex; the next turn re-resolves the prompt from that file + # (_get_system_prompt_for_channel), so no process-global state to update. if not persist_personality(name): return t("gateway.personality.save_failed", error="config write failed") if not name: - self._ephemeral_system_prompt = prompt_text( - cfg_get(config, "agent", "system_prompt", default="") - ) return t("gateway.personality.cleared") - - # Update in-memory so it takes effect on the very next message. - self._ephemeral_system_prompt = new_prompt return t("gateway.personality.set_to", name=name) async def _handle_retry_command(self, event: MessageEvent) -> str: diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index ba4847607c..5a8752122c 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -87,7 +87,6 @@ class TestGatewayPersonalityNone: def _make_runner(self, personalities=None): from gateway.run import GatewayRunner runner = GatewayRunner.__new__(GatewayRunner) - runner._ephemeral_system_prompt = "You are kawaii~" runner.config = { "agent": { "personalities": personalities or {"helpful": "You are helpful."} @@ -125,7 +124,9 @@ class TestGatewayPersonalityNone: saved = yaml.safe_load(config_file.read_text()) assert saved["agent"]["system_prompt"] == "manual forever" assert saved.get("display", {}).get("personality", None) == "" - assert runner._ephemeral_system_prompt == "manual forever" + # The next turn re-resolves from config (no in-memory snapshot). + with p1, p2: + assert runner._get_system_prompt_for_channel(None, "c") == "manual forever" @pytest.mark.asyncio async def test_set_persists_display_personality_not_system_prompt(self, tmp_path): @@ -147,7 +148,8 @@ class TestGatewayPersonalityNone: saved = yaml.safe_load(config_file.read_text()) assert saved["agent"]["system_prompt"] == "manual forever" assert saved["display"]["personality"] == "helpful" - assert runner._ephemeral_system_prompt == "You are helpful." + with p1, p2: + assert runner._get_system_prompt_for_channel(None, "c") == "You are helpful." assert "helpful" in result.lower() @pytest.mark.asyncio diff --git a/tests/gateway/test_personality_routed_profile.py b/tests/gateway/test_personality_routed_profile.py new file mode 100644 index 0000000000..9ad41b3628 --- /dev/null +++ b/tests/gateway/test_personality_routed_profile.py @@ -0,0 +1,45 @@ +"""#89161: a routed multiplex profile's personality must reach its turns. + +``GatewayRunner`` used to snapshot ``_ephemeral_system_prompt`` once at boot +from the launch profile's config and hand that string to every routed turn, +so a secondary profile's ``display.personality`` / ``agent.system_prompt`` never +injected. ``_get_system_prompt_for_channel`` now resolves from the config of +the profile currently in scope (``run_sync`` runs inside +``_profile_runtime_scope``). +""" + +from __future__ import annotations + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.run import GatewayRunner, _profile_runtime_scope + + +def test_routed_profile_prompt_resolves_from_its_own_config(tmp_path, monkeypatch): + default_home = tmp_path / "default" + routed_home = tmp_path / "profiles" / "beta" + default_home.mkdir() + routed_home.mkdir(parents=True) + (default_home / "config.yaml").write_text("agent:\n system_prompt: DEFAULT-PERSONA\n") + (routed_home / "config.yaml").write_text( + "agent:\n system_prompt: BETA-PERSONA\n personalities:\n pirate: ARR\n" + ) + monkeypatch.setattr(gateway_run, "_hermes_home", default_home) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + monkeypatch.delenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", raising=False) + + runner = object.__new__(GatewayRunner) + runner.config = None + + with _profile_runtime_scope(routed_home): + assert runner._get_system_prompt_for_channel(Platform.TELEGRAM, "c") == "BETA-PERSONA" + assert runner._get_system_prompt_for_channel(Platform.TELEGRAM, "c") == "DEFAULT-PERSONA" + + # /personality from the routed chat writes the routed profile and only it. + from hermes_cli.personality import persist_personality + + with _profile_runtime_scope(routed_home): + assert persist_personality("pirate") + assert runner._get_system_prompt_for_channel(Platform.TELEGRAM, "c") == "ARR" + assert "pirate" not in (default_home / "config.yaml").read_text() + assert runner._get_system_prompt_for_channel(Platform.TELEGRAM, "c") == "DEFAULT-PERSONA"