From d4d6aafea979acfd76e1af83821fdff8886bcbeb Mon Sep 17 00:00:00 2001 From: truetype Date: Thu, 19 Mar 2026 20:29:30 +0100 Subject: [PATCH] Implement secure configuration management with encryption and validation (Phase 1 improvements) --- README.md | 118 +++++ __pycache__/config_manager.cpython-314.pyc | Bin 0 -> 9710 bytes __pycache__/config_tui.cpython-314.pyc | Bin 0 -> 22098 bytes __pycache__/main.cpython-314.pyc | Bin 0 -> 4443 bytes agent_configuration_example.txt | 16 + config_cli.py | 378 ++++++++++++++++ config_manager.py | 424 ++++++++++++++++++ config_tui.py | 489 +++++++++++++++++++++ example_usage.py | 71 +++ main.py | 109 +++++ requirements.txt | 4 + test_agent.py | 47 ++ 12 files changed, 1656 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/config_manager.cpython-314.pyc create mode 100644 __pycache__/config_tui.cpython-314.pyc create mode 100644 __pycache__/main.cpython-314.pyc create mode 100644 agent_configuration_example.txt create mode 100644 config_cli.py create mode 100644 config_manager.py create mode 100644 config_tui.py create mode 100644 example_usage.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 test_agent.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..4eea18d --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# MaxClaw Agent + +A configurable agent harness using DeepAgents that supports multiple LLM providers. + +## Features + +- **Provider Agnostic**: Works with OpenAI, Ollama, OpenRouter, and any other provider supported by LangChain's `init_chat_model` +- **Built on DeepAgents**: Includes planning, filesystem access, subagents, and context management out of the box +- **Easy Configuration**: Set environment variables to choose your provider and model +- **Persistent Configuration**: Store and manage configurations via CLI or TUI +- **Extensible**: Add custom tools, modify prompts, and configure subagents as needed + +## Installation + +1. Clone this repository +2. Install the required packages: + ```bash + pip install -r requirements.txt + ``` + +## Configuration + +You can configure MaxClaw Agent in several ways: + +### 1. Environment Variables (Quick Start) + +Set the following environment variables to configure your LLM provider: + +- `MODEL_PROVIDER`: The provider to use (e.g., "openai", "ollama", "openrouter") +- `MODEL_NAME`: The specific model name (e.g., "gpt-4o", "gemma2:9b") +- Provider-specific variables: + - For OpenAI: `OPENAI_API_KEY` + - For Ollama: `OLLAMA_BASE_URL` (defaults to http://localhost:11434) + - For OpenRouter: `OPENROUTER_API_KEY` and optionally `OPENROUTER_BASE_URL` (defaults to https://openrouter.ai/api/v1) + +### Example Configurations + +#### OpenAI +```bash +export MODEL_PROVIDER="openai" +export MODEL_NAME="gpt-4o" +export OPENAI_API_KEY="your-openai-api-key" +``` + +#### Ollama (local) +```bash +export MODEL_PROVIDER="ollama" +export MODEL_NAME="gemma2:9b" +export OLLAMA_BASE_URL="http://localhost:11434" +``` + +#### OpenRouter +```bash +export MODEL_PROVIDER="openrouter" +export MODEL_NAME="anthropic/claude-3.5-sonnet" +export OPENROUTER_API_KEY="your-openrouter-api-key" +``` + +### 2. Persistent Configuration (Recommended) + +Use the CLI or TUI to manage configurations that persist across sessions. + +#### CLI Usage +```bash +# List configurations +python config_cli.py list + +# Add a new configuration interactively +python config_cli.py add + +# Add a new configuration non-interactively +python config_cli.py add --name "my-ollama" --provider "ollama" --model "gemma2:9b" --base-url "https://ollama.solarpi.net" --set-active + +# Remove a configuration +python config_cli.py remove "my-ollama" + +# Set active configuration +python config_cli.py set-active "my-ollama" + +# Show active configuration +python config_cli.py active +``` + +#### TUI Usage +```bash +# Launch the Text User Interface +python config_tui.py +``` + +Navigate using arrow keys, press Enter to select, and 'q' to quit. + +## Usage + +Run the agent: +```bash +python main.py +``` + +You can then interact with the agent in the terminal. Type 'exit' to quit. + +## Customization + +To customize the agent, edit `main.py`: + +- Change the default system prompt in `create_maxclaw_agent` +- Add custom tools to the `create_deep_agent` call +- Configure subagents or other DeepAgents features + +## Testing + +Run the test script: +```bash +python test_agent.py +``` + +## License + +MIT \ No newline at end of file diff --git a/__pycache__/config_manager.cpython-314.pyc b/__pycache__/config_manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..744b290a91d86dc4ed539f826d839082d32cc196 GIT binary patch literal 9710 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 T7HItgp45B)*86@f?e+fvv?b30 literal 0 HcmV?d00001 diff --git a/__pycache__/config_tui.cpython-314.pyc b/__pycache__/config_tui.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e721037527a845870bb16fa97705ce7494a2aae3 GIT binary patch literal 22098 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 F`oDv90muLV literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d64d5cdb0cee2e91e3c0c397f655acf1a167b66 GIT binary patch literal 4443 zcma)9O>Er86&^14hobgR|6WTLa9ddQp-GDtG0+@bGz9`QKrcPCaSuK8u+A#A!YW9S1SoP-A^{1SL;Gg9 z++90q(*a06&b)c^-kUe?ee>05gh$XmJ@p4=Ifl^ty=<+uGqWQ@%Rrh;~m(*AsCinBRN2l8!GyvoQKqg0%wtCBlMJtYm%k*fOsLW_&MnHCjgR6S zF6!EXvRE;tc~!;|?Ou{hO|~ptu@r3)pOiec!cZQ*i7wG{&2^S944}D?Cr}6!m@qo-%Yb&IjUwO-QqCik z)9r@$f6+H2aE(=bEpppjxdorVpRU_OWw%q=!v7^ZEJRvlf0-sx&jy1nu!rm1i($G_ zn>SgAd0b0Oi?($y6nq6%h@Y+>m(l;}r(}op8*mywtxF|rFS&t7g9^_}mR!QR){JBl zUnzdV3vBX>ixOcd%Xq=m%UIT~DWQeB6qRJ`$0(4nQ*-(|D zVy}+jW%;nG!nu&YSVD^DCsz!)XoEsuxI~seif3jgFX!@NZZ|;tmX2U z8e|ck>FM01ocKiU%B1+TFkNR-RjDlDBXVjnHHMdL+px0fv`%|dmaa;sp`QaKSd+k$bwx%N4f|+DCz2NBa#=c_Jv%>=6V>6b}Vxu12gG_EXgb+62ywBIy@2Qn&fm5e+x5D zU!4>h{#+Yqc(1{jl9aS$DCuj*oX(ah^=(>v;`4o9xW!npuql7LqL>f^3-zrPsCSb- zGXf{W3BfYr3-W3*;P`aQ2`)1`W?>8untcnL?rH~ zvdj+Pw#XC=%^6QGL5Qb|kZf1ds+Wjq@=lkfl_F$*H_gJIqNR*gr&omoZ7*0J%-X4~KgT|F+%an5vFX z)w=T6x$PF-KXRL~e3i=+?zzpj;XMv@_qzPYs~Mrzbp`l4yI)M+N>&HPYn{ighu)3# zZg&n;2c=r)d^J1|D{n=6HomnnR}GJB^$c!x@81jgv0X3|6-ipI zr)E+{=(C#zVjU9o^S(3Rg9;1%1L!Gci4mA)d6{dJm$80eueUT*-lXqCGUh}qTC_yi z%CccYD9~QkvQ3EGAOuQTvMsXqh`EpOhec7+G~FiUQ8Y1hoq@;dgV#Fx1Vx#SkI3s) z?)9$UMkr~Lm8l8vrHxcHP}jfvbs&fd3siZ5fqLed*#HU#Ge=COtw1Z7S4j+C!3T!0 zYdL;`)@v+jB?xl6Vrp1w;sYr6mMv*T880jLQZpSBU_s+H#jm~btX{#ADdRd283O@J zvT7_;R167_VgShRl1&q~0WmyA)2*%Rsx^jr66T}<8~|RjhApqyAO$o6gds12zp$-i zt1?ezSt;zTSduMeQG-(n!}D}%6ZbUa!q~EIzTjFwDuZ6J9t1flz#{=_jyVXy!RN7`vO=UB^2QoJI2>u+Yst4#Yaz;SksJW@uo?=S!roMXgDL=uvhMDa=Fi!Eq1CZ~t=nbKRZ>v%MbG{0^h%8$KFBo-SkuT&W9YqK ze7}BGA(sNm3kTS$6pL^dfSjSO4p$xrK7QxrTd&fSPH>*4FGDT@JR=ot_aqZF+~x7r zRYS&ytp z7AP4oi9a14u&XJ$qJpMu!9C95B}J@T=Pd~Xe&*x+G)b>ERY{r8iP-b+4O zJ#wzv`?bHtANzE>YhVvCk#QJ*De_A3&hW1mZa-7)P2G>D-;Ad}+hJksGtf8Ovxj_O z3YZWdf4%Fqr+(M_8dr^;-Q=>{T>DM#2Dj01v;9WdJ*b>C#+?T!^;WUs|Fkw7e4Uh;!NY(q6Nt5A)pT~+yL&H z*KW(jlzojc0C}dI@&eioA98bcD;X(Abiau<1Zj%muMsUWZa{G7p!!u*`7!e`;3WCf znt&Jhkzqdavy6Y2Lk$0S#J_{~zk`mvgO0t69{)Iam^uB)RR$o-$0L~O-nqaafB5>^ TCYyNM&;8)SM=bL9(?$LR4~#Pg literal 0 HcmV?d00001 diff --git a/agent_configuration_example.txt b/agent_configuration_example.txt new file mode 100644 index 0000000..42797ad --- /dev/null +++ b/agent_configuration_example.txt @@ -0,0 +1,16 @@ +# Example environment variables for OpenClaw Agent +# Copy this file to .env and fill in your actual values + +# Model Provider: openai, ollama, openrouter +MODEL_PROVIDER=openai +MODEL_NAME=gpt-4o-mini + +# OpenAI (if MODEL_PROVIDER=openai) +OPENAI_API_KEY=your-openai-api-key-here + +# Ollama (if MODEL_PROVIDER=ollama) +OLLAMA_BASE_URL=https://ollama.solarpi.net + +# OpenRouter (if MODEL_PROVIDER=openrouter) +OPENROUTER_API_KEY=your-openrouter-api-key-here +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 \ No newline at end of file diff --git a/config_cli.py b/config_cli.py new file mode 100644 index 0000000..5063247 --- /dev/null +++ b/config_cli.py @@ -0,0 +1,378 @@ +""" +Command-line interface for managing MaxClaw Agent configurations +""" +import sys +import argparse +from typing import Optional +from config_manager import config_manager + + +def print_configurations(): + """Print all configurations in a formatted way""" + configs = config_manager.get_configurations() + active = config_manager.get_active_configuration() + + if not configs: + print("No configurations found.") + return + + print("\nMaxClaw Agent Configurations:") + print("=" * 50) + + for name, config in configs.items(): + marker = " * " if name == active else " " + provider = config.get("provider", "unknown") + model = config.get("model_name", "unknown") + print(f"{marker}{name}:") + print(f" Provider: {provider}") + print(f" Model: {model}") + + # Show additional info based on provider + if provider == "openai": + print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}") + elif provider == "ollama": + base_url = config.get("base_url", "http://localhost:11434") + print(f" Base URL: {base_url}") + elif provider == "openrouter": + print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}") + base_url = config.get("base_url", "https://openrouter.ai/api/v1") + print(f" Base URL: {base_url}") + + print() + + +def add_configuration_interactive(): + """Interactively add a new configuration""" + print("\nAdd New Configuration") + print("=" * 30) + + name = input("Configuration name: ").strip() + if not name: + print("Error: Configuration name cannot be empty") + return + + if config_manager.configuration_exists(name): + overwrite = input(f"Configuration '{name}' already exists. Overwrite? (y/N): ").strip().lower() + if overwrite != 'y': + print("Cancelled.") + return + + print("\nSelect provider:") + print("1. OpenAI") + print("2. Ollama") + print("3. OpenRouter") + + provider_choice = input("Enter choice (1-3): ").strip() + provider_map = { + "1": "openai", + "2": "ollama", + "3": "openrouter" + } + + if provider_choice not in provider_map: + print("Invalid choice.") + return + + provider = provider_map[provider_choice] + config = {"provider": provider} + + # Get model name + model_name = input(f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ").strip() + if not model_name: + print("Error: Model name cannot be empty") + return + config["model_name"] = model_name + + # Get provider-specific settings + if provider == "openai": + api_key = input("OpenAI API key: ").strip() + if not api_key: + print("Warning: API key is required for OpenAI") + config["api_key"] = api_key + + elif provider == "ollama": + base_url = input(f"Ollama base URL (default: http://localhost:11434): ").strip() + if not base_url: + base_url = "http://localhost:11434" + config["base_url"] = base_url + + elif provider == "openrouter": + api_key = input("OpenRouter API key: ").strip() + if not api_key: + print("Warning: API key is required for OpenRouter") + config["api_key"] = api_key + + base_url = input(f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ").strip() + if not base_url: + base_url = "https://openrouter.ai/api/v1" + config["base_url"] = base_url + + # Ask if this should be the active configuration + set_active = input("Set as active configuration? (y/N): ").strip().lower() + + # Save the configuration + try: + config_manager.add_configuration(name, config) + print(f"\nConfiguration '{name}' saved successfully.") + + if set_active == 'y': + config_manager.set_active_configuration(name) + print(f"Configuration '{name}' set as active.") + except ValueError as e: + print(f"Error: {e}") + + +def remove_configuration_interactive(): + """Interactively remove a configuration""" + configs = config_manager.get_configurations() + if not configs: + print("No configurations to remove.") + return + + print("\nRemove Configuration") + print("=" * 30) + print_configurations() + + name = input("Enter configuration name to remove: ").strip() + if not name: + print("Error: Configuration name cannot be empty") + return + + if not config_manager.configuration_exists(name): + print(f"Error: Configuration '{name}' not found.") + return + + active = config_manager.get_active_configuration() + if name == active: + confirm = input(f"'{name}' is the active configuration. Remove it anyway? (y/N): ").strip().lower() + if confirm != 'y': + print("Cancelled.") + return + + confirm = input(f"Are you sure you want to remove configuration '{name}'? (y/N): ").strip().lower() + if confirm == 'y': + if config_manager.remove_configuration(name): + print(f"Configuration '{name}' removed successfully.") + if name == active: + print("Active configuration has been unset.") + else: + print(f"Error: Failed to remove configuration '{name}'.") + else: + print("Cancelled.") + + +def set_active_configuration_interactive(): + """Interactively set the active configuration""" + configs = config_manager.get_configurations() + if not configs: + print("No configurations available.") + return + + print("\nSet Active Configuration") + print("=" * 30) + print_configurations() + + name = input("Enter configuration name to set as active: ").strip() + if not name: + print("Error: Configuration name cannot be empty") + return + + if not config_manager.configuration_exists(name): + print(f"Error: Configuration '{name}' not found.") + return + + if config_manager.set_active_configuration(name): + print(f"Configuration '{name}' set as active.") + else: + print(f"Error: Failed to set configuration '{name}' as active.") + + +def show_active_configuration(): + """Show the currently active configuration""" + active_name = config_manager.get_active_configuration() + if not active_name: + print("No active configuration set.") + return + + config = config_manager.get_configuration(active_name) + if not config: + print(f"Error: Active configuration '{active_name}' not found.") + return + + print("\nActive Configuration") + print("=" * 30) + print(f"Name: {active_name}") + print(f"Provider: {config.get('provider', 'unknown')}") + print(f"Model: {config.get('model_name', 'unknown')}") + + # Show additional info based on provider + provider = config.get('provider') + if provider == "openai": + print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") + elif provider == "ollama": + base_url = config.get('base_url', 'http://localhost:11434') + print(f"Base URL: {base_url}") + elif provider == "openrouter": + print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") + base_url = config.get('base_url', 'https://openrouter.ai/api/v1') + print(f"Base URL: {base_url}") + + +def test_configuration(): + """Test connection to a configuration""" + configs = config_manager.get_configurations() + if not configs: + print("No configurations to test.") + return + + print("\nTest Configuration Connection") + print("=" * 30) + print_configurations() + + name = input("Enter configuration name to test: ").strip() + if not name: + print("Error: Configuration name cannot be empty") + return + + if not config_manager.configuration_exists(name): + print(f"Error: Configuration '{name}' not found.") + return + + print(f"\nTesting connection to '{name}'...") + success, message = config_manager.test_configuration_connection(name) + + if success: + print(f"✅ {message}") + else: + print(f"❌ {message}") + + +def main(): + """Main CLI entry point""" + parser = argparse.ArgumentParser(description="MaxClaw Agent Configuration Manager") + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # List command + subparsers.add_parser('list', help='List all configurations') + + # Add command + add_parser = subparsers.add_parser('add', help='Add a new configuration') + add_parser.add_argument('--non-interactive', action='store_true', + help='Non-interactive mode (requires all parameters)') + add_parser.add_argument('--name', help='Configuration name') + add_parser.add_argument('--provider', choices=['openai', 'ollama', 'openrouter'], help='Provider') + add_parser.add_argument('--model', help='Model name') + add_parser.add_argument('--api-key', help='API key (for OpenAI/OpenRouter)') + add_parser.add_argument('--base-url', help='Base URL (for Ollama/OpenRouter)') + add_parser.add_argument('--set-active', action='store_true', help='Set as active configuration') + + # Remove command + remove_parser = subparsers.add_parser('remove', help='Remove a configuration') + remove_parser.add_argument('name', nargs='?', help='Configuration name to remove') + + # Set active command + active_parser = subparsers.add_parser('set-active', help='Set active configuration') + active_parser.add_argument('name', nargs='?', help='Configuration name to set as active') + + # Show active command + subparsers.add_parser('active', help='Show active configuration') + + # Test command + test_parser = subparsers.add_parser('test', help='Test connection to a configuration') + test_parser.add_argument('name', nargs='?', help='Configuration name to test') + + args = parser.parse_args() + + if args.command == 'list': + print_configurations() + + elif args.command == 'add': + if args.non_interactive: + # Non-interactive mode + if not all([args.name, args.provider, args.model]): + print("Error: --name, --provider, and --model are required for non-interactive mode") + return 1 + + config = { + "provider": args.provider, + "model_name": args.model + } + + if args.provider in ['openai', 'openrouter']: + if not args.api_key: + print("Error: --api-key is required for OpenAI/OpenRouter providers") + return 1 + config["api_key"] = args.api_key + + if args.provider in ['ollama', 'openrouter']: + config["base_url"] = args.base_url or ( + "http://localhost:11434" if args.provider == "ollama" + else "https://openrouter.ai/api/v1" + ) + + try: + config_manager.add_configuration(args.name, config) + print(f"Configuration '{args.name}' added successfully.") + + if args.set_active: + config_manager.set_active_configuration(args.name) + print(f"Configuration '{args.name}' set as active.") + except ValueError as e: + print(f"Error: {e}") + return 1 + else: + # Interactive mode + add_configuration_interactive() + + elif args.command == 'remove': + if args.name: + # Non-interactive removal + if config_manager.remove_configuration(args.name): + print(f"Configuration '{args.name}' removed successfully.") + active = config_manager.get_active_configuration() + if args.name == active: + print("Active configuration has been unset.") + else: + print(f"Error: Configuration '{args.name}' not found.") + return 1 + else: + # Interactive removal + remove_configuration_interactive() + + elif args.command == 'set-active': + if args.name: + # Non-interactive setting + if config_manager.set_active_configuration(args.name): + print(f"Configuration '{args.name}' set as active.") + else: + print(f"Error: Configuration '{args.name}' not found.") + return 1 + else: + # Interactive setting + set_active_configuration_interactive() + + elif args.command == 'active': + show_active_configuration() + + elif args.command == 'test': + if args.name: + # Non-interactive test + print(f"Testing connection to '{args.name}'...") + success, message = config_manager.test_configuration_connection(args.name) + if success: + print(f"✅ {message}") + else: + print(f"❌ {message}") + return 1 + else: + # Interactive test + test_configuration() + + else: + parser.print_help() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..816c919 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,424 @@ +""" +Configuration manager for MaxClaw Agent +Handles persistent storage of provider configurations with encryption and validation +""" +import json +import os +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple +import copy +from cryptography.fernet import Fernet +import base64 + +try: + import requests +except ImportError: + requests = None + + +class ConfigManager: + """Manages MaxClaw Agent configuration with encryption and validation""" + + def __init__(self, config_dir: Optional[str] = None): + """ + Initialize the config manager + + Args: + config_dir: Directory to store config files. If None, uses ~/.maxclaw + """ + if config_dir is None: + self.config_dir = Path.home() / ".maxclaw" + else: + self.config_dir = Path(config_dir) + + self.config_file = self.config_dir / "config.json" + self.key_file = self.config_dir / ".key" + + # Ensure config directory exists with secure permissions + self._ensure_config_dir() + + # Load or create encryption key + self._encryption_key = self._load_or_create_key() + self._cipher = Fernet(self._encryption_key) + + # Load configuration + self.config = self._load_config() + + def _ensure_config_dir(self): + """Ensure the configuration directory exists with secure permissions""" + self.config_dir.mkdir(parents=True, exist_ok=True) + # Set directory permissions to owner-only (700) + os.chmod(self.config_dir, 0o700) + + def _load_or_create_key(self) -> bytes: + """Load existing encryption key or create a new one""" + if self.key_file.exists(): + try: + key = self.key_file.read_bytes() + # Validate key format + Fernet(key) # This will raise an exception if invalid + # Set key file permissions to owner-only (600) + os.chmod(self.key_file, 0o600) + return key + except Exception: + # If key is invalid or corrupted, create a new one + pass + + # Generate new key + key = Fernet.generate_key() + self.key_file.write_bytes(key) + # Set key file permissions to owner-only (600) + os.chmod(self.key_file, 0o600) + return key + + def _encrypt_data(self, data: str) -> str: + """Encrypt sensitive data""" + if not data: + return data + encrypted = self._cipher.encrypt(data.encode()) + return base64.b64encode(encrypted).decode('utf-8') + + def _decrypt_data(self, encrypted_data: str) -> str: + """Decrypt sensitive data""" + if not encrypted_data: + return encrypted_data + try: + decoded = base64.b64decode(encrypted_data.encode()) + decrypted = self._cipher.decrypt(decoded) + return decrypted.decode('utf-8') + except Exception: + # If decryption fails, return as-is (might be unencrypted legacy data) + return encrypted_data + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from file""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + config = json.load(f) + + # Decrypt any encrypted fields in existing config + return self._decrypt_config(config) + except (json.JSONDecodeError, IOError) as e: + # Return default config if file is corrupted + print(f"Warning: Could not load config file ({e}), using defaults") + return self._get_default_config() + else: + # Return default config if file doesn't exist + return self._get_default_config() + + def _decrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Decrypt sensitive fields in configuration""" + if not isinstance(config, dict): + return config + + # Create a deep copy to avoid modifying original + result = copy.deepcopy(config) + + # Decrypt configurations + if "configurations" in result and isinstance(result["configurations"], dict): + for name, config_data in result["configurations"].items(): + if isinstance(config_data, dict): + # Decrypt API keys for relevant providers + provider = config_data.get("provider", "").lower() + if provider in ["openai", "openrouter"] and "api_key" in config_data: + config_data["api_key"] = self._decrypt_data(config_data["api_key"]) + + return result + + def _encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Encrypt sensitive fields in configuration before storage""" + if not isinstance(config, dict): + return config + + # Create a deep copy to avoid modifying original + result = copy.deepcopy(config) + + # Encrypt configurations + if "configurations" in result and isinstance(result["configurations"], dict): + for name, config_data in result["configurations"].items(): + if isinstance(config_data, dict): + # Encrypt API keys for relevant providers + provider = config_data.get("provider", "").lower() + if provider in ["openai", "openrouter"] and "api_key" in config_data: + api_key = config_data["api_key"] + # Only encrypt if not already encrypted (avoid double encryption) + if api_key and not self._is_likely_encrypted(api_key): + config_data["api_key"] = self._encrypt_data(api_key) + + return result + + def _is_likely_encrypted(self, data: str) -> bool: + """Heuristic to check if data appears to be encrypted""" + if not data or len(data) < 10: + return False + # Encrypted data with Fernet is base64 encoded and typically longer + try: + # Try to decode as base64 + base64.b64decode(data.encode(), validate=True) + # If it decodes successfully and is reasonably long, likely encrypted + return len(data) >= 20 + except Exception: + return False + + def _get_default_config(self) -> Dict[str, Any]: + """Get default configuration structure""" + return { + "configurations": {}, + "active_configuration": None, + "version": "1.0" + } + + def _save_config(self): + """Save configuration to file with encryption of sensitive data""" + try: + # Encrypt sensitive data before saving + config_to_save = self._encrypt_config(self.config) + + # Create a backup of the existing config + if self.config_file.exists(): + backup_file = self.config_file.with_suffix('.json.bak') + backup_file.write_text(self.config_file.read_text()) + # Set backup file permissions to owner-only (600) + os.chmod(backup_file, 0o600) + + # Write the encrypted config + with open(self.config_file, 'w') as f: + json.dump(config_to_save, f, indent=2, sort_keys=True) + + # Set config file permissions to owner-only (600) + os.chmod(self.config_file, 0o600) + except IOError as e: + raise Exception(f"Failed to save configuration: {e}") + + def validate_configuration(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]: + """ + Validate a configuration dictionary + + Returns: + Tuple of (is_valid, list_of_error_messages) + """ + errors = [] + + # Check if config is a dictionary + if not isinstance(config, dict): + errors.append("Configuration must be a dictionary") + return False, errors + + # Validate provider + provider = config.get("provider", "").lower() + valid_providers = ["openai", "ollama", "openrouter"] + + if not provider: + errors.append("Provider is required") + elif provider not in valid_providers: + errors.append(f"Unsupported provider: {provider}. Valid options: {', '.join(valid_providers)}") + + # Validate model name + model_name = config.get("model_name", "").strip() + if not model_name: + errors.append("Model name is required and cannot be empty") + elif len(model_name) > 100: + errors.append("Model name is too long (maximum 100 characters)") + + # Provider-specific validation + if provider == "openai": + api_key = config.get("api_key", "").strip() + if not api_key: + errors.append("API key is required for OpenAI provider") + elif not api_key.startswith("sk-"): + errors.append("OpenAI API key should start with 'sk-'") + elif len(api_key) < 20: + errors.append("OpenAI API key appears too short") + + elif provider == "ollama": + base_url = config.get("base_url", "").strip() + if not base_url: + errors.append("Base URL is required for Ollama provider") + else: + # Normalize URL for validation + if not base_url.startswith(("http://", "https://")): + errors.append("Ollama base URL must start with http:// or https://") + else: + # Remove trailing slash for consistency + if base_url.endswith("/"): + config["base_url"] = base_url.rstrip("/") + + elif provider == "openrouter": + api_key = config.get("api_key", "").strip() + base_url = config.get("base_url", "").strip() + + if not api_key: + errors.append("API key is required for OpenRouter provider") + elif len(api_key) < 10: + errors.append("OpenRouter API key appears too short") + + if not base_url: + errors.append("Base URL is required for OpenRouter provider") + elif not base_url.startswith(("http://", "https://")): + errors.append("OpenRouter base URL must start with http:// or https://") + else: + # Remove trailing slash for consistency + if base_url.endswith("/"): + config["base_url"] = base_url.rstrip("/") + + return len(errors) == 0, errors + + def get_configurations(self) -> Dict[str, Any]: + """Get all configurations""" + return self.config.get("configurations", {}) + + def get_active_configuration(self) -> Optional[str]: + """Get the name of the active configuration""" + return self.config.get("active_configuration") + + def get_configuration(self, name: str) -> Optional[Dict[str, Any]]: + """Get a specific configuration by name""" + configurations = self.get_configurations() + config = configurations.get(name) + # Return a copy to prevent accidental modification of internal data + return copy.deepcopy(config) if config else None + + def add_configuration(self, name: str, config: Dict[str, Any]): + """ + Add or update a configuration with validation + + Args: + name: Configuration name + config: Configuration dictionary with provider, model, etc. + + Raises: + ValueError: If configuration is invalid + """ + if not name or not isinstance(name, str): + raise ValueError("Configuration name must be a non-empty string") + + # Validate the configuration + is_valid, errors = self.validate_configuration(config) + if not is_valid: + raise ValueError(f"Invalid configuration: {', '.join(errors)}") + + if "configurations" not in self.config: + self.config["configurations"] = {} + + # Deep copy to avoid reference issues + self.config["configurations"][name] = copy.deepcopy(config) + self._save_config() + + def remove_configuration(self, name: str) -> bool: + """ + Remove a configuration + + Args: + name: Configuration name to remove + + Returns: + True if removed, False if not found + """ + if "configurations" in self.config and name in self.config["configurations"]: + del self.config["configurations"][name] + + # If this was the active configuration, unset it + if self.config.get("active_configuration") == name: + self.config["active_configuration"] = None + + self._save_config() + return True + return False + + def set_active_configuration(self, name: str) -> bool: + """ + Set the active configuration + + Args: + name: Configuration name to set as active + + Returns: + True if successful, False if configuration not found + """ + if name in self.get_configurations(): + self.config["active_configuration"] = name + self._save_config() + return True + return False + + def get_active_config_details(self) -> Optional[Dict[str, Any]]: + """Get the details of the active configuration""" + active_name = self.get_active_configuration() + if active_name: + return self.get_configuration(active_name) + return None + + def list_configuration_names(self) -> List[str]: + """Get a list of all configuration names""" + return list(self.get_configurations().keys()) + + def configuration_exists(self, name: str) -> bool: + """Check if a configuration exists""" + return name in self.get_configurations() + + def test_configuration_connection(self, name: str) -> Tuple[bool, str]: + """ + Test connection to a configured provider + + Args: + name: Configuration name to test + + Returns: + Tuple of (success, message) + """ + config = self.get_configuration(name) + if not config: + return False, f"Configuration '{name}' not found" + + provider = config.get("provider", "").lower() + + # Check if requests is available + if requests is None: + return False, "The 'requests' library is not available. Please install it to test connections." + + try: + if provider == "ollama": + base_url = config.get("base_url", "http://localhost:11434") + # Ensure no trailing slash + if base_url.endswith("/"): + base_url = base_url.rstrip("/") + response = requests.get(f"{base_url}/api/tags", timeout=5) + if response.status_code == 200: + models = response.json().get("models", []) + model_names = [m.get("name", "unknown") for m in models] + return True, f"Connected successfully. Available models: {', '.join(model_names[:5])}{'...' if len(model_names) > 5 else ''}" + else: + return False, f"Ollama server returned status {response.status_code}" + + elif provider == "openai": + # For OpenAI, we can't easily test without making an actual API call + # which might incur costs. Just validate the key format. + api_key = config.get("api_key", "") + if api_key and api_key.startswith("sk-") and len(api_key) > 20: + return True, "API key format appears valid (no live test performed to avoid costs)" + else: + return False, "API key format appears invalid" + + elif provider == "openrouter": + # Similar to OpenAI, just validate format to avoid costs + api_key = config.get("api_key", "") + base_url = config.get("base_url", "https://openrouter.ai/api/v1") + if api_key and len(api_key) > 10: + return True, "API key format appears valid (no live test performed to avoid costs)" + else: + return False, "API key format appears invalid" + + else: + return False, f"Connection testing not implemented for provider: {provider}" + + except requests.exceptions.Timeout: + return False, "Connection timeout - please check the URL and network connectivity" + except requests.exceptions.ConnectionError: + return False, "Connection failed - please check the URL and ensure the service is running" + except Exception as e: + return False, f"Connection test failed: {str(e)}" + + +# Global config manager instance +config_manager = ConfigManager() \ No newline at end of file diff --git a/config_tui.py b/config_tui.py new file mode 100644 index 0000000..23a6e48 --- /dev/null +++ b/config_tui.py @@ -0,0 +1,489 @@ +""" +Text User Interface for MaxClaw Agent configuration management +""" +import curses +import sys +from typing import Optional, List, Dict, Any +from config_manager import config_manager + + +def draw_menu(stdscr, selected_row_idx, options): + """Draw a menu with options""" + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "MaxClaw Agent Configuration TUI" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + # Instructions + instructions = "Use arrow keys to navigate, Enter to select, 'q' to quit" + stdscr.addstr(2, (w - len(instructions)) // 2, instructions) + + # Menu options + for idx, option in enumerate(options): + x = w // 2 - len(option) // 2 + y = h // 2 - len(options) // 2 + idx + if idx == selected_row_idx: + stdscr.attron(curses.A_REVERSE) + stdscr.addstr(y, x, option) + stdscr.attroff(curses.A_REVERSE) + else: + stdscr.addstr(y, x, option) + + stdscr.refresh() + + +def get_string_input(stdscr, prompt, y, x, max_length=50): + """Get string input from user""" + curses.echo() + stdscr.addstr(y, x, prompt) + stdscr.refresh() + input_win = curses.newwin(1, max_length, y, x + len(prompt)) + input_win.box() + input_win.refresh() + curses.curs_set(1) + result = input_win.getstr(0, 0, max_length).decode('utf-8') + curses.curs_set(0) + curses.noecho() + return result + + +def get_password_input(stdscr, prompt, y, x, max_length=50): + """Get password input from user (hidden)""" + curses.noecho() + stdscr.addstr(y, x, prompt) + stdscr.refresh() + input_win = curses.newwin(1, max_length, y, x + len(prompt)) + input_win.box() + input_win.refresh() + curses.curs_set(1) + result = "" + while True: + ch = input_win.getch() + if ch in [curses.KEY_ENTER, 10, 13]: # Enter key + break + elif ch in [curses.KEY_BACKSPACE, 127, 8]: # Backspace + if len(result) > 0: + result = result[:-1] + input_win.addstr(0, len(result), " ") + input_win.move(0, len(result)) + elif ch >= 32 and ch <= 126 and len(result) < max_length: # Printable characters + result += chr(ch) + input_win.addstr(0, len(result)-1, "*") + input_win.refresh() + curses.curs_set(0) + return result + + +def show_message(stdscr, message, wait_for_key=True): + """Show a message to the user""" + h, w = stdscr.getmaxyx() + stdscr.addstr(h//2, (w - len(message)) // 2, message) + stdscr.refresh() + if wait_for_key: + stdscr.getch() + + +def main_tui(stdscr): + """Main TUI function""" + # Initialize colors if available + if curses.has_colors(): + curses.start_color() + curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) + + # Main menu options + main_options = [ + "View Configurations", + "Add Configuration", + "Remove Configuration", + "Set Active Configuration", + "View Active Configuration", + "Exit" + ] + + current_selection = 0 + + while True: + draw_menu(stdscr, current_selection, main_options) + + key = stdscr.getch() + + if key == curses.KEY_UP and current_selection > 0: + current_selection -= 1 + elif key == curses.KEY_DOWN and current_selection < len(main_options) - 1: + current_selection += 1 + elif key == curses.KEY_ENTER or key in [10, 13]: + # Enter key pressed + if current_selection == 0: # View Configurations + view_configurations_tui(stdscr) + elif current_selection == 1: # Add Configuration + add_configuration_tui(stdscr) + elif current_selection == 2: # Remove Configuration + remove_configuration_tui(stdscr) + elif current_selection == 3: # Set Active Configuration + set_active_configuration_tui(stdscr) + elif current_selection == 4: # View Active Configuration + view_active_configuration_tui(stdscr) + elif current_selection == 5: # Exit + break + elif key == ord('q') or key == ord('Q'): + break + + +def view_configurations_tui(stdscr): + """View all configurations in TUI""" + configs = config_manager.get_configurations() + active = config_manager.get_active_configuration() + + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "Configurations" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + if not configs: + stdscr.addstr(2, 0, "No configurations found.") + else: + # Header + header = f"{'Name':<20} {'Provider':<12} {'Model':<20} {'Active'}" + stdscr.addstr(2, 0, header) + stdscr.addstr(3, 0, "-" * len(header)) + + # Configurations + for idx, (name, config) in enumerate(configs.items()): + y = 4 + idx + if y >= h - 2: # Prevent overflow + break + + marker = "*" if name == active else " " + provider = config.get("provider", "unknown")[:11] + model = config.get("model_name", "unknown")[:19] + line = f"{marker} {name:<19} {provider:<12} {model:<20}" + stdscr.addstr(y, 0, line) + + # Instructions + instructions = "Press any key to return to main menu" + stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions) + stdscr.refresh() + stdscr.getch() + + +def add_configuration_tui(stdscr): + """Add a new configuration via TUI""" + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "Add New Configuration" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + # Get configuration name + name = get_string_input(stdscr, "Configuration name: ", 2, 0) + if not name: + show_message(stdscr, "Error: Configuration name cannot be empty") + return + + if config_manager.configuration_exists(name): + overwrite_prompt = f"Configuration '{name}' already exists. Overwrite? (y/n): " + overwrite = get_string_input(stdscr, overwrite_prompt, 3, 0, 1) + if overwrite.lower() != 'y': + show_message(stdscr, "Cancelled.") + return + + # Select provider + providers = ["OpenAI", "Ollama", "OpenRouter"] + provider_selection = 0 + + while True: + stdscr.clear() + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + stdscr.addstr(2, 0, f"Name: {name}") + stdscr.addstr(3, 0, "Select provider:") + + for idx, provider in enumerate(providers): + y = 4 + idx + if idx == provider_selection: + stdscr.attron(curses.A_REVERSE) + stdscr.addstr(y, 2, f"> {provider}") + stdscr.attroff(curses.A_REVERSE) + else: + stdscr.addstr(y, 2, f" {provider}") + + stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm") + stdscr.refresh() + + key = stdscr.getch() + if key == curses.KEY_UP and provider_selection > 0: + provider_selection -= 1 + elif key == curses.KEY_DOWN and provider_selection < len(providers) - 1: + provider_selection += 1 + elif key == curses.KEY_ENTER or key in [10, 13]: + break + + provider_map = {"OpenAI": "openai", "Ollama": "ollama", "OpenRouter": "openrouter"} + provider = provider_map[providers[provider_selection]] + config = {"provider": provider} + + # Get model name + model_name = get_string_input(stdscr, f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ", 5, 0) + if not model_name: + show_message(stdscr, "Error: Model name cannot be empty") + return + config["model_name"] = model_name + + # Get provider-specific settings + y_pos = 6 + if provider == "openai": + api_key = get_password_input(stdscr, "OpenAI API key: ", y_pos, 0) + config["api_key"] = api_key + y_pos += 1 + elif provider == "ollama": + base_url = get_string_input(stdscr, f"Ollama base URL (default: http://localhost:11434): ", y_pos, 0) + if not base_url: + base_url = "http://localhost:11434" + config["base_url"] = base_url + y_pos += 1 + elif provider == "openrouter": + api_key = get_password_input(stdscr, "OpenRouter API key: ", y_pos, 0) + config["api_key"] = api_key + y_pos += 1 + + base_url = get_string_input(stdscr, f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ", y_pos, 0) + if not base_url: + base_url = "https://openrouter.ai/api/v1" + config["base_url"] = base_url + y_pos += 1 + + # Ask if this should be the active configuration + set_active_prompt = "Set as active configuration? (y/n): " + set_active = get_string_input(stdscr, set_active_prompt, y_pos, 0, 1) + + # Save the configuration + try: + config_manager.add_configuration(name, config) + message = f"Configuration '{name}' saved successfully." + if set_active.lower() == 'y': + config_manager.set_active_configuration(name) + message += f" Configuration '{name}' set as active." + show_message(stdscr, message) + except Exception as e: + show_message(stdscr, f"Error saving configuration: {e}") + + +def remove_configuration_tui(stdscr): + """Remove a configuration via TUI""" + configs = config_manager.get_configurations() + if not configs: + show_message(stdscr, "No configurations to remove.") + return + + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "Remove Configuration" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + # List configurations + config_names = list(configs.keys()) + selection = 0 + + while True: + stdscr.clear() + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + stdscr.addstr(2, 0, "Select configuration to remove:") + + for idx, name in enumerate(config_names): + y = 3 + idx + if y >= h - 4: # Prevent overflow + break + + active_marker = " *" + if name == config_manager.get_active_configuration(): + active_marker = " (active)" + + if idx == selection: + stdscr.attron(curses.A_REVERSE) + stdscr.addstr(y, 2, f"> {name}{active_marker}") + stdscr.attroff(curses.A_REVERSE) + else: + stdscr.addstr(y, 2, f" {name}{active_marker}") + + stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel") + stdscr.refresh() + + key = stdscr.getch() + if key == curses.KEY_UP and selection > 0: + selection -= 1 + elif key == curses.KEY_DOWN and selection < len(config_names) - 1: + selection += 1 + elif key == curses.KEY_ENTER or key in [10, 13]: + # Confirm removal + name_to_remove = config_names[selection] + active = config_manager.get_active_configuration() + + if name_to_remove == active: + confirm_prompt = f"'{name_to_remove}' is the active configuration. Remove it anyway? (y/n): " + confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1) + if confirm.lower() != 'y': + show_message(stdscr, "Cancelled.") + return + + confirm_prompt = f"Are you sure you want to remove configuration '{name_to_remove}'? (y/n): " + confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1) + if confirm.lower() == 'y': + if config_manager.remove_configuration(name_to_remove): + show_message(stdscr, f"Configuration '{name_to_remove}' removed successfully.") + if name_to_remove == active: + show_message(stdscr, "Active configuration has been unset.") + else: + show_message(stdscr, f"Error: Failed to remove configuration '{name_to_remove}'.") + else: + show_message(stdscr, "Cancelled.") + return + elif key == ord('c') or key == ord('C'): + show_message(stdscr, "Cancelled.") + return + + +def set_active_configuration_tui(stdscr): + """Set active configuration via TUI""" + configs = config_manager.get_configurations() + if not configs: + show_message(stdscr, "No configurations available.") + return + + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "Set Active Configuration" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + # List configurations + config_names = list(configs.keys()) + selection = 0 + + while True: + stdscr.clear() + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + stdscr.addstr(2, 0, "Select configuration to set as active:") + + for idx, name in enumerate(config_names): + y = 3 + idx + if y >= h - 4: # Prevent overflow + break + + current_active = config_manager.get_active_configuration() + active_marker = " (current)" if name == current_active else "" + + if idx == selection: + stdscr.attron(curses.A_REVERSE) + stdscr.addstr(y, 2, f"> {name}{active_marker}") + stdscr.attroff(curses.A_REVERSE) + else: + stdscr.addstr(y, 2, f" {name}{active_marker}") + + stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel") + stdscr.refresh() + + key = stdscr.getch() + if key == curses.KEY_UP and selection > 0: + selection -= 1 + elif key == curses.KEY_DOWN and selection < len(config_names) - 1: + selection += 1 + elif key == curses.KEY_ENTER or key in [10, 13]: + # Set as active + name_to_set = config_names[selection] + if config_manager.set_active_configuration(name_to_set): + show_message(stdscr, f"Configuration '{name_to_set}' set as active.") + else: + show_message(stdscr, f"Error: Failed to set configuration '{name_to_set}' as active.") + return + elif key == ord('c') or key == ord('C'): + show_message(stdscr, "Cancelled.") + return + + +def view_active_configuration_tui(stdscr): + """View active configuration via TUI""" + active_name = config_manager.get_active_configuration() + if not active_name: + show_message(stdscr, "No active configuration set.") + return + + config = config_manager.get_configuration(active_name) + if not config: + show_message(stdscr, f"Error: Active configuration '{active_name}' not found.") + return + + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Title + title = "Active Configuration" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + # Display configuration details + details = [ + f"Name: {active_name}", + f"Provider: {config.get('provider', 'unknown')}", + f"Model: {config.get('model_name', 'unknown')}" + ] + + # Add provider-specific details + provider = config.get('provider') + if provider == "openai": + details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") + elif provider == "ollama": + base_url = config.get('base_url', 'http://localhost:11434') + details.append(f"Base URL: {base_url}") + elif provider == "openrouter": + details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") + base_url = config.get('base_url', 'https://openrouter.ai/api/v1') + details.append(f"Base URL: {base_url}") + + # Display details + for idx, detail in enumerate(details): + y = 2 + idx + if y >= h - 2: # Prevent overflow + break + stdscr.addstr(y, 0, detail) + + # Instructions + instructions = "Press any key to return to main menu" + stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions) + stdscr.refresh() + stdscr.getch() + + +def main(): + """Main entry point for the TUI""" + curses.wrapper(main_tui) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/example_usage.py b/example_usage.py new file mode 100644 index 0000000..8021573 --- /dev/null +++ b/example_usage.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Example usage of OpenClaw Agent with different LLM providers +""" + +import os +import sys +sys.path.insert(0, '.') + +from main import create_maxclaw_agent + +def example_with_openai(): + """Example using OpenAI""" + print("=== Example: OpenAI ===") + os.environ["MODEL_PROVIDER"] = "openai" + os.environ["MODEL_NAME"] = "gpt-4o-mini" + # You would need to set OPENAI_API_KEY in your environment + # os.environ["OPENAI_API_KEY"] = "your-api-key" + + try: + agent = create_maxclaw_agent() + print("✅ OpenAI agent created") + # Uncomment to test (requires API key) + # result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]}) + # print(f"Response: {result['messages'][-1].content}") + except Exception as e: + print(f"❌ OpenAI agent failed: {e}") + print("💡 Set OPENAI_API_KEY environment variable to test") + +def example_with_ollama(): + """Example using Ollama""" + print("\n=== Example: Ollama ===") + os.environ["MODEL_PROVIDER"] = "ollama" + os.environ["MODEL_NAME"] = "gemma2:9b" + os.environ["OLLAMA_BASE_URL"] = "https://ollama.solarpi.net" # From user's profile + + try: + agent = create_maxclaw_agent() + print("✅ Ollama agent created") + # Uncomment to test (requires Ollama server) + # result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]}) + # print(f"Response: {result['messages'][-1].content}") + except Exception as e: + print(f"❌ Ollama agent failed: {e}") + print("💡 Make sure Ollama is running at https://ollama.solarpi.net") + +def example_with_openrouter(): + """Example using OpenRouter""" + print("\n=== Example: OpenRouter ===") + os.environ["MODEL_PROVIDER"] = "openrouter" + os.environ["MODEL_NAME"] = "anthropic/claude-3.5-sonnet" + # You would need to set OPENROUTER_API_KEY in your environment + # os.environ["OPENROUTER_API_KEY"] = "your-api-key" + + try: + agent = create_maxclaw_agent() + print("✅ OpenRouter agent created") + # Uncomment to test (requires API key) + # result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]}) + # print(f"Response: {result['messages'][-1].content}") + except Exception as e: + print(f"❌ OpenRouter agent failed: {e}") + print("💡 Set OPENROUTER_API_KEY environment variable to test") + +if __name__ == "__main__": + print("OpenClaw Agent - Provider Configuration Examples\n") + example_with_openai() + example_with_ollama() + example_with_openrouter() + print("\n💡 To run the agent interactively, use: python main.py") + print(" Remember to set the appropriate environment variables for your chosen provider.") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..d4a28ec --- /dev/null +++ b/main.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +MaxClaw Agent - A configurable agent harness using DeepAgents +Supports multiple LLM providers via LangChain's init_chat_model +""" +import os +import sys +from typing import Optional +from deepagents import create_deep_agent +from langchain.chat_models import init_chat_model +from langchain_core.messages import HumanMessage, SystemMessage + + +def load_model_from_config(): + """ + Load the LLM model based on configuration. + Configuration can come from environment variables or a config file. + For simplicity, we'll use environment variables. + + Expected environment variables: + - OPENAI_API_KEY: For OpenAI models + - OLLAMA_BASE_URL: For Ollama (e.g., https://ollama.solarpi.net) + - MODEL_PROVIDER: One of "openai", "ollama", "openrouter", etc. + - MODEL_NAME: The specific model name (e.g., "gpt-4o", "gemma2:9b") + """ + provider = os.getenv("MODEL_PROVIDER", "openai").lower() + model_name = os.getenv("MODEL_NAME", "gpt-4o") + + # Configure based on provider + if provider == "openai": + # Uses OPENAI_API_KEY from environment + return init_chat_model(f"openai:{model_name}") + elif provider == "ollama": + # For Ollama, we need to specify the base URL + base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + return init_chat_model(f"ollama:{model_name}", base_url=base_url) + elif provider == "openrouter": + # For OpenRouter, we need OPENROUTER_API_KEY and optionally base URL + # Note: LangChain's init_chat_model for openrouter might require specific setup + # As of now, we assume it's supported via openai-compatible API + base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + raise ValueError("OPENROUTER_API_KEY environment variable is required for OpenRouter") + # Using openai-compatible API + return init_chat_model( + f"openai:{model_name}", + base_url=base_url, + api_key=api_key + ) + else: + # Fallback to openai for unknown providers (or add more as needed) + return init_chat_model(f"openai:{model_name}") + + +def create_maxclaw_agent(system_prompt: Optional[str] = None): + """ + Create and return a DeepAgent instance with configurable model. + """ + if system_prompt is None: + system_prompt = """You are MaxClaw, a helpful AI agent that can plan, use tools, + manage context, and delegate to subagents. You are designed to be configurable + to work with various LLM providers.""" + + model = load_model_from_config() + agent = create_deep_agent( + model=model, + system_prompt=system_prompt + ) + return agent + + +def main(): + """ + Main entry point for the MaxClaw agent. + """ + print("🚀 Initializing MaxClaw Agent...") + + try: + agent = create_maxclaw_agent() + print("✅ Agent initialized successfully!") + + # Simple test interaction + print("\n💬 You can now interact with the agent. Type 'exit' to quit.") + while True: + user_input = input("\n👤 You: ").strip() + if user_input.lower() in ['exit', 'quit']: + print("👋 Goodbye!") + break + + if not user_input: + continue + + # Invoke the agent + try: + result = agent.invoke({"messages": [HumanMessage(content=user_input)]}) + # Extract the AI's response + ai_message = result["messages"][-1] + print(f"\n🤖 MaxClaw: {ai_message.content}") + except Exception as e: + print(f"\n❌ Error: {str(e)}") + + except Exception as e: + print(f"❌ Failed to initialize agent: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ba1276 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +deepagents>=0.0.34 +langchain>=0.3.0 +langchain-community>=0.3.0 +langchain-openai>=0.1.0 \ No newline at end of file diff --git a/test_agent.py b/test_agent.py new file mode 100644 index 0000000..3a107ea --- /dev/null +++ b/test_agent.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Test script for OpenClaw Agent +""" + +import os +import sys +sys.path.insert(0, '.') + +from main import create_maxclaw_agent + +def test_agent_creation(): + """Test that the agent can be created successfully.""" + print("Testing agent creation...") + try: + agent = create_maxclaw_agent() + print("✅ Agent created successfully!") + return agent + except Exception as e: + print(f"❌ Failed to create agent: {e}") + print("💡 Make sure to set the required environment variables for your chosen model provider.") + print(" See README.md for configuration examples.") + return None + +def test_simple_query(agent): + """Test a simple query to the agent.""" + print("\nTesting simple query...") + try: + result = agent.invoke({"messages": [{"role": "user", "content": "Hello, how are you?"}]}) + ai_message = result["messages"][-1] + print(f"✅ Agent responded: {ai_message.content}") + return True + except Exception as e: + print(f"❌ Failed to get response: {e}") + return False + +if __name__ == "__main__": + # Set some default environment variables for testing + # In a real scenario, these would be set externally + if not os.getenv("MODEL_PROVIDER"): + os.environ["MODEL_PROVIDER"] = "openai" + if not os.getenv("MODEL_NAME"): + os.environ["MODEL_NAME"] = "gpt-4o-mini" # Using a cheaper model for testing + + agent = test_agent_creation() + if agent: + test_simple_query(agent) \ No newline at end of file