diff --git a/.gitignore b/.gitignore index 56e9f3c35..f73674695 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ code_puppy/bundled_skills/ .claude/hooks/ts-hooks/dist/ .json + +# Swarm agent runtime artifacts +.swarm/ +swarmos_debug.log diff --git a/claude-glm5.json b/claude-glm5.json new file mode 100644 index 000000000..6e4e63740 --- /dev/null +++ b/claude-glm5.json @@ -0,0 +1,9 @@ +{ + "name": "claude-glm5", + "description": "", + "models": { + "main": "claude-code-claude-haiku-4-5-20251001", + "compaction": "synthetic-GLM-4.7", + "subagent": "zai-glm-5-api" + } +} \ No newline at end of file diff --git a/code_puppy/command_line/_profile_tui_panels.py b/code_puppy/command_line/_profile_tui_panels.py new file mode 100644 index 000000000..2ef4e8a60 --- /dev/null +++ b/code_puppy/command_line/_profile_tui_panels.py @@ -0,0 +1,254 @@ +"""Panel renderers for the profile dual-panel TUI. + +Four render functions: + render_profile_list — left panel, always visible + render_agent_config — right panel, browse/edit agent models + render_model_picker — right panel overlay when picking a model + render_naming_panel — right panel overlay for naming a new profile +""" + +from typing import Dict, List, Optional + +from code_puppy.task_models import TASK_CONFIGS, Task + +_TASKS: List[Task] = list(TASK_CONFIGS.keys()) + +VISIBLE = 16 # max rows shown in the model picker at once + + +# ── tiny helpers ────────────────────────────────────────────────────────────── + + +def trunc(t: str, w: int) -> str: + return t if len(t) <= w else t[: w - 1] + "…" + + +def valid_name(n: str) -> bool: + return bool(n) and all(c.isalnum() or c in "-_" for c in n) + + +def load_models() -> List[str]: + try: + from code_puppy.command_line.model_picker_completion import load_model_names + + return load_model_names() or [] + except Exception: + return [] + + +# ── left panel: profile list ────────────────────────────────────────────────── + + +def render_profile_list( + profiles: list, + prof_idx: int, + active_name: Optional[str], + focused: bool, +) -> list: + """Scrollable list of saved profiles with active marker.""" + header_color = "bold cyan" if focused else "bold" + L: list = [ + (header_color, " Profiles\n"), + ("fg:ansibrightblack", " ─────────────────────────────\n\n"), + ] + + if not profiles: + L += [ + ("fg:ansiyellow", " No saved profiles yet.\n\n"), + ("fg:ansibrightblack", " Press N to create the first one.\n"), + ] + else: + for i, p in enumerate(profiles): + pname = p.get("name", "?") + desc = p.get("description", "") + is_active = pname == active_name + mark = "✓" if is_active else " " + is_sel = i == prof_idx + + if is_sel: + row_color = "fg:ansigreen bold" if focused else "fg:ansicyan bold" + L += [(row_color, f" ▶{mark} {trunc(pname, 22)}"), ("", "\n")] + if desc: + L += [("fg:ansibrightblack", f" {trunc(desc, 24)}\n")] + elif is_active: + L += [("fg:ansicyan", f" {mark} {trunc(pname, 22)}"), ("", "\n")] + if desc: + L += [("fg:ansibrightblack", f" {trunc(desc, 24)}\n")] + else: + dim = "fg:ansibrightblack" + L += [(dim, f" {trunc(pname, 22)}"), ("", "\n")] + + L += [("", "\n")] + + # key hints adapt to focus + if focused: + L += [ + ("fg:ansibrightblack", " ↑↓ browse\n"), + ("fg:ansigreen bold", " Enter activate\n"), + ("fg:ansibrightblack", " N new profile\n"), + ("fg:ansibrightblack", " Tab configure →\n"), + ("fg:ansired", " Ctrl+C exit\n"), + ] + else: + L += [ + ("fg:ansibrightblack", " Tab ← switch here\n"), + ] + + return L + + +# ── right panel: agent config ───────────────────────────────────────────────── + + +def render_agent_config( + agent_models: Dict[Task, str], + agent_idx: int, + focused: bool, + prof_name: str, + status: str, + active_name: Optional[str], +) -> list: + """Agent-model assignment list with status line and key hints.""" + is_active = bool(prof_name) and prof_name == active_name + + header_color = "bold cyan" if focused else "bold" + active_badge = ( + ("fg:ansigreen", " ✓ active") + if is_active + else ("fg:ansibrightblack", " (preview)") + ) + display_name = trunc(prof_name, 32) if prof_name else "—" + + L: list = [ + (header_color, f" {display_name}"), + active_badge, + ("", "\n"), + ("fg:ansibrightblack", " ─────────────────────────────────────────\n\n"), + ] + + for idx, task in enumerate(_TASKS): + label = task.name.lower() + model = trunc(agent_models.get(task, "—"), 36) + is_sel = idx == agent_idx + + if is_sel and focused: + L += [ + ("fg:ansigreen bold", f" ▶ {label:<12}"), + ("fg:ansigreen", model), + ("", "\n"), + ] + elif is_sel: + L += [ + ("fg:ansicyan bold", f" ▶ {label:<12}"), + ("fg:ansicyan", model), + ("", "\n"), + ] + else: + row_color = "" if focused else "fg:ansibrightblack" + model_color = "fg:ansicyan" if focused else "fg:ansibrightblack" + L += [ + (row_color, f" {label:<12}"), + (model_color, model), + ("", "\n"), + ] + + L += [("", "\n")] + + if status: + err = status.lower().startswith("fail") or status.lower().startswith("error") + L += [("fg:ansired" if err else "fg:ansigreen", f" {status}\n"), ("", "\n")] + else: + L += [("", "\n")] + + if focused: + L += [ + ("fg:ansibrightblack", " ↑↓ navigate agents\n"), + ("fg:ansigreen bold", " Enter pick model\n"), + ] + if is_active: + L += [("fg:ansigreen bold", " S save changes\n")] + else: + L += [("fg:ansiyellow", " (activate profile to save)\n")] + L += [ + ("fg:ansibrightblack", " Tab ← profiles\n"), + ("fg:ansired", " Ctrl+C exit\n"), + ] + else: + L += [("fg:ansibrightblack", " Tab switch here\n")] + + return L + + +# ── right panel: model picker overlay ───────────────────────────────────────── + + +def render_model_picker( + task: Task, + model_names: List[str], + pick_idx: int, + scroll: int, + current: str, +) -> list: + """Scrollable model list — replaces agent config while picking.""" + L: list = [ + ("bold cyan", f" Model for '{task.name.lower()}'\n"), + ("fg:ansibrightblack", " ──────────────────────────────────────────\n\n"), + ] + total = len(model_names) + end = min(scroll + VISIBLE, total) + + L += ( + [("fg:ansibrightblack", f" ↑ {scroll} more above\n")] + if scroll > 0 + else [("", "\n")] + ) + + for i in range(scroll, end): + m = model_names[i] + mark = " ✓" if m == current else " " + if i == pick_idx: + L += [("fg:ansigreen bold", f" ▶{mark} {trunc(m, 38)}"), ("", "\n")] + else: + color = "fg:ansicyan" if m == current else "fg:ansibrightblack" + L += [(color, f" {mark} {trunc(m, 38)}"), ("", "\n")] + + rem = total - end + L += ( + [("fg:ansibrightblack", f" ↓ {rem} more below\n")] + if rem > 0 + else [("", "\n")] + ) + + L += [ + ("", "\n"), + ("fg:ansibrightblack", f" {pick_idx + 1} / {total}\n\n"), + ("fg:ansigreen bold", " Enter confirm\n"), + ("fg:ansiyellow", " Esc cancel\n"), + ] + return L + + +# ── right panel: naming overlay ──────────────────────────────────────────────── + + +def render_naming_panel(name_input: str, status: str) -> list: + """Inline text input for naming a new profile.""" + L: list = [ + ("bold cyan", " New Profile\n"), + ("fg:ansibrightblack", " ──────────────────────────────────────────\n\n"), + ("", "\n"), + ("fg:ansibrightblack", " Name: "), + ("fg:ansigreen bold", name_input), + ("fg:ansigreen", "█"), # cursor + ("", "\n\n"), + ("fg:ansibrightblack", " Use letters, digits, hyphens, underscores\n\n"), + ] + if status: + err = status.lower().startswith("fail") or status.lower().startswith("invalid") + L += [("fg:ansired" if err else "fg:ansigreen", f" {status}\n"), ("", "\n")] + L += [ + ("", "\n"), + ("fg:ansigreen bold", " Enter create profile\n"), + ("fg:ansiyellow", " Esc cancel\n"), + ] + return L diff --git a/code_puppy/command_line/config_commands.py b/code_puppy/command_line/config_commands.py index 8724ef24e..c273c6132 100644 --- a/code_puppy/command_line/config_commands.py +++ b/code_puppy/command_line/config_commands.py @@ -91,6 +91,610 @@ def handle_show_command(command: str) -> bool: return True +def _show_profile_wizard() -> None: + """Show first-time wizard explaining the profile system.""" + from rich import box + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + from code_puppy.messaging import emit_info, emit_success + + # Title panel + title = Panel( + Text.from_markup(""" +[bold bright_white]⚡ Advanced Feature: Model Profiles[/bold bright_white] + +[dim]This feature lets you configure different models for different agent roles,[/dim] +[dim]and save/load named profiles for quick switching.[/dim] +"""), + border_style="bright_cyan", + box=box.ROUNDED, + padding=(1, 2), + ) + emit_info(title) + + # Explanation table + table = Table( + show_header=True, + header_style="bold bright_magenta", + box=box.SIMPLE, + padding=(0, 2), + ) + table.add_column("Agent", style="bright_cyan", width=12) + table.add_column("What It Does", style="bright_green", width=45) + table.add_column("Why Override?", style="bright_yellow", width=30) + + table.add_row( + "main", "Your normal conversations with the agent", "Default for everything" + ) + table.add_row( + "compaction", + "Summarizes old messages when context fills up", + "[dim]Use a cheaper/faster model[/dim]", + ) + table.add_row( + "subagent", + "Delegated tasks via invoke_agent() tool", + "[dim]Use a balanced model[/dim]", + ) + + emit_info(table) + + # How it works + how_it_works = Text.from_markup(""" +[bold]How It Works:[/bold] + + [cyan]1.[/cyan] [dim]Set an agent model:[/dim] [green]/profile set compaction gpt-4.1-nano[/green] + [cyan]2.[/cyan] [dim]Save as profile:[/dim] [green]/profile save cheap-fast[/green] + [cyan]3.[/cyan] [dim]Load later:[/dim] [green]/profile load cheap-fast[/green] + +[bold]Example Use Cases:[/bold] + + • [bright_yellow]Cost Saving:[/bright_yellow] Use Cerebras/GPT-nano for compaction instead of Claude Opus + • [bright_yellow]Speed:[/bright_yellow] Use a fast model for subagent tasks + • [bright_yellow]Multi-Provider:[/bright_yellow] Save profiles for Gemini, Claude, OpenAI, etc. +""") + emit_info( + Panel( + how_it_works, border_style="bright_black", box=box.ROUNDED, padding=(0, 1) + ) + ) + + # Quick reference + quick_ref = Text.from_markup(""" +[dim]Quick Reference:[/dim] + [green]/profile[/green] [dim]# View current settings[/dim] + [green]/profile set [/green] [dim]# Set agent model (Tab to autocomplete!)[/dim] + [green]/profile list[/green] [dim]# List saved profiles[/dim] + [green]/profile save [/green] [dim]# Save current as profile[/dim] + [green]/profile load [/green] [dim]# Load a profile[/dim] + [green]/profile reset[/green] [dim]# Clear all overrides[/dim] +""") + emit_info(quick_ref) + + emit_success("✅ Run /profile anytime to manage your model profiles!") + + +@register_command( + name="profile", + description="Manage model profiles - view, set, save, and load named configurations", + usage="/profile [new|set|save|load|list|delete|reset|guide] [agent] [model]", + aliases=["profiles"], + category="config", + detailed_help="""Model Profile Management + +View current settings: + /profile Show current agent model configurations + +Create a new profile (TUI wizard): + /profile new Open interactive wizard — pre-filled with current models + /profile new Same, but pre-fill the profile name + +Set an agent model: + /profile set Set a specific model for an agent role + /profile Shorthand form + +Named Profiles: + /profile save Save current settings as a named profile + /profile load Load a saved profile + /profile list List all saved profiles + /profile delete Delete a saved profile + +Reset: + /profile reset Clear all agent-specific overrides + /profile reset Reset a single agent to default + +Examples: + /profile # View current configuration + /profile new # Launch profile creation wizard + /profile new my-gpt4 # Wizard with name pre-filled + /profile set compaction gpt-4.1-nano # Set compaction agent model + /profile set subagent claude-3-5-haiku # Set sub-agent model + /profile save gemini # Save as "gemini" profile + /profile load gemini # Load "gemini" profile + /profile list # Show all saved profiles + +Available agents: + main - Main conversation model (global default) + compaction - Message summarization / context compaction + subagent - Delegated sub-agent invocations +""", +) +def handle_profile_command(command: str) -> bool: + """Handle the /profile command for agent model and profile configuration.""" + from rich.text import Text + + from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning + from code_puppy.config import get_value, set_value + from code_puppy.task_models import ( + Task, + TASK_CONFIGS, + clear_active_profile, + clear_model_for, + delete_profile, + get_active_profile, + get_model_for, + load_profile, + save_profile, + set_model_for, + ) + from code_puppy.command_line.model_picker_completion import load_model_names + + # ── helpers ──────────────────────────────────────────────────────────────── + _configurable = list(Task) # All tasks including MAIN are configurable + _agent_names = ", ".join(t.name.lower() for t in _configurable) + + def _resolve_agent(name: str) -> Task | None: + """Return the Task for *name* (case-insensitive), or None.""" + try: + return Task[name.upper()] + except KeyError: + return None + + def _set_agent_model(task: Task, model_name: str) -> bool: + """Validate *model_name* and apply it; return True on success.""" + try: + available = load_model_names() + except Exception as exc: + emit_warning( + f"Could not load model list: {exc}. Check your models config and retry." + ) + emit_info("Use /model to browse available models.") + return False + if model_name not in available: + emit_warning(f"Model '{model_name}' not in known models list.") + emit_info("Use /model to browse available models.") + return False + set_model_for(task, model_name) + emit_success( + f"✅ {task.name.lower()} agent → [bold cyan]{model_name}[/bold cyan]" + ) + _display_profile_table() + return True + + parts = command.strip().split() + subcommand = parts[1].lower() if len(parts) > 1 else "" + + # ── check first-time wizard (only short-circuit bare /profile) ─────────────── + if not get_value("profile_wizard_shown"): + _show_profile_wizard() + set_value("profile_wizard_shown", "true") + if len(parts) == 1: + return True # bare /profile - stop after wizard + + # ── /profile ── open the TUI directly ───────────────────────────────────── + if len(parts) == 1: + import asyncio + import concurrent.futures + + from code_puppy.command_line.profile_new_tui import interactive_new_profile_tui + + # Pre-fill the name with the active profile so the user can edit and + # re-save without having to re-type the name. + initial_name = get_active_profile() or "" + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: asyncio.run(interactive_new_profile_tui(initial_name)) + ) + saved = future.result(timeout=300) + if saved: + _display_profile_table() + return True + + # ── /profile list ────────────────────────────────────────────────────────── + if subcommand == "list": + _display_profiles_list() + return True + + # ── /profile guide ───────────────────────────────────────────────────────── + if subcommand == "guide": + _show_profile_wizard() + return True + + # ── /profile new [name] ──────────────────────────────────────────────────── + if subcommand in ("new", "create"): + import asyncio + import concurrent.futures + + from code_puppy.command_line.profile_new_tui import interactive_new_profile_tui + + initial_name = parts[2] if len(parts) > 2 else "" + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: asyncio.run(interactive_new_profile_tui(initial_name)) + ) + saved = future.result(timeout=300) + if saved: + _display_profile_table() + return True + + # ── /profile save ─────────────────────────────────────────────────── + if subcommand == "save": + if len(parts) < 3: + emit_error("Usage: /profile save ") + return True + name = parts[2] + description = " ".join(parts[3:]) if len(parts) > 3 else "" + if save_profile(name, description): + emit_success(f"✅ Saved profile '{name}'") + _display_profile_table() + else: + emit_error( + "Failed to save profile. Name must be alphanumeric with dashes/underscores." + ) + return True + + # ── /profile load ─────────────────────────────────────────────────── + if subcommand == "load": + if len(parts) < 3: + emit_error("Usage: /profile load ") + return True + name = parts[2] + success, message = load_profile(name) + if success: + emit_success(f"✅ {message}") + _display_profile_table() + else: + emit_error(message) + return True + + # ── /profile delete ───────────────────────────────────────────────── + if subcommand in ("delete", "rm", "remove"): + if len(parts) < 3: + emit_error("Usage: /profile delete ") + return True + name = parts[2] + success, message = delete_profile(name) + if success: + emit_success(f"✅ {message}") + else: + emit_error(message) + return True + + # ── /profile reset [agent] ───────────────────────────────────────────────── + if subcommand in ("reset", "clear"): + if len(parts) >= 3: + # Reset a single agent + task = _resolve_agent(parts[2]) + if task is None or task == Task.MAIN: + emit_error(f"Unknown agent: {parts[2]}") + emit_info(f"Available agents: {_agent_names}") + return True + clear_model_for(task) + emit_success(f"✅ Reset {task.name.lower()} agent to default model") + else: + # Reset all + clear_active_profile() + emit_success("✅ Cleared all agent model overrides") + emit_info("All agents now use the global default model.") + _display_profile_table() + return True + + # ── /profile set ─────────────────────────────────────────── + if subcommand == "set": + if len(parts) < 4: + emit_error("Usage: /profile set ") + emit_info(f"Available agents: {_agent_names}") + return True + task = _resolve_agent(parts[2]) + if task is None or task == Task.MAIN: + emit_error(f"Unknown agent: {parts[2]}") + emit_info(f"Available agents: {_agent_names}") + return True + model_name = " ".join(parts[3:]) + _set_agent_model(task, model_name) + return True + + # ── /profile [model] (shorthand) ───────────────────────────────── + task = _resolve_agent(parts[1]) + if task is not None and task != Task.MAIN: + if len(parts) == 2: + # Show info for this agent + config = TASK_CONFIGS.get(task) + current_model = get_model_for(task) + if config: + emit_info( + Text.from_markup( + f"[bold cyan]{task.name.lower()}[/bold cyan]: {config.description}" + ) + ) + emit_info( + Text.from_markup(f" Current model: [cyan]{current_model}[/cyan]") + ) + if config: + emit_info( + Text.from_markup( + f" Set with: [dim]/profile set {task.name.lower()} [/dim]" + ) + ) + else: + model_name = " ".join(parts[2:]) + _set_agent_model(task, model_name) + return True + + # ── unknown ──────────────────────────────────────────────────────────────── + emit_error(f"Unknown agent or subcommand: {parts[1]}") + emit_info(f"Available agents: {_agent_names}") + emit_info("Subcommands: new, set, save, load, list, delete, reset, guide") + return True + + +def _display_profiles_list() -> None: + """Display all saved profiles.""" + from rich import box + from rich.table import Table + from rich.text import Text + + from code_puppy.messaging import emit_info, emit_warning + from code_puppy.task_models import list_profiles, get_active_profile + + profiles = list_profiles() + active = get_active_profile() + + if not profiles: + emit_warning("No saved profiles found.") + emit_info( + Text.from_markup("\n[dim]Create one with: /profile save [/dim]") + ) + return + + table = Table( + title="[bold bright_white]📚 Saved Profiles[/bold bright_white]", + show_header=True, + header_style="bold bright_magenta", + box=box.ROUNDED, + border_style="bright_black", + padding=(0, 1), + ) + table.add_column("Name", style="bright_cyan", width=15) + table.add_column("Models", style="bright_green", width=40) + table.add_column("Status", style="bright_yellow", width=10) + + for profile in profiles: + name = profile["name"] + models_str = ", ".join(f"{k}={v}" for k, v in profile.get("models", {}).items()) + + if active == name: + status = "[bold green]● active[/bold green]" + else: + status = "" + + table.add_row(name, models_str[:40], status) + + emit_info(table) + emit_info( + Text.from_markup( + "\n[dim]Usage: /profile load to activate a profile[/dim]" + ) + ) + + +def _display_profile_table() -> None: + """Display the current model profile configuration as a rich table. + + Terminal-aware rendering that adapts to width: + - Wide (>100 cols): Full table with all columns + - Medium (70-100 cols): Compact table without recommended column + - Narrow (<70 cols): Minimal list format + """ + import shutil + + from code_puppy.task_models import get_all_task_configs + + # Get terminal dimensions + try: + term_width, _ = shutil.get_terminal_size((80, 24)) + except Exception: + term_width, _ = 80, 24 + + configs = get_all_task_configs() + + # Determine render mode based on terminal width + if term_width >= 100: + # Wide mode: Full table with all columns + _render_wide_table(configs, term_width) + elif term_width >= 70: + # Medium mode: Compact table without recommended + _render_medium_table(configs, term_width) + else: + # Narrow mode: List format + _render_narrow_list(configs, term_width) + + # Add helpful footer (adapted to width) + _render_footer(term_width) + + +def _render_wide_table(configs: dict, term_width: int) -> None: + """Render full table for wide terminals (>=100 cols).""" + from rich import box + from rich.table import Table + + from code_puppy.messaging import emit_info + from code_puppy.task_models import Task + + # Calculate column widths based on terminal width + model_width = min(32, term_width - 40) + + table = Table( + title="[bold bright_white]📋 Model Profile[/bold bright_white]", + show_header=True, + header_style="bold bright_magenta", + box=box.ROUNDED, + border_style="bright_black", + title_justify="center", + padding=(0, 1), + ) + table.add_column("Agent", style="bright_cyan", width=12, no_wrap=True) + table.add_column("Model", style="bright_green", width=model_width) + table.add_column("Status", style="bright_yellow", width=16, no_wrap=True) + + for task in Task: + info = configs.get(task) + if not info: + continue + + effective = info["effective"] or "default" + agent_label = task.name.lower() + + # Determine status with clear language + if info["is_custom"]: + status = "✓ set" + status_style = "bold bright_green" + model_display = f"[bold bright_green]{effective}[/bold bright_green]" + elif task == Task.MAIN: + status = "default" + status_style = "dim" + model_display = effective + else: + status = "← default" + status_style = "dim" + model_display = effective + + table.add_row( + f"[bright_cyan]{agent_label}[/bright_cyan]", + model_display, + f"[{status_style}]{status}[/{status_style}]", + ) + + emit_info(table) + + +def _render_medium_table(configs: dict, term_width: int) -> None: + """Render compact table for medium terminals (70-99 cols).""" + from rich import box + from rich.table import Table + + from code_puppy.messaging import emit_info + from code_puppy.task_models import Task + + model_width = min(24, term_width - 32) + + table = Table( + title="[bold bright_white]📋 Model Profile[/bold bright_white]", + show_header=True, + header_style="bold bright_magenta", + box=box.SIMPLE, + border_style="bright_black", + padding=(0, 1), + ) + table.add_column("Agent", style="bright_cyan", width=12, no_wrap=True) + table.add_column("Model", style="bright_green", width=model_width) + table.add_column("", width=8, no_wrap=True) + + for task in Task: + info = configs.get(task) + if not info: + continue + + effective = info["effective"] or "default" + + # Simple status indicator + if info["is_custom"]: + status = "✓" + model_display = f"[bold bright_green]{effective}[/bold bright_green]" + elif task == Task.MAIN: + status = "" + model_display = effective + else: + status = "←" + model_display = effective + + table.add_row(task.name.lower(), model_display, status) + + emit_info(table) + + +def _render_narrow_list(configs: dict, term_width: int) -> None: + """Render compact list for narrow terminals (<70 cols).""" + from rich.console import Group + from rich.panel import Panel + from rich.text import Text + + from code_puppy.messaging import emit_info + from code_puppy.task_models import Task + + lines = [] + + for task in Task: + info = configs.get(task) + if not info: + continue + + effective = info["effective"] or "default" + + # Simple status icon + if info["is_custom"]: + icon = "✓" + style = "bold bright_green" + elif task == Task.MAIN: + icon = " " + style = "bright_green" + else: + icon = "←" + style = "dim bright_green" + + # Truncate model name if needed + max_model_len = max(10, term_width - 18) + if len(effective) > max_model_len: + effective = effective[: max_model_len - 2] + ".." + + lines.append( + Text.from_markup( + f"{icon} [bright_cyan]{task.name.lower():10}[/bright_cyan] [{style}]{effective}[/{style}]" + ) + ) + + group = Group(*lines) + panel = Panel( + group, + title="[bold bright_white]📋 Profiles[/bold bright_white]", + border_style="bright_black", + padding=(0, 1), + ) + emit_info(panel) + + +def _render_footer(term_width: int) -> None: + """Render helpful footer adapted to terminal width.""" + from rich.text import Text + + from code_puppy.messaging import emit_info + + if term_width >= 80: + footer = Text.from_markup( + "\n[dim]💡 [bold]Usage:[/bold] /profile set │ " + "[bold]Example:[/bold] /profile set compaction gpt-4.1-nano │ " + "[bold]Reset:[/bold] /profile reset[/dim]" + ) + else: + footer = Text.from_markup( + "\n[dim]💡 /profile set │ /profile reset[/dim]" + ) + + emit_info(footer) + + @register_command( name="reasoning", description="Set OpenAI reasoning effort for GPT-5 models (e.g., /reasoning high)", diff --git a/code_puppy/command_line/profile_completion.py b/code_puppy/command_line/profile_completion.py new file mode 100644 index 000000000..4b6bf834f --- /dev/null +++ b/code_puppy/command_line/profile_completion.py @@ -0,0 +1,226 @@ +""" +Tab-completion for the /profile command. + +Provides context-aware completions: + /profile → subcommands + agent role shortcuts + /profile set → agent role names (compaction, subagent, …) + /profile set → model names with provider hints + /profile reset → agent role names + /profile load|delete → saved profile names +""" + +from typing import Iterable + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document + +# ── Agent roles that can be configured ──────────────────────────────────────── +# These match Task enum member names (lowercase) from task_models.py, +# excluding MAIN (the global default which isn't set through /profile). +AGENT_ROLES: dict[str, str] = { + "compaction": "Summarization / context-compaction model", + "subagent": "Sub-agent dispatch model", +} + +# ── /profile subcommands ─────────────────────────────────────────────────────── +PROFILE_SUBCOMMANDS: dict[str, str] = { + "new": "Create a new profile with the TUI wizard", + "create": "Create a new profile with the TUI wizard (alias for new)", + "set": "Set model for an agent role", + "reset": "Reset an agent role to its default", + "save": "Save current config as a named profile", + "load": "Load a named profile", + "list": "List all saved profiles", + "delete": "Delete a named profile", + "guide": "Show configuration reference", +} + + +# ── Lazy data loaders (never raise) ─────────────────────────────────────────── + + +def _load_profile_names() -> list[str]: + try: + from code_puppy.task_models import list_profiles + + return [p["name"] for p in list_profiles()] + except Exception: + return [] + + +def _load_model_names() -> list[str]: + try: + from code_puppy.command_line.model_picker_completion import load_model_names + + return load_model_names() + except Exception: + return [] + + +def _model_provider_hint(model_name: str) -> str: + """Short provider label derived from the model name.""" + lower = model_name.lower() + if ( + model_name.startswith("openai:") + or "gpt" in lower + or "o1" in lower + or "o3" in lower + ): + return "OpenAI" + if model_name.startswith("anthropic:") or "claude" in lower: + return "Anthropic" + if model_name.startswith("google-gla:") or "gemini" in lower: + return "Google" + if model_name.startswith("groq:") or "llama" in lower or "mixtral" in lower: + return "Groq" + if model_name.startswith("mistral:"): + return "Mistral" + if model_name.startswith("cerebras:") or "cerebras" in lower or "glm" in lower: + return "Cerebras" + if model_name.startswith("xai:") or "grok" in lower: + return "xAI" + if ":" in model_name: + return model_name.split(":", 1)[0].title() + return "model" + + +# ── Completer ───────────────────────────────────────────────────────────────── + + +class ProfileCompleter(Completer): + """ + Context-aware tab-completion for ``/profile``. + + Plugs into the prompt_toolkit completion pipeline alongside the existing + SlashCommandCompleter and ModelNameCompleter. + """ + + TRIGGER = "/profile" + + def get_completions( + self, document: Document, complete_event + ) -> Iterable[Completion]: + text = document.text_before_cursor + stripped = text.lstrip() + + if not stripped.startswith(self.TRIGGER): + return + + # Slice off everything before and including "/profile" + trigger_pos = text.find(self.TRIGGER) + after = text[trigger_pos + len(self.TRIGGER) :] + + # Nothing typed yet (cursor right after "/profile") — don't complete + if not after: + return + + tokens = after.split() + ends_with_space = after.endswith(" ") + + # ── /profile → subcommands + role shortcuts ─────────────── + if len(tokens) == 0 or (len(tokens) == 1 and not ends_with_space): + partial = tokens[0] if tokens else "" + # Agent role shortcuts (e.g. /profile compaction gpt-4o) + for name, meta in AGENT_ROLES.items(): + if name.startswith(partial): + yield Completion( + name, + start_position=-len(partial), + display_meta=meta, + ) + # Subcommands + for name, meta in PROFILE_SUBCOMMANDS.items(): + if name.startswith(partial): + yield Completion( + name, + start_position=-len(partial), + display_meta=meta, + ) + return + + sub = tokens[0] + + # ── /profile set … ──────────────────────────────────────────────────── + if sub == "set": + if len(tokens) == 1 and ends_with_space: + # /profile set → agent roles + for name, meta in AGENT_ROLES.items(): + yield Completion(name, display_meta=meta) + + elif len(tokens) == 2 and not ends_with_space: + # /profile set comp + partial = tokens[1] + for name, meta in AGENT_ROLES.items(): + if name.startswith(partial): + yield Completion( + name, + start_position=-len(partial), + display_meta=meta, + ) + + elif (len(tokens) == 2 and ends_with_space) or ( + len(tokens) == 3 and not ends_with_space + ): + # /profile set compaction → model names + partial = tokens[2] if len(tokens) == 3 else "" + yield from _model_completions(partial) + + return + + # ── /profile … (shorthand: /profile compaction gpt-4o) ─────── + if sub in AGENT_ROLES: + if len(tokens) == 1 and ends_with_space: + yield from _model_completions("") + elif len(tokens) == 2 and not ends_with_space: + yield from _model_completions(tokens[1]) + return + + # ── /profile reset → agent roles ─────────────────────── + if sub == "reset": + if len(tokens) == 1 and ends_with_space: + for name, meta in AGENT_ROLES.items(): + yield Completion(name, display_meta=meta) + elif len(tokens) == 2 and not ends_with_space: + partial = tokens[1] + for name, meta in AGENT_ROLES.items(): + if name.startswith(partial): + yield Completion( + name, + start_position=-len(partial), + display_meta=meta, + ) + return + + # ── /profile load|delete → saved profile names ───────── + if sub in ("load", "delete"): + profiles = _load_profile_names() + if len(tokens) == 1 and ends_with_space: + for name in profiles: + yield Completion(name, display_meta="saved profile") + elif len(tokens) == 2 and not ends_with_space: + partial = tokens[1] + for name in profiles: + if name.startswith(partial): + yield Completion( + name, + start_position=-len(partial), + display_meta="saved profile", + ) + + +def _model_completions(partial: str) -> Iterable[Completion]: + """Yield model name completions filtered by *partial*, with provider hints.""" + models = _load_model_names() + partial_lower = partial.lower() + for model in models: + model_lower = model.lower() + if ( + not partial + or model_lower.startswith(partial_lower) + or partial_lower in model_lower + ): + yield Completion( + model, + start_position=-len(partial), + display_meta=_model_provider_hint(model), + ) diff --git a/code_puppy/command_line/profile_new_tui.py b/code_puppy/command_line/profile_new_tui.py new file mode 100644 index 000000000..6bf3a7899 --- /dev/null +++ b/code_puppy/command_line/profile_new_tui.py @@ -0,0 +1,403 @@ +"""Profile TUI — dual-panel: profile list on the left, agent config on the right. + +Both panels are always visible. Tab switches which panel has keyboard focus. +Navigating profiles in the left panel live-previews their model assignments on +the right. Press Enter on a profile to activate it; then Tab to the right panel +to tweak individual agent models, and S to save. + +Key bindings +──────────── + Tab switch focus between panels + ↑ / ↓ navigate (profiles or agents) + Enter activate profile (left) · open model picker (right) · confirm pick + Esc cancel model picker / cancel naming + N new profile (inline name input in right panel) + S save agent-model changes to the active profile (right panel) + Ctrl+C exit +""" + +from typing import Dict, List, Optional + +from prompt_toolkit.application import Application +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Dimension, Layout, VSplit, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.widgets import Frame + +from code_puppy.command_line._profile_tui_panels import ( + VISIBLE, + load_models, + render_agent_config, + render_model_picker, + render_naming_panel, + render_profile_list, + valid_name, +) +from code_puppy.task_models import ( + TASK_CONFIGS, + Task, + get_active_profile, + get_model_for, + list_profiles, + load_profile, + save_profile_from_models, +) +from code_puppy.tools.command_runner import set_awaiting_user_input + +_TASKS: List[Task] = list(TASK_CONFIGS.keys()) +_FOCUS_PROFILES = "profiles" +_FOCUS_AGENTS = "agents" + + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def _models_from_profile(profile: dict) -> Dict[Task, str]: + """Build a Task→model dict from a raw profile dict.""" + raw = profile.get("models", {}) + base = {t: get_model_for(t) for t in _TASKS} + for task in _TASKS: + val = raw.get(task.name.lower()) + if val: + base[task] = val + return base + + +def _desc_for_profile(name: str) -> str: + try: + for p in list_profiles(): + if p.get("name") == name: + return p.get("description", "") + except Exception: + pass + return "" + + +# ── main TUI ────────────────────────────────────────────────────────────────── + + +async def interactive_new_profile_tui(initial_name: str = "") -> Optional[str]: + """ + Dual-panel profile TUI. + + Args: + initial_name: Profile to highlight/pre-select on open. + + Returns: + Name of the last activated profile, or ``None``. + """ + + # ── mutable state ───────────────────────────────────────────────────────── + profiles: List[List[dict]] = [[]] + prof_idx = [0] + focus = [_FOCUS_PROFILES] + + agent_idx = [0] + agent_models: List[Dict[Task, str]] = [{t: get_model_for(t) for t in _TASKS}] + + # model-picker overlay (shown in right panel instead of agent config) + picking = [False] + pick_task: List[Optional[Task]] = [None] + pick_names: List[List[str]] = [[]] + pick_idx = [0] + pick_scroll = [0] + + # naming mode (inline text input for new profile name) + naming = [False] + name_input = [""] + + status = [""] + last_activated: List[Optional[str]] = [None] + + # ── state helpers ───────────────────────────────────────────────────────── + + def reload_profiles(): + try: + profiles[0] = list_profiles() + except Exception: + profiles[0] = [] + active = get_active_profile() + prof_idx[0] = 0 + for i, p in enumerate(profiles[0]): + if p.get("name") == active: + prof_idx[0] = i + break + # honour initial_name on first load + if initial_name and not last_activated[0]: + for i, p in enumerate(profiles[0]): + if p.get("name") == initial_name: + prof_idx[0] = i + break + + def sync_agent_models(): + """Update right panel to reflect the currently highlighted profile.""" + ps = profiles[0] + if ps and 0 <= prof_idx[0] < len(ps): + agent_models[0] = _models_from_profile(ps[prof_idx[0]]) + else: + agent_models[0] = {t: get_model_for(t) for t in _TASKS} + + reload_profiles() + sync_agent_models() + + # ── widgets ─────────────────────────────────────────────────────────────── + left_ctrl = FormattedTextControl(text="") + right_ctrl = FormattedTextControl(text="") + + def refresh(): + active = get_active_profile() + left_ctrl.text = render_profile_list( + profiles[0], + prof_idx[0], + active, + focus[0] == _FOCUS_PROFILES and not naming[0], + ) + prof_name = profiles[0][prof_idx[0]].get("name", "") if profiles[0] else "" + if naming[0]: + right_ctrl.text = render_naming_panel(name_input[0], status[0]) + elif picking[0] and pick_task[0] is not None: + right_ctrl.text = render_model_picker( + pick_task[0], + pick_names[0], + pick_idx[0], + pick_scroll[0], + agent_models[0].get(pick_task[0], ""), + ) + else: + right_ctrl.text = render_agent_config( + agent_models[0], + agent_idx[0], + focus[0] == _FOCUS_AGENTS, + prof_name, + status[0], + active, + ) + + layout = Layout( + VSplit( + [ + Frame( + Window(content=left_ctrl, wrap_lines=False), + title="Profiles", + width=Dimension(weight=36), + ), + Frame( + Window(content=right_ctrl, wrap_lines=False), + title="Configure", + width=Dimension(weight=64), + ), + ] + ) + ) + + # ── key bindings ────────────────────────────────────────────────────────── + kb = KeyBindings() + + @kb.add("tab") + def _tab(event): + if picking[0] or naming[0]: + return + focus[0] = _FOCUS_AGENTS if focus[0] == _FOCUS_PROFILES else _FOCUS_PROFILES + status[0] = "" + refresh() + + @kb.add("up") + def _up(event): + if naming[0]: + return + if picking[0]: + if pick_idx[0] > 0: + pick_idx[0] -= 1 + if pick_idx[0] < pick_scroll[0]: + pick_scroll[0] = pick_idx[0] + refresh() + elif focus[0] == _FOCUS_PROFILES: + if prof_idx[0] > 0: + prof_idx[0] -= 1 + sync_agent_models() + status[0] = "" + refresh() + else: + if agent_idx[0] > 0: + agent_idx[0] -= 1 + status[0] = "" + refresh() + + @kb.add("down") + def _down(event): + if naming[0]: + return + if picking[0]: + if pick_idx[0] < len(pick_names[0]) - 1: + pick_idx[0] += 1 + if pick_idx[0] >= pick_scroll[0] + VISIBLE: + pick_scroll[0] = pick_idx[0] - VISIBLE + 1 + refresh() + elif focus[0] == _FOCUS_PROFILES: + if prof_idx[0] < len(profiles[0]) - 1: + prof_idx[0] += 1 + sync_agent_models() + status[0] = "" + refresh() + else: + if agent_idx[0] < len(_TASKS) - 1: + agent_idx[0] += 1 + status[0] = "" + refresh() + + @kb.add("enter") + def _enter(event): + if naming[0]: + # confirm new profile name + v = name_input[0].strip() + if v and valid_name(v): + active_desc = _desc_for_profile(get_active_profile() or "") + if save_profile_from_models(v, active_desc, agent_models[0]): + ok2, _ = load_profile(v) + if ok2: + last_activated[0] = v + reload_profiles() + sync_agent_models() + status[0] = f"Created '{v}' — Tab to configure" + else: + status[0] = "Failed to create profile" + else: + status[0] = "Invalid name — use letters, digits, - or _" + refresh() + return + naming[0] = False + name_input[0] = "" + refresh() + return + + if picking[0]: + # confirm model selection + if pick_names[0] and pick_task[0] is not None: + chosen = pick_names[0][pick_idx[0]] + agent_models[0][pick_task[0]] = chosen + status[0] = f"Set {pick_task[0].name.lower()} → {chosen[:28]}" + picking[0] = False + pick_task[0] = None + refresh() + + elif focus[0] == _FOCUS_PROFILES: + # activate highlighted profile + ps = profiles[0] + if not ps: + return + pname = ps[prof_idx[0]].get("name", "") + if pname: + ok, msg = load_profile(pname) + if ok: + last_activated[0] = pname + reload_profiles() + sync_agent_models() + status[0] = f"'{pname}' is now active — Tab to configure" + else: + status[0] = f"Failed: {msg}" + refresh() + + else: + # open model picker for highlighted agent + task = _TASKS[agent_idx[0]] + names = load_models() + if not names: + status[0] = "No models available" + refresh() + return + cur = agent_models[0].get(task, "") + start = names.index(cur) if cur in names else 0 + pick_task[0] = task + pick_names[0] = names + pick_idx[0] = start + pick_scroll[0] = max(0, start - VISIBLE // 2) + picking[0] = True + status[0] = "" + refresh() + + @kb.add("escape") + def _esc(event): + if naming[0]: + naming[0] = False + name_input[0] = "" + status[0] = "" + refresh() + elif picking[0]: + picking[0] = False + pick_task[0] = None + status[0] = "" + refresh() + + @kb.add("n") + def _kn(event): + if picking[0] or naming[0]: + return + naming[0] = True + name_input[0] = "" + status[0] = "" + refresh() + + @kb.add("backspace") + def _kbs(event): + if naming[0] and name_input[0]: + name_input[0] = name_input[0][:-1] + refresh() + + # Handle printable characters for naming mode + @kb.add("") + def _kany(event): + if naming[0]: + ch = event.key_sequence[0].key + if len(ch) == 1 and ch.isprintable(): + # Only allow alphanumeric, hyphen, underscore + if ch.isalnum() or ch in "-_": + name_input[0] += ch + refresh() + + @kb.add("s") + def _ks(event): + if picking[0] or naming[0] or focus[0] != _FOCUS_AGENTS: + return + active = get_active_profile() + prof_name = profiles[0][prof_idx[0]].get("name", "") if profiles[0] else "" + if not active or prof_name != active: + status[0] = "Activate this profile first (Enter in left panel)" + refresh() + return + # Save the current agent_models to the active profile + if save_profile_from_models(active, _desc_for_profile(active), agent_models[0]): + reload_profiles() + status[0] = f"Saved '{active}'" + else: + status[0] = "Save failed" + refresh() + + @kb.add("c-c") + def _kcc(event): + event.app._ptu = "cancel" # type: ignore[attr-defined] + event.app.exit() + + # ── run loop ────────────────────────────────────────────────────────────── + app = Application( + layout=layout, + key_bindings=kb, + full_screen=True, + mouse_support=False, + cursor=CursorShape.BLOCK, + ) + app._ptu = None # type: ignore[attr-defined] + + set_awaiting_user_input(True) + + try: + # Initial render and run the app - all state changes happen inline + # without exiting/restarting, so no screen flash + refresh() + await app.run_async() + # TUI exited - return the last activated profile + # (no emit here to avoid noise - the status line already showed feedback) + return last_activated[0] + + finally: + set_awaiting_user_input(False) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 29b88f3ec..58b87fffa 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -40,6 +40,7 @@ get_active_model, ) from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter +from code_puppy.command_line.profile_completion import ProfileCompleter from code_puppy.command_line.skills_completion import SkillsCompleter from code_puppy.command_line.utils import list_directory from code_puppy.config import ( @@ -574,6 +575,7 @@ async def get_input_with_combined_completion( UnpinCompleter(trigger="/unpin"), AgentCompleter(trigger="/agent"), AgentCompleter(trigger="/a"), + ProfileCompleter(), MCPCompleter(trigger="/mcp"), SkillsCompleter(trigger="/skills"), SlashCompleter(), diff --git a/code_puppy/config.py b/code_puppy/config.py index 79b5c2088..2059fd6a4 100644 --- a/code_puppy/config.py +++ b/code_puppy/config.py @@ -48,6 +48,7 @@ def _get_xdg_dir(env_var: str, fallback: str) -> str: AGENTS_DIR = os.path.join(DATA_DIR, "agents") SKILLS_DIR = os.path.join(DATA_DIR, "skills") CONTEXTS_DIR = os.path.join(DATA_DIR, "contexts") +PROFILES_DIR = os.path.join(DATA_DIR, "profiles") _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite") # OAuth plugin model files (XDG_DATA_HOME) diff --git a/code_puppy/summarization_agent.py b/code_puppy/summarization_agent.py index 6702540a7..d4797db46 100644 --- a/code_puppy/summarization_agent.py +++ b/code_puppy/summarization_agent.py @@ -6,9 +6,6 @@ from pydantic_ai import Agent -from code_puppy.config import ( - get_global_model_name, -) from code_puppy.model_factory import ModelFactory, make_model_settings # Keep a module-level agent reference to avoid rebuilding per call @@ -69,10 +66,13 @@ def run_summarization_sync(prompt: str, message_history: List) -> List: original_error=e, ) from e - # Handle claude-code models: prepend system prompt to user prompt + # Handle claude-code models: prepend system prompt to user prompt. + # Use the compaction model (not the global model) so prompt shaping matches + # the model actually executing the summarization. from code_puppy.model_utils import prepare_prompt_for_model + from code_puppy.task_models import get_compaction_model - model_name = get_global_model_name() + model_name = get_compaction_model() prepared = prepare_prompt_for_model( model_name, _get_summarization_instructions(), prompt ) @@ -137,9 +137,12 @@ def _get_summarization_instructions() -> str: def reload_summarization_agent(): """Create a specialized agent for summarizing messages when context limit is reached.""" from code_puppy.model_utils import prepare_prompt_for_model + from code_puppy.task_models import get_compaction_model models_config = ModelFactory.load_config() - model_name = get_global_model_name() + model_name = ( + get_compaction_model() + ) # Use dedicated compaction model for cost savings model = ModelFactory.get_model(model_name, models_config) # Handle claude-code models: swap instructions (prompt prepending happens in run_summarization_sync) diff --git a/code_puppy/task_models.py b/code_puppy/task_models.py new file mode 100644 index 000000000..21de72431 --- /dev/null +++ b/code_puppy/task_models.py @@ -0,0 +1,633 @@ +""" +Task Model Resolution - Surgical Profile System + +This module provides a unified, extensible way to configure different models +for different tasks (compaction, vision, subagents, etc.). + +Design Principles: +1. Single source of truth for task→model resolution +2. Graceful fallback chain (never breaks) +3. Minimal changes to existing code +4. Easy to extend with new task types +5. Great UX through /models and /profile commands + +Configuration (puppy.cfg): + # Task-specific model overrides (optional) + compaction_model = gpt-4.1-nano + vision_model = gemini-2.5-flash + subagent_model = gpt-4.1 + +Usage: + from code_puppy.task_models import get_model_for, Task + + model_name = get_model_for(Task.COMPACTATION) + model = ModelFactory.get_model(model_name, config) +""" + +from enum import Enum, auto +from typing import Optional, Dict, List, Tuple +from dataclasses import dataclass +import datetime +import json +import os +from pathlib import Path + +from code_puppy.config import ( + get_value, + get_global_model_name, + get_agent_pinned_model, + set_value, + reset_value, + set_model_name, + reset_session_model, +) + + +class Task(Enum): + """ + Task types that can have dedicated model configurations. + + Each task type represents a distinct use case that may benefit from + a different model (cheaper, faster, or more capable). + + Only tasks that have actual integration points in the codebase are listed. + """ + + MAIN = auto() # Main agent conversations + COMPACTION = auto() # Message summarization/compaction + SUBAGENT = auto() # Delegated agent invocations + + +@dataclass +class TaskModelConfig: + """ + Configuration for a specific task type. + + Attributes: + config_key: The puppy.cfg key for this task (e.g., "compaction_model") + description: Human-readable description for UI + fallback_task: Task to fall back to if not configured (None = global default) + recommended_default: Suggested model for this task (informational) + requires_capability: Optional capability required (e.g., "vision") + """ + + config_key: str + description: str + fallback_task: Optional["Task"] = None + recommended_default: Optional[str] = None + requires_capability: Optional[str] = None + env_var: Optional[str] = None # Environment variable override (highest priority) + + +# Task configuration registry - single source of truth +TASK_CONFIGS: Dict[Task, TaskModelConfig] = { + Task.MAIN: TaskModelConfig( + config_key="model", # matches config.py get_global_model_name / set_model_name + description="Main conversation model", + fallback_task=None, + ), + Task.COMPACTION: TaskModelConfig( + config_key="compaction_model", + description="Message compaction and summarization", + fallback_task=Task.MAIN, + env_var="CODE_PUPPY_COMPACTION_MODEL", + ), + Task.SUBAGENT: TaskModelConfig( + config_key="subagent_model", + description="Delegated agent invocations", + fallback_task=Task.MAIN, + env_var="CODE_PUPPY_SUBAGENT_MODEL", + ), +} + + +class TaskModelResolver: + """ + Resolves the appropriate model for a given task type. + + Resolution Chain (in order): + 1. Task-specific override (puppy.cfg: compaction_model, etc.) + 2. Agent-specific default (if agent context available) + 3. Global default model (puppy.cfg: model_name) + 4. Hard fallback (first available in models.json) + + This class is stateless and can be used anywhere. + All methods are class methods for convenience. + """ + + _cache: Dict[Task, Optional[str]] = {} + + @classmethod + def get_model(cls, task: Task, agent_name: Optional[str] = None) -> str: + """ + Get the configured model for a task type. + + Resolution order (highest → lowest priority): + 0. Environment variable override (CODE_PUPPY_COMPACTION_MODEL, etc.) + 1. Active profile (read directly from profile JSON) + 2. Task-specific config key in puppy.cfg + 3. Agent-specific pinned model + 4. Fallback task (recursive) + 5. Global default model + """ + config = TASK_CONFIGS.get(task) + if not config: + return get_global_model_name() + + # 0. Environment variable override + if config.env_var: + env_val = os.environ.get(config.env_var) + if env_val: + return env_val + + # 1. Active profile — read directly from the profile file so that + # profile resolution doesn't depend on config keys being written + active_profile = get_value("active_profile") + if active_profile: + try: + profile_path = _get_profile_path(active_profile) + if profile_path.exists(): + with open(profile_path) as _pf: + _pd = json.load(_pf) + profile_model = _pd.get("models", {}).get(task.name.lower()) + if profile_model: + return profile_model + except Exception: + pass # Profile unreadable — fall through + + # 2. Task-specific config key in puppy.cfg + task_model = get_value(config.config_key) + if task_model: + return task_model + + # 3. Agent-specific pinned model + if agent_name: + agent_model = get_agent_pinned_model(agent_name) + if agent_model: + return agent_model + + # 4. Fall back to parent task or global default + if config.fallback_task: + return cls.get_model(config.fallback_task, agent_name) + + # 5. Global default + return get_global_model_name() + + @classmethod + def set_model(cls, task: Task, model_name: str) -> None: + """ + Set the model for a task type in config. + + For Task.MAIN, routes through set_model_name() so the in-process + session cache (_SESSION_MODEL) is updated immediately. + + If an active profile is loaded, the change is also written into the + profile's JSON file so that the profile layer (highest-priority) sees + the update immediately and the file stays in sync. + """ + config = TASK_CONFIGS.get(task) + if config: + if task == Task.MAIN: + # set_model_name updates _SESSION_MODEL and writes "model" to cfg + set_model_name(model_name) + else: + set_value(config.config_key, model_name) + # Patch active profile on disk so layer-1 reads the updated value + cls._patch_active_profile(task, model_name) + cls._cache.pop(task, None) + + @classmethod + def _patch_active_profile(cls, task: Task, model_name: Optional[str]) -> None: + """ + If a profile is currently active, update (or remove) the task's entry + inside the profile's JSON file so that layer-1 resolution stays in sync + with in-session changes. + + ``model_name=None`` removes the task key from the profile (used by + ``clear_model`` when a profile is active). + """ + active_profile = get_value("active_profile") + if not active_profile: + return + try: + profile_path = _get_profile_path(active_profile) + if not profile_path.exists(): + return + with open(profile_path, "r") as _pf: + data = json.load(_pf) + models = data.setdefault("models", {}) + if model_name is None: + models.pop(task.name.lower(), None) + else: + models[task.name.lower()] = model_name + with open(profile_path, "w") as _pf: + json.dump(data, _pf, indent=2) + except Exception: + pass # Never crash — profile update is best-effort + + @classmethod + def clear_model(cls, task: Task) -> None: + """Clear task-specific model, reverting to default.""" + config = TASK_CONFIGS.get(task) + if config: + if task == Task.MAIN: + # Reset the session cache so get_global_model_name() re-reads + reset_session_model() + reset_value(config.config_key) + # Remove from active profile so layer-1 stops shadowing the default + cls._patch_active_profile(task, None) + cls._cache.pop(task, None) + + @classmethod + def get_all_configs(cls, agent_name: Optional[str] = None) -> Dict[Task, Dict]: + """ + Get all task model configurations for display. + + Returns dict with task -> {configured, effective, description, recommended} + """ + result = {} + for task, config in TASK_CONFIGS.items(): + configured = get_value(config.config_key) + effective = cls.get_model(task, agent_name) + result[task] = { + "task": task, + "config_key": config.config_key, + "configured": configured, + "effective": effective, + "description": config.description, + "recommended": config.recommended_default, + "requires_capability": config.requires_capability, + "is_custom": configured is not None, + } + return result + + @classmethod + def get_profile_summary(cls, agent_name: Optional[str] = None) -> str: + """ + Get a human-readable summary of the current profile. + + Returns: + Multi-line string suitable for display in CLI + """ + lines = ["📋 Model Profile Configuration", ""] + configs = cls.get_all_configs(agent_name) + + for task, info in configs.items(): + task_name = task.name.ljust(12) + effective = info["effective"] or "default" + + if info["is_custom"]: + # Show configured override + configured = info["configured"] + lines.append(f" {task_name} {configured} (override → {effective})") + else: + # Show effective model + lines.append(f" {task_name} {effective}") + if info["recommended"]: + lines.append(f" 💡 Recommended: {info['recommended']}") + + return "\n".join(lines) + + +# ============================================================================= +# Convenience Functions - Primary API +# ============================================================================= + + +def get_model_for(task: Task, agent_name: Optional[str] = None) -> str: + """ + Get the configured model for a task type. + + This is the primary API for getting task-specific models. + + Args: + task: The task type (Task.COMPACTION, Task.VISION, etc.) + agent_name: Optional agent name for context-aware resolution + + Returns: + Model name string + + Example: + >>> from code_puppy.task_models import get_model_for, Task + >>> model_name = get_model_for(Task.COMPACTION) + >>> model = ModelFactory.get_model(model_name, config) + """ + return TaskModelResolver.get_model(task, agent_name) + + +def set_model_for(task: Task, model_name: str) -> None: + """ + Set the model for a task type. + + Args: + task: The task type to configure + model_name: The model to use + """ + TaskModelResolver.set_model(task, model_name) + + +def clear_model_for(task: Task) -> None: + """Clear task-specific model override.""" + TaskModelResolver.clear_model(task) + + +def get_profile_summary(agent_name: Optional[str] = None) -> str: + """Get human-readable profile summary.""" + return TaskModelResolver.get_profile_summary(agent_name) + + +def get_all_task_configs(agent_name: Optional[str] = None) -> Dict[Task, Dict]: + """Get all task configurations for UI display.""" + return TaskModelResolver.get_all_configs(agent_name) + + +# ============================================================================= +# Backward Compatibility Aliases +# ============================================================================= + + +def get_compaction_model() -> str: + """Get model for message compaction. Convenience alias.""" + return get_model_for(Task.COMPACTION) + + +def get_subagent_model() -> str: + """Get model for subagent invocations. Convenience alias.""" + return get_model_for(Task.SUBAGENT) + + +# ============================================================================= +# Named Profile Management +# ============================================================================= + + +def _get_profiles_dir() -> Path: + """Get the profiles directory, creating it if needed.""" + from code_puppy.config import PROFILES_DIR + + profiles_dir = Path(PROFILES_DIR) + profiles_dir.mkdir(parents=True, exist_ok=True) + return profiles_dir + + +def _is_safe_profile_name(name: str) -> bool: + """Return True iff *name* is a valid, non-traversal profile name.""" + return bool(name) and all(c.isalnum() or c in "-_" for c in name) + + +def _get_profile_path(name: str) -> Path: + """ + Return the resolved path for *name* inside the profiles directory. + + Raises ValueError if the resolved path escapes the profiles directory + (directory-traversal guard). + """ + profiles_dir = _get_profiles_dir().resolve() + candidate = (profiles_dir / f"{name}.json").resolve() + if not candidate.is_relative_to(profiles_dir): + raise ValueError(f"Invalid profile name: {name!r}") + return candidate + + +def list_profiles() -> List[Dict]: + """ + List all saved profiles. + + Returns: + List of profile dicts with 'name' and 'description' keys + """ + profiles_dir = _get_profiles_dir() + profiles = [] + + for profile_file in profiles_dir.glob("*.json"): + try: + with open(profile_file, "r") as f: + data = json.load(f) + profiles.append( + { + "name": data.get("name", profile_file.stem), + "description": data.get("description", ""), + "models": data.get("models", {}), + } + ) + except (json.JSONDecodeError, IOError): + continue + + return sorted(profiles, key=lambda p: p["name"]) + + +def profile_exists(name: str) -> bool: + """Check if a profile with the given name exists.""" + if not _is_safe_profile_name(name): + return False + try: + return _get_profile_path(name).exists() + except ValueError: + return False + + +def save_profile(name: str, description: str = "") -> bool: + """ + Save current model settings as a named profile. + + Args: + name: Profile name (alphanumeric, dashes, underscores) + description: Optional description of the profile + + Returns: + True if saved successfully + """ + # Validate name + if not name or not all(c.isalnum() or c in "-_" for c in name): + return False + + # Collect current model settings + models = {} + for task, config in TASK_CONFIGS.items(): + current = get_value(config.config_key) + if current: + models[task.name.lower()] = current + + profile_data = { + "name": name, + "description": description, + "models": models, + "created": datetime.datetime.now().isoformat(), + } + + profile_path = _get_profile_path(name) + with open(profile_path, "w") as f: + json.dump(profile_data, f, indent=2) + + return True + + +def load_profile(name: str) -> Tuple[bool, str]: + """ + Load a named profile, applying its model settings. + + Args: + name: Profile name to load + + Returns: + Tuple of (success, message) + """ + if not _is_safe_profile_name(name): + return False, f"Invalid profile name: {name!r}" + + try: + profile_path = _get_profile_path(name) + except ValueError as exc: + return False, str(exc) + + if not profile_path.exists(): + return False, f"Profile '{name}' not found" + + try: + with open(profile_path, "r") as f: + data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + return False, f"Failed to read profile: {e}" + + models = data.get("models", {}) + applied = [] + + # Switch active_profile marker FIRST so subsequent clear/set_model_for() + # calls patch the NEW profile (not the previously active one) + set_value("active_profile", name) + + # Clear existing per-task overrides so keys omitted from this profile + # don't linger from a previously loaded profile or manual /set. + for task in Task: + clear_model_for(task) + + # Apply each model setting from the profile + for task_name, model_name in models.items(): + task_name_upper = task_name.upper() + try: + task = Task[task_name_upper] + set_model_for(task, model_name) + applied.append(f"{task_name_upper}={model_name}") + except KeyError: + continue # Unknown task key in profile file, skip + + return True, f"Loaded profile '{name}': {', '.join(applied)}" + + +def delete_profile(name: str) -> Tuple[bool, str]: + """ + Delete a named profile. + + Args: + name: Profile name to delete + + Returns: + Tuple of (success, message) + """ + if not _is_safe_profile_name(name): + return False, f"Invalid profile name: {name!r}" + + try: + profile_path = _get_profile_path(name) + except ValueError as exc: + return False, str(exc) + + if not profile_path.exists(): + return False, f"Profile '{name}' not found" + + try: + profile_path.unlink() + + # Clear active profile if this was it + if get_value("active_profile") == name: + reset_value("active_profile") + + return True, f"Deleted profile '{name}'" + except IOError as e: + return False, f"Failed to delete profile: {e}" + + +def get_active_profile() -> Optional[str]: + """Get the name of the currently active profile, if any.""" + return get_value("active_profile") + + +def clear_active_profile() -> None: + """Clear all task-specific model settings and the active profile.""" + # Deactivate profile FIRST so clear_model_for() doesn't patch the saved JSON + reset_value("active_profile") + for task in Task: + clear_model_for(task) + + +def save_profile_from_models( + name: str, + description: str, + models: Dict[Task, str], +) -> bool: + """ + Save a named profile using an explicit agent→model mapping. + + Unlike ``save_profile()``, this does NOT read from the live puppy.cfg — + it serialises exactly the *models* dict supplied by the caller. Useful + for TUI wizards that build a custom model set before writing to disk. + + Args: + name: Profile name (alphanumeric, dashes, underscores). + description: Optional human-readable description. + models: Mapping of Task → model name to persist. + + Returns: + True on success, False on validation or I/O failure. + """ + if not _is_safe_profile_name(name): + return False + try: + _get_profiles_dir().mkdir(parents=True, exist_ok=True) + profile_path = _get_profile_path(name) + serialised = { + task.name.lower(): model for task, model in models.items() if model + } + data = { + "name": name, + "description": description, + "models": serialised, + "created": datetime.datetime.now().isoformat(), + } + with open(profile_path, "w") as f: + json.dump(data, f, indent=2) + return True + except Exception: + return False + + +# ============================================================================= +# Exports +# ============================================================================= + +__all__ = [ + # Types + "Task", + "TaskModelConfig", + "TaskModelResolver", + # Primary API + "get_model_for", + "set_model_for", + "clear_model_for", + "get_profile_summary", + "get_all_task_configs", + # Convenience aliases + "get_compaction_model", + "get_subagent_model", + # Profile management + "list_profiles", + "profile_exists", + "save_profile", + "save_profile_from_models", + "load_profile", + "delete_profile", + "get_active_profile", + "clear_active_profile", + # Registry + "TASK_CONFIGS", +] diff --git a/code_puppy/tools/agent_tools.py b/code_puppy/tools/agent_tools.py index 56879cc8f..49f9e4244 100644 --- a/code_puppy/tools/agent_tools.py +++ b/code_puppy/tools/agent_tools.py @@ -381,12 +381,13 @@ async def invoke_agent( try: # Lazy import to break circular dependency with messaging module from code_puppy.model_factory import ModelFactory, make_model_settings + from code_puppy.task_models import get_model_for, Task # Load the specified agent config agent_config = load_agent(agent_name) - # Get the current model for creating a temporary agent - model_name = agent_config.get_model_name() + # Get model for subagent (uses subagent_model if configured, else agent's default) + model_name = get_model_for(Task.SUBAGENT, agent_name=agent_name) models_config = ModelFactory.load_config() # Only proceed if we have a valid model configuration diff --git a/tests/agents/test_base_agent_full_coverage.py b/tests/agents/test_base_agent_full_coverage.py index 4165df12e..e33cfa07e 100644 --- a/tests/agents/test_base_agent_full_coverage.py +++ b/tests/agents/test_base_agent_full_coverage.py @@ -2129,9 +2129,11 @@ def test_loads_from_project_dir(self, agent, tmp_path): patch("code_puppy.config.CONFIG_DIR", str(tmp_path / "nonexistent")), patch( "pathlib.Path.exists", - side_effect=lambda self: str(self) == str(rules_file) - or str(self).endswith("AGENTS.md") - and "nonexistent" not in str(self), + side_effect=lambda self: ( + str(self) == str(rules_file) + or str(self).endswith("AGENTS.md") + and "nonexistent" not in str(self) + ), ), ): # Complex to test due to pathlib patching, just test cached path diff --git a/tests/command_line/test_model_settings_menu_coverage.py b/tests/command_line/test_model_settings_menu_coverage.py index 97e257f24..c96a224c8 100644 --- a/tests/command_line/test_model_settings_menu_coverage.py +++ b/tests/command_line/test_model_settings_menu_coverage.py @@ -151,10 +151,13 @@ def test_get_supported_settings(self, mock_supports): def test_load_model_settings_with_openai( self, mock_supports, mock_get_all, mock_effort, mock_verb ): - mock_supports.side_effect = lambda m, s: s in ( - "temperature", - "reasoning_effort", - "verbosity", + mock_supports.side_effect = lambda m, s: ( + s + in ( + "temperature", + "reasoning_effort", + "verbosity", + ) ) menu = _make_menu() menu._load_model_settings("gpt-5") diff --git a/tests/test_agent_tools_coverage.py b/tests/test_agent_tools_coverage.py index 00615df6b..73b6f3b08 100644 --- a/tests/test_agent_tools_coverage.py +++ b/tests/test_agent_tools_coverage.py @@ -472,6 +472,10 @@ async def test_invoke_agent_model_not_found_error(self): "code_puppy.agents.agent_manager.load_agent", return_value=mock_agent_config, ), + patch( + "code_puppy.task_models.get_model_for", + return_value="nonexistent-model", + ), patch( "code_puppy.model_factory.ModelFactory.load_config", return_value={}, # No models configured diff --git a/tests/test_coverage_agents_gaps.py b/tests/test_coverage_agents_gaps.py index 70e126667..3ff57b103 100644 --- a/tests/test_coverage_agents_gaps.py +++ b/tests/test_coverage_agents_gaps.py @@ -156,7 +156,7 @@ def test_run_summarization_sync_llm_failure(self): return_value=mock_agent, ), patch( - "code_puppy.summarization_agent.get_global_model_name", + "code_puppy.task_models.get_compaction_model", return_value="test", ), patch("code_puppy.model_utils.prepare_prompt_for_model") as mock_prep, diff --git a/tests/test_summarization_agent.py b/tests/test_summarization_agent.py index e4d108ec4..f809ec9f8 100644 --- a/tests/test_summarization_agent.py +++ b/tests/test_summarization_agent.py @@ -98,9 +98,7 @@ def test_reload_summarization_agent_basic(self, mock_model, mock_models_config): patch( "code_puppy.summarization_agent.ModelFactory.get_model" ) as mock_get_model, - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, patch("code_puppy.summarization_agent.Agent") as mock_agent_class, ): mock_load_config.return_value = mock_models_config @@ -115,7 +113,6 @@ def test_reload_summarization_agent_basic(self, mock_model, mock_models_config): mock_load_config.call_count >= 1 ) # May be called multiple times due to imports mock_get_model.assert_called_once_with("test-model", mock_models_config) - mock_get_name.assert_called_once() # Verify Agent() was instantiated with the mock_model mock_agent_class.assert_called_once() call_kwargs = mock_agent_class.call_args.kwargs @@ -170,9 +167,7 @@ def test_reload_summarization_agent_instructions( patch( "code_puppy.summarization_agent.ModelFactory.get_model" ) as mock_get_model, - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, patch("code_puppy.summarization_agent.Agent") as mock_agent_class, ): mock_load_config.return_value = mock_models_config @@ -486,7 +481,6 @@ def test_agent_creation_model_failure(self): "code_puppy.summarization_agent.ModelFactory.load_config" ) as mock_load_config, patch("code_puppy.summarization_agent.ModelFactory.get_model"), - patch("code_puppy.summarization_agent.get_global_model_name"), ): mock_load_config.side_effect = Exception("Config load failed") @@ -498,9 +492,7 @@ def test_agent_creation_model_name_failure(self): with ( patch("code_puppy.summarization_agent.ModelFactory.load_config"), patch("code_puppy.summarization_agent.ModelFactory.get_model"), - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, ): mock_get_name.side_effect = Exception("Model name error") @@ -540,9 +532,7 @@ def test_concurrent_agent_access(self): patch( "code_puppy.summarization_agent.ModelFactory.get_model" ) as mock_get_model, - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, patch("code_puppy.summarization_agent.Agent") as mock_agent_class, ): mock_load_config.return_value = {"test-model": {"context": 128000}} @@ -671,9 +661,7 @@ def test_summarization_instructions_completeness(self): patch( "code_puppy.summarization_agent.ModelFactory.get_model" ) as mock_get_model, - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, patch("code_puppy.summarization_agent.Agent") as mock_agent_class, ): mock_load_config.return_value = {} @@ -713,9 +701,7 @@ def test_agent_configuration_parameters(self): patch( "code_puppy.summarization_agent.ModelFactory.get_model" ) as mock_get_model, - patch( - "code_puppy.summarization_agent.get_global_model_name" - ) as mock_get_name, + patch("code_puppy.task_models.get_compaction_model") as mock_get_name, patch("code_puppy.summarization_agent.Agent") as mock_agent_class, ): mock_load_config.return_value = {} diff --git a/tests/test_task_models_profile_sync.py b/tests/test_task_models_profile_sync.py new file mode 100644 index 000000000..b22268f61 --- /dev/null +++ b/tests/test_task_models_profile_sync.py @@ -0,0 +1,197 @@ +""" +Tests for task_models profile-sync behaviour. + +Bug: when a named profile was active, calling set_model_for() wrote the new +value to puppy.cfg but the get_model() resolution chain checked the profile +JSON first (layer 1) and always returned the stale profile value. + +Fix: TaskModelResolver.set_model() now calls _patch_active_profile() which +also updates the profile JSON on disk so the two sources stay in sync. +""" + +import json +from unittest.mock import patch + +import pytest + + +@pytest.fixture() +def tmp_profiles_dir(tmp_path): + """Return a temporary directory usable as the profiles store.""" + return tmp_path / "profiles" + + +@pytest.fixture() +def dummy_profile(tmp_profiles_dir): + """ + Write a minimal profile JSON and return its path. + + The profile has compaction=initial-compaction-model. + """ + tmp_profiles_dir.mkdir(parents=True, exist_ok=True) + data = { + "name": "test-profile", + "description": "", + "models": { + "main": "initial-main-model", + "compaction": "initial-compaction-model", + "subagent": "initial-subagent-model", + }, + } + profile_path = tmp_profiles_dir / "test-profile.json" + profile_path.write_text(json.dumps(data, indent=2)) + return profile_path + + +def _make_patches(tmp_profiles_dir, profile_name="test-profile"): + """Return a list of patch objects that isolate task_models from real config.""" + return [ + # Make get_value("active_profile") return our profile name + patch( + "code_puppy.task_models.get_value", + side_effect=lambda key: profile_name if key == "active_profile" else None, + ), + # Redirect profile directory to tmp + patch( + "code_puppy.task_models._get_profiles_dir", + return_value=tmp_profiles_dir, + ), + # Stub out config writes (we only test the JSON patch) + patch("code_puppy.task_models.set_value"), + patch("code_puppy.task_models.reset_value"), + patch("code_puppy.task_models.set_model_name"), + patch( + "code_puppy.task_models.get_global_model_name", return_value="global-model" + ), + patch("code_puppy.task_models.get_agent_pinned_model", return_value=None), + ] + + +class TestSetModelPatchesActiveProfile: + """set_model_for() must update the active profile JSON.""" + + def test_compaction_model_written_to_profile_json( + self, tmp_profiles_dir, dummy_profile + ): + from code_puppy.task_models import Task, set_model_for + + patches = _make_patches(tmp_profiles_dir) + [p.start() for p in patches] + try: + set_model_for(Task.COMPACTION, "new-compaction-model") + finally: + for p in patches: + p.stop() + + updated = json.loads(dummy_profile.read_text()) + assert updated["models"]["compaction"] == "new-compaction-model", ( + "Profile JSON should be updated with the new compaction model" + ) + + def test_subagent_model_written_to_profile_json( + self, tmp_profiles_dir, dummy_profile + ): + from code_puppy.task_models import Task, set_model_for + + patches = _make_patches(tmp_profiles_dir) + [p.start() for p in patches] + try: + set_model_for(Task.SUBAGENT, "new-subagent-model") + finally: + for p in patches: + p.stop() + + updated = json.loads(dummy_profile.read_text()) + assert updated["models"]["subagent"] == "new-subagent-model" + + def test_get_model_returns_updated_value_after_set( + self, tmp_profiles_dir, dummy_profile + ): + """ + After set_model_for() the subsequent get_model_for() must return the + new value, not the stale profile value. + """ + from code_puppy.task_models import Task, get_model_for, set_model_for + + patches = _make_patches(tmp_profiles_dir) + [p.start() for p in patches] + try: + set_model_for(Task.COMPACTION, "fresh-model") + resolved = get_model_for(Task.COMPACTION) + finally: + for p in patches: + p.stop() + + assert resolved == "fresh-model", ( + "get_model_for() should return the model that was just set, " + f"but got {resolved!r}" + ) + + +class TestClearModelRemovesFromActiveProfile: + """clear_model_for() must remove the key from the active profile JSON.""" + + def test_clear_removes_key_from_profile_json(self, tmp_profiles_dir, dummy_profile): + from code_puppy.task_models import Task, clear_model_for + + patches = _make_patches(tmp_profiles_dir) + [p.start() for p in patches] + try: + clear_model_for(Task.COMPACTION) + finally: + for p in patches: + p.stop() + + updated = json.loads(dummy_profile.read_text()) + assert "compaction" not in updated["models"], ( + "After clear_model_for, the key should be removed from profile JSON" + ) + + def test_clear_does_not_touch_other_keys(self, tmp_profiles_dir, dummy_profile): + from code_puppy.task_models import Task, clear_model_for + + patches = _make_patches(tmp_profiles_dir) + [p.start() for p in patches] + try: + clear_model_for(Task.COMPACTION) + finally: + for p in patches: + p.stop() + + updated = json.loads(dummy_profile.read_text()) + assert updated["models"]["subagent"] == "initial-subagent-model" + assert updated["models"]["main"] == "initial-main-model" + + +class TestNoPatchWhenNoActiveProfile: + """When no profile is active, set_model_for() must not touch any JSON.""" + + def test_no_profile_no_json_written(self, tmp_profiles_dir): + from code_puppy.task_models import Task, set_model_for + + # No profile is active (all get_value calls return None) + no_profile_patches = [ + patch("code_puppy.task_models.get_value", return_value=None), + patch( + "code_puppy.task_models._get_profiles_dir", + return_value=tmp_profiles_dir, + ), + patch("code_puppy.task_models.set_value"), + patch("code_puppy.task_models.reset_value"), + patch("code_puppy.task_models.set_model_name"), + patch( + "code_puppy.task_models.get_global_model_name", return_value="global" + ), + patch("code_puppy.task_models.get_agent_pinned_model", return_value=None), + ] + [p.start() for p in no_profile_patches] + try: + set_model_for(Task.COMPACTION, "whatever") + finally: + for p in no_profile_patches: + p.stop() + + # No JSON files should have been created + assert not any(tmp_profiles_dir.glob("*.json")), ( + "No profile JSON should be written when no profile is active" + )