diff --git a/.gitignore b/.gitignore index c762f45..44dc8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ coverage.xml *.cover *.py.cover .hypothesis/ + +# Model configuration (contains API keys) +.aloop/models.yaml .pytest_cache/ cover/ diff --git a/CLAUDE.md b/CLAUDE.md index 404014a..92136a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,14 @@ pre-commit install Never commit directly to `main`. All changes go through PR review. +## Checkpoint Commits + +Prefer small, reviewable commits: +- Before committing, run `./scripts/dev.sh check` (precommit + typecheck + tests). +- Keep mechanical changes (formatting, renames) in their own commit when possible. +- **Human-in-the-loop**: at key checkpoints, the agent should *ask* whether to `git commit` and/or `git push` (do not do it automatically). +- Before asking to commit, show a short change summary (e.g. `git diff --stat`) and the `./scripts/dev.sh check` result. + ## CI GitHub Actions runs `./scripts/dev.sh precommit`, `./scripts/dev.sh test -q`, and strict typecheck on PRs. @@ -44,7 +52,7 @@ TYPECHECK_STRICT=1 ./scripts/dev.sh typecheck ``` Manual doc/workflow checks: -- README/AGENTS/docs: avoid legacy/removed commands (`LLM_PROVIDER`, `pip install -e`, `requirements.txt`, `setup.py`) +- README/AGENTS/docs: avoid legacy/removed commands or env-based config; use current docs only - Docker examples use `--mode`/`--task` - Python 3.12+ + uv-only prerequisites documented consistently @@ -53,7 +61,7 @@ Change impact reminders: - Config changes → update `docs/configuration.md` - Workflow scripts → update `AGENTS.md`, `docs/packaging.md` -Run a quick smoke task (requires a configured provider in `.aloop/config`): +Run a quick smoke task (requires a configured provider in `.aloop/models.yaml`): ```bash python main.py --task "Calculate 1+1" @@ -122,7 +130,7 @@ Unified entrypoint: `./scripts/dev.sh format` ## Docs Pointers -- Configuration & `.aloop/config`: `docs/configuration.md` +- Configuration & `.aloop/models.yaml`: `docs/configuration.md` - Packaging & release checklist: `docs/packaging.md` - Extending tools/agents: `docs/extending.md` - Memory system: `docs/memory-management.md`, `docs/memory_persistence.md` diff --git a/README.md b/README.md index 4041eca..54c5077 100644 --- a/README.md +++ b/README.md @@ -57,57 +57,56 @@ pre-commit install ### 1. Configuration -On first run, `.aloop/config` is created automatically with sensible defaults. Edit it to configure your LLM provider: +On first run, `.aloop/models.yaml` is created automatically with a template. Edit it to configure your models and API keys (this file is gitignored): ```bash -$EDITOR .aloop/config +$EDITOR .aloop/models.yaml ``` -Example `.aloop/config`: +Example `.aloop/models.yaml`: -```bash -# LiteLLM Model Configuration (supports 100+ providers) -# Format: provider/model_name -LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 +```yaml +models: + openai/gpt-4o: + api_key: sk-... + timeout: 300 -# API Keys (set the key for your chosen provider) -ANTHROPIC_API_KEY=your_anthropic_api_key_here -OPENAI_API_KEY=your_openai_api_key_here -GEMINI_API_KEY=your_gemini_api_key_here + anthropic/claude-3-5-sonnet-20241022: + api_key: sk-ant-... -# Optional: Custom base URL for proxies or custom endpoints -LITELLM_API_BASE= + # Local model example + ollama/llama2: + api_base: http://localhost:11434 -# Optional: LiteLLM-specific settings -LITELLM_DROP_PARAMS=true # Drop unsupported params instead of erroring -LITELLM_TIMEOUT=600 # Request timeout in seconds +default: openai/gpt-4o +``` -# Agent Configuration -MAX_ITERATIONS=100 # Maximum iteration loops +Non-model runtime settings live in `.aloop/config` (created automatically). Example: -# Memory Management +```bash +MAX_ITERATIONS=100 MEMORY_ENABLED=true -MEMORY_COMPRESSION_THRESHOLD=25000 -MEMORY_SHORT_TERM_SIZE=100 -MEMORY_COMPRESSION_RATIO=0.3 +``` -# Retry Configuration (for handling rate limits) -RETRY_MAX_ATTEMPTS=3 -RETRY_INITIAL_DELAY=1.0 -RETRY_MAX_DELAY=60.0 +**Switching Models:** -# Logging -LOG_LEVEL=DEBUG +In interactive mode, use the `/model` command: +```bash +# Pick a model +/model + +# Or edit the config +/model edit +``` + +Or use the CLI flag: +```bash +python main.py --task "Hello" --model openai/gpt-4o ``` -**Quick setup for different providers:** +**Model setup:** -- **Anthropic Claude**: `LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022` -- **OpenAI GPT**: `LITELLM_MODEL=openai/gpt-4o` -- **Google Gemini**: `LITELLM_MODEL=gemini/gemini-1.5-pro` -- **Azure OpenAI**: `LITELLM_MODEL=azure/gpt-4` -- **AWS Bedrock**: `LITELLM_MODEL=bedrock/anthropic.claude-v2` -- **Local (Ollama)**: `LITELLM_MODEL=ollama/llama2` +Edit `.aloop/models.yaml` and add your provider model IDs + API keys. See [LiteLLM Providers](https://docs.litellm.ai/docs/providers) for 100+ supported providers. @@ -238,10 +237,7 @@ See the [Configuration Guide](docs/configuration.md) for all options. Key settin | Setting | Description | Default | |---------|-------------|---------| -| `LITELLM_MODEL` | LiteLLM model (provider/model format) | `anthropic/claude-3-5-sonnet-20241022` | -| `LITELLM_API_BASE` | Custom base URL for proxies | Empty | -| `LITELLM_DROP_PARAMS` | Drop unsupported params | `true` | -| `LITELLM_TIMEOUT` | Request timeout in seconds | `600` | +| `.aloop/models.yaml` | Model configuration (models + keys + default) | - | | `MAX_ITERATIONS` | Maximum agent iterations | `100` | | `MEMORY_COMPRESSION_THRESHOLD` | Compress when exceeded | `25000` | | `MEMORY_SHORT_TERM_SIZE` | Recent messages to keep | `100` | diff --git a/agent/base.py b/agent/base.py index 94bdc97..4f088dc 100644 --- a/agent/base.py +++ b/agent/base.py @@ -14,7 +14,7 @@ from .tool_executor import ToolExecutor if TYPE_CHECKING: - from llm import LiteLLMAdapter + from llm import LiteLLMAdapter, ModelManager logger = get_logger(__name__) @@ -27,6 +27,7 @@ def __init__( llm: "LiteLLMAdapter", tools: List[BaseTool], max_iterations: int = 10, + model_manager: Optional["ModelManager"] = None, ): """Initialize the agent. @@ -34,9 +35,11 @@ def __init__( llm: LLM instance to use max_iterations: Maximum number of agent loop iterations tools: List of tools available to the agent + model_manager: Optional model manager for switching models """ self.llm = llm self.max_iterations = max_iterations + self.model_manager = model_manager # Initialize todo list system self.todo_list = TodoList() @@ -56,6 +59,16 @@ def __init__( # This injects current todo state into summaries instead of preserving all todo messages self.memory.set_todo_context_provider(self._get_todo_context) + def _set_llm_adapter(self, llm: "LiteLLMAdapter") -> None: + self.llm = llm + + # Keep memory/compressor in sync with the active LLM. + # Otherwise stats/compression might continue using the previous model. + if hasattr(self, "memory") and self.memory: + self.memory.llm = llm + if hasattr(self.memory, "compressor") and self.memory.compressor: + self.memory.compressor.llm = llm + @abstractmethod def run(self, task: str) -> str: """Execute the agent on a task and return final answer.""" @@ -225,3 +238,65 @@ async def _react_loop( await self.memory.add_message(result_messages) else: messages.append(result_messages) + + def switch_model(self, model_id: str) -> bool: + """Switch to a different model. + + Args: + model_id: LiteLLM model ID to switch to + + Returns: + True if switch was successful, False otherwise + """ + if not self.model_manager: + logger.warning("No model manager available for switching models") + return False + + profile = self.model_manager.get_model(model_id) + if not profile: + logger.error(f"Model '{model_id}' not found") + return False + + # Validate the model + is_valid, error_msg = self.model_manager.validate_model(profile) + if not is_valid: + logger.error(f"Invalid model: {error_msg}") + return False + + # Switch the model + new_profile = self.model_manager.switch_model(model_id) + if not new_profile: + logger.error(f"Failed to switch to model '{model_id}'") + return False + + # Reinitialize LLM adapter with new model + from llm import LiteLLMAdapter + + new_llm = LiteLLMAdapter( + model=new_profile.model_id, + api_key=new_profile.api_key, + api_base=new_profile.api_base, + timeout=new_profile.timeout, + drop_params=new_profile.drop_params, + ) + self._set_llm_adapter(new_llm) + + logger.info(f"Switched to model: {new_profile.model_id}") + return True + + def get_current_model_info(self) -> Optional[dict]: + """Get information about the current model. + + Returns: + Dictionary with model info or None if not available + """ + if self.model_manager: + profile = self.model_manager.get_current_model() + if not profile: + return None + return { + "name": profile.model_id, + "model_id": profile.model_id, + "provider": profile.provider, + } + return None diff --git a/config.py b/config.py index f5b894c..3bb987a 100644 --- a/config.py +++ b/config.py @@ -11,20 +11,10 @@ # Default configuration template _DEFAULT_CONFIG = """\ # AgenticLoop Configuration +# +# NOTE: Model configuration lives in `.aloop/models.yaml`. +# This file controls non-model runtime settings only. -# LiteLLM Model Configuration -# Format: provider/model_name (e.g. "anthropic/claude-3-5-sonnet-20241022") -LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 - -# API Keys (set the key for your chosen provider) -ANTHROPIC_API_KEY= -OPENAI_API_KEY= -GEMINI_API_KEY= - -# Optional settings -LITELLM_API_BASE= -LITELLM_DROP_PARAMS=true -LITELLM_TIMEOUT=600 TOOL_TIMEOUT=600 MAX_ITERATIONS=1000 """ @@ -63,25 +53,23 @@ def _ensure_config(): _cfg = _load_config(_CONFIG_FILE) +def get_raw_config() -> dict[str, str]: + """Get the raw config dictionary. + + Returns: + Dictionary of config key-value pairs + """ + return _cfg.copy() + + class Config: """Configuration for the agentic system. All configuration is centralized here. Access config values directly via Config.XXX. """ - # LiteLLM Model Configuration - # Format: provider/model_name (e.g. "anthropic/claude-3-5-sonnet-20241022") - LITELLM_MODEL = _cfg.get("LITELLM_MODEL", "anthropic/claude-3-5-sonnet-20241022") - - # Common provider API keys (optional depending on provider) - ANTHROPIC_API_KEY = _cfg.get("ANTHROPIC_API_KEY") or None - OPENAI_API_KEY = _cfg.get("OPENAI_API_KEY") or None - GEMINI_API_KEY = _cfg.get("GEMINI_API_KEY") or _cfg.get("GOOGLE_API_KEY") or None - - # Optional LiteLLM Configuration - LITELLM_API_BASE = _cfg.get("LITELLM_API_BASE") or None - LITELLM_DROP_PARAMS = _cfg.get("LITELLM_DROP_PARAMS", "true").lower() == "true" - LITELLM_TIMEOUT = int(_cfg.get("LITELLM_TIMEOUT", "600")) + # Model configuration is handled by `.aloop/models.yaml` via ModelManager. + # `.aloop/config` controls non-model runtime settings only. TOOL_TIMEOUT = float(_cfg.get("TOOL_TIMEOUT", "600")) # Agent Configuration @@ -147,17 +135,6 @@ def validate(cls): Raises: ValueError: If required configuration is missing """ - if not cls.LITELLM_MODEL: - raise ValueError( - "LITELLM_MODEL not set. Please set it in .aloop/config.\n" - "Example: LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022" - ) - - # Validate common providers (LiteLLM supports many; only enforce the ones we document). - provider = cls.LITELLM_MODEL.split("/", 1)[0].lower() if "/" in cls.LITELLM_MODEL else "" - if provider == "anthropic" and not cls.ANTHROPIC_API_KEY: - raise ValueError("ANTHROPIC_API_KEY not set. Please set it in .aloop/config.") - if provider == "openai" and not cls.OPENAI_API_KEY: - raise ValueError("OPENAI_API_KEY not set. Please set it in .aloop/config.") - if provider == "gemini" and not cls.GEMINI_API_KEY: - raise ValueError("GEMINI_API_KEY not set. Please set it in .aloop/config.") + # Model configuration is handled by `.aloop/models.yaml` via ModelManager. + # `.aloop/config` is used for non-model runtime settings only. + return diff --git a/docs/advanced-features.md b/docs/advanced-features.md index ef27828..fc691cc 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -194,9 +194,15 @@ Support for proxies, Azure, and local deployments: ### Configuration -```bash +```yaml # Proxy / custom endpoint -LITELLM_API_BASE=http://proxy.company.com +# Set `api_base` on the model you want to route through a proxy. +models: + openai/gpt-4o: + api_key: sk-... + api_base: http://proxy.company.com + +default: openai/gpt-4o ``` ### Use Cases @@ -210,11 +216,14 @@ LITELLM_API_BASE=http://proxy.company.com ### Example: Azure OpenAI ```bash -# .aloop/config -LITELLM_MODEL=azure/gpt-4 -AZURE_API_KEY=your_azure_key -AZURE_API_BASE=https://your-resource.openai.azure.com -AZURE_API_VERSION=2024-02-15-preview +# .aloop/models.yaml +models: + azure/gpt-4: + api_key: your_azure_key + api_base: https://your-resource.openai.azure.com + api_version: 2024-02-15-preview + +default: azure/gpt-4 ``` ## Agent Mode Comparison diff --git a/docs/configuration.md b/docs/configuration.md index d24fffe..fb36680 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,86 +1,95 @@ # Configuration Guide -This repo uses a **single configuration surface** via `.aloop/config` and `config.py`. +This repo uses YAML-based configuration for model management via `.aloop/models.yaml`. +Model settings are not read from environment variables; use `.aloop/models.yaml` only. -## Configuration File +## Model Configuration -On first run, `.aloop/config` is created automatically with sensible defaults. Edit it to configure your LLM provider: +On first run, `.aloop/models.yaml` is created automatically with a template. Edit it to configure your LLM providers: ```bash # Open the config file -$EDITOR .aloop/config +$EDITOR .aloop/models.yaml ``` -## LLM Configuration (Recommended: LiteLLM) +### YAML Configuration Format -### Required +```yaml +# Model Configuration +# This file is gitignored - do not commit to version control -```bash -# Format: provider/model_name -LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 - -# Set the key for your chosen provider -ANTHROPIC_API_KEY=your_key_here -OPENAI_API_KEY= -GEMINI_API_KEY= -``` - -LiteLLM auto-detects which key is needed based on the `LITELLM_MODEL` prefix. +models: + anthropic/claude-3-5-sonnet-20241022: + api_key: sk-ant-... + timeout: 600 + drop_params: true -### Model Examples - -```bash -# Anthropic -LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 + openai/gpt-4o: + api_key: sk-... + timeout: 300 -# OpenAI -LITELLM_MODEL=openai/gpt-4o + ollama/llama2: + api_base: http://localhost:11434 -# Gemini -LITELLM_MODEL=gemini/gemini-1.5-pro +default: anthropic/claude-3-5-sonnet-20241022 ``` -For the full list of providers/models, see: https://docs.litellm.ai/docs/providers - -### Base URL (Optional) - -Use this for proxies or custom endpoints: +### Configuration Fields -```bash -LITELLM_API_BASE= -``` +The model ID (LiteLLM `provider/model`) is the key under `models`. -### LiteLLM Behavior (Optional) +| Field | Required | Description | Example | +|-------|----------|-------------|---------| +| `api_key` | Yes* | API key | `sk-ant-xxx` | +| `api_base` | No | Custom base URL for proxies | `https://custom.api.com` | +| `timeout` | No | Request timeout in seconds | `600` | +| `drop_params` | No | Drop unsupported params | `true` | -```bash -LITELLM_DROP_PARAMS=true -LITELLM_TIMEOUT=600 -``` +*Required for most providers except local ones like Ollama. -## Tool Configuration +### Model Examples -```bash -TOOL_TIMEOUT=600 +```yaml +# Anthropic Claude +models: + anthropic/claude-3-5-sonnet-20241022: + api_key: sk-ant-... + +# OpenAI GPT +models: + openai/gpt-4o: + api_key: sk-... + +# Google Gemini +models: + gemini/gemini-1.5-pro: + api_key: ... + +# Local Ollama (no API key needed) +models: + ollama/llama2: + api_base: http://localhost:11434 ``` -### Legacy (Compatibility) +For the full list of providers/models, see: https://docs.litellm.ai/docs/providers -This repo does not support legacy `LLM_PROVIDER` / `MODEL` configuration. Use `LITELLM_MODEL`. +## Interactive Mode Commands -## Agent Configuration +When running in interactive mode, you can manage models using the `/model` command: +### Pick Model (Cursor) ```bash -MAX_ITERATIONS=100 +> /model ``` +Pick a model with arrow keys and Enter (Esc to cancel). -## Memory Configuration - +### Edit Model Config ```bash -MEMORY_ENABLED=true -MEMORY_COMPRESSION_THRESHOLD=25000 -MEMORY_SHORT_TERM_SIZE=100 -MEMORY_COMPRESSION_RATIO=0.3 +> /model edit ``` +Open `.aloop/models.yaml` in your editor, then it will auto-reload after you save. + +Add/remove/default are done by editing `.aloop/models.yaml` directly. ## Email Notification Configuration (Resend) @@ -93,30 +102,30 @@ NOTIFY_EMAIL_FROM=AgenticLoop ## Retry Configuration +## CLI Usage + +You can specify a model when starting the agent: + ```bash -RETRY_MAX_ATTEMPTS=3 -RETRY_INITIAL_DELAY=1.0 -RETRY_MAX_DELAY=60.0 +# Use specific model for a single task +python main.py --task "Calculate 1+1" --model openai/gpt-4o + +# Start interactive mode with specific model +python main.py --model openai/gpt-4o ``` ## Validation ```bash -# Sanity check (requires correct API key for your model/provider) +# Sanity check (requires correct API key for your model) python main.py --task "Calculate 1+1" # Run tests python -m pytest test/ ``` -Integration tests that call a live LLM are skipped by default: - -```bash -RUN_INTEGRATION_TESTS=1 python -m pytest -m integration -``` - -## Security Best Practices +## Security Notes -1. Never commit `.aloop/config` or API keys. -2. Treat publishing as a manual step (see `docs/packaging.md`). -3. Keep `MAX_ITERATIONS` low when experimenting to avoid runaway cost. +- `.aloop/models.yaml` is automatically gitignored to prevent accidental commits of API keys +- Keep your API keys secure and rotate them regularly +- The YAML file permissions should be set to user-readable only (0600) on Unix systems diff --git a/docs/examples.md b/docs/examples.md index 153dc9f..d1a42a1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -88,12 +88,16 @@ What would you like me to help you with? ## Using Different LLM Providers +Models are configured only via `.aloop/models.yaml` (do not use legacy env-based config). + ### With OpenAI GPT ```bash -# Set in .aloop/config: -OPENAI_API_KEY=your_key_here -LITELLM_MODEL=openai/gpt-4o +# Add to .aloop/models.yaml: +# models: +# openai/gpt-4o: +# api_key: your_key_here +# default: openai/gpt-4o # Run: python main.py --task "Your task here" @@ -102,9 +106,11 @@ python main.py --task "Your task here" ### With Google Gemini ```bash -# Set in .aloop/config: -GEMINI_API_KEY=your_key_here -LITELLM_MODEL=gemini/gemini-1.5-flash +# Add to .aloop/models.yaml: +# models: +# gemini/gemini-1.5-flash: +# api_key: your_key_here +# default: gemini/gemini-1.5-flash # Run: python main.py --task "Your task here" @@ -113,9 +119,11 @@ python main.py --task "Your task here" ### With Anthropic Claude ```bash -# Set in .aloop/config: -ANTHROPIC_API_KEY=your_key_here -LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 +# Add to .aloop/models.yaml: +# models: +# anthropic/claude-3-5-sonnet-20241022: +# api_key: your_key_here +# default: anthropic/claude-3-5-sonnet-20241022 # Run: python main.py --task "Your task here" @@ -222,7 +230,7 @@ See [Memory Management](memory-management.md) for more details. import asyncio from agent.react_agent import ReActAgent -from llm import LiteLLMAdapter +from llm import LiteLLMAdapter, ModelManager from tools import CalculatorTool, FileReadTool from config import Config @@ -232,12 +240,12 @@ async def main(): Config.RETRY_INITIAL_DELAY = 2.0 Config.RETRY_MAX_DELAY = 60.0 - llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, - ) + mm = ModelManager() + profile = mm.get_current_model() + if not profile: + raise RuntimeError("No models configured. Edit .aloop/models.yaml and set `default`.") + + llm = LiteLLMAdapter(model=profile.model_id, api_key=profile.api_key, api_base=profile.api_base) # Create agent with specific tools agent = ReActAgent( @@ -313,7 +321,7 @@ MEMORY_ENABLED=true MEMORY_COMPRESSION_THRESHOLD=40000 # Use more efficient models: -LITELLM_MODEL=openai/gpt-4o-mini # or gemini/gemini-1.5-flash, anthropic/claude-3-5-haiku-20241022 +# Edit `.aloop/models.yaml` and switch `default`, or use `/model` (picker) in interactive mode. ``` ### API Errors @@ -322,7 +330,7 @@ For consistent API errors: ```bash # Check your API key configuration -grep API_KEY .aloop/config +grep api_key .aloop/models.yaml # Test with a simple task first python main.py --task "Calculate 1+1" diff --git a/docs/extending.md b/docs/extending.md index 16fa1cb..155f087 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -409,23 +409,29 @@ class MyProviderLLM(BaseLLM): ### 2. Update Configuration -This repo is configured via LiteLLM (`LITELLM_MODEL` in `.aloop/config`). For most providers, **no code changes** are required: +This repo is configured via LiteLLM using `.aloop/models.yaml`. For most providers, **no code changes** are required: ```bash -LITELLM_MODEL=my_provider/my-model -MY_PROVIDER_API_KEY=... +# Add to .aloop/models.yaml: +# models: +# my_provider/my-model: +# api_key: ... +# default: my_provider/my-model ``` If a provider is not supported by LiteLLM, implement a custom `BaseLLM` adapter under `llm/` and instantiate it directly in your app code (avoid adding more branching to `config.py`). -### 4. Update .aloop/config +### 4. Update `.aloop/models.yaml` -Add your provider key to `.aloop/config`: +Add your provider key to `.aloop/models.yaml`: ```bash -# MyProvider Configuration -MY_PROVIDER_API_KEY=your_api_key_here -MY_PROVIDER_BASE_URL= # Optional: custom API endpoint +models: + my_provider/my-model: + api_key: your_api_key_here + api_base: # Optional: custom API endpoint + +default: my_provider/my-model ``` ## Testing Your Extensions @@ -466,12 +472,13 @@ from llm import LiteLLMAdapter from config import Config async def test_my_agent(): - llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, - ) + from llm import ModelManager + + mm = ModelManager() + profile = mm.get_current_model() + assert profile is not None + + llm = LiteLLMAdapter(model=profile.model_id, api_key=profile.api_key, api_base=profile.api_base) agent = MyCustomAgent(llm=llm) result = await agent.run("Test task") diff --git a/examples/react_example.py b/examples/react_example.py index 69d94d8..011925b 100644 --- a/examples/react_example.py +++ b/examples/react_example.py @@ -8,8 +8,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from agent.react_agent import ReActAgent -from config import Config -from llm import LiteLLMAdapter +from llm import LiteLLMAdapter, ModelManager from tools.calculator import CalculatorTool from tools.file_ops import FileReadTool, FileWriteTool from tools.web_search import WebSearchTool @@ -21,19 +20,18 @@ async def main(): print("ReAct Agent Example") print("=" * 60) - # Validate configuration - try: - Config.validate() - except ValueError as e: - print(f"Error: {e}") - print("Please configure .aloop/config (see README.md)") + mm = ModelManager() + profile = mm.get_current_model() + if not profile: + print("No models configured. Edit .aloop/models.yaml and set `default`.") return llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, + model=profile.model_id, + api_key=profile.api_key, + api_base=profile.api_base, + drop_params=profile.drop_params, + timeout=profile.timeout, ) # Initialize agent with tools diff --git a/examples/web_fetch_example.py b/examples/web_fetch_example.py index 13f19d4..030bdcb 100644 --- a/examples/web_fetch_example.py +++ b/examples/web_fetch_example.py @@ -7,8 +7,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from agent.react_agent import ReActAgent -from config import Config -from llm import LiteLLMAdapter +from llm import LiteLLMAdapter, ModelManager from tools.web_fetch import WebFetchTool @@ -18,18 +17,18 @@ async def main(): print("WebFetchTool Example") print("=" * 60) - try: - Config.validate() - except ValueError as exc: - print(f"Error: {exc}") - print("Please set your API key in .aloop/config") + mm = ModelManager() + profile = mm.get_current_model() + if not profile: + print("No models configured. Edit .aloop/models.yaml and set `default`.") return llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, + model=profile.model_id, + api_key=profile.api_key, + api_base=profile.api_base, + drop_params=profile.drop_params, + timeout=profile.timeout, ) agent = ReActAgent( diff --git a/interactive.py b/interactive.py index fa96ec4..3910c1e 100644 --- a/interactive.py +++ b/interactive.py @@ -1,6 +1,7 @@ """Interactive multi-turn conversation mode for the agent.""" import json +import shlex from datetime import datetime from pathlib import Path @@ -9,10 +10,17 @@ from rich.table import Table from config import Config +from llm import ModelManager from memory.store import MemoryStore from utils import get_log_file_path, terminal_ui from utils.runtime import get_exports_dir, get_history_file from utils.tui.input_handler import InputHandler +from utils.tui.model_ui import ( + mask_secret, + open_config_and_wait_for_save, + parse_kv_args, + pick_model_id, +) from utils.tui.status_bar import StatusBar from utils.tui.theme import Theme, set_theme @@ -31,6 +39,9 @@ def __init__(self, agent): self.show_thinking = Config.TUI_SHOW_THINKING self.compact_mode = Config.TUI_COMPACT_MODE + # Use the agent's model manager to avoid divergence + self.model_manager = getattr(agent, "model_manager", None) or ModelManager() + # Initialize TUI components self.input_handler = InputHandler( history_file=get_history_file(), @@ -43,6 +54,7 @@ def __init__(self, agent): "theme", "verbose", "compact", + "model", "exit", "quit", ], @@ -103,6 +115,13 @@ def _show_help(self) -> None: terminal_ui.console.print( f" [{colors.primary}]/compact[/{colors.primary}] - Toggle compact output mode" ) + terminal_ui.console.print( + f" [{colors.primary}]/model[/{colors.primary}] - Manage models" + ) + terminal_ui.console.print( + f" [{colors.text_muted}]/model - Pick model (cursor)\n" + f" /model edit - Edit `.aloop/models.yaml` (auto-reload on save)[/{colors.text_muted}]" + ) terminal_ui.console.print( f" [{colors.primary}]/exit[/{colors.primary}] - Exit interactive mode" ) @@ -267,11 +286,14 @@ def _toggle_compact(self) -> None: def _update_status_bar(self) -> None: """Update status bar with current stats.""" stats = self.agent.memory.get_stats() + model_info = self.agent.get_current_model_info() + model_name = model_info["name"] if model_info else "" self.status_bar.update( input_tokens=stats.get("total_input_tokens", 0), output_tokens=stats.get("total_output_tokens", 0), context_tokens=stats.get("current_tokens", 0), cost=stats.get("total_cost", 0), + model_name=model_name, ) async def _handle_command(self, user_input: str) -> bool: @@ -328,6 +350,9 @@ async def _handle_command(self, user_input: str) -> bool: elif command == "/compact": self._toggle_compact() + elif command == "/model": + await self._handle_model_command(user_input) + else: colors = Theme.get_colors() terminal_ui.console.print( @@ -339,6 +364,153 @@ async def _handle_command(self, user_input: str) -> bool: return True + def _show_models(self) -> None: + """Display available models and current selection.""" + colors = Theme.get_colors() + profiles = self.model_manager.list_models() + current = self.model_manager.get_current_model() + default_model_id = self.model_manager.get_default_model_id() + + terminal_ui.console.print( + f"\n[bold {colors.primary}]Available Models:[/bold {colors.primary}]\n" + ) + + if not profiles: + terminal_ui.print_error("No models configured.") + terminal_ui.console.print( + f"[{colors.text_muted}]Use /model edit (recommended) or edit `.aloop/models.yaml` manually.[/{colors.text_muted}]\n" + ) + return + + for i, profile in enumerate(profiles, start=1): + markers: list[str] = [] + if current and profile.model_id == current.model_id: + markers.append(f"[{colors.success}]CURRENT[/{colors.success}]") + if default_model_id and profile.model_id == default_model_id: + markers.append(f"[{colors.primary}]DEFAULT[/{colors.primary}]") + marker = ( + " ".join(markers) + if markers + else f"[{colors.text_muted}] [/{colors.text_muted}]" + ) + + terminal_ui.console.print( + f" {marker} [{colors.text_muted}]{i:>2}[/] {profile.model_id}" + ) + + terminal_ui.console.print( + f"\n[{colors.text_muted}]Tip: run /model to pick; /model edit to change config.[/]\n" + ) + + def _switch_model(self, model_id: str) -> None: + """Switch to a different model. + + Args: + model_id: LiteLLM model ID to switch to + """ + colors = Theme.get_colors() + + # Validate the profile + profile = self.model_manager.get_model(model_id) + if profile is None: + terminal_ui.print_error(f"Model '{model_id}' not found") + available = ", ".join(self.model_manager.get_model_ids()) + if available: + terminal_ui.console.print( + f"[{colors.text_muted}]Available: {available}[/{colors.text_muted}]\n" + ) + return + + is_valid, error_msg = self.model_manager.validate_model(profile) + if not is_valid: + terminal_ui.print_error(error_msg) + return + + # Perform the switch + if self.agent.switch_model(model_id): + new_profile = self.model_manager.get_current_model() + if new_profile: + terminal_ui.print_success(f"Switched to model: {new_profile.model_id}") + self._update_status_bar() + else: + terminal_ui.print_error("Failed to get current model after switch") + else: + terminal_ui.print_error(f"Failed to switch to model '{model_id}'") + + def _parse_kv_args(self, tokens: list[str]) -> tuple[dict[str, str], list[str]]: + return parse_kv_args(tokens) + + def _mask_secret(self, value: str | None) -> str: + return mask_secret(value) + + async def _handle_model_command(self, user_input: str) -> None: + """Handle the /model command. + + Args: + user_input: Full user input string + """ + colors = Theme.get_colors() + + try: + parts = shlex.split(user_input) + except ValueError as e: + terminal_ui.print_error(str(e), title="Invalid /model command") + return + + if len(parts) == 1: + if not self.model_manager.list_models(): + terminal_ui.print_error("No models configured yet.") + terminal_ui.console.print( + f"[{colors.text_muted}]Run /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n" + ) + return + picked = await pick_model_id(self.model_manager, title="Select Model") + if picked: + self._switch_model(picked) + return + return + + sub = parts[1] + + if sub == "edit": + if len(parts) != 2: + terminal_ui.print_error("Usage: /model edit") + terminal_ui.console.print( + f"[{colors.text_muted}]Edit the YAML directly instead of using subcommands.[/{colors.text_muted}]\n" + ) + return + + terminal_ui.console.print( + f"[{colors.text_muted}]Save the file to auto-reload (Ctrl+C to cancel)...[/]\n" + ) + ok = await open_config_and_wait_for_save(self.model_manager.config_path) + if not ok: + terminal_ui.print_error( + f"Could not open editor. Please edit `{self.model_manager.config_path}` manually." + ) + terminal_ui.console.print( + f"[{colors.text_muted}]Tip: set EDITOR='code' (or similar) for /model edit.[/{colors.text_muted}]\n" + ) + return + + self.model_manager.reload() + terminal_ui.print_success("Reloaded `.aloop/models.yaml`") + current_after = self.model_manager.get_current_model() + if not current_after: + terminal_ui.print_error( + "No models configured after reload. Edit `.aloop/models.yaml` and set `default`." + ) + return + + # Reinitialize LLM adapter to pick up updated api_key/api_base/timeout/drop_params. + self.agent.switch_model(current_after.model_id) + terminal_ui.print_info(f"Reload applied (current: {current_after.model_id}).") + return + terminal_ui.print_error("Unknown /model command.") + terminal_ui.console.print( + f"[{colors.text_muted}]Use /model to pick, or /model edit to configure.[/{colors.text_muted}]\n" + ) + async def run(self) -> None: """Run the interactive session loop.""" # Print header @@ -348,13 +520,9 @@ async def run(self) -> None: ) # Display configuration + current = self.model_manager.get_current_model() config_dict = { - "LLM Provider": ( - Config.LITELLM_MODEL.split("/")[0].upper() - if "/" in Config.LITELLM_MODEL - else "UNKNOWN" - ), - "Model": Config.LITELLM_MODEL, + "Model": current.model_id if current else "NOT CONFIGURED", "Theme": Theme.get_theme_name(), "Commands": "/help for all commands", } @@ -453,6 +621,180 @@ async def run(self) -> None: terminal_ui.print_log_location(log_file) +class ModelSetupSession: + """Lightweight interactive session for configuring models before the agent can run.""" + + def __init__(self, model_manager: ModelManager | None = None): + self.model_manager = model_manager or ModelManager() + self.input_handler = InputHandler( + history_file=get_history_file(), + commands=["help", "model", "continue", "start", "exit", "quit"], + ) + + def _show_help(self) -> None: + colors = Theme.get_colors() + terminal_ui.console.print( + f"\n[bold {colors.primary}]Model Setup[/bold {colors.primary}] " + f"[{colors.text_muted}](edit `.aloop/models.yaml`)[/{colors.text_muted}]\n" + ) + terminal_ui.console.print( + f"[{colors.text_muted}]Commands:[/{colors.text_muted}]\n" + f" /model - Pick a model\n" + f" /model edit - Open `.aloop/models.yaml` in editor\n" + f" /continue - Validate and start agent\n" + f" /exit - Quit\n" + ) + + def _show_models(self) -> None: + colors = Theme.get_colors() + models = self.model_manager.list_models() + current = self.model_manager.get_current_model() + default_model_id = self.model_manager.get_default_model_id() + + terminal_ui.console.print( + f"\n[bold {colors.primary}]Configured Models:[/bold {colors.primary}]\n" + ) + + if not models: + terminal_ui.print_error("No models configured yet.") + terminal_ui.console.print( + f"[{colors.text_muted}]Use /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n" + ) + return + + for i, model in enumerate(models, start=1): + markers: list[str] = [] + if current and model.model_id == current.model_id: + markers.append(f"[{colors.success}]CURRENT[/{colors.success}]") + if default_model_id and model.model_id == default_model_id: + markers.append(f"[{colors.primary}]DEFAULT[/{colors.primary}]") + marker = ( + " ".join(markers) + if markers + else f"[{colors.text_muted}] [/{colors.text_muted}]" + ) + terminal_ui.console.print(f" {marker} [{colors.text_muted}]{i:>2}[/] {model.model_id}") + + terminal_ui.console.print() + terminal_ui.console.print( + f"[{colors.text_muted}]Tip: run /model to pick; /model edit to change config.[/]\n" + ) + + def _parse_kv_args(self, tokens: list[str]) -> tuple[dict[str, str], list[str]]: + return parse_kv_args(tokens) + + def _mask_secret(self, value: str | None) -> str: + return mask_secret(value) + + async def _handle_model_command(self, user_input: str) -> None: + colors = Theme.get_colors() + + try: + parts = shlex.split(user_input) + except ValueError as e: + terminal_ui.print_error(str(e), title="Invalid /model command") + return + + if len(parts) == 1: + if not self.model_manager.list_models(): + terminal_ui.print_error("No models configured yet.") + terminal_ui.console.print( + f"[{colors.text_muted}]Run /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n" + ) + return + picked = await pick_model_id(self.model_manager, title="Select Model") + if picked: + self.model_manager.switch_model(picked) + terminal_ui.print_success(f"Selected model: {picked}") + return + return + + sub = parts[1] + + if sub == "edit": + if len(parts) != 2: + terminal_ui.print_error("Usage: /model edit") + terminal_ui.console.print( + f"[{colors.text_muted}]Edit the YAML directly instead of using subcommands.[/{colors.text_muted}]\n" + ) + return + + terminal_ui.console.print( + f"[{colors.text_muted}]Save the file to auto-reload (Ctrl+C to cancel)...[/]\n" + ) + ok = await open_config_and_wait_for_save(self.model_manager.config_path) + if not ok: + terminal_ui.print_error( + f"Could not open editor. Please edit `{self.model_manager.config_path}` manually." + ) + terminal_ui.console.print( + f"[{colors.text_muted}]Tip: set EDITOR='code' (or similar) for /model edit.[/{colors.text_muted}]\n" + ) + return + self.model_manager.reload() + terminal_ui.print_success("Reloaded `.aloop/models.yaml`") + self._show_models() + return + terminal_ui.print_error("Unknown /model command.") + terminal_ui.console.print( + f"[{colors.text_muted}]Use /model to pick, or /model edit to configure.[/{colors.text_muted}]\n" + ) + + async def run(self) -> bool: + colors = Theme.get_colors() + terminal_ui.print_header( + "Agentic Loop - Model Setup", subtitle="Configure `.aloop/models.yaml` to continue" + ) + terminal_ui.console.print( + f"[{colors.text_muted}]Tip: Use /model edit (recommended) then /continue.[/{colors.text_muted}]\n" + ) + self._show_help() + + while True: + user_input = await self.input_handler.prompt_async("> ") + if not user_input: + continue + + # Allow typing model_id without the /model prefix. + if not user_input.startswith("/"): + user_input = f"/model {user_input}" + + parts = user_input.split() + cmd = parts[0].lower() + + if cmd in ("/exit", "/quit"): + return False + + if cmd == "/help": + self._show_help() + continue + + if cmd == "/model": + await self._handle_model_command(user_input) + continue + + if cmd in ("/continue", "/start"): + current = self.model_manager.get_current_model() + if not current: + terminal_ui.print_error( + "No models configured. Use /model edit to configure `.aloop/models.yaml`." + ) + continue + + is_valid, error_msg = self.model_manager.validate_model(current) + if not is_valid: + terminal_ui.print_error(error_msg) + terminal_ui.console.print( + f"[{colors.text_muted}]Fix the config and try /continue again.[/{colors.text_muted}]\n" + ) + continue + + terminal_ui.print_success("Model configuration looks good. Starting agent…") + return True + + terminal_ui.print_error(f"Unknown command: {cmd}. Try /help.") + + async def run_interactive_mode(agent) -> None: """Run agent in interactive multi-turn conversation mode. @@ -461,3 +803,9 @@ async def run_interactive_mode(agent) -> None: """ session = InteractiveSession(agent) await session.run() + + +async def run_model_setup_mode(model_manager: ModelManager | None = None) -> bool: + """Run model setup mode; returns True when ready to start agent.""" + session = ModelSetupSession(model_manager=model_manager) + return await session.run() diff --git a/llm/__init__.py b/llm/__init__.py index 3ee6405..9d5815b 100644 --- a/llm/__init__.py +++ b/llm/__init__.py @@ -23,6 +23,7 @@ ToolCallBlock, ToolResult, ) +from .model_manager import ModelManager, ModelProfile __all__ = [ # Core types @@ -35,6 +36,9 @@ "StopReason", # Adapter "LiteLLMAdapter", + # Model Manager + "ModelManager", + "ModelProfile", # Utilities "extract_text", "extract_text_from_message", diff --git a/llm/model_manager.py b/llm/model_manager.py new file mode 100644 index 0000000..41f2b85 --- /dev/null +++ b/llm/model_manager.py @@ -0,0 +1,265 @@ +"""Model manager for handling multiple models with YAML persistence.""" + +from __future__ import annotations + +import os +import tempfile +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +from utils import get_logger + +logger = get_logger(__name__) + +DEFAULT_CONFIG_TEMPLATE = """# Model Configuration +# This file is gitignored - do not commit to version control +# +# The key under `models` is the LiteLLM model ID (provider/model). +# Fill in `api_key` directly in this file. +# +# Supported fields: +# - api_key: API key (required for most hosted providers) +# - api_base: Custom base URL (optional) +# - timeout: Request timeout in seconds (default: 600) +# - drop_params: Drop unsupported params (default: true) + +models: + # openai/gpt-4o: + # api_key: sk-... + # timeout: 300 + # anthropic/claude-3-5-sonnet-20241022: + # api_key: sk-ant-... + # ollama/llama2: + # api_base: http://localhost:11434 +default: null +""" + + +def _coerce_int(value: Any, default: int) -> int: + if value is None: + return default + if isinstance(value, bool): + return default + if isinstance(value, int): + return value + try: + return int(str(value).strip()) + except (ValueError, TypeError): + return default + + +def _coerce_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in {"true", "1", "yes", "y", "on"}: + return True + if v in {"false", "0", "no", "n", "off"}: + return False + return default + + +def _is_local_api_base(api_base: str | None) -> bool: + if not api_base: + return False + raw = str(api_base).strip() + if not raw: + return False + if "://" not in raw: + raw = f"http://{raw}" + parsed = urlparse(raw) + host = (parsed.hostname or "").lower() + return host in {"localhost", "127.0.0.1", "::1"} + + +@dataclass +class ModelProfile: + """Configuration for a single model.""" + + model_id: str # LiteLLM model ID (e.g. "openai/gpt-4o") + api_key: str | None = None + api_base: str | None = None + timeout: int = 600 + drop_params: bool = True + extra: dict[str, Any] = field(default_factory=dict) + + @property + def provider(self) -> str: + return self.model_id.split("/")[0] if "/" in self.model_id else "unknown" + + @property + def display_name(self) -> str: + return self.model_id + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {"timeout": self.timeout, "drop_params": self.drop_params} + if self.api_key: + result["api_key"] = self.api_key + if self.api_base is not None: + result["api_base"] = self.api_base + if self.extra: + result.update(self.extra) + return result + + +class ModelManager: + """Manages multiple models with YAML persistence.""" + + CONFIG_PATH = ".aloop/models.yaml" + + def __init__(self, config_path: str | None = None): + self.config_path = config_path or self.CONFIG_PATH + self.models: dict[str, ModelProfile] = {} + self.default_model_id: str | None = None + self.current_model_id: str | None = None + self._load() + + def _ensure_yaml(self) -> None: + try: + import yaml # noqa: F401 + except ImportError as e: + raise RuntimeError( + "PyYAML is required for model configuration. Install it (e.g. `uv add pyyaml`)." + ) from e + + def _atomic_write(self, content: str) -> None: + directory = os.path.dirname(self.config_path) or "." + os.makedirs(directory, exist_ok=True) + + fd, tmp_path = tempfile.mkstemp(prefix=".models.", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, self.config_path) + with suppress(OSError): + os.chmod(self.config_path, 0o600) + finally: + with suppress(OSError): + os.unlink(tmp_path) + + def _create_default_config(self) -> None: + self._atomic_write(DEFAULT_CONFIG_TEMPLATE) + logger.info(f"Created model config template at {self.config_path}") + + def _load(self) -> None: + self._ensure_yaml() + import yaml + + if not os.path.exists(self.config_path): + self._create_default_config() + + with open(self.config_path, encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + + models = config.get("models") or {} + if not isinstance(models, dict): + logger.warning("Invalid models.yaml format: 'models' should be a mapping") + models = {} + + for model_id, data in models.items(): + if not isinstance(model_id, str) or not model_id.strip(): + continue + if not isinstance(data, dict): + logger.warning(f"Invalid model config for '{model_id}', skipping") + continue + + api_key = data.get("api_key") + api_base = data.get("api_base") + timeout = _coerce_int(data.get("timeout"), default=600) + drop_params = _coerce_bool(data.get("drop_params"), default=True) + extra = { + k: v + for k, v in data.items() + if k not in {"name", "api_key", "api_base", "timeout", "drop_params"} + } + + self.models[model_id] = ModelProfile( + model_id=model_id, + api_key=None if api_key is None else str(api_key), + api_base=None if api_base is None else str(api_base), + timeout=timeout, + drop_params=drop_params, + extra=extra, + ) + + default = config.get("default") + self.default_model_id = default if isinstance(default, str) else None + if self.default_model_id not in self.models: + self.default_model_id = next(iter(self.models.keys()), None) + + self.current_model_id = self.default_model_id + logger.info(f"Loaded {len(self.models)} models from {self.config_path}") + + def _save(self) -> None: + self._ensure_yaml() + import yaml + + config = { + "models": {mid: profile.to_dict() for mid, profile in self.models.items()}, + "default": self.default_model_id, + } + header = "# Model Configuration\n# This file is gitignored - do not commit to version control\n\n" + body = yaml.safe_dump(config, sort_keys=False, allow_unicode=True) + self._atomic_write(header + body) + + def is_configured(self) -> bool: + return bool(self.models) and bool(self.default_model_id) + + def get_model(self, model_id: str) -> ModelProfile | None: + return self.models.get(model_id) + + def list_models(self) -> list[ModelProfile]: + return list(self.models.values()) + + def get_model_ids(self) -> list[str]: + return list(self.models.keys()) + + def get_default_model_id(self) -> str | None: + return self.default_model_id + + def get_current_model(self) -> ModelProfile | None: + if not self.current_model_id: + return None + return self.models.get(self.current_model_id) + + def set_default(self, model_id: str) -> bool: + if model_id not in self.models: + return False + self.default_model_id = model_id + if not self.current_model_id: + self.current_model_id = model_id + self._save() + return True + + def switch_model(self, model_id: str) -> ModelProfile | None: + if model_id not in self.models: + return None + self.current_model_id = model_id + return self.get_current_model() + + def validate_model(self, model: ModelProfile) -> tuple[bool, str]: + """Validate a model has required configuration.""" + if not model.model_id: + return False, "Model ID is missing." + if ( + model.provider not in {"ollama", "localhost"} + and not _is_local_api_base(model.api_base) + and not (model.api_key or "").strip() + ): + return ( + False, + f"API key not configured for {model.provider}. " + f"Edit `{self.config_path}` and set models['{model.model_id}'].api_key.", + ) + return True, "" + + def reload(self) -> None: + self.models.clear() + self.default_model_id = None + self.current_model_id = None + self._load() diff --git a/main.py b/main.py index ba16306..e51e35a 100644 --- a/main.py +++ b/main.py @@ -6,8 +6,8 @@ from agent.react_agent import ReActAgent from config import Config -from interactive import run_interactive_mode -from llm import LiteLLMAdapter +from interactive import run_interactive_mode, run_model_setup_mode +from llm import LiteLLMAdapter, ModelManager from tools.advanced_file_ops import EditTool, GlobTool, GrepTool from tools.calculator import CalculatorTool from tools.code_navigator import CodeNavigatorTool @@ -27,9 +27,12 @@ warnings.filterwarnings("ignore", message="Pydantic serializer warnings.*", category=UserWarning) -def create_agent(): +def create_agent(model_id: str | None = None): """Factory function to create agents with tools. + Args: + model_id: Optional LiteLLM model ID to use (defaults to current/default) + Returns: Configured ReActAgent instance with all tools """ @@ -55,18 +58,48 @@ def create_agent(): NotifyTool(), ] - # Create LLM instance with LiteLLM (retry config is read from Config directly) + # Initialize model manager + model_manager = ModelManager() + + if not model_manager.is_configured(): + raise ValueError( + "No models configured. Run `aloop` without --task and use /model edit, " + "or edit `.aloop/models.yaml` to add at least one model and set `default`." + ) + + # Get the model to use + if model_id: + profile = model_manager.get_model(model_id) + if profile: + model_manager.switch_model(model_id) + else: + available = ", ".join(model_manager.get_model_ids()) + terminal_ui.print_error(f"Model '{model_id}' not found, using default") + if available: + terminal_ui.console.print(f"Available: {available}") + + current_profile = model_manager.get_current_model() + if not current_profile: + raise ValueError("No model available. Please check `.aloop/models.yaml`.") + + is_valid, error_msg = model_manager.validate_model(current_profile) + if not is_valid: + raise ValueError(error_msg) + + # Create LLM instance with the current profile llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, + model=current_profile.model_id, + api_key=current_profile.api_key, + api_base=current_profile.api_base, + drop_params=current_profile.drop_params, + timeout=current_profile.timeout, ) agent = ReActAgent( llm=llm, tools=tools, max_iterations=Config.MAX_ITERATIONS, + model_manager=model_manager, ) # Add tools that require agent reference @@ -91,6 +124,12 @@ def main(): action="store_true", help="Enable verbose logging to .aloop/logs/", ) + parser.add_argument( + "--model", + "-m", + type=str, + help="Model to use (LiteLLM model ID, e.g. openai/gpt-4o)", + ) args = parser.parse_args() @@ -108,8 +147,26 @@ def main(): terminal_ui.print_error(str(e), title="Configuration Error") return - # Create agent - agent = create_agent() + # Create agent with optional model selection. If we're going into interactive mode and + # models aren't configured yet, enter a setup session first. + try: + agent = create_agent(model_id=args.model) + except ValueError as e: + if args.task: + terminal_ui.print_error(str(e), title="Model Configuration Error") + terminal_ui.console.print( + "Edit `.aloop/models.yaml` to add models and set `default` (this file is gitignored). " + "Tip: run `aloop` (interactive) and use /model edit." + ) + return + + terminal_ui.print_error(str(e), title="Model Setup Required") + ready = asyncio.run(run_model_setup_mode()) + if not ready: + return + + # Retry after setup. + agent = create_agent(model_id=args.model) async def _run() -> None: # If no task provided, enter interactive mode (default behavior) @@ -125,13 +182,18 @@ async def _run() -> None: "🤖 Agentic Loop System", subtitle="Intelligent AI Agent with Tool-Calling Capabilities" ) + # Get current model info for display + model_info = agent.get_current_model_info() + if model_info: + model_display = model_info["model_id"] + provider_display = model_info["provider"].upper() + else: + model_display = "NOT CONFIGURED" + provider_display = "UNKNOWN" + config_dict = { - "LLM Provider": ( - Config.LITELLM_MODEL.split("/")[0].upper() - if "/" in Config.LITELLM_MODEL - else "UNKNOWN" - ), - "Model": Config.LITELLM_MODEL, + "LLM Provider": provider_display, + "Model": model_display, "Task": task if len(task) < 100 else task[:97] + "...", } terminal_ui.print_config(config_dict) diff --git a/pyproject.toml b/pyproject.toml index de66f64..bf2837a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "aiofiles>=24.1.0", "aiosqlite>=0.20.0", "litellm>=1.30.0", + "PyYAML>=6.0", "tiktoken>=0.5.0", "tenacity>=8.2.0", "tree-sitter>=0.21.0,<0.22.0", @@ -52,6 +53,7 @@ dev = [ "pre-commit>=3.0.0", "types-aiofiles>=25.1.0", "types-croniter>=1.3.0", + "types-PyYAML>=6.0", ] [project.urls] diff --git a/rfc/006-multi-model-v2.md b/rfc/006-multi-model-v2.md new file mode 100644 index 0000000..7be3db8 --- /dev/null +++ b/rfc/006-multi-model-v2.md @@ -0,0 +1,118 @@ +# RFC: Multi-Model Configuration and Runtime Switching (v2) + +Status: **Completed** (2026-01-30) + +## Problem Statement + +AgenticLoop historically behaves like a single-model app. Users need to: +- Configure multiple models (different providers / endpoints / keys) +- Switch the active model in interactive mode +- Keep secrets out of git by default + +## Design Goals + +1. **Multiple Models**: Configure multiple LiteLLM model IDs. +2. **Runtime Switching**: Switch models in interactive mode with a simple UX. +3. **YAML-First**: Depend on `.aloop/models.yaml` only (no env/legacy config). +4. **Minimal Commands**: Avoid a large `/model` subcommand surface. +5. **Secret Safety**: Config is gitignored and file-permission hardened where possible. +6. **No Backward Compatibility**: Clean slate; users migrate manually. + +## Proposed Solution + +### 1. Configuration Format (YAML) + +File: `.aloop/models.yaml` (gitignored) + +```yaml +# Model Configuration +# This file is gitignored - do not commit to version control +# +# The key under `models` is the LiteLLM model ID: provider/model-name + +models: + anthropic/claude-3-5-sonnet-20241022: + api_key: sk-ant-... + timeout: 600 + drop_params: true + + openai/gpt-4o: + api_key: sk-... + timeout: 300 + + # Local model example (no API key needed) + ollama/llama2: + api_base: http://localhost:11434 + +default: anthropic/claude-3-5-sonnet-20241022 +``` + +Fields per model: +- `api_key` (required for most hosted providers) +- `api_base` (optional; custom endpoint/proxy) +- `timeout` (optional; seconds; default 600) +- `drop_params` (optional; default true) + +### 2. Model Manager + +`ModelManager` loads/saves `.aloop/models.yaml` and tracks: +- `models: dict[model_id, profile]` +- `default_model_id` +- `current_model_id` + +Behavior: +- Create a template file on first run. +- Atomic writes to prevent corruption. +- Attempt to set file mode to `0600` (best-effort). +- Ignore deprecated fields like `name` if present (and do not write them back). + +### 3. Interactive Mode UX + +Keep only two commands: + +``` +/model - Open a TUI picker (↑/↓ + Enter; Esc cancels) +/model edit - Open `.aloop/models.yaml` and auto-reload after save +``` + +When no models are configured, `/model` should show an actionable reminder: +“No models configured yet. Run `/model edit` to configure `.aloop/models.yaml`.” + +### 4. CLI Flag + +Support selecting a configured model at startup: + +```bash +python main.py --task "Hello" --model openai/gpt-4o +python main.py --model openai/gpt-4o +``` + +### 5. Git Protection + +`.aloop/models.yaml` must be in `.gitignore` by default. + +## Key Design Decisions + +### 1. No `name` Field + +The YAML schema does not include `name`. The canonical identifier is the LiteLLM `model_id`. + +If an old YAML contains `name`, it is ignored (so existing local configs don’t crash) and removed on the next save. + +### 2. “Edit the YAML” Instead of “Command API” + +Most model operations (add/remove/default) are config edits. Keeping them in one place: +- reduces duplication and parsing logic +- keeps the CLI surface small +- makes the workflow transparent + +### 3. No ENV/Legacy Compatibility + +The system does not attempt to read previous env-based or legacy model config formats. + +## Success Criteria + +- Users can configure multiple models in `.aloop/models.yaml` +- Interactive mode can switch models via a picker +- Editing YAML reloads without restarting the app +- Secrets are gitignored by default and file permissions are tightened where possible diff --git a/test/README.md b/test/README.md index 45dfcf3..dc8a84c 100644 --- a/test/README.md +++ b/test/README.md @@ -43,5 +43,5 @@ python3 -m pytest test/ ## Notes - Live LLM integration tests are skipped by default (set `RUN_INTEGRATION_TESTS=1` to enable). -- Set up your `.aloop/config` file before running tests that require API access +- Set up your `.aloop/models.yaml` file before running tests that require API access - Memory tests use a mock LLM and don't require API keys diff --git a/test/test_smart_edit_integration.py b/test/test_smart_edit_integration.py index c9527c9..ef8556f 100644 --- a/test/test_smart_edit_integration.py +++ b/test/test_smart_edit_integration.py @@ -8,32 +8,30 @@ import pytest from agent.react_agent import ReActAgent -from config import Config -from llm import LiteLLMAdapter +from llm import LiteLLMAdapter, ModelManager from tools.file_ops import FileReadTool, FileWriteTool from tools.smart_edit import SmartEditTool -def _has_api_key_for_model(model: str) -> bool: - provider = model.split("/")[0] if model else "" - if provider == "anthropic": - return bool(os.getenv("ANTHROPIC_API_KEY")) - if provider == "openai": - return bool(os.getenv("OPENAI_API_KEY")) - if provider in {"gemini", "google"}: - return bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")) - if provider == "ollama": - return bool(os.getenv("OLLAMA_HOST")) - return False - - @pytest.mark.integration def test_smart_edit_in_agent(): """Test that SmartEditTool works when used by an agent.""" if os.getenv("RUN_INTEGRATION_TESTS") != "1": pytest.skip("Set RUN_INTEGRATION_TESTS=1 to run live LLM integration tests") - if not _has_api_key_for_model(Config.LITELLM_MODEL): - pytest.skip(f"Missing API key (or server) for model: {Config.LITELLM_MODEL}") + + mm = ModelManager() + profile = mm.get_current_model() + if not profile: + pytest.skip("No models configured. Edit .aloop/models.yaml and set `default`.") + + is_valid, error_msg = mm.validate_model(profile) + if not is_valid: + pytest.skip(error_msg) + + if profile.provider == "ollama" and not profile.api_base: + pytest.skip( + "Ollama model requires api_base in .aloop/models.yaml (e.g. http://localhost:11434)" + ) # Create a temporary test file with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f: @@ -48,10 +46,11 @@ def test_smart_edit_in_agent(): try: # Create minimal agent with just SmartEditTool llm = LiteLLMAdapter( - model=Config.LITELLM_MODEL, - api_base=Config.LITELLM_API_BASE, - drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT, + model=profile.model_id, + api_key=profile.api_key, + api_base=profile.api_base, + drop_params=profile.drop_params, + timeout=profile.timeout, ) tools = [ diff --git a/utils/tui/model_ui.py b/utils/tui/model_ui.py new file mode 100644 index 0000000..b5b7558 --- /dev/null +++ b/utils/tui/model_ui.py @@ -0,0 +1,200 @@ +"""TUI helpers for model selection and configuration editing.""" + +from __future__ import annotations + +import asyncio +import os +import shlex +import shutil +import sys +from pathlib import Path +from typing import Protocol, Sequence + +import aiofiles.os +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import HSplit, Layout, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.styles import Style + +from utils.tui.theme import Theme + + +class _Model(Protocol): + model_id: str + + +class _ModelManager(Protocol): + config_path: str + + def list_models(self) -> Sequence[_Model]: ... + + def get_current_model(self) -> _Model | None: ... + + +async def open_in_editor(path: str) -> tuple[bool, bool]: + """Open a file in an editor (best-effort). + + Returns: + (opened, waited): waited indicates we blocked until editing likely finished. + """ + path = str(Path(path)) + + editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") + if editor: + cmd = shlex.split(editor) + [path] + try: + proc = await asyncio.create_subprocess_exec(*cmd) + except FileNotFoundError: + return False, False + return (await proc.wait()) == 0, True + + if shutil.which("code"): + # Don't use `-w` here; we prefer to return to the TUI after the file is saved + # (and auto-reloaded), without requiring the user to close the editor tab. + proc = await asyncio.create_subprocess_exec("code", "--reuse-window", path) + return (await proc.wait()) == 0, False + + if sys.platform == "darwin" and shutil.which("open"): + # `open` returns immediately; we can't reliably wait for editing completion. + proc = await asyncio.create_subprocess_exec("open", "-t", path) + return (await proc.wait()) == 0, False + + if shutil.which("xdg-open"): + # xdg-open returns immediately; we can't reliably wait for editing completion. + proc = await asyncio.create_subprocess_exec("xdg-open", path) + return (await proc.wait()) == 0, False + + return False, False + + +async def get_mtime(path: str) -> tuple[int, int] | None: + try: + stat = await aiofiles.os.stat(path) + return stat.st_mtime_ns, stat.st_size + except FileNotFoundError: + return None + + +async def wait_for_file_change(path: str, old_mtime: tuple[int, int] | None) -> None: + while True: + new_mtime = await get_mtime(path) + if old_mtime is None: + if new_mtime is not None: + return + elif new_mtime is not None and new_mtime != old_mtime: + return + await asyncio.sleep(0.25) + + +async def open_config_and_wait_for_save(config_path: str) -> bool: + """Open config file and return when it is likely saved at least once.""" + before = await get_mtime(config_path) + ok, waited = await open_in_editor(config_path) + if not ok: + return False + if not waited: + await wait_for_file_change(config_path, before) + return True + + +async def pick_model_id(model_manager: _ModelManager, title: str) -> str | None: + """Pick a model_id using a keyboard-only list (Codex-style).""" + models = list(model_manager.list_models()) + if not models: + return None + + colors = Theme.get_colors() + current = model_manager.get_current_model() + current_id = current.model_id if current else None + + selected_index = 0 + if current_id: + for i, m in enumerate(models): + if m.model_id == current_id: + selected_index = i + break + + kb = KeyBindings() + + @kb.add("up") + @kb.add("k") + def _up(event) -> None: + nonlocal selected_index + selected_index = (selected_index - 1) % len(models) + + @kb.add("down") + @kb.add("j") + def _down(event) -> None: + nonlocal selected_index + selected_index = (selected_index + 1) % len(models) + + @kb.add("enter") + def _enter(event) -> None: + event.app.exit(result=models[selected_index].model_id) + + @kb.add("escape") + @kb.add("c-c") + def _cancel(event) -> None: + event.app.exit(result=None) + + def _render() -> list[tuple[str, str]]: + lines: list[tuple[str, str]] = [] + lines.append(("class:title", f"{title}\n")) + lines.append(("class:hint", "Use ↑/↓ and Enter to select, Esc to cancel.\n\n")) + + for idx, m in enumerate(models, start=1): + is_selected = (idx - 1) == selected_index + is_current = m.model_id == current_id + + prefix = "› " if is_selected else " " + marker = "(current) " if is_current else "" + text = f"{prefix}{idx}. {marker}{m.model_id}\n" + style = "class:selected" if is_selected else "class:item" + lines.append((style, text)) + + return lines + + control = FormattedTextControl(_render, focusable=True) + window = Window(content=control, dont_extend_height=True, always_hide_cursor=True) + layout = Layout(HSplit([window])) + + style_dict = Theme.get_prompt_toolkit_style() + style_dict.update( + { + "title": f"{colors.primary} bold", + "hint": colors.text_muted, + "item": colors.text_primary, + "selected": f"bg:{colors.primary} {colors.bg_primary}", + } + ) + + app = Application( + layout=layout, + key_bindings=kb, + style=Style.from_dict(style_dict), + full_screen=False, + mouse_support=False, + ) + return await app.run_async() + + +def mask_secret(value: str | None) -> str: + if not value: + return "(not set)" + v = value.strip() + if len(v) <= 8: + return "*" * len(v) + return f"{v[:4]}…{v[-4:]}" + + +def parse_kv_args(tokens: list[str]) -> tuple[dict[str, str], list[str]]: + kv: dict[str, str] = {} + rest: list[str] = [] + for token in tokens: + if "=" in token: + k, _, v = token.partition("=") + kv[k.strip()] = v + else: + rest.append(token) + return kv, rest diff --git a/utils/tui/status_bar.py b/utils/tui/status_bar.py index 172887b..d0e4c84 100644 --- a/utils/tui/status_bar.py +++ b/utils/tui/status_bar.py @@ -23,6 +23,7 @@ class StatusBarState: cost: float = 0.0 is_processing: bool = False status_message: str = "" + model_name: str = "" class StatusBar: @@ -65,6 +66,12 @@ def _render(self) -> Panel: # Build status items items = [] + # Model name (if set) + if self.state.model_name: + items.append( + f"[{colors.text_secondary}]Model:[/{colors.text_secondary}] [{colors.primary}]{self.state.model_name}[/{colors.primary}]" + ) + # Mode items.append( f"[{colors.text_secondary}]Mode:[/{colors.text_secondary}] [{colors.primary}]{self.state.mode}[/{colors.primary}]" @@ -111,6 +118,7 @@ def update( cost: Optional[float] = None, is_processing: Optional[bool] = None, status_message: Optional[str] = None, + model_name: Optional[str] = None, ) -> None: """Update status bar state. @@ -122,6 +130,7 @@ def update( cost: Current cost is_processing: Whether currently processing status_message: Optional status message + model_name: Current model name """ if mode is not None: self.state.mode = mode @@ -137,6 +146,8 @@ def update( self.state.is_processing = is_processing if status_message is not None: self.state.status_message = status_message + if model_name is not None: + self.state.model_name = model_name # Refresh live display if active if self._live is not None: