commit d4d6aafea979acfd76e1af83821fdff8886bcbeb Author: truetype Date: Thu Mar 19 20:29:30 2026 +0100 Implement secure configuration management with encryption and validation (Phase 1 improvements) 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 0000000..744b290 Binary files /dev/null and b/__pycache__/config_manager.cpython-314.pyc differ diff --git a/__pycache__/config_tui.cpython-314.pyc b/__pycache__/config_tui.cpython-314.pyc new file mode 100644 index 0000000..e721037 Binary files /dev/null and b/__pycache__/config_tui.cpython-314.pyc differ diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..4d64d5c Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ 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