diff --git a/code_puppy/plugins/personality_tone/__init__.py b/code_puppy/plugins/personality_tone/__init__.py new file mode 100644 index 000000000..8b97467b4 --- /dev/null +++ b/code_puppy/plugins/personality_tone/__init__.py @@ -0,0 +1 @@ +"""Personality tone control plugin.""" diff --git a/code_puppy/plugins/personality_tone/register_callbacks.py b/code_puppy/plugins/personality_tone/register_callbacks.py new file mode 100644 index 000000000..d3757ae76 --- /dev/null +++ b/code_puppy/plugins/personality_tone/register_callbacks.py @@ -0,0 +1,191 @@ +"""Plugin for controlling Code Puppy's response tone. + +The core Code Puppy prompt intentionally has a playful personality. This plugin +keeps that prompt unchanged and appends a configurable tone override through the +existing load_prompt hook. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from code_puppy.callbacks import register_callback +from code_puppy.config import get_value, set_config_value +from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning + +CONFIG_KEY = "personality_tone" +DEFAULT_TONE = "default" + + +@dataclass(frozen=True) +class ToneProfile: + description: str + prompt: str + + +TONE_PROFILES: dict[str, ToneProfile] = { + "professional": ToneProfile( + description="Dry, direct, business-professional responses", + prompt=""" +## Tone Override + +This section supersedes earlier playful or sassy personality guidance. +- Use a business-professional tone: direct, concise, objective, and practical. +- Avoid sass, sarcasm, cutesy phrasing, pet/dog jokes, playful metaphors, and emojis unless the user explicitly asks. +- Keep the focus on the engineering objective, tradeoffs, risks, verification, and next steps. +- Do not mention this tone policy unless the user asks about it. +""", + ), + "neutral": ToneProfile( + description="Friendly and restrained, with minimal personality", + prompt=""" +## Tone Override + +This section supersedes earlier playful or sassy personality guidance. +- Use a friendly but restrained tone. +- Keep humor and personality light, rare, and secondary to technical clarity. +- Avoid sarcasm, cutesy phrasing, pet/dog jokes, and emojis unless the user explicitly asks. +- Do not mention this tone policy unless the user asks about it. +""", + ), + "default": ToneProfile( + description="Native Code Puppy personality with no extra override", + prompt="", + ), + "playful": ToneProfile( + description="Explicitly playful, while staying useful and concise", + prompt=""" +## Tone Override + +Use Code Puppy's playful personality, but keep it useful. +- Light humor is fine when it does not distract from the task. +- Keep technical answers accurate, actionable, and concise. +- Do not let jokes, sass, or roleplay obscure risks, bugs, commands, or next steps. +""", + ), +} + +ALIASES: dict[str, str] = { + "0": "professional", + "business": "professional", + "business-professional": "professional", + "dry": "professional", + "direct": "professional", + "serious": "professional", + "1": "neutral", + "balanced": "neutral", + "friendly": "neutral", + "minimal": "neutral", + "2": "default", + "native": "default", + "normal": "default", + "off": "default", + "reset": "default", + "3": "playful", + "fun": "playful", + "sassy": "playful", + "spunky": "playful", +} + + +def normalize_tone(value: str | None) -> str: + """Return a supported tone name, falling back to the default tone.""" + if value is None: + return DEFAULT_TONE + + normalized = value.strip().lower() + if not normalized: + return DEFAULT_TONE + if normalized in TONE_PROFILES: + return normalized + return ALIASES.get(normalized, DEFAULT_TONE) + + +def get_current_tone() -> str: + """Read the configured tone from persistent config.""" + return normalize_tone(get_value(CONFIG_KEY)) + + +def get_tone_prompt_addition() -> str: + """Return the prompt addition for the current tone. + + load_prompt callbacks must return a string. Returning an empty string keeps + the default prompt behavior unchanged while still satisfying the hook. + """ + return TONE_PROFILES[get_current_tone()].prompt + + +def _custom_help() -> list[tuple[str, str]]: + return [ + ( + "tone", + "Set response tone: professional, neutral, default, playful", + ) + ] + + +def _available_tones_text() -> str: + lines = ["Available tones:"] + for tone_name in ("professional", "neutral", "default", "playful"): + profile = TONE_PROFILES[tone_name] + lines.append(f" - {tone_name}: {profile.description}") + lines.append("") + lines.append("Usage: /tone ") + lines.append("Aliases: /tone 0, /tone 1, /tone 2, /tone 3") + return "\n".join(lines) + + +def _show_tone() -> None: + tone = get_current_tone() + emit_info(f"Personality tone: {tone}") + emit_info(_available_tones_text()) + + +def _reload_current_agent() -> None: + try: + from code_puppy.agents import get_current_agent + + get_current_agent().reload_code_generation_agent() + emit_info("Agent reloaded with updated tone prompt.") + except Exception as exc: + emit_warning(f"Tone saved, but agent reload failed: {exc}") + + +def _set_tone( + raw_tone: str, reload_agent: Callable[[], None] = _reload_current_agent +) -> None: + tone = normalize_tone(raw_tone) + if tone == DEFAULT_TONE and raw_tone.strip().lower() not in { + DEFAULT_TONE, + *ALIASES, + }: + valid = ", ".join(TONE_PROFILES) + emit_error(f"Unknown tone '{raw_tone}'. Valid tones: {valid}") + return + + set_config_value(CONFIG_KEY, tone) + emit_success(f"Personality tone set to {tone}.") + reload_agent() + + +def _handle_custom_command(command: str, name: str) -> bool | None: + if name != "tone": + return None + + parts = command.split() + if len(parts) == 1 or parts[1].lower() in {"show", "list", "help"}: + _show_tone() + return True + + if len(parts) > 2: + emit_error("Usage: /tone ") + return True + + _set_tone(parts[1]) + return True + + +register_callback("load_prompt", get_tone_prompt_addition) +register_callback("custom_command_help", _custom_help) +register_callback("custom_command", _handle_custom_command) diff --git a/tests/plugins/test_personality_tone.py b/tests/plugins/test_personality_tone.py new file mode 100644 index 000000000..cf34d9811 --- /dev/null +++ b/tests/plugins/test_personality_tone.py @@ -0,0 +1,101 @@ +"""Tests for the personality tone plugin.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +from code_puppy.plugins.personality_tone import register_callbacks as plugin + + +def test_normalize_tone_accepts_names_and_aliases(): + assert plugin.normalize_tone("professional") == "professional" + assert plugin.normalize_tone("dry") == "professional" + assert plugin.normalize_tone("1") == "neutral" + assert plugin.normalize_tone("off") == "default" + assert plugin.normalize_tone("spunky") == "playful" + + +def test_normalize_tone_defaults_unknown_values(): + assert plugin.normalize_tone(None) == "default" + assert plugin.normalize_tone("") == "default" + assert plugin.normalize_tone("unknown") == "default" + + +def test_default_tone_preserves_existing_prompt_behavior(): + with patch( + "code_puppy.plugins.personality_tone.register_callbacks.get_value", + return_value="default", + ): + assert plugin.get_tone_prompt_addition() == "" + + +def test_professional_tone_adds_override(): + with patch( + "code_puppy.plugins.personality_tone.register_callbacks.get_value", + return_value="professional", + ): + prompt = plugin.get_tone_prompt_addition() + + assert "Tone Override" in prompt + assert "business-professional" in prompt + assert "supersedes earlier playful or sassy" in prompt + assert "Avoid sass" in prompt + + +def test_handle_tone_show_emits_current_tone_and_options(): + with ( + patch( + "code_puppy.plugins.personality_tone.register_callbacks.get_value", + return_value="neutral", + ), + patch( + "code_puppy.plugins.personality_tone.register_callbacks.emit_info" + ) as emit_info, + ): + result = plugin._handle_custom_command("/tone", "tone") + + assert result is True + assert "Personality tone: neutral" in str(emit_info.call_args_list[0]) + assert "Available tones" in str(emit_info.call_args_list[1]) + + +def test_handle_tone_sets_config_and_reloads(): + reload_agent = Mock() + + with ( + patch( + "code_puppy.plugins.personality_tone.register_callbacks.set_config_value" + ) as set_config, + patch("code_puppy.plugins.personality_tone.register_callbacks.emit_success"), + ): + plugin._set_tone("dry", reload_agent=reload_agent) + + set_config.assert_called_once_with(plugin.CONFIG_KEY, "professional") + reload_agent.assert_called_once_with() + + +def test_handle_tone_rejects_unknown_tone(): + reload_agent = Mock() + + with ( + patch( + "code_puppy.plugins.personality_tone.register_callbacks.set_config_value" + ) as set_config, + patch( + "code_puppy.plugins.personality_tone.register_callbacks.emit_error" + ) as emit_error, + ): + plugin._set_tone("weird", reload_agent=reload_agent) + + set_config.assert_not_called() + reload_agent.assert_not_called() + assert "Unknown tone" in str(emit_error.call_args) + + +def test_custom_help_includes_tone_command(): + entries = dict(plugin._custom_help()) + assert "tone" in entries + + +def test_handle_custom_command_ignores_other_commands(): + assert plugin._handle_custom_command("/other", "other") is None