diff --git a/cli.py b/cli.py index 937966b05..3ecde4263 100755 --- a/cli.py +++ b/cli.py @@ -1877,6 +1877,19 @@ class HermesCLI: print(" /personality - Use a predefined personality") print() + + @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) + def _handle_personality_command(self, cmd: str): """Handle the /personality command to set predefined personalities.""" parts = cmd.split(maxsplit=1) @@ -1885,8 +1898,16 @@ class HermesCLI: # Set personality personality_name = parts[1].strip().lower() - if personality_name in self.personalities: - self.system_prompt = self.personalities[personality_name] + if personality_name in ("none", "default", "neutral"): + self.system_prompt = "" + self.agent = None # Force re-init + if save_config_value("agent.system_prompt", ""): + 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]) self.agent = None # Force re-init if save_config_value("agent.system_prompt", self.system_prompt): print(f"(^_^)b Personality set to '{personality_name}' (saved to config)") @@ -1895,7 +1916,7 @@ class HermesCLI: print(f" \"{self.system_prompt[:60]}{'...' if len(self.system_prompt) > 60 else ''}\"") else: print(f"(._.) Unknown personality: {personality_name}") - print(f" Available: {', '.join(self.personalities.keys())}") + print(f" Available: none, {', '.join(self.personalities.keys())}") else: # Show available personalities print() @@ -1903,8 +1924,13 @@ class HermesCLI: print("|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|") print("+" + "-" * 50 + "+") print() + print(f" {'none':<12} - (no personality overlay)") for name, prompt in self.personalities.items(): - print(f" {name:<12} - \"{prompt}\"") + if isinstance(prompt, dict): + preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] + else: + preview = str(prompt)[:50] + print(f" {name:<12} - {preview}") print() print(" Usage: /personality ") print() diff --git a/gateway/run.py b/gateway/run.py index b32f2d2d0..8fbd8d28b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1536,14 +1536,39 @@ class GatewayRunner: if not args: lines = ["🎭 **Available Personalities**\n"] + lines.append("• `none` — (no personality overlay)") for name, prompt in personalities.items(): - preview = prompt[:50] + "..." if len(prompt) > 50 else prompt + 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(f"• `{name}` — {preview}") lines.append(f"\nUsage: `/personality `") return "\n".join(lines) - if args in personalities: - new_prompt = personalities[args] + 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) + + if args in ("none", "default", "neutral"): + try: + if "agent" not in config or not isinstance(config.get("agent"), dict): + config["agent"] = {} + config["agent"]["system_prompt"] = "" + with open(config_path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + except Exception as e: + return f"⚠️ Failed to save personality change: {e}" + self._ephemeral_system_prompt = "" + return "🎭 Personality cleared — using base agent behavior.\n_(takes effect on next message)_" + elif args in personalities: + new_prompt = _resolve_prompt(personalities[args]) # Write to config.yaml, same pattern as CLI save_config_value. try: @@ -1560,7 +1585,7 @@ class GatewayRunner: return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_" - available = ", ".join(f"`{n}`" for n in personalities.keys()) + available = "`none`, " + ", ".join(f"`{n}`" for n in personalities.keys()) return f"Unknown personality: `{args}`\n\nAvailable: {available}" async def _handle_retry_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0e6f51c1a..1695f2b0a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -147,6 +147,10 @@ DEFAULT_CONFIG = { # Permanently allowed dangerous command patterns (added via "always" approval) "command_allowlist": [], + # Custom personalities — add your own entries here + # Supports string format: {"name": "system prompt"} + # Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}} + "personalities": {}, # Config schema version - bump this when adding new required fields "_config_version": 5, diff --git a/tests/test_personality_none.py b/tests/test_personality_none.py new file mode 100644 index 000000000..ec27838fe --- /dev/null +++ b/tests/test_personality_none.py @@ -0,0 +1,212 @@ +"""Tests for /personality none — clearing personality overlay.""" +import pytest +from unittest.mock import MagicMock, patch, mock_open +import yaml + + +# ── CLI tests ────────────────────────────────────────────────────────────── + +class TestCLIPersonalityNone: + + def _make_cli(self, personalities=None): + from cli import HermesCLI + cli = HermesCLI.__new__(HermesCLI) + cli.personalities = personalities or { + "helpful": "You are helpful.", + "concise": "You are concise.", + } + cli.system_prompt = "You are kawaii~" + cli.agent = MagicMock() + cli.console = MagicMock() + return cli + + def test_none_clears_system_prompt(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality none") + assert cli.system_prompt == "" + + def test_default_clears_system_prompt(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality default") + assert cli.system_prompt == "" + + def test_neutral_clears_system_prompt(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality neutral") + assert cli.system_prompt == "" + + def test_none_forces_agent_reinit(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality none") + assert cli.agent is None + + def test_none_saves_to_config(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True) as mock_save: + cli._handle_personality_command("/personality none") + mock_save.assert_called_once_with("agent.system_prompt", "") + + def test_known_personality_still_works(self): + cli = self._make_cli() + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality helpful") + assert cli.system_prompt == "You are helpful." + + def test_unknown_personality_shows_none_in_available(self, capsys): + cli = self._make_cli() + cli._handle_personality_command("/personality nonexistent") + output = capsys.readouterr().out + assert "none" in output.lower() + + def test_list_shows_none_option(self): + cli = self._make_cli() + with patch("builtins.print") as mock_print: + cli._handle_personality_command("/personality") + output = " ".join(str(c) for c in mock_print.call_args_list) + assert "none" in output.lower() + + +# ── Gateway tests ────────────────────────────────────────────────────────── + +class TestGatewayPersonalityNone: + + def _make_event(self, args=""): + event = MagicMock() + event.get_command.return_value = "personality" + event.get_command_args.return_value = args + return event + + def _make_runner(self, personalities=None): + from gateway.run import GatewayRunner + runner = GatewayRunner.__new__(GatewayRunner) + runner._ephemeral_system_prompt = "You are kawaii~" + runner.config = { + "agent": { + "personalities": personalities or {"helpful": "You are helpful."} + } + } + return runner + + @pytest.mark.asyncio + async def test_none_clears_ephemeral_prompt(self, tmp_path): + runner = self._make_runner() + config_data = {"agent": {"personalities": {"helpful": "You are helpful."}, "system_prompt": "kawaii"}} + 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("none") + result = await runner._handle_personality_command(event) + + assert runner._ephemeral_system_prompt == "" + assert "cleared" in result.lower() + + @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_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("default") + result = await runner._handle_personality_command(event) + + assert runner._ephemeral_system_prompt == "" + + @pytest.mark.asyncio + async def test_list_includes_none(self, tmp_path): + runner = self._make_runner() + config_data = {"agent": {"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("") + result = await runner._handle_personality_command(event) + + assert "none" in result.lower() + + @pytest.mark.asyncio + async def test_unknown_shows_none_in_available(self, tmp_path): + runner = self._make_runner() + config_data = {"agent": {"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("nonexistent") + result = await runner._handle_personality_command(event) + + assert "none" in result.lower() + + +class TestPersonalityDictFormat: + """Test dict-format custom personalities with description, tone, style.""" + + def _make_cli(self, personalities): + from cli import HermesCLI + cli = HermesCLI.__new__(HermesCLI) + cli.personalities = personalities + cli.system_prompt = "" + cli.agent = None + cli.console = MagicMock() + return cli + + def test_dict_personality_uses_system_prompt(self): + cli = self._make_cli({ + "coder": { + "description": "Expert programmer", + "system_prompt": "You are an expert programmer.", + "tone": "technical", + "style": "concise", + } + }) + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality coder") + assert "You are an expert programmer." in cli.system_prompt + + def test_dict_personality_includes_tone(self): + cli = self._make_cli({ + "coder": { + "system_prompt": "You are an expert programmer.", + "tone": "technical and precise", + } + }) + with patch("cli.save_config_value", return_value=True): + cli._handle_personality_command("/personality coder") + assert "Tone: technical and precise" in cli.system_prompt + + def test_dict_personality_includes_style(self): + cli = self._make_cli({ + "coder": { + "system_prompt": "You are an expert programmer.", + "style": "use code examples", + } + }) + with patch("cli.save_config_value", 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): + cli._handle_personality_command("/personality helper") + assert cli.system_prompt == "You are helpful." + + def test_resolve_prompt_dict_no_tone_no_style(self): + from cli import HermesCLI + result = HermesCLI._resolve_personality_prompt({ + "description": "A helper", + "system_prompt": "You are helpful.", + }) + assert result == "You are helpful." + + def test_resolve_prompt_string(self): + from cli import HermesCLI + result = HermesCLI._resolve_personality_prompt("You are helpful.") + assert result == "You are helpful."