From 8b7f234f56f601f65e719625e6db0a25a0717624 Mon Sep 17 00:00:00 2001 From: rosspeili Date: Sun, 31 May 2026 13:33:34 +0300 Subject: [PATCH] Add YAML settings, config init/reset, and wizard env cleanup Optional rooms.settings.yaml for LiteLLM defaults and personas (#26, #27). Clear API keys set during the wizard when the session ends (#5). Default model remains ollama/gemma4:e2b. Fixes ARPAHLS/rooms#5 Fixes ARPAHLS/rooms#26 Fixes ARPAHLS/rooms#27 --- .gitignore | 3 + README.md | 27 +++- cli.py | 268 +++++++++++++++++++++---------- docs/ARCHITECTURE.md | 11 ++ docs/EXAMPLES.md | 1 + docs/TESTING.md | 19 ++- requirements.txt | 3 +- rooms.settings.example.yaml | 35 ++++ rooms/config.py | 2 +- rooms/settings.py | 242 ++++++++++++++++++++++++++++ tests/test_cli.py | 46 ++++++ tests/test_cli_settings_smoke.py | 91 +++++++++++ tests/test_settings.py | 103 ++++++++++++ 13 files changed, 758 insertions(+), 93 deletions(-) create mode 100644 rooms.settings.example.yaml create mode 100644 rooms/settings.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_cli_settings_smoke.py create mode 100644 tests/test_settings.py diff --git a/.gitignore b/.gitignore index 3a56287..2cda71f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ venv/ env/ .env +# User-local settings (copy from rooms.settings.example.yaml or: python cli.py config init) +rooms.settings.yaml + # Cache __pycache__/ *.py[cod] diff --git a/README.md b/README.md index 3e34c7c..b1b69cc 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,18 @@ Rooms/ │ ├── config.py # Pydantic Configuration Models │ ├── agent.py # Agent & LiteLLM/Custom Logic │ ├── session.py # Turn Orchestration & Memory +│ ├── settings.py # YAML settings loader (#26, #27) │ └── storage.py # Secure Log Serialization ├── tests/ # Unit Tests │ └── test_session.py # Logic Verification ├── outputs/ # Session Transcripts -├── cli.py # Interactive Wizard Entry Point -└── requirements.txt # Project Dependencies +├── cli.py # Interactive Wizard Entry Point +├── rooms.settings.example.yaml # Settings template (commit this) +├── requirements.txt # Project Dependencies ``` +`rooms.settings.yaml` is gitignored — create it locally with `python cli.py config init` or by copying the example file. + ## Quick Start ### 1. Installation @@ -93,7 +97,24 @@ venv\Scripts\activate # Windows: venv\Scripts\activate | Unix: source venv/bin/ pip install -r requirements.txt ``` -### 2. Usage +### 2. Configure defaults (optional) + +You do **not** need a settings file to run the CLI — built-in defaults apply (see `rooms.settings.example.yaml` for the shape). To customize per machine, create a local file (gitignored): + +| File | In git? | Purpose | +|------|---------|---------| +| `rooms.settings.example.yaml` | Yes (template) | Committed reference; copy or use `config init` | +| `rooms.settings.yaml` | No (gitignored) | Your local overrides (model tag, user name, personas) | + +```bash +python cli.py config init # copies example → rooms.settings.yaml in cwd +# Or manually: copy rooms.settings.example.yaml to rooms.settings.yaml +# Edit rooms.settings.yaml — e.g. defaults.litellm_model from `ollama list` +python cli.py config reset # remove user file; revert to shipped defaults +python cli.py --config path/to/settings.yaml +``` + +### 3. Usage **Start the Interactive Wizard** ```bash diff --git a/cli.py b/cli.py index e9fd6e3..129104f 100644 --- a/cli.py +++ b/cli.py @@ -1,5 +1,9 @@ +import argparse import os import sys +from pathlib import Path +from typing import List, Optional + from rich.console import Console from rich.panel import Panel from rich.prompt import Prompt, Confirm @@ -9,101 +13,111 @@ from rooms.agent import Agent from rooms.session import Session from rooms.storage import save_transcript +from rooms.settings import ( + RoomsSettings, + SettingsError, + load_settings, + get_default_personas, + init_settings_file, + reset_settings_file, + find_settings_file, + EXAMPLE_SETTINGS_FILENAME, + USER_SETTINGS_FILENAME, +) console = Console() -# Deep Personas -DEFAULT_AGENTS = [ - AgentConfig( - name="Elena (The Lawyer)", - system_prompt=( - "You are Elena, a highly skilled corporate lawyer with 10 years of experience. " - "Recently, you lost a major case because of a tiny, overlooked detail in 'Clause Y', " - "and now you are extremely sensitive, defensive, and incredibly picky about specific wording. " - "You often bring up this past trauma when reviewing anything." - ), - expertise=["law", "contracts", "compliance", "clause y"], - model="ollama/llama3", - color="magenta" - ), - AgentConfig( - name="Viktor (The Coder)", - system_prompt=( - "You are Viktor, a cynical senior backend engineer who has seen too many startups fail. " - "You are brutally honest, hate buzzwords, and prioritize performance and actual hardware specs. " - "You communicate strictly in practical terms and think most new tech is just a fad." - ), - expertise=["engineering", "backend", "performance", "realism"], - model="ollama/llama3", - color="green" - ), - AgentConfig( - name="Nyx (The Visionary)", - system_prompt=( - "You are Nyx, a creative visionary who looks at everything from a 10,000-foot view. " - "You dislike getting bogged down in tiny details (which often annoys lawyers and engineers). " - "You focus on the 'why' and the 'future impact' rather than the 'how'." - ), - expertise=["creative", "vision", "future", "strategy"], - model="ollama/llama3", - color="cyan" - ) -] -def create_custom_agent_wizard() -> AgentConfig: +def _set_session_env_key(tracked_keys: List[str], key_name: str, value: str) -> None: + """Set a wizard-provided secret and track it for cleanup. Skips if the key already exists.""" + if not key_name or key_name in os.environ: + return + os.environ[key_name] = value + tracked_keys.append(key_name) + + +def _prompt_api_key_if_needed(tracked_keys: List[str], key_prompt: str = "Enter the environment variable name (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY)") -> None: + """Prompt for an API key env var when the model needs one; track keys set during this session.""" + if not Confirm.ask("Does this model require an API Key?"): + return + key_name = Prompt.ask(key_prompt) + if key_name and key_name not in os.environ: + _set_session_env_key(tracked_keys, key_name, Prompt.ask(f"Enter your {key_name}", password=True)) + + +def _cleanup_session_env(tracked_keys: List[str]) -> None: + """Remove environment variables that were added by the wizard for this session.""" + for key in tracked_keys: + os.environ.pop(key, None) + + +def create_custom_agent_wizard(settings: RoomsSettings, tracked_env_keys: Optional[List[str]] = None) -> AgentConfig: """Guided wizard to create a brand new agent.""" + defaults = settings.defaults console.print(Panel("[bold yellow]Create Custom Agent[/bold yellow]")) name = Prompt.ask("Agent Name") sys_prompt = Prompt.ask("System Prompt (Background, personality, rules)") exp = Prompt.ask("Expertise keywords (comma separated, e.g., 'trading, data')") expertise = [x.strip() for x in exp.split(',')] if exp else [] - + mtype_str = Prompt.ask( "Model Type", choices=["litellm", "custom_function"], default="litellm" ) - - config = AgentConfig(name=name, system_prompt=sys_prompt, expertise=expertise) - + + config = AgentConfig( + name=name, + system_prompt=sys_prompt, + expertise=expertise, + timeout=defaults.timeout, + ) + if mtype_str == "custom_function": config.model_type = ModelType.CUSTOM_FUNCTION config.custom_function_path = Prompt.ask("Enter full path to the .py file (e.g. ./my_model.py)") config.custom_function_name = Prompt.ask("Enter the exact function name to call (e.g. process_inference)") else: config.model_type = ModelType.LITELLM - # Offer quick hints - console.print("[dim]Hint: For local Ollama use 'ollama/llama3'. For OpenAI use 'gpt-4o'. For Anthropic use 'claude-3-opus-20240229'.[/dim]") - model_str = Prompt.ask("Enter LiteLLM model string", default="ollama/llama3") + default_model = defaults.litellm_model + console.print( + "[dim]Hint: For local Ollama use your tag from `ollama list` (e.g. " + f"'{default_model}'). For OpenAI use 'gpt-4o'.[/dim]" + ) + model_str = Prompt.ask("Enter LiteLLM model string", default=default_model) config.model = model_str - + if not model_str.startswith("ollama/"): - if Confirm.ask("Does this model require an API Key?"): - key_name = Prompt.ask("Enter the environment variable name (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY)") - if key_name and key_name not in os.environ: - os.environ[key_name] = Prompt.ask(f"Enter your {key_name}", password=True) - + _prompt_api_key_if_needed(tracked_env_keys or []) + config.color = Prompt.ask("CLI output color (e.g. red, green, blue, cyan, magenta, yellow)", default="blue") - config.temperature = float(Prompt.ask("Generation Temperature", default="0.7")) + config.temperature = float(Prompt.ask("Generation Temperature", default=str(defaults.temperature))) return config -def main_menu(): + +def main_menu(settings: RoomsSettings): console.print(Panel.fit("[bold magenta]Multi-Agent Room Framework[/bold magenta]", subtitle="Advanced Scenario Wizard")) - + default_personas = get_default_personas(settings) + defaults = settings.defaults + # 0. User Profile console.print("\n[bold cyan]--- 0. Your Profile ---[/bold cyan]") console.print("[dim]This helps agents treat you as an equal participant in the room.[/dim]") - user_name = Prompt.ask("Your name (or alias)", default="User") - user_background = Prompt.ask("Brief background or role (e.g. 'CTO with 15 years in cloud infrastructure')", default="") + user_name = Prompt.ask("Your name (or alias)", default=settings.user.name) + user_background = Prompt.ask( + "Brief background or role (e.g. 'CTO with 15 years in cloud infrastructure')", + default=settings.user.background, + ) user_profile = {"name": user_name, "background": user_background} + tracked_env_keys: List[str] = [] # 1. Session Basics console.print("\n[bold cyan]--- 1. Session Setup ---[/bold cyan]") topic = Prompt.ask("Enter the Topic or Problem statement for this room") turns = int(Prompt.ask("Max total turns for the entire session before exiting", default="20")) session_type_str = Prompt.ask( - "Select session type (round_robin/dynamic/argumentative)", - choices=["round_robin", "dynamic", "argumentative"], + "Select session type (round_robin/dynamic/argumentative)", + choices=["round_robin", "dynamic", "argumentative"], default="dynamic" ) console.print("[dim]Agents can talk freely, but when do you want to step in?[/dim]") @@ -112,16 +126,16 @@ def main_menu(): # 2. Agent Selection console.print("\n[bold cyan]--- 2. Participant Setup ---[/bold cyan]") active_agent_configs = [] - + console.print("\n[bold green]Available Default Personas:[/bold green]") - for i, a in enumerate(DEFAULT_AGENTS): + for i, a in enumerate(default_personas): console.print(f"{i+1}. {a.name} - {a.expertise}") - - for a in DEFAULT_AGENTS: + + for a in default_personas: if Confirm.ask(f"Include {a.name} in this room?", default=False): custom_instr = Prompt.ask(f"Any specific instructions for {a.name} just for this session? (Enter to skip)", default="") - temp = float(Prompt.ask(f"Temperature for {a.name}?", default="0.7")) - + temp = float(Prompt.ask(f"Temperature for {a.name}?", default=str(a.temperature))) + new_config = a.model_copy() new_config.temperature = temp if custom_instr.strip(): @@ -130,7 +144,7 @@ def main_menu(): while True: if Confirm.ask("Would you like to build and invite a Custom Agent?", default=False): - custom_agent = create_custom_agent_wizard() + custom_agent = create_custom_agent_wizard(settings, tracked_env_keys) active_agent_configs.append(custom_agent) else: break @@ -144,28 +158,28 @@ def main_menu(): orchestrator_config = None if Confirm.ask("Do you want a Global Orchestrator to monitor the room and interject occasionally?", default=False): sys_prompt = Prompt.ask( - "Orchestrator System Prompt", + "Orchestrator System Prompt", default="You are the room moderator. Summarize progress or steer the agents if they go off topic. Say exactly 'PASS' if you have nothing to add." ) - model = Prompt.ask("Orchestrator Model", default="ollama/llama3") - + model = Prompt.ask("Orchestrator Model", default=defaults.resolved_orchestrator_model) + if not model.startswith("ollama/"): - if Confirm.ask("Does this orchestrator model require an API Key?"): - key_name = Prompt.ask("Enter the environment variable name (e.g. OPENAI_API_KEY)") - if key_name and key_name not in os.environ: - os.environ[key_name] = Prompt.ask(f"Enter your {key_name}", password=True) + _prompt_api_key_if_needed( + tracked_env_keys, + key_prompt="Enter the environment variable name (e.g. OPENAI_API_KEY)", + ) orchestrator_config = AgentConfig( name="System Moderator", system_prompt=sys_prompt, model=model, temperature=0.3, + timeout=defaults.timeout, color="bright_black" ) agents = [Agent(config=ac) for ac in active_agent_configs] - - # Compile Session + session_config = SessionConfig( topic=topic, agents=active_agent_configs, @@ -176,16 +190,22 @@ def main_menu(): ) console.print("\n[bold yellow]Starting Room Session...[/bold yellow]") - run_session(session_config, agents, user_profile) + run_session(session_config, agents, user_profile, tracked_env_keys) -def run_session(config: SessionConfig, agents: list[Agent], user_profile: dict = None): + +def run_session( + config: SessionConfig, + agents: list[Agent], + user_profile: dict = None, + tracked_env_keys: Optional[List[str]] = None, +): session = Session(config, agents, user_profile=user_profile) - + env_keys = tracked_env_keys if tracked_env_keys is not None else [] + console.print(Panel(session.global_intro, title="System Introduction", border_style="bold grey53")) - + try: while session.turn_count < config.max_turns: - # Human in the loop logic (interval OR early trigger if user is addressed) if session.needs_human_input(): console.print("") console.rule("[bold white on dark_orange] Your Turn [/bold white on dark_orange]") @@ -198,29 +218,29 @@ def run_session(config: SessionConfig, agents: list[Agent], user_profile: dict = session.add_user_message(user_display_name, user_msg) console.print(Panel(user_msg, title=f"[bold white]{user_display_name}[/bold white]", border_style="white", padding=(0, 1))) - # Generate next agent turn console.print("[dim]Thinking...[/dim]", end="\r") next_turn = session.generate_next_turn() if not next_turn: break - # Silently skip PASS turns — agent had nothing to add if next_turn.get("skipped"): console.print(f"[dim]{next_turn['role']} passed.[/dim]", end="\r") continue - # Print turn with agent's color color = next_turn.get("color", "blue") console.print(f"\n[bold {color}]{next_turn['role']}:[/bold {color}]") console.print(next_turn["content"]) - + except KeyboardInterrupt: console.print("\n[yellow]Session interrupted via keyboard.[/yellow]") - + finally: + _cleanup_session_env(env_keys) + console.print("\n[bold green]Session ended.[/bold green]") prompt_save(session) + def prompt_save(session: Session): console.print("\n[bold red]WARNING: Memory is ephemeral and private. If you exit, this conversation is lost.[/bold red]") save = Confirm.ask("Do you want to save this conversation transcript locally?", default=False) @@ -228,17 +248,91 @@ def prompt_save(session: Session): fmt = Prompt.ask("Save format", choices=["markdown", "csv"], default="markdown") ext = "md" if fmt == "markdown" else "csv" path = Prompt.ask("Enter directory path to save to", default="./outputs") - + from rooms.storage import slugify_topic slug = slugify_topic(session.config.topic) default_name = f"{slug}.{ext}" filename = Prompt.ask("Enter filename", default=default_name) full_path = os.path.join(path, filename) - + save_transcript(session.history, full_path, format=fmt) console.print(f"[bold green]Saved securely to {full_path}[/bold green]") else: console.print("[bold yellow]Conversation discarded. Privacy maintained.[/bold yellow]") + +def cmd_config_init(_args: argparse.Namespace) -> int: + try: + dest = init_settings_file() + console.print(f"[green]Created {dest}[/green]") + console.print(f"[dim]Edit {USER_SETTINGS_FILENAME} (see {EXAMPLE_SETTINGS_FILENAME}).[/dim]") + return 0 + except SettingsError as e: + console.print(f"[red]{e}[/red]") + return 1 + + +def cmd_config_reset(args: argparse.Namespace) -> int: + target = Path(args.path) if getattr(args, "path", None) else None + if not args.yes: + if not Confirm.ask("Remove user settings and revert to shipped defaults?", default=False): + console.print("[yellow]Cancelled.[/yellow]") + return 0 + removed = reset_settings_file(target) + if removed: + console.print("[green]Settings removed. Next run uses shipped personas and built-in defaults.[/green]") + else: + console.print(f"[yellow]No {USER_SETTINGS_FILENAME} found to remove.[/yellow]") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Multi-Agent Room Framework") + parser.add_argument( + "--config", + metavar="PATH", + help=f"Path to settings YAML (default: search {USER_SETTINGS_FILENAME})", + ) + sub = parser.add_subparsers(dest="command") + + config_parser = sub.add_parser("config", help="Manage rooms.settings.yaml") + config_sub = config_parser.add_subparsers(dest="config_cmd", required=True) + + config_sub.add_parser("init", help=f"Copy {EXAMPLE_SETTINGS_FILENAME} to {USER_SETTINGS_FILENAME}") + reset_p = config_sub.add_parser("reset", help="Remove user settings file") + reset_p.add_argument("--path", help="Specific settings file to remove") + reset_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "config": + if args.config_cmd == "init": + return cmd_config_init(args) + if args.config_cmd == "reset": + return cmd_config_reset(args) + return 1 + + try: + settings = load_settings(args.config, required=bool(args.config)) + except SettingsError as e: + console.print(f"[red]{e}[/red]") + return 1 + + if args.config: + console.print(f"[dim]Using settings: {args.config}[/dim]") + else: + found = find_settings_file() + if found: + console.print(f"[dim]Using settings: {found}[/dim]") + + main_menu(settings) + return 0 + + if __name__ == "__main__": - main_menu() + sys.exit(main()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38e890a..4d98492 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,6 +14,17 @@ Because the request never leaves your computer, **there are no API keys required When the Agents reply to you in the terminal, it means your computer's local CPU/GPU is quietly processing the inference via the Ollama application running in your background! +## User settings (optional) + +Default model strings, timeouts, user profile, and optional persona overrides are loaded from YAML at CLI startup (`rooms/settings.py`). **No file is required** — if `rooms.settings.yaml` is missing, built-in defaults apply (same values as `rooms.settings.example.yaml`). + +| File | Committed? | Role | +|------|------------|------| +| `rooms.settings.example.yaml` | Yes | Template and documentation of all supported keys | +| `rooms.settings.yaml` | No (gitignored) | Per-machine overrides; create via `python cli.py config init` or manual copy | + +Search order: `--config path` → `./rooms.settings.yaml` → user config dir (`~/.config/rooms/settings.yaml` or `%APPDATA%\rooms\settings.yaml` on Windows). + ## Session Memory & Timestamps All conversation history is held in RAM for the duration of the session. Each entry — agent turn, user message, and system introduction — is tagged with a `timestamp` (format: `YYYY-MM-DD HH:MM:SS`). This makes transcripts auditable without any external database. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index d480e0c..2601778 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -15,6 +15,7 @@ The Multi-Agent Rooms framework is extremely versatile. This guide covers practi | `session_type` | Agent interaction style | See the table below | | `expertise` | Keywords for smart routing | Be specific — improves `dynamic` mode selection accuracy | | `system_prompt` | Agent identity and role | The more vivid and specific, the more coherent the agent | +| `rooms.settings.yaml` | Local defaults (optional) | Copy from `rooms.settings.example.yaml` or run `python cli.py config init`; set `defaults.litellm_model` to your Ollama tag | **Session Type Guide:** diff --git a/docs/TESTING.md b/docs/TESTING.md index 02e0d6e..3fb7ed8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,22 @@ PYTHONPATH=. python -m pytest tests/ -v > **Note**: Running `python tests/test_session.py` directly will fail with a `ModuleNotFoundError`. Always use `pytest`. -## Current Test Coverage (12 tests) +## Settings / CLI smoke (no Ollama) + +Fast checks for [#26](https://github.com/ARPAHLS/rooms/issues/26) / [#27](https://github.com/ARPAHLS/rooms/issues/27) — no interactive wizard, no live inference: + +```bash +# Windows (PowerShell) +$env:PYTHONPATH="."; python -m pytest tests/test_settings.py tests/test_cli_settings_smoke.py tests/test_cli.py -q +``` + +| Test file | What it verifies | +|---|---| +| `tests/test_settings.py` | YAML load/validation, personas, builtin defaults; asserts `rooms.settings.example.yaml` exists in repo | +| `tests/test_cli_settings_smoke.py` | `cli.py config init`, `config reset`, `--config` wiring | +| `tests/test_cli.py` | Wizard API-key env cleanup (#5) | + +## Session logic coverage | Test | What It Verifies | |---|---| @@ -40,6 +55,8 @@ PYTHONPATH=. python -m pytest tests/ -v | `test_expertise_word_boundaries` | Strict keyword matching using word boundaries (e.g., `law` vs `flaw`) | | `test_hitl_trigger_only_once_per_message` | Ensures HITL pause doesn't trigger repeatedly for the same event | +Run the full suite with `python -m pytest tests/ -v` (see **Running Tests** above). + ## How to Write Custom Tests ### Step 1: Define a Mock Configuration diff --git a/requirements.txt b/requirements.txt index 0cbd364..17035f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ sentence-transformers>=2.2.2 torch>=2.0.0 rich>=13.0.0 pydantic>=2.0.0 -prompt_toolkit>=3.0.0 \ No newline at end of file +prompt_toolkit>=3.0.0 +pyyaml>=6.0.0 \ No newline at end of file diff --git a/rooms.settings.example.yaml b/rooms.settings.example.yaml new file mode 100644 index 0000000..413d217 --- /dev/null +++ b/rooms.settings.example.yaml @@ -0,0 +1,35 @@ +# Copy to rooms.settings.yaml (gitignored) or run: python cli.py config init +# See: https://github.com/ARPAHLS/rooms/issues/26 and #27 + +defaults: + litellm_model: "ollama/gemma4:e2b" + orchestrator_model: "ollama/gemma4:e2b" + temperature: 0.7 + timeout: 30 + +presets: + local-ollama: + litellm_model: "ollama/gemma4:e2b" + openai: + litellm_model: "gpt-4o" + api_key_env: "OPENAI_API_KEY" + +ollama: + auto_select_first: false + base_url: "http://localhost:11434" + +user: + name: "User" + background: "" + +# true = use built-in Elena / Viktor / Nyx personas (prompts from repo) +use_shipped_personas: true + +# Optional: override personas entirely (set use_shipped_personas: false) +# personas: +# - name: "Elena (The Lawyer)" +# system_prompt: "..." +# expertise: ["law", "contracts"] +# model: null +# temperature: null +# color: "magenta" diff --git a/rooms/config.py b/rooms/config.py index b9250d4..0f7dad7 100644 --- a/rooms/config.py +++ b/rooms/config.py @@ -16,7 +16,7 @@ class AgentConfig(BaseModel): system_prompt: str = Field(..., description="System prompt defining the role") expertise: List[str] = Field(default_factory=list, description="Keywords defining expertise areas") model_type: ModelType = Field(default=ModelType.LITELLM, description="Whether to use LiteLLM routing or a custom python script") - model: str = Field(default="ollama/llama3", description="LiteLLM compatible model name or placeholder for custom") + model: str = Field(default="ollama/gemma4:e2b", description="LiteLLM compatible model name or placeholder for custom") temperature: float = Field(default=0.7, description="Generation temperature") max_tokens: Optional[int] = Field(default=None, description="Max generated tokens") timeout: int = Field(default=30, description="Timeout in seconds for model generation") diff --git a/rooms/settings.py b/rooms/settings.py new file mode 100644 index 0000000..53ccf13 --- /dev/null +++ b/rooms/settings.py @@ -0,0 +1,242 @@ +"""Load user settings from YAML with shipped fallbacks (#26, #27).""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from typing import Dict, List, Optional + +import yaml +from pydantic import BaseModel, Field, ValidationError + +from .config import AgentConfig + +# Shipped persona definitions (single source for reset / use_shipped_personas) +SHIPPED_PERSONAS: List[dict] = [ + { + "name": "Elena (The Lawyer)", + "system_prompt": ( + "You are Elena, a highly skilled corporate lawyer with 10 years of experience. " + "Recently, you lost a major case because of a tiny, overlooked detail in 'Clause Y', " + "and now you are extremely sensitive, defensive, and incredibly picky about specific wording. " + "You often bring up this past trauma when reviewing anything." + ), + "expertise": ["law", "contracts", "compliance", "clause y"], + "color": "magenta", + }, + { + "name": "Viktor (The Coder)", + "system_prompt": ( + "You are Viktor, a cynical senior backend engineer who has seen too many startups fail. " + "You are brutally honest, hate buzzwords, and prioritize performance and actual hardware specs. " + "You communicate strictly in practical terms and think most new tech is just a fad." + ), + "expertise": ["engineering", "backend", "performance", "realism"], + "color": "green", + }, + { + "name": "Nyx (The Visionary)", + "system_prompt": ( + "You are Nyx, a creative visionary who looks at everything from a 10,000-foot view. " + "You dislike getting bogged down in tiny details (which often annoys lawyers and engineers). " + "You focus on the 'why' and the 'future impact' rather than the 'how'." + ), + "expertise": ["creative", "vision", "future", "strategy"], + "color": "cyan", + }, +] + +BUILTIN_DEFAULT_MODEL = "ollama/gemma4:e2b" +EXAMPLE_SETTINGS_FILENAME = "rooms.settings.example.yaml" +USER_SETTINGS_FILENAME = "rooms.settings.yaml" + + +class DefaultsSettings(BaseModel): + litellm_model: str = BUILTIN_DEFAULT_MODEL + orchestrator_model: Optional[str] = None + temperature: float = 0.7 + timeout: int = 30 + + @property + def resolved_orchestrator_model(self) -> str: + return self.orchestrator_model or self.litellm_model + + +class PresetSettings(BaseModel): + litellm_model: str + api_key_env: Optional[str] = None + + +class OllamaSettings(BaseModel): + auto_select_first: bool = False + base_url: str = "http://localhost:11434" + + +class UserSettings(BaseModel): + name: str = "User" + background: str = "" + + +class PersonaSettings(BaseModel): + name: str + system_prompt: str + expertise: List[str] = Field(default_factory=list) + model: Optional[str] = None + temperature: Optional[float] = None + color: str = "blue" + + +class RoomsSettings(BaseModel): + defaults: DefaultsSettings = Field(default_factory=DefaultsSettings) + presets: Dict[str, PresetSettings] = Field(default_factory=dict) + ollama: OllamaSettings = Field(default_factory=OllamaSettings) + user: UserSettings = Field(default_factory=UserSettings) + use_shipped_personas: bool = True + personas: List[PersonaSettings] = Field(default_factory=list) + + +class SettingsError(Exception): + """Raised when settings YAML is missing, invalid, or cannot be written.""" + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def example_settings_path() -> Path: + return repo_root() / EXAMPLE_SETTINGS_FILENAME + + +def settings_search_paths(explicit_path: Optional[str] = None) -> List[Path]: + """Precedence: explicit --config, cwd, user config dir.""" + paths: List[Path] = [] + if explicit_path: + paths.append(Path(explicit_path).expanduser()) + paths.append(Path.cwd() / USER_SETTINGS_FILENAME) + if os.name == "nt": + appdata = os.environ.get("APPDATA") + if appdata: + paths.append(Path(appdata) / "rooms" / "settings.yaml") + else: + paths.append(Path.home() / ".config" / "rooms" / "settings.yaml") + return paths + + +def find_settings_file(explicit_path: Optional[str] = None) -> Optional[Path]: + for path in settings_search_paths(explicit_path): + if path.is_file(): + return path + return None + + +def _apply_ollama_env(settings: RoomsSettings) -> None: + if settings.ollama.base_url: + os.environ.setdefault("OLLAMA_API_BASE", settings.ollama.base_url) + + +def load_settings(explicit_path: Optional[str] = None, *, required: bool = False) -> RoomsSettings: + """Load settings from the first matching file, or return built-in defaults.""" + path = find_settings_file(explicit_path) + if path is None: + if required and explicit_path: + raise SettingsError( + f"Settings file not found: {explicit_path}\n" + f"Copy {EXAMPLE_SETTINGS_FILENAME} to {USER_SETTINGS_FILENAME} or run: python cli.py config init" + ) + settings = RoomsSettings() + _apply_ollama_env(settings) + return settings + + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + settings = RoomsSettings.model_validate(raw) + except (yaml.YAMLError, ValidationError) as e: + raise SettingsError( + f"Invalid settings in {path}: {e}\n" + f"See {EXAMPLE_SETTINGS_FILENAME} for the expected format." + ) from e + + _apply_ollama_env(settings) + return settings + + +def persona_settings_to_agent_config(persona: PersonaSettings, defaults: DefaultsSettings) -> AgentConfig: + return AgentConfig( + name=persona.name, + system_prompt=persona.system_prompt, + expertise=persona.expertise, + model=persona.model or defaults.litellm_model, + temperature=persona.temperature if persona.temperature is not None else defaults.temperature, + timeout=defaults.timeout, + color=persona.color, + ) + + +def _shipped_persona_dicts_to_configs(defaults: DefaultsSettings) -> List[AgentConfig]: + configs: List[AgentConfig] = [] + for data in SHIPPED_PERSONAS: + configs.append( + AgentConfig( + name=data["name"], + system_prompt=data["system_prompt"], + expertise=data["expertise"], + model=defaults.litellm_model, + temperature=defaults.temperature, + timeout=defaults.timeout, + color=data["color"], + ) + ) + return configs + + +def get_default_personas(settings: RoomsSettings) -> List[AgentConfig]: + """Resolve persona list: custom YAML personas or shipped defaults.""" + if settings.personas: + return [persona_settings_to_agent_config(p, settings.defaults) for p in settings.personas] + if settings.use_shipped_personas: + return _shipped_persona_dicts_to_configs(settings.defaults) + return _shipped_persona_dicts_to_configs(settings.defaults) + + +def resolve_preset_model(settings: RoomsSettings, preset_name: str) -> str: + preset = settings.presets.get(preset_name) + if not preset: + raise SettingsError(f"Unknown preset '{preset_name}'. Available: {', '.join(settings.presets) or '(none)'}") + return preset.litellm_model + + +def user_settings_path_preferred() -> Path: + """Where config init writes the user file (cwd first).""" + return Path.cwd() / USER_SETTINGS_FILENAME + + +def init_settings_file(target: Optional[Path] = None, *, force: bool = False) -> Path: + src = example_settings_path() + if not src.is_file(): + raise SettingsError(f"Example settings missing: {src}") + dest = target or user_settings_path_preferred() + if dest.exists() and not force: + raise SettingsError(f"Settings file already exists: {dest}") + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dest) + return dest + + +def reset_settings_file(target: Optional[Path] = None) -> bool: + """Remove user settings file if present. Returns True if a file was removed.""" + removed = False + if target: + paths = [Path(target).expanduser()] + else: + paths = [Path.cwd() / USER_SETTINGS_FILENAME] + appdata = os.environ.get("APPDATA") + if appdata: + paths.append(Path(appdata) / "rooms" / "settings.yaml") + paths.append(Path.home() / ".config" / "rooms" / "settings.yaml") + + for path in paths: + if path.is_file(): + path.unlink() + removed = True + return removed diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8fea80f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,46 @@ +import os + +import cli + + +def test_set_session_env_key_tracks_new_keys(): + tracked = [] + key = "ROOMS_TEST_WIZARD_KEY" + os.environ.pop(key, None) + + cli._set_session_env_key(tracked, key, "secret-value") + + assert tracked == [key] + assert os.environ[key] == "secret-value" + + cli._cleanup_session_env(tracked) + assert key not in os.environ + + +def test_set_session_env_key_skips_existing_keys(): + tracked = [] + key = "ROOMS_TEST_EXISTING_KEY" + os.environ[key] = "pre-existing" + + cli._set_session_env_key(tracked, key, "new-value") + + assert tracked == [] + assert os.environ[key] == "pre-existing" + + os.environ.pop(key, None) + + +def test_cleanup_session_env_removes_only_tracked_keys(): + wizard_key = "ROOMS_TEST_CLEANUP_WIZARD" + existing_key = "ROOMS_TEST_CLEANUP_EXISTING" + os.environ.pop(wizard_key, None) + os.environ[existing_key] = "keep-me" + + tracked = [] + cli._set_session_env_key(tracked, wizard_key, "wizard-secret") + cli._cleanup_session_env(tracked) + + assert wizard_key not in os.environ + assert os.environ[existing_key] == "keep-me" + + os.environ.pop(existing_key, None) diff --git a/tests/test_cli_settings_smoke.py b/tests/test_cli_settings_smoke.py new file mode 100644 index 0000000..35b80aa --- /dev/null +++ b/tests/test_cli_settings_smoke.py @@ -0,0 +1,91 @@ +"""CLI + settings smoke tests (no Ollama / no interactive wizard).""" + +from unittest.mock import patch + +import pytest +import yaml + +import cli +from rooms.settings import ( + SettingsError, + USER_SETTINGS_FILENAME, + load_settings, + get_default_personas, +) + + +def test_cli_config_init_creates_settings_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert cli.main(["config", "init"]) == 0 + dest = tmp_path / USER_SETTINGS_FILENAME + assert dest.is_file() + data = yaml.safe_load(dest.read_text(encoding="utf-8")) + assert data["defaults"]["litellm_model"] == "ollama/gemma4:e2b" + + +def test_cli_config_init_refuses_overwrite(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert cli.main(["config", "init"]) == 0 + assert cli.main(["config", "init"]) == 1 + + +def test_cli_config_reset_removes_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cli.main(["config", "init"]) + dest = tmp_path / USER_SETTINGS_FILENAME + assert dest.is_file() + assert cli.main(["config", "reset", "-y"]) == 0 + assert not dest.is_file() + + +def test_cli_config_reset_with_explicit_path(tmp_path): + dest = tmp_path / USER_SETTINGS_FILENAME + dest.write_text("defaults:\n litellm_model: ollama/x\n", encoding="utf-8") + assert cli.main(["config", "reset", "-y", "--path", str(dest)]) == 0 + assert not dest.is_file() + + +def test_cli_main_loads_explicit_config_without_wizard(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = tmp_path / "my.settings.yaml" + cfg.write_text( + yaml.dump({ + "defaults": {"litellm_model": "ollama/smoke:1b", "timeout": 99}, + "user": {"name": "SmokeUser", "background": "Tester"}, + }), + encoding="utf-8", + ) + + with patch.object(cli, "main_menu") as mock_menu: + rc = cli.main(["--config", str(cfg)]) + + assert rc == 0 + mock_menu.assert_called_once() + settings = mock_menu.call_args[0][0] + assert settings.defaults.litellm_model == "ollama/smoke:1b" + assert settings.defaults.timeout == 99 + assert settings.user.name == "SmokeUser" + + +def test_cli_main_missing_required_config_exits_nonzero(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + missing = tmp_path / "missing.yaml" + rc = cli.main(["--config", str(missing)]) + assert rc == 1 + + +def test_init_settings_produces_loadable_personas(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cli.main(["config", "init"]) + settings = load_settings() + personas = get_default_personas(settings) + assert len(personas) == 3 + assert all(p.model == "ollama/gemma4:e2b" for p in personas) + + +def test_settings_error_from_init_guard(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cli.main(["config", "init"]) + with pytest.raises(SettingsError): + from rooms.settings import init_settings_file + init_settings_file() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..095a55f --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path + +import pytest +import yaml + +from rooms.config import AgentConfig +from rooms.settings import ( + RoomsSettings, + SettingsError, + load_settings, + get_default_personas, + init_settings_file, + reset_settings_file, + persona_settings_to_agent_config, + PersonaSettings, + DefaultsSettings, + SHIPPED_PERSONAS, + repo_root, +) + + +def test_builtin_defaults_without_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + settings = load_settings() + assert settings.defaults.litellm_model == "ollama/gemma4:e2b" + assert settings.user.name == "User" + + +def test_load_settings_from_file(tmp_path): + cfg = tmp_path / "rooms.settings.yaml" + cfg.write_text( + yaml.dump({ + "defaults": {"litellm_model": "ollama/gemma4:e2b", "timeout": 120}, + "user": {"name": "Theo", "background": "Engineer"}, + }), + encoding="utf-8", + ) + settings = load_settings(str(cfg)) + assert settings.defaults.litellm_model == "ollama/gemma4:e2b" + assert settings.defaults.timeout == 120 + assert settings.user.name == "Theo" + + +def test_invalid_yaml_raises(tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("defaults: [not", encoding="utf-8") + with pytest.raises(SettingsError): + load_settings(str(bad)) + + +def test_shipped_personas_use_defaults_model(): + settings = RoomsSettings(defaults=DefaultsSettings(litellm_model="ollama/custom:7b")) + personas = get_default_personas(settings) + assert len(personas) == len(SHIPPED_PERSONAS) + assert all(p.model == "ollama/custom:7b" for p in personas) + assert personas[0].name == "Elena (The Lawyer)" + + +def test_custom_personas_from_yaml(): + settings = RoomsSettings( + use_shipped_personas=False, + defaults=DefaultsSettings(litellm_model="ollama/base"), + personas=[ + PersonaSettings( + name="Custom", + system_prompt="You are custom.", + expertise=["test"], + model=None, + color="red", + ) + ], + ) + personas = get_default_personas(settings) + assert len(personas) == 1 + assert personas[0].name == "Custom" + assert personas[0].model == "ollama/base" + + +def test_persona_null_model_inherits_default(): + persona = PersonaSettings(name="X", system_prompt="sys", model=None) + agent = persona_settings_to_agent_config(persona, DefaultsSettings(litellm_model="ollama/x")) + assert agent.model == "ollama/x" + + +def test_init_and_reset_settings(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + dest = init_settings_file() + assert dest.is_file() + with pytest.raises(SettingsError): + init_settings_file() + assert reset_settings_file(dest) is True + assert not dest.is_file() + + +def test_example_settings_file_exists(): + assert (repo_root() / "rooms.settings.example.yaml").is_file() + + +def test_explicit_config_required_missing(tmp_path): + missing = tmp_path / "nope.yaml" + with pytest.raises(SettingsError): + load_settings(str(missing), required=True)