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:
brooklyn! 2026-08-08 14:20:54 -05:00 committed by GitHub
commit daabb2d445
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 468 additions and 38 deletions

7
cli.py
View File

@ -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", {})

View File

@ -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,

View File

@ -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))

View File

@ -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())}")

View File

@ -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.

View File

@ -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

View File

@ -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"

View File

@ -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():

View File

@ -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"

View File

@ -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}"
)

View File

@ -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