Merge pull request #81946 from NousResearch/bb/personality-preserve-system-prompt
fix(personality): preserve manual system prompts (supersedes #81792, #56773)
This commit is contained in:
commit
daabb2d445
7
cli.py
7
cli.py
|
|
@ -4502,10 +4502,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
# AGENTS.md/SOUL.md/.cursorrules and persistent memory are not loaded.
|
||||
self.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
|
||||
# Ephemeral system prompt: env var takes precedence, then config
|
||||
# Ephemeral system prompt: env var takes precedence, then
|
||||
# display.personality / agent.system_prompt from config.
|
||||
from hermes_cli.config import resolve_ephemeral_system_prompt_from_config
|
||||
|
||||
self.system_prompt = (
|
||||
os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
|
||||
or CLI_CONFIG["agent"].get("system_prompt", "")
|
||||
or resolve_ephemeral_system_prompt_from_config(CLI_CONFIG)
|
||||
)
|
||||
self.personalities = CLI_CONFIG["agent"].get("personalities", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -8170,15 +8170,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
@staticmethod
|
||||
def _load_ephemeral_system_prompt() -> str:
|
||||
"""Load ephemeral system prompt from config or env var.
|
||||
|
||||
Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then falls back to
|
||||
agent.system_prompt in ~/.hermes/config.yaml.
|
||||
|
||||
Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then
|
||||
``display.personality`` / ``agent.system_prompt`` in config.yaml.
|
||||
"""
|
||||
from hermes_cli.config import resolve_ephemeral_system_prompt_from_config
|
||||
|
||||
prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
|
||||
if prompt:
|
||||
return prompt
|
||||
cfg = _load_gateway_runtime_config()
|
||||
return str(cfg_get(cfg, "agent", "system_prompt", default="") or "").strip()
|
||||
return resolve_ephemeral_system_prompt_from_config(cfg)
|
||||
|
||||
def _resolve_model_for_channel(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -2519,34 +2519,34 @@ class GatewaySlashCommandsMixin:
|
|||
lines.append(t("gateway.personality.usage"))
|
||||
return "\n".join(lines)
|
||||
|
||||
def _resolve_prompt(value):
|
||||
if isinstance(value, dict):
|
||||
parts = [value.get("system_prompt", "")]
|
||||
if value.get("tone"):
|
||||
parts.append(f'Tone: {value["tone"]}')
|
||||
if value.get("style"):
|
||||
parts.append(f'Style: {value["style"]}')
|
||||
return "\n".join(p for p in parts if p)
|
||||
return str(value)
|
||||
from hermes_cli.config import (
|
||||
_prompt_text,
|
||||
render_personality_prompt,
|
||||
)
|
||||
|
||||
if args in {"none", "default", "neutral"}:
|
||||
# Persist the selection only. Never clear agent.system_prompt —
|
||||
# that field is the user-owned manual overlay.
|
||||
try:
|
||||
if "agent" not in config or not isinstance(config.get("agent"), dict):
|
||||
config["agent"] = {}
|
||||
config["agent"]["system_prompt"] = ""
|
||||
if "display" not in config or not isinstance(config.get("display"), dict):
|
||||
config["display"] = {}
|
||||
config["display"]["personality"] = ""
|
||||
atomic_config_write(config_path, config)
|
||||
except Exception as e:
|
||||
return t("gateway.personality.save_failed", error=str(e))
|
||||
self._ephemeral_system_prompt = ""
|
||||
self._ephemeral_system_prompt = _prompt_text(
|
||||
cfg_get(config, "agent", "system_prompt", default="")
|
||||
)
|
||||
return t("gateway.personality.cleared")
|
||||
elif args in personalities:
|
||||
new_prompt = _resolve_prompt(personalities[args])
|
||||
new_prompt = render_personality_prompt(personalities[args])
|
||||
|
||||
# Write to config.yaml, same pattern as CLI save_config_value.
|
||||
# Persist the personality name only — never write personality text
|
||||
# into agent.system_prompt (user-owned manual overlay).
|
||||
try:
|
||||
if "agent" not in config or not isinstance(config.get("agent"), dict):
|
||||
config["agent"] = {}
|
||||
config["agent"]["system_prompt"] = new_prompt
|
||||
if "display" not in config or not isinstance(config.get("display"), dict):
|
||||
config["display"] = {}
|
||||
config["display"]["personality"] = args
|
||||
atomic_config_write(config_path, config)
|
||||
except Exception as e:
|
||||
return t("gateway.personality.save_failed", error=str(e))
|
||||
|
|
|
|||
|
|
@ -1340,21 +1340,34 @@ class CLICommandsMixin:
|
|||
personality_name = parts[1].strip().lower()
|
||||
|
||||
if personality_name in {"none", "default", "neutral"}:
|
||||
self.system_prompt = ""
|
||||
# Persist the selection only. Never clear agent.system_prompt —
|
||||
# that field is the user-owned manual overlay.
|
||||
saved = save_config_value("display.personality", "")
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, read_raw_config, _prompt_text
|
||||
|
||||
self.system_prompt = _prompt_text(
|
||||
cfg_get(read_raw_config(), "agent", "system_prompt", default="")
|
||||
)
|
||||
except Exception:
|
||||
self.system_prompt = ""
|
||||
self.agent = None # Force re-init
|
||||
if save_config_value("agent.system_prompt", ""):
|
||||
if saved:
|
||||
print("(^_^)b Personality cleared (saved to config)")
|
||||
else:
|
||||
print("(^_^) Personality cleared (session only)")
|
||||
print(" No personality overlay — using base agent behavior.")
|
||||
elif personality_name in self.personalities:
|
||||
self.system_prompt = self._resolve_personality_prompt(self.personalities[personality_name])
|
||||
personality_prompt = self._resolve_personality_prompt(
|
||||
self.personalities[personality_name]
|
||||
)
|
||||
self.system_prompt = personality_prompt
|
||||
self.agent = None # Force re-init
|
||||
if save_config_value("agent.system_prompt", self.system_prompt):
|
||||
if save_config_value("display.personality", personality_name):
|
||||
print(f"(^_^)b Personality set to '{personality_name}' (saved to config)")
|
||||
else:
|
||||
print(f"(^_^) Personality set to '{personality_name}' (session only)")
|
||||
print(f" \"{self.system_prompt[:60]}{'...' if len(self.system_prompt) > 60 else ''}\"")
|
||||
print(f" \"{personality_prompt[:60]}{'...' if len(personality_prompt) > 60 else ''}\"")
|
||||
else:
|
||||
print(f"(._.) Unknown personality: {personality_name}")
|
||||
print(f" Available: none, {', '.join(self.personalities.keys())}")
|
||||
|
|
|
|||
|
|
@ -2929,6 +2929,49 @@ def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> A
|
|||
return node
|
||||
|
||||
|
||||
_NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"})
|
||||
|
||||
|
||||
def _prompt_text(value: Any) -> str:
|
||||
"""Normalize config prompt values from YAML before handing them to AIAgent."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, list):
|
||||
return "\n".join(str(item).strip() for item in value if str(item).strip())
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def render_personality_prompt(value: Any) -> str:
|
||||
"""Render a string or structured personality definition to a prompt."""
|
||||
if isinstance(value, dict):
|
||||
parts = [value.get("system_prompt", "")]
|
||||
if value.get("tone"):
|
||||
parts.append(f'Tone: {value["tone"]}')
|
||||
if value.get("style"):
|
||||
parts.append(f'Style: {value["style"]}')
|
||||
return "\n".join(str(part).strip() for part in parts if str(part).strip())
|
||||
return _prompt_text(value)
|
||||
|
||||
|
||||
def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) -> str:
|
||||
"""Resolve the session overlay from config.yaml.
|
||||
|
||||
``display.personality`` is the selected named personality and wins when set.
|
||||
Otherwise fall back to the user-owned ``agent.system_prompt``. Callers should
|
||||
still prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set.
|
||||
"""
|
||||
name = str(cfg_get(cfg, "display", "personality", default="") or "").strip().lower()
|
||||
personalities = cfg_get(cfg, "agent", "personalities", default={}) or {}
|
||||
if (
|
||||
name not in _NEUTRAL_PERSONALITY_NAMES
|
||||
and isinstance(personalities, dict)
|
||||
and name in personalities
|
||||
):
|
||||
return render_personality_prompt(personalities[name])
|
||||
return _prompt_text(cfg_get(cfg, "agent", "system_prompt", default=""))
|
||||
|
||||
|
||||
def read_raw_config() -> Dict[str, Any]:
|
||||
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
|
||||
|
|
|
|||
|
|
@ -22,11 +22,41 @@ class TestCLIPersonalityNone:
|
|||
|
||||
|
||||
|
||||
def test_neutral_clears_system_prompt(self):
|
||||
def test_set_persists_display_personality_not_system_prompt(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
saves = []
|
||||
|
||||
def _save(key, value):
|
||||
saves.append((key, value))
|
||||
return True
|
||||
|
||||
with patch("cli.save_config_value", side_effect=_save):
|
||||
cli._handle_personality_command("/personality helpful")
|
||||
|
||||
assert cli.system_prompt == "You are helpful."
|
||||
assert ("display.personality", "helpful") in saves
|
||||
assert not any(k == "agent.system_prompt" for k, _ in saves)
|
||||
|
||||
def test_neutral_restores_manual_system_prompt_without_wiping_config(self):
|
||||
cli = self._make_cli()
|
||||
saves = []
|
||||
|
||||
def _save(key, value):
|
||||
saves.append((key, value))
|
||||
return True
|
||||
|
||||
with (
|
||||
patch("cli.save_config_value", side_effect=_save),
|
||||
patch(
|
||||
"hermes_cli.config.read_raw_config",
|
||||
return_value={"agent": {"system_prompt": "manual forever"}},
|
||||
),
|
||||
):
|
||||
cli._handle_personality_command("/personality neutral")
|
||||
assert cli.system_prompt == ""
|
||||
|
||||
assert cli.system_prompt == "manual forever"
|
||||
assert ("display.personality", "") in saves
|
||||
assert not any(k == "agent.system_prompt" for k, _ in saves)
|
||||
|
||||
|
||||
|
||||
|
|
@ -59,7 +89,13 @@ class TestGatewayPersonalityNone:
|
|||
@pytest.mark.asyncio
|
||||
async def test_default_clears_ephemeral_prompt(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}}
|
||||
config_data = {
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
"display": {"personality": "helpful"},
|
||||
}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
|
|
@ -67,7 +103,32 @@ class TestGatewayPersonalityNone:
|
|||
event = self._make_event("default")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert runner._ephemeral_system_prompt == ""
|
||||
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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_persists_display_personality_not_system_prompt(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
}
|
||||
}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
event = self._make_event("helpful")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
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."
|
||||
assert "helpful" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
"""Unit tests for resolve_ephemeral_system_prompt_from_config."""
|
||||
|
||||
from hermes_cli.config import (
|
||||
render_personality_prompt,
|
||||
resolve_ephemeral_system_prompt_from_config,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_uses_named_personality_when_set():
|
||||
cfg = {
|
||||
"display": {"personality": "helpful"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
}
|
||||
assert resolve_ephemeral_system_prompt_from_config(cfg) == "You are helpful."
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_manual_system_prompt():
|
||||
cfg = {
|
||||
"display": {"personality": "none"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
}
|
||||
assert resolve_ephemeral_system_prompt_from_config(cfg) == "manual forever"
|
||||
|
||||
|
||||
def test_resolve_ignores_unknown_personality_name():
|
||||
cfg = {
|
||||
"display": {"personality": "missing"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
}
|
||||
assert resolve_ephemeral_system_prompt_from_config(cfg) == "manual forever"
|
||||
|
||||
|
||||
def test_resolve_renders_dict_personality():
|
||||
cfg = {
|
||||
"display": {"personality": "coder"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {
|
||||
"coder": {
|
||||
"system_prompt": "You are an expert programmer.",
|
||||
"tone": "technical",
|
||||
"style": "concise",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
resolved = resolve_ephemeral_system_prompt_from_config(cfg)
|
||||
assert "You are an expert programmer." in resolved
|
||||
assert "Tone: technical" in resolved
|
||||
assert "Style: concise" in resolved
|
||||
|
||||
|
||||
def test_render_personality_prompt_string():
|
||||
assert render_personality_prompt(" hi ") == "hi"
|
||||
|
|
@ -7439,6 +7439,7 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch):
|
|||
history_version=4,
|
||||
)
|
||||
emits = []
|
||||
writes = []
|
||||
|
||||
server._sessions["sid"] = session
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -7450,7 +7451,11 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch):
|
|||
server, "_session_info", lambda agent, *a: {"model": getattr(agent, "model", "?")}
|
||||
)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_write_config_key",
|
||||
lambda path, value: writes.append((path, value)),
|
||||
)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
|
|
@ -7473,6 +7478,8 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch):
|
|||
assert agent.ephemeral_system_prompt == "You are helpful."
|
||||
assert agent._cached_system_prompt == "old"
|
||||
assert ("session.info", "sid", {"model": "?"}) in emits
|
||||
assert ("display.personality", "helpful") in writes
|
||||
assert not any(path == "agent.system_prompt" for path, _ in writes)
|
||||
|
||||
|
||||
def test_compress_session_history_passes_force():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
"""_make_agent resolves ephemeral prompt from display.personality."""
|
||||
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _runtime():
|
||||
return {
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"api_key": None,
|
||||
"api_mode": None,
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
|
||||
def _call_make_agent(cfg):
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch("tui_gateway.server._load_cfg", return_value=cfg))
|
||||
stack.enter_context(patch("tui_gateway.server._get_db", return_value=MagicMock()))
|
||||
stack.enter_context(
|
||||
patch("tui_gateway.server._load_tool_progress_mode", return_value="compact")
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("tui_gateway.server._load_reasoning_config", return_value=None)
|
||||
)
|
||||
stack.enter_context(patch("tui_gateway.server._load_service_tier", return_value=None))
|
||||
stack.enter_context(
|
||||
patch("tui_gateway.server._load_enabled_toolsets", return_value=None)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"tui_gateway.server._resolve_startup_runtime",
|
||||
return_value=("test-model", None),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_runtime(),
|
||||
)
|
||||
)
|
||||
mock_agent = stack.enter_context(patch("run_agent.AIAgent"))
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-1", "key-1")
|
||||
return mock_agent.call_args.kwargs
|
||||
|
||||
|
||||
def test_make_agent_uses_display_personality_when_set():
|
||||
cfg = {
|
||||
"display": {"personality": "helpful"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
}
|
||||
kwargs = _call_make_agent(cfg)
|
||||
assert kwargs["ephemeral_system_prompt"] == "You are helpful."
|
||||
|
||||
|
||||
def test_make_agent_preserves_manual_prompt_without_personality():
|
||||
cfg = {
|
||||
"display": {"personality": "none"},
|
||||
"agent": {
|
||||
"system_prompt": "manual forever",
|
||||
"personalities": {"helpful": "You are helpful."},
|
||||
},
|
||||
}
|
||||
kwargs = _call_make_agent(cfg)
|
||||
assert kwargs["ephemeral_system_prompt"] == "manual forever"
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"""Reproduction: /personality (desktop config.set) clobbers agent.system_prompt.
|
||||
|
||||
The desktop/TUI backend exposes a `config.set key=personality` RPC
|
||||
(tui_gateway/server.py, the `@method("config.set")` handler). When the user
|
||||
selects a personality (the desktop "assistant style" picker routes here), the
|
||||
handler does:
|
||||
|
||||
_write_config_key("display.personality", pname)
|
||||
_write_config_key("agent.system_prompt", new_prompt) # <-- clobbers manual text
|
||||
|
||||
`agent.system_prompt` is the user's GLOBAL manual system prompt (read by
|
||||
`/prompt` and the CLI's ephemeral-prompt path). Overwriting it with a
|
||||
personality's text destroys the manual prompt. The sibling in-chat slash path
|
||||
(server.py `/personality` handler) does NOT touch `agent.system_prompt` -- it
|
||||
only sets the in-session `agent.ephemeral_system_prompt` -- so the two paths
|
||||
disagree.
|
||||
|
||||
Contract tested here:
|
||||
1. A pre-existing manual `agent.system_prompt` must survive selecting a
|
||||
personality via `config.set key=personality` (the global manual prompt is
|
||||
a SEPARATE concept from the per-session personality overlay).
|
||||
2. Selecting a different personality must not leave a stale personality's
|
||||
text in `agent.system_prompt`.
|
||||
|
||||
This test drives the REAL `_write_config_key` / `_save_cfg` against a temp
|
||||
HERMES_HOME config.yaml (no mocks of the persistence layer), so the captured
|
||||
config file is genuine proof of the write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import tui_gateway.server as server
|
||||
import yaml
|
||||
|
||||
|
||||
MANUAL_PROMPT = "manual_prompt_1"
|
||||
PERSONALITY_1 = "personality_1"
|
||||
PERSONALITY_2 = "personality_2"
|
||||
|
||||
|
||||
def _seed_config(home: str) -> None:
|
||||
cfg = {
|
||||
"agent": {
|
||||
"system_prompt": MANUAL_PROMPT,
|
||||
"personalities": {
|
||||
"personality_1": PERSONALITY_1,
|
||||
"personality_2": PERSONALITY_2,
|
||||
},
|
||||
},
|
||||
"display": {"personality": "none"},
|
||||
}
|
||||
with open(os.path.join(home, "config.yaml"), "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(cfg, f)
|
||||
|
||||
|
||||
def _read_saved_system_prompt(home: str) -> str:
|
||||
with open(os.path.join(home, "config.yaml"), encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return (data.get("agent", {}) or {}).get("system_prompt", "")
|
||||
|
||||
|
||||
def _set(params: dict) -> dict:
|
||||
return server._methods["config.set"]("rid-1", params)
|
||||
|
||||
|
||||
def _make_session(session_id: str):
|
||||
"""Mirror a live desktop session: real agent namespace + history buffer."""
|
||||
agent = MagicMock()
|
||||
agent.ephemeral_system_prompt = None
|
||||
return {
|
||||
"session_key": session_id,
|
||||
"agent": agent,
|
||||
"history": [],
|
||||
"history_version": 0,
|
||||
"history_lock": MagicMock(__enter__=lambda self: None, __exit__=lambda self, *a: None),
|
||||
}
|
||||
|
||||
|
||||
def _run(home: str, session_id: str = ""):
|
||||
"""Select `personality_1` via the desktop personality RPC.
|
||||
|
||||
We stub the live-session pivot (_apply_personality_to_session) because the
|
||||
bug under test is the *config write* at server.py:11328-11329, not the
|
||||
in-session marker injection. Keeping it real would require a fully-built
|
||||
agent; the write happens before it regardless.
|
||||
"""
|
||||
sid = session_id or "s1"
|
||||
session = _make_session(sid)
|
||||
with (
|
||||
patch.dict(
|
||||
server._sessions, {sid: session}, clear=False
|
||||
),
|
||||
patch.object(server, "_apply_personality_to_session", return_value=(False, None)),
|
||||
# The handler calls these for the live-session pivot; not under test.
|
||||
patch.object(server, "_persist_live_session_runtime"),
|
||||
patch.object(server, "_emit"),
|
||||
):
|
||||
resp = _set({"key": "personality", "value": "personality_1", "session_id": sid})
|
||||
return resp
|
||||
|
||||
|
||||
def test_personality_selection_preserves_manual_system_prompt(tmp_path, monkeypatch):
|
||||
"""Selecting a personality must NOT overwrite the manual agent.system_prompt."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Force the server module to pick up the temp home for config reads/writes.
|
||||
monkeypatch.setattr(server, "_hermes_home", Path(tmp_path))
|
||||
monkeypatch.setattr(server, "_cfg_path", None)
|
||||
monkeypatch.setattr(server, "_cfg_cache", None)
|
||||
_seed_config(str(tmp_path))
|
||||
|
||||
resp = _run(str(tmp_path))
|
||||
|
||||
assert resp["result"]["value"] == "personality_1", resp
|
||||
# THE BUG: the manual system_prompt was overwritten by the personality.
|
||||
saved = _read_saved_system_prompt(str(tmp_path))
|
||||
assert saved == MANUAL_PROMPT, (
|
||||
"agent.system_prompt was clobbered by the personality selection.\n"
|
||||
f" expected (manual): {MANUAL_PROMPT!r}\n"
|
||||
f" got (personality): {saved!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_switching_personality_leaves_no_stale_text(tmp_path, monkeypatch):
|
||||
"""After choosing `personality_2`, agent.system_prompt must retain the manual prompt.
|
||||
|
||||
The bug: selecting any personality overwrites agent.system_prompt with that
|
||||
personality's text. So after `personality_1` then `personality_2`, the field
|
||||
holds `personality_2`'s text -- still NOT the user's manual prompt. The field
|
||||
should have been left untouched (the personality overlay belongs in the
|
||||
in-session ephemeral prompt, not the durable global system prompt).
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(server, "_hermes_home", Path(tmp_path))
|
||||
monkeypatch.setattr(server, "_cfg_path", None)
|
||||
monkeypatch.setattr(server, "_cfg_cache", None)
|
||||
_seed_config(str(tmp_path))
|
||||
|
||||
_run(str(tmp_path), session_id="a")
|
||||
# Now switch to a different personality.
|
||||
session = _make_session("a")
|
||||
with (
|
||||
patch.dict(
|
||||
server._sessions, {"a": session}, clear=False
|
||||
),
|
||||
patch.object(server, "_apply_personality_to_session", return_value=(False, None)),
|
||||
patch.object(server, "_persist_live_session_runtime"),
|
||||
patch.object(server, "_emit"),
|
||||
):
|
||||
resp = _set({"key": "personality", "value": "personality_2", "session_id": "a"})
|
||||
|
||||
assert resp["result"]["value"] == "personality_2", resp
|
||||
saved = _read_saved_system_prompt(str(tmp_path))
|
||||
assert saved == MANUAL_PROMPT, (
|
||||
"agent.system_prompt still holds a personality's text after switching "
|
||||
f"personalities; the manual prompt was lost.\n"
|
||||
f" expected (manual): {MANUAL_PROMPT!r}\n"
|
||||
f" got: {saved!r}"
|
||||
)
|
||||
|
|
@ -6471,8 +6471,9 @@ def _make_agent(
|
|||
pass
|
||||
|
||||
cfg = _load_cfg()
|
||||
agent_cfg = cfg.get("agent") or {}
|
||||
system_prompt = _prompt_text(agent_cfg.get("system_prompt", ""))
|
||||
from hermes_cli.config import resolve_ephemeral_system_prompt_from_config
|
||||
|
||||
system_prompt = resolve_ephemeral_system_prompt_from_config(cfg)
|
||||
startup_skills = _parse_tui_skills_env()
|
||||
if startup_skills:
|
||||
from agent.skill_commands import build_preloaded_skills_prompt
|
||||
|
|
@ -11336,8 +11337,10 @@ def _(rid, params: dict) -> dict:
|
|||
elif key == "personality":
|
||||
sid_key = params.get("session_id", "")
|
||||
pname, new_prompt = _validate_personality(str(value or ""), cfg)
|
||||
# Personality text is an in-session overlay. Keep the
|
||||
# user-owned global system prompt intact so changing a
|
||||
# personality cannot destroy manual configuration.
|
||||
_write_config_key("display.personality", pname)
|
||||
_write_config_key("agent.system_prompt", new_prompt)
|
||||
nv = str(value or "none")
|
||||
history_reset, info = _apply_personality_to_session(
|
||||
sid_key, session, new_prompt, pname
|
||||
|
|
|
|||
Loading…
Reference in New Issue