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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
45
tests/gateway/test_personality_routed_profile.py
Normal file
45
tests/gateway/test_personality_routed_profile.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user