fix(personality): single-owner personality state + one-time reset migration

Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.

- hermes_cli/personality.py: new single owner of personality state.
  Built-in personality definitions, neutral-name normalization, rendering,
  availability (built-ins overlaid by agent.personalities), overlay
  resolution, and the ONLY sanctioned persistence path
  (persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
  (announcing which personality was cleared and how to re-enable), plus a
  scrub of agent.system_prompt when it verbatim-equals a known personality
  render (machine-written by the old CLI/gateway). Hand-written manual
  prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
  marker in the list), gateway /personality, TUI config.set + slash path
  (which previously applied without persisting), TUI config.get (reports
  the EFFECTIVE personality), completer, hermes config display, and the
  tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
  desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
  available, one-time reset note.
This commit is contained in:
Teknium 2026-08-09 10:16:50 -07:00
parent c9411b72df
commit 244d296646
18 changed files with 791 additions and 279 deletions

View File

@ -14,6 +14,10 @@ import {
import { REASONING_EFFORTS } from '@/lib/reasoning-effort'
import type { ThemeMode } from '@/themes/context'
// Single source of truth for built-in personality names lives in
// lib/personalities (mirrors hermes_cli/personality.py BUILTIN_PERSONALITIES).
export { BUILTIN_PERSONALITIES } from '@/lib/personalities'
import { defineFieldCopy } from './field-copy'
import type { DesktopConfigSection } from './types'
@ -223,23 +227,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [
}
]
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]
// Schema-side select overrides for desktop-relevant enum fields whose
// backend schema only declares a string type.
export const ENUM_OPTIONS: Record<string, string[]> = {

View File

@ -9,22 +9,7 @@ import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]
export { BUILTIN_PERSONALITIES } from '@/lib/personalities'
const THINKING_STATUS_PREFIX_RE =
/^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i

View File

@ -0,0 +1,19 @@
// Single source of truth for built-in personality names on the desktop.
// Mirrors hermes_cli/personality.py BUILTIN_PERSONALITIES — the backend
// single owner. Keep in sync when a built-in is added there.
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]

View File

@ -933,22 +933,19 @@ agent:
# "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable
reasoning_overrides: {}
# Predefined personalities (use with /personality command)
personalities:
helpful: "You are a helpful, friendly AI assistant."
concise: "You are a concise assistant. Keep responses brief and to the point."
technical: "You are a technical expert. Provide detailed, accurate technical information."
creative: "You are a creative assistant. Think outside the box and offer innovative solutions."
teacher: "You are a patient teacher. Explain concepts clearly with examples."
kawaii: "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)"
catgirl: "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!"
pirate: "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!"
shakespeare: "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?"
surfer: "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga! 🤙"
noir: "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?"
uwu: "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<"
philosopher: "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself."
hype: "YOOO LET'S GOOOO!!! 🔥🔥🔥 I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS! 💪😤🚀"
# Custom personalities (use with /personality command).
# Built-ins (helpful, concise, technical, creative, teacher, kawaii, catgirl,
# pirate, shakespeare, surfer, noir, uwu, philosopher, hype) are always
# available on every surface — defined once in hermes_cli/personality.py.
# Entries here ADD new personalities or OVERRIDE a built-in by name.
# The active selection is stored in display.personality (never here, and
# never in agent.system_prompt — that field is your manual system prompt).
personalities: {}
# mentor: "You are a supportive mentor. Guide, don't lecture."
# reviewer:
# system_prompt: "You are a meticulous code reviewer."
# tone: "direct"
# style: "terse"
# =============================================================================
# Toolsets

46
cli.py
View File

@ -478,22 +478,10 @@ def load_cli_config() -> Dict[str, Any]:
"prefill_messages_file": "",
"reasoning_effort": "",
"service_tier": "",
"personalities": {
"helpful": "You are a helpful, friendly AI assistant.",
"concise": "You are a concise assistant. Keep responses brief and to the point.",
"technical": "You are a technical expert. Provide detailed, accurate technical information.",
"creative": "You are a creative assistant. Think outside the box and offer innovative solutions.",
"teacher": "You are a patient teacher. Explain concepts clearly with examples.",
"kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)",
"catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!",
"pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!",
"shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?",
"surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!",
"noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?",
"uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<",
"philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
"hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
},
# Built-in personalities live in hermes_cli.personality
# (BUILTIN_PERSONALITIES) — the single owner. Entries here are
# user-defined additions/overrides merged on top by name.
"personalities": {},
},
"display": {
@ -4550,13 +4538,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# 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
# hermes_cli.personality is the single owner of overlay resolution.
from hermes_cli.personality import (
available_personalities,
resolve_ephemeral_system_prompt,
)
self.system_prompt = (
os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
or resolve_ephemeral_system_prompt_from_config(CLI_CONFIG)
or resolve_ephemeral_system_prompt(CLI_CONFIG)
)
self.personalities = CLI_CONFIG["agent"].get("personalities", {})
self.personalities = available_personalities(CLI_CONFIG)
# Ephemeral prefill messages (few-shot priming, never persisted)
self.prefill_messages = _load_prefill_messages(
@ -9923,15 +9915,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
@staticmethod
def _resolve_personality_prompt(value) -> str:
"""Accept string or dict personality value; return system prompt string."""
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)
"""Accept string or dict personality value; return system prompt string.
Delegates to hermes_cli.personality (single owner of rendering).
"""
from hermes_cli.personality import render_personality_prompt
return render_personality_prompt(value)

View File

@ -2490,74 +2490,65 @@ class GatewaySlashCommandsMixin:
return f"{prefix} {result.message}"
async def _handle_personality_command(self, event: MessageEvent) -> str:
"""Handle /personality command - list or set a personality."""
from gateway.run import _hermes_home, _load_gateway_config
from hermes_constants import display_hermes_home
"""Handle /personality command - list or set a personality.
args = event.get_command_args().strip().lower()
config_path = _hermes_home / 'config.yaml'
All resolution/persistence goes through hermes_cli.personality
the single owner of personality state on every surface.
"""
from gateway.run import _load_gateway_config
from hermes_cli.personality import (
active_personality_name,
available_personalities,
describe_personality,
persist_personality,
prompt_text,
resolve_personality,
)
args = event.get_command_args().strip()
try:
config = _load_gateway_config()
personalities = cfg_get(config, "agent", "personalities", default={})
except Exception:
config = {}
personalities = {}
if not personalities:
return t("gateway.personality.none_configured", path=display_hermes_home())
personalities = available_personalities(config)
if not args:
current = active_personality_name(config)
lines = [t("gateway.personality.header")]
lines.append(t("gateway.personality.none_option"))
for name, prompt in personalities.items():
if isinstance(prompt, dict):
preview = prompt.get("description") or prompt.get("system_prompt", "")[:50]
else:
preview = prompt[:50] + "..." if len(prompt) > 50 else prompt
lines.append(t("gateway.personality.item", name=name, preview=preview))
marker = "" if name == current else ""
lines.append(
t(
"gateway.personality.item",
name=f"{name}{marker}",
preview=describe_personality(prompt),
)
)
lines.append(t("gateway.personality.usage"))
return "\n".join(lines)
from hermes_cli.config import (
_prompt_text,
render_personality_prompt,
)
try:
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)
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 "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 = _prompt_text(
# Persist the selection only — hermes_cli.personality never writes
# agent.system_prompt (user-owned manual overlay).
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")
elif args in personalities:
new_prompt = render_personality_prompt(personalities[args])
# Persist the personality name only — never write personality text
# into agent.system_prompt (user-owned manual overlay).
try:
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))
# 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=args)
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
return t("gateway.personality.unknown", name=args, available=available)
# 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:
"""Handle /retry command - re-send the last user message."""

View File

@ -1334,22 +1334,40 @@ class CLICommandsMixin:
_cprint(f" Branch session: {new_session_id}")
def _handle_personality_command(self, cmd: str):
"""Handle the /personality command to set predefined personalities."""
from cli import save_config_value
"""Handle the /personality command to set predefined personalities.
All resolution/persistence goes through hermes_cli.personality
the single owner of personality state on every surface.
"""
from hermes_cli.personality import (
describe_personality,
normalize_personality_name,
persist_personality,
prompt_text,
resolve_personality,
)
parts = cmd.split(maxsplit=1)
if len(parts) > 1:
# Set personality
personality_name = parts[1].strip().lower()
if personality_name in {"none", "default", "neutral"}:
# 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
personality_name = parts[1].strip()
self.system_prompt = _prompt_text(
try:
name, personality_prompt = resolve_personality(
personality_name, getattr(self, "config", None)
)
except ValueError:
print(f"(._.) Unknown personality: {personality_name.lower()}")
print(f" Available: none, {', '.join(self.personalities.keys())}")
return
saved = persist_personality(name)
if not name:
# Neutral reset — fall back to the user-owned manual prompt.
try:
from hermes_cli.config import cfg_get, read_raw_config
self.system_prompt = prompt_text(
cfg_get(read_raw_config(), "agent", "system_prompt", default="")
)
except Exception:
@ -1360,36 +1378,36 @@ class CLICommandsMixin:
else:
print("(^_^) Personality cleared (session only)")
print(" No personality overlay — using base agent behavior.")
elif personality_name in self.personalities:
personality_prompt = self._resolve_personality_prompt(
self.personalities[personality_name]
)
else:
self.system_prompt = personality_prompt
self.agent = None # Force re-init
if save_config_value("display.personality", personality_name):
print(f"(^_^)b Personality set to '{personality_name}' (saved to config)")
if saved:
print(f"(^_^)b Personality set to '{name}' (saved to config)")
else:
print(f"(^_^) Personality set to '{personality_name}' (session only)")
print(f"(^_^) Personality set to '{name}' (session only)")
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())}")
else:
# Show available personalities
try:
from hermes_cli.config import read_raw_config
current = normalize_personality_name(
(read_raw_config().get("display") or {}).get("personality", "")
)
except Exception:
current = ""
print()
print("+" + "-" * 50 + "+")
print("|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|")
print("+" + "-" * 50 + "+")
print()
print(f" {'none':<12} - (no personality overlay)")
marker = " *" if not current else " "
print(f" {marker}{'none':<12} - (no personality overlay)")
for name, prompt in self.personalities.items():
if isinstance(prompt, dict):
preview = prompt.get("description") or prompt.get("system_prompt", "")[:50]
else:
preview = str(prompt)[:50]
print(f" {name:<12} - {preview}")
marker = " *" if name == current else " "
print(f" {marker}{name:<12} - {describe_personality(prompt)}")
print()
print(" Usage: /personality <name>")
print(" Usage: /personality <name> (* = active)")
print()
def _handle_pet_command(self, cmd: str):

View File

@ -2022,15 +2022,16 @@ class SlashCommandCompleter(Completer):
@staticmethod
def _personality_completions(sub_text: str, sub_lower: str):
"""Yield completions for /personality from configured personalities."""
"""Yield completions for /personality via hermes_cli.personality."""
try:
# Resolve from the same source the runtime applies personalities —
# agent.personalities via the CLI config (which ships the built-ins).
# load_config()'s schema has no agent.personalities, so the completer
# used to come back empty even with personalities available.
# Single owner: built-ins + user overrides from agent.personalities.
from cli import load_cli_config
from hermes_cli.personality import (
available_personalities,
describe_personality,
)
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
personalities = available_personalities(load_cli_config())
if "none".startswith(sub_lower) and "none" != sub_lower:
yield Completion(
"none",
@ -2040,15 +2041,11 @@ class SlashCommandCompleter(Completer):
)
for name, prompt in personalities.items():
if name.startswith(sub_lower) and name != sub_lower:
if isinstance(prompt, dict):
meta = prompt.get("description") or prompt.get("system_prompt", "")[:50]
else:
meta = str(prompt)[:50]
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=meta,
display_meta=describe_personality(prompt),
)
except Exception:
pass

View File

@ -2929,30 +2929,26 @@ def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> A
return node
_NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"})
# Back-compat alias — canonical set lives in hermes_cli.personality.
from hermes_cli.personality import NEUTRAL_PERSONALITY_NAMES as _NEUTRAL_PERSONALITY_NAMES # noqa: F401
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()
"""Normalize config prompt values from YAML before handing them to AIAgent.
Delegates to :mod:`hermes_cli.personality` the single owner of
personality/overlay semantics. Kept as a re-export for existing importers.
"""
from hermes_cli.personality import prompt_text
return prompt_text(value)
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)
from hermes_cli.personality import render_personality_prompt as _render
return _render(value)
def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) -> str:
@ -2961,16 +2957,12 @@ def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) -
``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.
Delegates to :mod:`hermes_cli.personality` (single owner).
"""
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=""))
from hermes_cli.personality import resolve_ephemeral_system_prompt
return resolve_ephemeral_system_prompt(cfg)
def read_raw_config() -> Dict[str, Any]:
@ -4401,7 +4393,13 @@ def show_config():
print()
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
display = config.get('display', {})
print(f" Personality: {display.get('personality') or 'none'}")
try:
from hermes_cli.personality import active_personality_name
_active_personality = active_personality_name(config) or 'none'
except Exception:
_active_personality = display.get('personality') or 'none'
print(f" Personality: {_active_personality}")
print(f" Reasoning: {'on' if display.get('show_reasoning', True) else 'off'}")
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}

View File

@ -3224,7 +3224,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 33,
"_config_version": 34,
}
# Optional environment variables that enhance functionality

View File

@ -644,6 +644,80 @@ def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None:
)
def _migrate_to_34(results: Dict[str, Any], quiet: bool) -> None:
# ── Version 33 → 34: one-time personality reset (post-#81946 unification) ──
# Personality persistence used to be split per surface: the TUI/desktop
# wrote the NAME to display.personality while the CLI/gateway wrote the
# rendered TEXT into agent.system_prompt (and their "/personality none"
# only blanked the text, leaving the name behind). When #81946 made
# display.personality authoritative everywhere, stale names written years
# ago resurrected personalities users had already turned off ("kawaii
# defaults on after updating"). There is no way to know which of the two
# divergent fields reflects the user's intent, so reset the selection to
# none once and tell the user how to re-enable it. Two scrubs:
#
# 1. display.personality → "" (announce the old name).
# 2. agent.system_prompt → "" ONLY when it verbatim-equals the rendered
# text of a known personality — that shape was written by the old
# CLI/gateway /personality, never typed by hand. Any other text is a
# user-owned manual prompt and is never touched.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
from hermes_cli.personality import (
available_personalities,
normalize_personality_name,
prompt_text,
render_personality_prompt,
)
config = read_raw_config()
touched = False
raw_display = config.get("display")
old_name = ""
if isinstance(raw_display, dict):
old_name = normalize_personality_name(raw_display.get("personality", ""))
if old_name:
raw_display["personality"] = ""
config["display"] = raw_display
touched = True
raw_agent = config.get("agent")
scrubbed_text = False
if isinstance(raw_agent, dict):
manual = prompt_text(raw_agent.get("system_prompt", ""))
if manual:
rendered = {
render_personality_prompt(defn)
for defn in available_personalities(config).values()
}
if manual in rendered:
raw_agent["system_prompt"] = ""
config["agent"] = raw_agent
touched = True
scrubbed_text = True
if touched:
_persist_migration(config)
results["config_added"].append("display.personality=none (one-time reset)")
if not quiet:
if old_name:
print(
f" ✓ Personality reset to none (was '{old_name}'). Personality "
"state was previously saved inconsistently across surfaces and "
"could re-enable a personality you had turned off. "
f"Run /personality {old_name} to turn it back on."
)
if scrubbed_text:
print(
" ✓ Removed personality text from agent.system_prompt (written "
"by an older /personality). That field is now reserved for "
"manual system prompts; personalities live in display.personality."
)
#: Registry of (target_version, migration_fn), strictly ascending. The driver
#: applies every entry whose target version is greater than the on-disk
#: version captured before the ladder started. Order matters: later steps may
@ -665,6 +739,7 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
(31, _migrate_to_31),
(32, _migrate_to_32),
(33, _migrate_to_33),
(34, _migrate_to_34),
)

186
hermes_cli/personality.py Normal file
View File

@ -0,0 +1,186 @@
"""Single owner for personality overlays.
Every surface (CLI ``/personality``, gateway ``/personality``, TUI + desktop
``config.set personality`` RPC, agent-startup overlay resolution) goes through
this module. Nothing else may:
* define built-in personalities,
* decide what counts as a "neutral" name,
* render a personality definition into prompt text,
* resolve the active overlay from config, or
* persist the selection.
History: personality state used to be written differently per surface the
old CLI/gateway wrote rendered personality TEXT into ``agent.system_prompt``
while the TUI/desktop wrote the NAME to ``display.personality``. When
``display.personality`` became authoritative (PR #81946), years of stale
per-surface state resurrected personalities users had turned off. The v34
config migration resets the selection once; this module ensures the split
cannot happen again.
Contract:
* ``display.personality`` holds the selected NAME (empty = no overlay).
* ``agent.system_prompt`` is the user-owned manual overlay. Personality code
never writes it.
* ``agent.personalities`` holds user-defined/overridden personalities; they
overlay the built-ins by name.
This module deliberately has no module-level imports from ``hermes_cli.config``
(that module imports us), keeping the import direction acyclic.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
#: Names that mean "no personality overlay".
NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"})
#: Built-in personalities, available on every surface (CLI, gateway, TUI,
#: desktop) without any config. User entries in ``agent.personalities``
#: overlay these by name.
BUILTIN_PERSONALITIES: Dict[str, str] = {
"helpful": "You are a helpful, friendly AI assistant.",
"concise": "You are a concise assistant. Keep responses brief and to the point.",
"technical": "You are a technical expert. Provide detailed, accurate technical information.",
"creative": "You are a creative assistant. Think outside the box and offer innovative solutions.",
"teacher": "You are a patient teacher. Explain concepts clearly with examples.",
"kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)",
"catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!",
"pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!",
"shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?",
"surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!",
"noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?",
"uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<",
"philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
"hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
}
def _get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Nested dict lookup tolerant of None/non-dict intermediate nodes."""
node: Any = cfg
for key in keys:
if not isinstance(node, dict) or key not in node:
return default
node = node[key]
return node
def prompt_text(value: Any) -> str:
"""Normalize config prompt values from YAML (str | list | None) to text."""
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 prompt text."""
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 describe_personality(value: Any, width: int = 50) -> str:
"""Short preview line for list UIs (CLI table, gateway /personality list)."""
if isinstance(value, dict):
preview = value.get("description") or str(value.get("system_prompt", ""))
else:
preview = str(value)
preview = preview.strip().replace("\n", " ")
return preview[:width] + ("..." if len(preview) > width else "")
def normalize_personality_name(value: Any) -> str:
"""Canonical form of a personality name ('' for any neutral spelling)."""
name = str(value or "").strip().lower()
return "" if name in NEUTRAL_PERSONALITY_NAMES else name
def available_personalities(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Built-ins overlaid by the user's ``agent.personalities`` (user wins)."""
merged: Dict[str, Any] = dict(BUILTIN_PERSONALITIES)
user = _get(cfg, "agent", "personalities", default={})
if isinstance(user, dict):
for name, definition in user.items():
key = str(name).strip().lower()
if key and key not in NEUTRAL_PERSONALITY_NAMES:
merged[key] = definition
return merged
def resolve_personality(
value: Any, cfg: Optional[Dict[str, Any]] = None
) -> Tuple[str, str]:
"""Resolve a requested personality to ``(canonical_name, prompt_text)``.
Neutral names resolve to ``("", "")``. Unknown names raise ``ValueError``
with an availability listing usable verbatim in user-facing errors.
"""
name = normalize_personality_name(value)
if not name:
return "", ""
personalities = available_personalities(cfg)
if name not in personalities:
names = ", ".join(f"`{n}`" for n in sorted(personalities))
raise ValueError(
f"Unknown personality: `{str(value).strip()}`.\n\nAvailable: `none`, {names}"
)
return name, render_personality_prompt(personalities[name])
def active_personality_name(cfg: Optional[Dict[str, Any]]) -> str:
"""The currently selected personality name ('' when none is active)."""
name = normalize_personality_name(_get(cfg, "display", "personality", default=""))
if name and name in available_personalities(cfg):
return name
return ""
def resolve_ephemeral_system_prompt(cfg: Optional[Dict[str, Any]]) -> str:
"""Resolve the session overlay from config.
``display.personality`` wins when it names a known personality; otherwise
the user-owned ``agent.system_prompt`` applies. Callers should still
prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set.
"""
name = active_personality_name(cfg)
if name:
return render_personality_prompt(available_personalities(cfg)[name])
return prompt_text(_get(cfg, "agent", "system_prompt", default=""))
def persist_personality(value: Any) -> bool:
"""Persist the personality selection — the ONLY sanctioned write path.
Writes the canonical name (or '') to ``display.personality`` in the active
HERMES_HOME config.yaml atomically, preserving comments and ordering.
Never touches ``agent.system_prompt``. Returns True on success.
"""
name = normalize_personality_name(value)
try:
from hermes_constants import get_hermes_home
from utils import atomic_roundtrip_yaml_update
config_path = get_hermes_home() / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
atomic_roundtrip_yaml_update(config_path, "display.personality", name)
try:
import os
os.chmod(config_path, 0o600)
except (OSError, NotImplementedError):
pass
return True
except Exception:
return False

View File

@ -1,4 +1,10 @@
"""Tests for /personality none — clearing personality overlay."""
"""Tests for /personality none — clearing personality overlay.
Updated for the single-owner unification (hermes_cli.personality): built-ins
always exist, resolution reads config (agent.personalities overlays), and
persistence flows exclusively through persist_personality().
"""
import os
import pytest
from unittest.mock import MagicMock, patch
import yaml
@ -10,27 +16,29 @@ class TestCLIPersonalityNone:
def _make_cli(self, personalities=None):
from cli import HermesCLI
from hermes_cli.personality import available_personalities
cli = HermesCLI.__new__(HermesCLI)
cli.personalities = personalities or {
user = personalities or {
"helpful": "You are helpful.",
"concise": "You are concise.",
}
cli.config = {"agent": {"personalities": user}}
cli.personalities = available_personalities(cli.config)
cli.system_prompt = "You are kawaii~"
cli.agent = MagicMock()
cli.console = MagicMock()
return cli
def test_set_persists_display_personality_not_system_prompt(self):
cli = self._make_cli()
saves = []
def _save(key, value):
saves.append((key, value))
def _persist(name):
saves.append(("display.personality", name))
return True
with patch("cli.save_config_value", side_effect=_save):
with patch("hermes_cli.personality.persist_personality", side_effect=_persist):
cli._handle_personality_command("/personality helpful")
assert cli.system_prompt == "You are helpful."
@ -41,12 +49,12 @@ class TestCLIPersonalityNone:
cli = self._make_cli()
saves = []
def _save(key, value):
saves.append((key, value))
def _persist(name):
saves.append(("display.personality", name))
return True
with (
patch("cli.save_config_value", side_effect=_save),
patch("hermes_cli.personality.persist_personality", side_effect=_persist),
patch(
"hermes_cli.config.read_raw_config",
return_value={"agent": {"system_prompt": "manual forever"}},
@ -58,10 +66,12 @@ class TestCLIPersonalityNone:
assert ("display.personality", "") in saves
assert not any(k == "agent.system_prompt" for k, _ in saves)
def test_builtin_personality_works_without_config_entry(self):
# Built-ins come from hermes_cli.personality, not from config.
cli = self._make_cli(personalities={})
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality kawaii")
assert "kawaii" in cli.system_prompt.lower()
# ── Gateway tests ──────────────────────────────────────────────────────────
@ -85,6 +95,14 @@ class TestGatewayPersonalityNone:
}
return runner
def _gateway_env(self, tmp_path):
# The gateway reads via _load_gateway_config (rooted at
# gateway.run._hermes_home) and persists via persist_personality
# (rooted at HERMES_HOME) — point both at the same tmp dir.
return (
patch("gateway.run._hermes_home", tmp_path),
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}),
)
@pytest.mark.asyncio
async def test_default_clears_ephemeral_prompt(self, tmp_path):
@ -99,7 +117,8 @@ class TestGatewayPersonalityNone:
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
with patch("gateway.run._hermes_home", tmp_path):
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("default")
result = await runner._handle_personality_command(event)
@ -120,7 +139,8 @@ class TestGatewayPersonalityNone:
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
with patch("gateway.run._hermes_home", tmp_path):
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("helpful")
result = await runner._handle_personality_command(event)
@ -130,7 +150,6 @@ class TestGatewayPersonalityNone:
assert runner._ephemeral_system_prompt == "You are helpful."
assert "helpful" in result.lower()
@pytest.mark.asyncio
async def test_unknown_shows_none_in_available(self, tmp_path):
runner = self._make_runner()
@ -138,23 +157,27 @@ class TestGatewayPersonalityNone:
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
with patch("gateway.run._hermes_home", tmp_path):
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("nonexistent")
result = await runner._handle_personality_command(event)
assert "none" in result.lower()
@pytest.mark.asyncio
async def test_empty_personality_list_uses_profile_display_path(self, tmp_path):
async def test_empty_personality_list_still_lists_builtins(self, tmp_path):
# Built-ins are always available — an empty agent.personalities no
# longer means "no personalities configured".
runner = self._make_runner(personalities={})
(tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}}))
with patch("gateway.run._hermes_home", tmp_path), \
patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"):
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("")
result = await runner._handle_personality_command(event)
assert result == "No personalities configured in `~/.hermes/profiles/coder/config.yaml`"
assert "kawaii" in result.lower()
assert "pirate" in result.lower()
class TestPersonalityDictFormat:
@ -162,8 +185,11 @@ class TestPersonalityDictFormat:
def _make_cli(self, personalities):
from cli import HermesCLI
from hermes_cli.personality import available_personalities
cli = HermesCLI.__new__(HermesCLI)
cli.personalities = personalities
cli.config = {"agent": {"personalities": personalities}}
cli.personalities = available_personalities(cli.config)
cli.system_prompt = ""
cli.agent = None
cli.console = MagicMock()
@ -178,11 +204,10 @@ class TestPersonalityDictFormat:
"style": "concise",
}
})
with patch("cli.save_config_value", return_value=True):
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality coder")
assert "You are an expert programmer." in cli.system_prompt
def test_dict_personality_includes_style(self):
cli = self._make_cli({
"coder": {
@ -190,13 +215,13 @@ class TestPersonalityDictFormat:
"style": "use code examples",
}
})
with patch("cli.save_config_value", return_value=True):
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality coder")
assert "Style: use code examples" in cli.system_prompt
def test_string_personality_still_works(self):
cli = self._make_cli({"helper": "You are helpful."})
with patch("cli.save_config_value", return_value=True):
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality helper")
assert cli.system_prompt == "You are helpful."

View File

@ -0,0 +1,223 @@
"""Tests for hermes_cli.personality — the single owner of personality state —
and the v34 one-time personality reset migration.
Regression coverage for the post-#81946 resurrection bug: personality state
used to be persisted differently per surface (TUI/desktop wrote the NAME to
display.personality, CLI/gateway wrote rendered TEXT to agent.system_prompt),
so making display.personality authoritative resurrected personalities users
had already turned off ("kawaii defaults on after updating").
"""
import os
from unittest.mock import patch
import pytest
import yaml
from hermes_cli.personality import (
BUILTIN_PERSONALITIES,
available_personalities,
active_personality_name,
describe_personality,
normalize_personality_name,
persist_personality,
prompt_text,
render_personality_prompt,
resolve_ephemeral_system_prompt,
resolve_personality,
)
KAWAII = BUILTIN_PERSONALITIES["kawaii"]
# ── module semantics ──────────────────────────────────────────────────────────
def test_builtins_available_without_any_config():
merged = available_personalities(None)
assert len(merged) >= 1
for name in merged:
assert name == name.lower()
# built-ins render to non-empty prompts
for defn in merged.values():
assert render_personality_prompt(defn)
def test_user_entries_overlay_builtins_by_name():
cfg = {"agent": {"personalities": {"kawaii": "toned down", "custom": "hi"}}}
merged = available_personalities(cfg)
assert merged["kawaii"] == "toned down"
assert merged["custom"] == "hi"
def test_neutral_names_normalize_to_empty():
for raw in ("", "none", "None", " DEFAULT ", "neutral", None):
assert normalize_personality_name(raw) == ""
def test_resolve_personality_neutral_and_case_insensitive():
assert resolve_personality("none", {}) == ("", "")
name, prompt = resolve_personality(" KAWAII ", {})
assert name == "kawaii"
assert prompt == KAWAII
def test_resolve_personality_unknown_raises_with_listing():
with pytest.raises(ValueError) as exc:
resolve_personality("doesnotexist", {})
assert "Available" in str(exc.value)
assert "`none`" in str(exc.value)
def test_resolve_overlay_personality_wins_over_manual_prompt():
cfg = {
"display": {"personality": "kawaii"},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == KAWAII
def test_resolve_overlay_falls_back_to_manual_prompt():
for neutral in ("", "none", "default", "neutral"):
cfg = {
"display": {"personality": neutral},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == "manual forever"
def test_resolve_overlay_ignores_unknown_name():
cfg = {
"display": {"personality": "ghost"},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == "manual forever"
assert active_personality_name(cfg) == ""
def test_render_dict_personality():
rendered = render_personality_prompt(
{"system_prompt": "You are X.", "tone": "warm", "style": "brief"}
)
assert "You are X." in rendered
assert "Tone: warm" in rendered
assert "Style: brief" in rendered
def test_prompt_text_normalizes_none_str_list():
assert prompt_text(None) == ""
assert prompt_text(" hi ") == "hi"
assert prompt_text(["a", " b ", ""]) == "a\nb"
def test_describe_personality_truncates_and_flattens():
assert describe_personality("x" * 80) == "x" * 50 + "..."
assert "\n" not in describe_personality("a\nb")
assert describe_personality({"description": "short desc"}) == "short desc"
# ── persistence (single write path) ──────────────────────────────────────────
def test_persist_personality_roundtrip(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
assert persist_personality("KAWAII ") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["display"]["personality"] == "kawaii"
assert persist_personality("none") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["display"]["personality"] == ""
def test_persist_personality_never_touches_system_prompt(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
yaml.safe_dump({"agent": {"system_prompt": "manual forever"}})
)
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
assert persist_personality("kawaii") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["agent"]["system_prompt"] == "manual forever"
assert raw["display"]["personality"] == "kawaii"
# ── v34 migration: one-time reset of stale split-brain state ─────────────────
def _run_migration(home, cfg):
(home / "config.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True))
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
from hermes_cli.config import migrate_config, read_raw_config
results = migrate_config(interactive=False, quiet=True)
return read_raw_config(), results
def test_migration_resets_stale_personality_name(tmp_path):
# Shape 1: TUI/desktop wrote the name years ago; the old CLI/gateway
# "/personality none" never cleared it. Post-#81946 it resurrected.
home = tmp_path / ".hermes"
home.mkdir()
raw, results = _run_migration(
home,
{"_config_version": 33, "display": {"personality": "kawaii"}},
)
assert raw["display"]["personality"] == ""
assert resolve_ephemeral_system_prompt(raw) == ""
assert any("personality" in item for item in results["config_added"])
def test_migration_scrubs_personality_text_from_system_prompt(tmp_path):
# Shape 2: old CLI/gateway wrote rendered personality TEXT into
# agent.system_prompt. Verbatim match with a known personality render
# proves machine-written — scrub it.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{"_config_version": 33, "agent": {"system_prompt": KAWAII}},
)
assert raw["agent"]["system_prompt"] == ""
assert resolve_ephemeral_system_prompt(raw) == ""
def test_migration_preserves_manual_system_prompt(tmp_path):
# Shape 3: a hand-written prompt never verbatim-matches a personality
# render — it must survive untouched while the stale name is reset.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{
"_config_version": 33,
"display": {"personality": "pirate"},
"agent": {"system_prompt": "my manual prompt"},
},
)
assert raw["display"]["personality"] == ""
assert raw["agent"]["system_prompt"] == "my manual prompt"
assert resolve_ephemeral_system_prompt(raw) == "my manual prompt"
def test_migration_noop_when_nothing_stale(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
raw, results = _run_migration(home, {"_config_version": 33})
assert not any("personality" in item for item in results["config_added"])
def test_post_v34_choice_is_never_reset(tmp_path):
# The reset fires exactly once (33→34). A personality chosen AFTER the
# migration is the user's real selection and must survive later runs.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{"_config_version": 34, "display": {"personality": "kawaii"}},
)
assert raw["display"]["personality"] == "kawaii"
assert resolve_ephemeral_system_prompt(raw) == KAWAII

View File

@ -7451,6 +7451,15 @@ 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))
# Persistence now flows through the single owner (hermes_cli.personality),
# never _write_config_key / agent.system_prompt.
import hermes_cli.personality as personality_mod
monkeypatch.setattr(
personality_mod,
"persist_personality",
lambda name: writes.append(("display.personality", name)) or True,
)
monkeypatch.setattr(
server,
"_write_config_key",

View File

@ -209,9 +209,13 @@ def _(rid, params: dict) -> dict:
{"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE},
)
if key == "personality":
# Report the EFFECTIVE personality via the single owner — a stale or
# unknown name in config must not display as active.
from hermes_cli.personality import active_personality_name
return _ok(
rid,
{"value": (_load_cfg().get("display") or {}).get("personality") or "none"},
{"value": active_personality_name(_load_cfg()) or "none"},
)
if key == "reasoning":
cfg = _load_cfg()

View File

@ -5061,16 +5061,19 @@ def _probe_config_health(cfg: dict) -> str:
agent_cfg = cfg.get("agent")
if isinstance(display_cfg, dict):
personality = str(display_cfg.get("personality", "") or "").strip().lower()
if (
personality
and personality not in {"default", "none", "neutral"}
and isinstance(agent_cfg, dict)
and agent_cfg.get("personalities") is None
):
warnings.append(
"`display.personality` is set but `agent.personalities` is empty/null; "
"personality overlay will be skipped."
)
if personality and personality not in {"default", "none", "neutral"}:
try:
from hermes_cli.personality import available_personalities
if personality not in available_personalities(cfg):
warnings.append(
f"`display.personality: {personality}` does not match any "
"built-in or `agent.personalities` entry; personality "
"overlay will be skipped."
)
except Exception:
pass
_ = agent_cfg # retained for shape parity; built-ins exist without config
return " ".join(warnings).strip()
@ -5938,60 +5941,51 @@ def _wire_callbacks(sid: str):
def _render_personality_prompt(value) -> str:
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)
"""Delegates to hermes_cli.personality (single owner of rendering)."""
from hermes_cli.personality import render_personality_prompt
return render_personality_prompt(value)
def _available_personalities(cfg: dict | None = None) -> dict:
try:
from cli import load_cli_config
"""Built-ins + user overrides, via hermes_cli.personality (single owner)."""
from hermes_cli.personality import available_personalities
return (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
except Exception:
try:
from hermes_cli.config import load_config as _load_full_cfg
return (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {}
except Exception:
cfg = cfg or _load_cfg()
return (cfg.get("agent") or {}).get("personalities", {}) or {}
if cfg is None:
cfg = _load_cfg()
return available_personalities(cfg)
def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]:
raw = str(value or "").strip()
name = raw.lower()
if not name or name in {"none", "default", "neutral"}:
return "", ""
"""Resolve a requested personality against _available_personalities.
Same contract as hermes_cli.personality.resolve_personality (name,
prompt) or ValueError but resolves through the module-level
_available_personalities so tests (and future gateway-side overrides)
keep a single patch point.
"""
from hermes_cli.personality import normalize_personality_name
name = normalize_personality_name(value)
if not name:
return "", ""
personalities = _available_personalities(cfg)
if name not in personalities:
names = sorted(personalities)
available = ", ".join(f"`{n}`" for n in names)
base = f"Unknown personality: `{raw}`."
if available:
base += f"\n\nAvailable: `none`, {available}"
else:
base += "\n\nNo personalities configured."
raise ValueError(base)
names = ", ".join(f"`{n}`" for n in sorted(personalities))
raise ValueError(
f"Unknown personality: `{str(value).strip()}`.\n\nAvailable: `none`, {names}"
)
return name, _render_personality_prompt(personalities[name])
def _prompt_text(value) -> 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()
"""Normalize config prompt values from YAML before handing them to AIAgent.
Delegates to hermes_cli.personality (single owner).
"""
from hermes_cli.personality import prompt_text
return prompt_text(value)
def _apply_personality_to_session(
@ -11323,10 +11317,12 @@ 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)
# Personality text is an in-session overlay. Persistence goes
# through hermes_cli.personality (single owner) and never
# touches the user-owned global system prompt.
from hermes_cli.personality import persist_personality
persist_personality(pname)
nv = str(value or "none")
history_reset, info = _apply_personality_to_session(
sid_key, session, new_prompt, pname
@ -12755,6 +12751,12 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
return result.get("warning", "")
elif name == "personality" and arg and agent:
pname, new_prompt = _validate_personality(arg, _load_cfg())
# Persist through the single owner so this surface can never
# drift from the others (the old TUI slash path applied the
# overlay in-session but skipped persistence entirely).
from hermes_cli.personality import persist_personality
persist_personality(pname)
_apply_personality_to_session(sid, session, new_prompt, pname)
elif name == "prompt" and agent:
cfg = _load_cfg()

View File

@ -211,7 +211,7 @@ These are convenient overlays, but your global `SOUL.md` still gives Hermes its
## Custom personalities in config
You can also define named custom personalities in `~/.hermes/config.yaml` under `agent.personalities`.
Built-in personalities are always available on every surface (CLI, messaging platforms, TUI, and the desktop app). You can add your own — or override a built-in by reusing its name — in `~/.hermes/config.yaml` under `agent.personalities`.
```yaml
agent:
@ -227,9 +227,11 @@ Then switch to it with:
/personality codereviewer
```
Your selection is stored as a name in `display.personality`. Personalities never touch `agent.system_prompt` — that field is reserved for a manual system prompt you write yourself, and it applies only when no personality is selected.
## Resetting to the default
To cancel the active personality overlay and return to base behavior (your `SOUL.md` persona), use any of:
To cancel the active personality overlay and return to base behavior (your `SOUL.md` persona, plus `agent.system_prompt` if you set one), use any of:
```text
/personality none
@ -237,7 +239,11 @@ To cancel the active personality overlay and return to base behavior (your `SOUL
/personality neutral
```
All three clear the overlay: the saved `agent.system_prompt` is emptied and the change takes effect on your next message. Running `/personality` with no arguments also lists `none` alongside the available presets.
All three clear the selection (`display.personality`) and the change takes effect on your next message. Running `/personality` with no arguments also lists `none` alongside the available presets and marks the active one.
:::note One-time reset on upgrade
Older Hermes versions saved personality state inconsistently across surfaces, which could re-enable a personality you had previously turned off. On your first run after upgrading, any saved personality selection is reset to `none` once (the migration prints which personality was cleared). Re-enable it with `/personality <name>` if you still want it. Manual `agent.system_prompt` text is never touched.
:::
## Recommended workflow