Add personality tone control plugin#313
Conversation
|
quality checks plz - I added a note in AGENTS.md |
61c0fcc to
f889ad0
Compare
mpfaffenberger
left a comment
There was a problem hiding this comment.
PR 313 Deep Review: personality_tone Plugin
Thanks @nhicks00 for this plugin! It's a well-thought-out feature. Here's my deep dive:
✅ What's Great
-
Clean Plugin Architecture - Uses the existing hooks (
load_prompt,custom_command,custom_command_help) correctly. No core changes needed. 🎯 -
Comprehensive Alias System - The aliases dict is thorough with numeric shortcuts (
0,1,2,3) making it easy to switch. -
Good Test Coverage - Tests cover normalization, command handling, config persistence, and error cases.
-
Graceful Failure -
_reload_current_agentwraps in try/except with a warning, so tone still saves even if reload fails. -
Idempotent by Default -
defaulttone returns empty string, preserving existing behavior for all users.
⚠️ Issues to Address
-
Missing Test for
_show_tone()function - The test file doesn't directly test_show_tone(). It's tested indirectly via_handle_custom_command("/tone", "tone"), but a direct unit test would be clearer. -
No Test for Playful Tone Profile -
test_professional_tone_adds_overrideexists, buttest_playful_tone_adds_overrideis missing. Consider adding one for completeness. -
Potential Edge Case in
_handle_custom_command- The checklen(parts) > 2accepts/tone a b csilently. Consider emitting a warning for extra arguments. -
Module-level
register_callbackat Import Time - While this is the standard pattern in code_puppy, it means callbacks are registered when the module is imported. This is fine for the plugin system but worth documenting.
🔧 Nitpicks (Minor)
-
Unused Import -
Callablefromtypingis imported but not directly used (only in type hints via string annotation). Could remove it. -
Docstring Clarity - The
get_tone_prompt_additiondocstring mentions "Returning an empty string keeps the default prompt behavior unchanged" buton_load_promptin callbacks.py returns a list, not a string. The plugin returns a string that gets added to the list. Consider clarifying. -
TONE_PROFILES Order - The dict maintains insertion order (Python 3.7+), but
_available_tones_texthardcodes the tuple. If a new tone is added, both places need updating. Consider iteratingTONE_PROFILES.keys()instead.
🚀 Suggestions for Future Enhancement
- Config-driven Tones - Allow users to define custom tones via config file.
- Per-Conversation Tones - Support session-local tone that resets on new session.
- Tone Preview - Show a sample response demonstrating each tone.
Verdict
Approve with minor suggestions. The implementation is solid, follows project conventions, and adds real value. The issues are minor and don't block merge.
Reviewed with 🐶 by Code Puppy
mpfaffenberger
left a comment
There was a problem hiding this comment.
PR 313 Deep Review: personality_tone Plugin 🔍
Hey @nhicks00 — Biscuit here, doing a deep sniff-through of this PR. I've also re-examined the existing review by @mpfaffenberger and found several points that need correction or deeper analysis.
✅ What's Genuinely Good
-
Plugin architecture is textbook — Uses
load_prompt,custom_command, andcustom_command_helphooks exactly as intended. Zero core changes. 🐾 -
Alias system is thoughtful — Numeric shortcuts (0-3) are a nice UX touch, and the alias map covers most intuitive synonyms without being bloated.
-
normalize_toneis clean and defensive — HandlesNone, empty string, whitespace, and unknown values gracefully. Always returns a valid tone name. -
_reload_current_agentfails gracefully — The try/except with warning is correct: config still persists even if the agent can't reload. Users aren't left in a broken state. -
ToneProfileis a frozen dataclass — Immutable, clean, minimal. Exactly right. -
Default tone is zero-impact — Returns
"", which means existing users who never touch/tonesee zero behavior change. The PR description explicitly calls this out, and it's correct.
🔴 Issues That Should Be Fixed Before Merge
1. load_prompt returns "" for default tone — this is a systemic prompt pollution problem
This is the most important issue in this PR.
The on_load_prompt() hook collects ALL callback return values into a list via _trigger_callbacks_sync, and the callers join them:
# agent_code_puppy.py, agent_planning.py
prompt_additions = callbacks.on_load_prompt()
if len(prompt_additions):
result += "\n".join(prompt_additions)When the default tone is active, get_tone_prompt_addition() returns "". This gets appended to the results list as-is. If file_permission_handler is also active (it always is unless yolo mode), the list becomes:
["<file permission prompt>", ""]"\n".join(["<file permission prompt>", ""]) produces "<file permission prompt>\n" — a trailing newline that adds whitespace to the system prompt.
Why this matters: LLMs are sensitive to prompt structure. A trailing newline might seem harmless, but when you have 2+ load_prompt plugins returning empty strings, you get multiple blank lines injected into the system prompt. This is a ticking time bomb.
Fix: Return None instead of "" for the default tone, and update get_tone_prompt_addition to return None when no override is needed. Then fix _trigger_callbacks_sync (or the callers) to filter out None/falsy values before joining. OR — simpler — just filter at the consumer level:
prompt_additions = [a for a in callbacks.on_load_prompt() if a]
⚠️ Note: The existing review says "Returning an empty string keeps the default prompt behavior unchanged while still satisfying the hook" — this is technically true but practically wrong. The empty string does get joined and does affect the final prompt text. The docstring should be corrected.
2. Leading newlines in tone prompts get doubled
All non-empty tone prompts start with \n (from the triple-quoted string), and the callers join with \n. So the effective prompt becomes:
...existing system prompt...
\n ← from "\n".join()
\n ← from the leading \n in the tone prompt
## Tone Override
That's a double-blank-line gap before the tone section. This isn't catastrophic, but it's sloppy prompt engineering. Strip the leading newline from each prompt, or strip it when joining.
3. normalize_tone silently swallows typos
If a user types /tone profesisonal (typo), normalize_tone falls back to "default" with no warning. The user has no idea their command was ignored. The _set_tone function only emits an error when the unknown value resolves to DEFAULT_TONE — but that check uses a clever heuristic that fails in edge cases (e.g., if someone adds an alias that points to "default", it would be treated as valid even if the raw input was gibberish that happened to map to default via an alias).
Fix: normalize_tone should return a tuple[str, bool] or raise for unknown values, rather than silently falling back. Let the caller decide whether to warn.
🟡 Issues Worth Addressing (Non-blocking)
4. _available_tones_text hardcodes the tone order
for tone_name in ("professional", "neutral", "default", "playful"):This duplicates the key order of TONE_PROFILES. If someone adds a 5th tone, they must update both TONE_PROFILES AND this tuple. The existing review flagged this correctly — it's a DRY violation.
Fix: Just iterate TONE_PROFILES:
for tone_name, profile in TONE_PROFILES.items():5. Callable import is dead code
from typing import CallableWith from __future__ import annotations, all annotations are stringified and never evaluated at runtime. Callable is used only in the type hint Callable[[], None] for _set_tone's default parameter. It's never referenced at runtime. The existing review flagged this correctly but called it a "nitpick" — I'd bump it to "should fix" since dead imports are lint noise.
Fix: Remove from typing import Callable.
6. No test for the playful tone prompt
The test file has test_professional_tone_adds_override but no equivalent for playful or neutral. The existing review flagged this. Even a simple assertion that the playful prompt contains "playful personality" would catch regressions.
7. No test for _reload_current_agent failure path
The _reload_current_agent function has a try/except that emits a warning. This failure path is untested. A test that patches get_current_agent() to raise and then asserts emit_warning was called would close the gap.
8. No test for the len(parts) > 2 error path
The _handle_custom_command emits an error for /tone foo bar, but no test exercises this. Simple addition.
🔧 Nitpicks
9. _set_tone unknown-tone detection heuristic is fragile
if tone == DEFAULT_TONE and raw_tone.strip().lower() not in {
DEFAULT_TONE,
*ALIASES,
}:This checks: "if the resolved tone is default AND the raw input wasn't a known alias for default, then it's unknown." This works today, but it's clever-clever. If someone adds an alias "off" → "default", then /tone off would pass. But if someone adds an alias "unknown_alias" → "professional" by mistake, /tone unknown_alias would NOT be caught as unknown — it would silently set professional. The heuristic conflates "is this a valid input?" with "did this resolve to default?".
Fix: Use a set of all valid inputs instead:
VALID_INPUTS = set(TONE_PROFILES) | set(ALIASES)
if raw_tone.strip().lower() not in VALID_INPUTS:
emit_error(...)
return10. The playful tone prompt doesn't say "This section supersedes earlier..."
The professional and neutral prompts both start with:
"This section supersedes earlier playful or sassy personality guidance."
But the playful prompt doesn't include this line. This means the core prompt's playful personality instructions AND the playful override both apply, potentially conflicting. The playful override says "keep it useful" but doesn't explicitly negate any of the core sassy instructions.
Fix: Add the same supersedes line to the playful prompt, or document that playful is intentionally additive rather than overriding.
11. get_current_tone calls get_value every time
Every call to get_tone_prompt_addition (which runs on every prompt assembly) reads from config. This is fine for a file-based config, but if config ever becomes remote, this becomes a latency issue. Not worth fixing now, just noting it.
🚫 Corrections to the Existing Review
The existing review by @mpfaffenberger made a few claims that need correction:
-
"Docstring clarity —
on_load_promptin callbacks.py returns a list, not a string" — The review implied the plugin's return type is wrong. It's not. Each callback returns a string, and_trigger_callbacks_synccollects them into a list. The plugin is correct to return a string. However, the review's underlying concern (that empty strings pollute the list) is valid — just the diagnosis was wrong. -
"Potential Edge Case in
_handle_custom_command— accepts/tone a b csilently" — This is actually handled! Look at line 178-179:if len(parts) > 2: emit_error(...). The existing review missed this and suggested a fix that's already implemented. -
"Missing Test for
_show_tone()" — The review says there's no test for_show_tone(). While there's no direct unit test,test_handle_tone_show_emits_current_tone_and_optionsexercises it thoroughly via_handle_custom_command("/tone", "tone"). I'd still prefer a direct test, but the claim that it's "not tested" is overstated.
🚀 Future Enhancement Ideas (Not for this PR)
- Config-driven custom tones — Let users define their own tone profiles in
~/.code_puppy/config.toml - Per-session tones — A
--toneCLI flag or session-local override that resets on new conversation - Tone preview —
/tone preview professionalshows a sample response in that tone - Hook into
agent_reload— Instead of manually callingreload_code_generation_agent(), register anagent_reloadcallback so the tone plugin participates in the standard reload flow
Verdict
Request changes on items #1, #2, and #3. The plugin is architecturally sound and follows project conventions perfectly. But the empty-string-in-load_prompt issue (#1) is a real prompt hygiene problem that will compound as more load_prompt plugins are added. The leading newlines (#2) and silent typo swallowing (#3) are also worth fixing before this ships.
Items #4-#8 are strongly recommended but non-blocking. Items #9-#11 are nits for the author's consideration.
Reviewed with 🐶 by Biscuit (code-puppy-819fbe)
mpfaffenberger
left a comment
There was a problem hiding this comment.
Inline Review Comments
Detailed line-by-line observations from my deep review. See the main review for the full analysis.
Reviewed with 🐶 by Biscuit (code-puppy-819fbe)
| } | ||
|
|
||
|
|
||
| def normalize_tone(value: str | None) -> str: |
There was a problem hiding this comment.
🔴 Empty string pollutes the prompt additions list
Returning "" here means _trigger_callbacks_sync appends an empty string to the results list. When another load_prompt plugin (like file_permission_handler) is also active, the join produces a trailing newline: "\n".join(["<perm prompt>", ""]) → "<perm prompt>\n".
Consider returning None and filtering falsy values at the consumer level, or at minimum strip the empty string before joining. See my review comment #1 for the full analysis.
| """Return a supported tone name, falling back to the default tone.""" | ||
| if value is None: | ||
| return DEFAULT_TONE | ||
|
|
There was a problem hiding this comment.
🔴 Leading newline gets doubled
This triple-quoted string starts with \n, and the caller joins prompt additions with "\n".join(...). The effective output becomes:
...existing prompt...\n\n## Tone Override
Two blank lines before the tone section. Strip the leading newline from the string, or .lstrip("\n") when returning.
| from typing import Callable | ||
|
|
||
| from code_puppy.callbacks import register_callback | ||
| from code_puppy.config import get_value, set_config_value |
There was a problem hiding this comment.
🟡 Dead import
With from __future__ import annotations at line 1, Callable is never evaluated at runtime. It's only used in the type hint Callable[[], None] for _set_tone, which is stringified by __future__ annotations. Safe to remove.
|
|
||
| def _set_tone( | ||
| raw_tone: str, reload_agent: Callable[[], None] = _reload_current_agent | ||
| ) -> None: |
There was a problem hiding this comment.
🔴 Silent typo swallowing
If a user types /tone profesisonal (typo), normalize_tone returns "default" silently. The check on line 158 only catches this because the result equals DEFAULT_TONE, but the heuristic is fragile. If someone adds an alias that maps to "default", any unknown input that happens to resolve to default through that alias would be accepted without warning.
Prefer a VALID_INPUTS set: VALID_INPUTS = set(TONE_PROFILES) | set(ALIASES) and check membership directly.
|
|
||
| def _available_tones_text() -> str: | ||
| lines = ["Available tones:"] | ||
| for tone_name in ("professional", "neutral", "default", "playful"): |
There was a problem hiding this comment.
🟡 DRY violation — This hardcoded tuple duplicates the key order of TONE_PROFILES. If a 5th tone is added, both places need updating.
Prefer: for tone_name, profile in TONE_PROFILES.items():
| def _custom_help() -> list[tuple[str, str]]: | ||
| return [ | ||
| ( | ||
| "tone", |
There was a problem hiding this comment.
The playful tone prompt omits the "This section supersedes earlier playful or sassy personality guidance" line that both professional and neutral include. This means the core prompt's sassy instructions and the playful override both apply without a clear precedence. Is this intentional? If so, worth a comment. If not, add the supersedes line.
| assert "Tone Override" in prompt | ||
| assert "business-professional" in prompt | ||
| assert "supersedes earlier playful or sassy" in prompt | ||
| assert "Avoid sass" in prompt |
There was a problem hiding this comment.
🟡 Missing test coverage — There's a test for the professional tone prompt but none for playful or neutral. Adding:
def test_playful_tone_adds_override():
with patch(
"code_puppy.plugins.personality_tone.register_callbacks.get_value",
return_value="playful",
):
prompt = plugin.get_tone_prompt_addition()
assert "playful personality" in promptwould catch regressions if the playful prompt is edited.
| @@ -0,0 +1,101 @@ | |||
| """Tests for the personality tone plugin.""" | |||
There was a problem hiding this comment.
🟡 Missing test: _reload_current_agent failure path
The function has a try/except that emits emit_warning on failure, but no test exercises this. Also missing: a test for the len(parts) > 2 error in _handle_custom_command (e.g., /tone foo bar).
Summary
personality_toneplugin that controls Code Puppy's response style without editing the core prompt directly./tonefor quick switching betweenprofessional,neutral,default, andplayfultones.personality_toneand reloads the active agent so the updated prompt applies immediately.Why
Code Puppy's default prompt is intentionally playful and sassy, which is useful for some users but too informal for others. This keeps the existing default behavior intact while giving users a lightweight way to opt into a dry, business-professional style when they want focused engineering work.
Usage
/toneor/tone show: display the current tone and available options./tone professional: dry, direct, business-professional responses./tone neutral: friendly but restrained responses./tone default: native Code Puppy personality with no override./tone playful: explicitly playful while staying task-focused./tone 0,/tone 1,/tone 2,/tone 3.Implementation Notes
code_puppy/plugins/personality_tone/.load_prompthook to append a tone override, so core prompt text is not rewritten.custom_commandandcustom_command_helphooks for/toneand help-menu integration.default, so existing users see no behavior change unless they opt in.Validation
uv run pytest --no-cov tests/plugins/test_personality_tone.py tests/plugins/test_plugins_init_coverage.py -quv run ruff check code_puppy/plugins/personality_tone tests/plugins/test_personality_tone.pyNote: I also attempted the broader command-handler coverage suite, but this local environment fails during import before reaching this feature because
pydantic_ai.mcpcannot import the optionalmcp.client.ssepath.