diff --git a/.gitignore b/.gitignore index ac56288d..95b87dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,101 @@ -*.pth -*.env -*# -*~ -*.pyc -*__pycache__ -*.json +# Git Ignore File Guide +# --------------------- +# - Each line specifies a pattern for files/directories that Git should ignore +# - '*' means "match any characters" +# - A trailing '/' indicates a directory +# - Lines starting with '#' are comments +# - Patterns are matched relative to the location of the .gitignore file +# - More specific rules override more general rules +# - Use '!' to negate a pattern (include something that would otherwise be ignored) +# Development Environment Files +## Python-specific +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.pyi # Python interface stub files +*.pth # PyTorch model files +*# # Emacs temporary files +*~ # Backup files + +## Environment & Configuration +*.env # Environment variable files +.env # Root environment file +venv/ +env/ +ENV/ +.env + +## IDE-specific (consolidated) +.idea/ # JetBrains IDEs (IntelliJ, PyCharm, etc.) +.vscode/ # Visual Studio Code +*.swp # Temporary files +*.swo +.DS_Store + +## Operating System Files +# macOS +.DS_Store *.DS_Store -dist/ \ No newline at end of file + +## Data & Output Files +*.json # JSON data files + +## Testing & Debugging +coverage/ # Coverage report +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ +.tox/ +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +*.iml # IntelliJ Project files +*.sublime-project # Sublime Text Project files +*.sublime-workspace # Sublime Text Workspace files +.vscode/settings.json +tests/__pycache__/ +tests/**/__pycache__/ +tests/.pytest_cache/ +tests/**/.pytest_cache/ +*.test + +## Documentation +docs/_build/ +_build/ + +## Jupyter Notebook +.ipynb_checkpoints + +## GitHub specific +.github/ + +## Local development settings +local_settings.py +db.sqlite3 +db.sqlite3-journal + +## Misc +*.swp # Temporary files diff --git a/docs/api/agent.md b/docs/api/agent.md new file mode 100644 index 00000000..944d1e14 --- /dev/null +++ b/docs/api/agent.md @@ -0,0 +1,249 @@ +# Agent + +The `Agent` class is the high-level planner in the GAME SDK. It coordinates workers and manages the overall agent state. + +## Overview + +An agent is responsible for: +1. Managing multiple workers +2. Creating and tracking tasks +3. Making high-level decisions +4. Maintaining session state + +## Basic Usage + +Here's how to create and use an agent: + +```python +from game_sdk.game.agent import Agent +from game_sdk.game.worker_config import WorkerConfig + +# Create agent +agent = Agent( + goal="Handle weather reporting", + description="A weather reporting system" +) + +# Add worker +worker_config = create_worker_config() +agent.add_worker(worker_config) + +# Compile and run +agent.compile() +agent.run() +``` + +## Components + +### Goal + +The agent's primary objective: + +```python +goal="Provide accurate weather information and recommendations" +``` + +### Description + +Detailed description of the agent's purpose and capabilities: + +```python +description=""" +This agent: +1. Reports weather conditions +2. Provides clothing recommendations +3. Tracks weather patterns +""" +``` + +### Workers + +Add workers to handle specific tasks: + +```python +# Add multiple workers +agent.add_worker(weather_worker_config) +agent.add_worker(recommendation_worker_config) +agent.add_worker(analytics_worker_config) +``` + +## Best Practices + +### 1. Clear Goals + +Set specific, measurable goals: + +```python +# Good +goal="Provide hourly weather updates for specified cities" + +# Bad +goal="Handle weather stuff" +``` + +### 2. Detailed Descriptions + +Provide comprehensive descriptions: + +```python +description=""" +Weather reporting agent that: +1. Monitors weather conditions +2. Provides clothing recommendations +3. Tracks temperature trends +4. Alerts on severe weather +""" +``` + +### 3. Worker Organization + +Organize workers by function: + +```python +# Weather monitoring +agent.add_worker(weather_monitor_config) + +# Recommendations +agent.add_worker(clothing_advisor_config) + +# Analytics +agent.add_worker(trend_analyzer_config) +``` + +### 4. Error Handling + +Handle errors at the agent level: + +```python +try: + agent.compile() + agent.run() +except Exception as e: + logger.error(f"Agent error: {e}") + # Handle error appropriately +``` + +## Examples + +### Weather Agent + +```python +def create_weather_agent(): + """Create a weather reporting agent.""" + # Create agent + agent = Agent( + goal="Provide weather updates and recommendations", + description=""" + Weather reporting system that: + 1. Monitors current conditions + 2. Provides clothing recommendations + 3. Tracks weather patterns + """ + ) + + # Add workers + agent.add_worker(create_weather_worker_config()) + agent.add_worker(create_recommendation_worker_config()) + + return agent +``` + +### Task Manager + +```python +def create_task_manager(): + """Create a task management agent.""" + # Create agent + agent = Agent( + goal="Manage and track tasks efficiently", + description=""" + Task management system that: + 1. Creates and assigns tasks + 2. Tracks task progress + 3. Generates reports + """ + ) + + # Add workers + agent.add_worker(create_task_worker_config()) + agent.add_worker(create_report_worker_config()) + + return agent +``` + +## Testing + +Test your agents thoroughly: + +```python +def test_weather_agent(): + """Test weather agent functionality.""" + # Create agent + agent = create_weather_agent() + + # Test worker addition + assert len(agent.workers) > 0 + + # Test compilation + agent.compile() + assert agent.is_compiled + + # Test execution + result = agent.run() + assert result.status == 'success' +``` + +## Advanced Features + +### 1. Session Management + +```python +# Start new session +session = agent.start_session() + +# Resume existing session +agent.resume_session(session_id) +``` + +### 2. State Tracking + +```python +# Get agent state +state = agent.get_state() + +# Update state +agent.update_state(new_state) +``` + +### 3. Worker Communication + +```python +# Get worker results +results = agent.get_worker_results() + +# Share data between workers +agent.share_data(worker1_id, worker2_id, data) +``` + +## Common Issues + +1. **Worker Conflicts** + - Ensure workers have unique IDs + - Define clear worker responsibilities + - Handle shared resources properly + +2. **State Management** + - Keep state minimal + - Handle state updates atomically + - Document state structure + +3. **Performance** + - Monitor worker execution time + - Optimize resource usage + - Cache frequently used data + +## Further Reading + +- [Worker Documentation](worker.md) +- [Worker Configuration](worker_config.md) +- [Examples](../examples/) diff --git a/docs/api/worker.md b/docs/api/worker.md new file mode 100644 index 00000000..63a9c058 --- /dev/null +++ b/docs/api/worker.md @@ -0,0 +1,242 @@ +# Worker Implementation + +The `Worker` class is a core component of the GAME SDK that executes functions and manages state based on a worker configuration. + +## Overview + +A worker is responsible for: +1. Executing functions defined in its action space +2. Managing state between function calls +3. Handling errors and logging +4. Providing feedback on execution results + +## Basic Usage + +Here's how to create and use a worker: + +```python +from game_sdk.game.worker import Worker +from game_sdk.game.worker_config import WorkerConfig + +# Create worker configuration +config = create_worker_config() + +# Create worker instance +worker = Worker(config) + +# Execute a function +result = worker.execute_function( + "function_name", + {"param1": "value1"} +) +``` + +## Components + +### Worker Configuration + +A worker requires a `WorkerConfig` that defines its behavior: + +```python +worker_config = WorkerConfig( + id="my_worker", + worker_description="Does something useful", + get_state_fn=get_state, + action_space=[function1, function2], + instruction="Do something" +) +``` + +### Function Execution + +Workers execute functions from their action space: + +```python +# Execute with parameters +result = worker.execute_function( + "get_weather", + {"city": "New York"} +) + +# Check result +if result['status'] == 'success': + print(result['data']) +else: + print(f"Error: {result['message']}") +``` + +### State Management + +Workers maintain state between function calls: + +```python +def get_state(function_result, current_state): + """Update worker state after function execution.""" + if current_state is None: + return {'requests': 0} + + current_state['requests'] += 1 + return current_state +``` + +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully: + +```python +def my_function(param1: str) -> Dict[str, Any]: + try: + # Do something + return { + 'status': 'success', + 'message': 'Operation completed', + 'data': result + } + except Exception as e: + logger.error(f"Error: {e}") + return { + 'status': 'error', + 'message': str(e), + 'data': None + } +``` + +### 2. Logging + +Use appropriate logging levels: + +```python +import logging + +logger = logging.getLogger(__name__) + +def my_function(param1: str): + logger.info(f"Starting operation with {param1}") + try: + result = do_something(param1) + logger.debug(f"Operation result: {result}") + return result + except Exception as e: + logger.error(f"Operation failed: {e}") + raise +``` + +### 3. Type Hints + +Use type hints for better code clarity: + +```python +from typing import Dict, Any, Optional + +def get_state( + function_result: Optional[Dict[str, Any]], + current_state: Optional[Dict[str, Any]] +) -> Dict[str, Any]: + """Update worker state.""" + pass +``` + +### 4. Documentation + +Document all functions thoroughly: + +```python +def execute_function( + self, + function_name: str, + parameters: Dict[str, Any] +) -> Dict[str, Any]: + """Execute a function with given parameters. + + Args: + function_name: Name of function to execute + parameters: Function parameters + + Returns: + Dict containing: + - status: 'success' or 'error' + - message: Human-readable message + - data: Function result data + + Raises: + ValueError: If function not found + """ + pass +``` + +## Examples + +### Weather Worker + +```python +# Create weather worker +weather_config = create_weather_worker_config(api_key) +weather_worker = Worker(weather_config) + +# Get weather +result = weather_worker.execute_function( + "get_weather", + {"city": "New York"} +) + +# Process result +if result['status'] == 'success': + weather = result['data'] + print(f"Temperature: {weather['temperature']}") + print(f"Condition: {weather['condition']}") + print(f"Recommendation: {weather['clothing']}") +else: + print(f"Error: {result['message']}") +``` + +### Task Worker + +```python +# Create task worker +task_config = create_task_worker_config() +task_worker = Worker(task_config) + +# Add task +result = task_worker.execute_function( + "add_task", + { + "title": "Complete documentation", + "priority": 1 + } +) + +# Check result +if result['status'] == 'success': + print(f"Task added: {result['data']['task_id']}") +else: + print(f"Failed to add task: {result['message']}") +``` + +## Testing + +Test your workers thoroughly: + +```python +def test_weather_worker(): + """Test weather worker functionality.""" + # Create worker + config = create_weather_worker_config(test_api_key) + worker = Worker(config) + + # Test valid city + result = worker.execute_function( + "get_weather", + {"city": "New York"} + ) + assert result['status'] == 'success' + assert 'temperature' in result['data'] + + # Test invalid city + result = worker.execute_function( + "get_weather", + {"city": "NonexistentCity"} + ) + assert result['status'] == 'error' +``` diff --git a/docs/api/worker_config.md b/docs/api/worker_config.md new file mode 100644 index 00000000..0ee9d450 --- /dev/null +++ b/docs/api/worker_config.md @@ -0,0 +1,215 @@ +# Worker Configuration + +The `WorkerConfig` class is a fundamental component of the GAME SDK that defines how a worker behaves. This document explains how to create and use worker configurations effectively. + +## Overview + +A worker configuration defines: + +1. The worker's identity and description +2. Available actions (functions) that the worker can perform +3. State management behavior +4. Instructions for the worker + +## Basic Usage + +Here's a simple example of creating a worker configuration: + +```python +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function, Argument + +# Define a function +my_function = Function( + fn_name="my_function", + fn_description="Does something useful", + executable=my_function_handler, + args=[ + Argument( + name="param1", + type="string", + description="First parameter" + ) + ] +) + +# Create configuration +config = WorkerConfig( + id="my_worker", + worker_description="A useful worker", + get_state_fn=get_state_handler, + action_space=[my_function], + instruction="Do something useful" +) +``` + +## Components + +### Worker ID + +A unique identifier for the worker. This should be: +- Descriptive of the worker's purpose +- Unique within your agent +- Valid Python identifier (no spaces or special characters) + +```python +id="weather_worker" # Good +id="weather-worker" # Bad (contains hyphen) +``` + +### Worker Description + +A clear description of what the worker does. This should: +- Explain the worker's purpose +- List key capabilities +- Be concise but informative + +```python +worker_description="Provides weather information and clothing recommendations" +``` + +### State Management + +The `get_state_fn` defines how the worker maintains state between function calls: + +```python +def get_state(function_result, current_state): + """Manage worker state. + + Args: + function_result: Result of last function call + current_state: Current state dictionary + + Returns: + Updated state dictionary + """ + if current_state is None: + return {'count': 0} + + current_state['count'] += 1 + return current_state +``` + +### Action Space + +The `action_space` defines what functions the worker can perform: + +```python +action_space=[ + Function( + fn_name="function1", + fn_description="Does something", + executable=handler1, + args=[...] + ), + Function( + fn_name="function2", + fn_description="Does something else", + executable=handler2, + args=[...] + ) +] +``` + +### Instructions + +Clear instructions for the worker: + +```python +instruction=""" +This worker can: +1. Do something useful +2. Handle specific tasks +3. Process certain data +""" +``` + +## Best Practices + +1. **Clear Documentation** + - Document all functions thoroughly + - Include usage examples + - Explain parameters clearly + +2. **Error Handling** + - Handle errors gracefully in function handlers + - Return meaningful error messages + - Log errors appropriately + +3. **State Management** + - Keep state minimal and focused + - Handle None state appropriately + - Document state structure + +4. **Testing** + - Test all functions thoroughly + - Verify error handling + - Check state management + +## Examples + +### Weather Worker + +```python +def create_weather_worker_config(api_key: str) -> WorkerConfig: + """Create weather worker configuration. + + Args: + api_key: API key for authentication + + Returns: + Configured WorkerConfig + """ + weather_fn = Function( + fn_name="get_weather", + fn_description="Get weather for a city", + executable=get_weather, + args=[ + Argument( + name="city", + type="string", + description="City name" + ) + ] + ) + + return WorkerConfig( + id="weather_worker", + worker_description="Weather information system", + get_state_fn=get_state, + action_space=[weather_fn], + instruction="Fetch weather data" + ) +``` + +### Task Worker + +```python +def create_task_worker_config() -> WorkerConfig: + """Create task management worker configuration.""" + add_task = Function( + fn_name="add_task", + fn_description="Add a new task", + executable=add_task_handler, + args=[ + Argument( + name="title", + type="string", + description="Task title" + ), + Argument( + name="priority", + type="integer", + description="Task priority (1-5)" + ) + ] + ) + + return WorkerConfig( + id="task_worker", + worker_description="Task management system", + get_state_fn=get_task_state, + action_space=[add_task], + instruction="Manage tasks" + ) +``` diff --git a/docs/examples/weather_agent.md b/docs/examples/weather_agent.md new file mode 100644 index 00000000..be42f258 --- /dev/null +++ b/docs/examples/weather_agent.md @@ -0,0 +1,145 @@ +# Weather Agent Example + +This example demonstrates how to create a weather agent using the GAME SDK. It showcases several key features of the SDK and serves as a practical guide for building your own agents. + +## Overview + +The weather agent example consists of three main components: + +1. **Weather Agent** (`example_weather_agent.py`): The main agent that coordinates weather-related tasks +2. **Weather Worker** (`example_weather_worker.py`): A worker that handles weather data fetching and processing +3. **Worker Configuration** (`worker_config.py`): Configuration for the weather worker + +## Features + +- Fetch weather data for different cities +- Provide clothing recommendations based on temperature +- Handle API errors gracefully +- Track request statistics +- Comprehensive test suite + +## Prerequisites + +Before running the example, you'll need: + +1. A Virtuals API key (set as `VIRTUALS_API_KEY` environment variable) +2. Python 3.9 or later +3. Required packages installed (see `requirements.txt`) + +## Quick Start + +```bash +# Set your API key +export VIRTUALS_API_KEY="your_api_key" + +# Run the example +python examples/example_weather_agent.py +``` + +## Code Structure + +### Weather Agent (`example_weather_agent.py`) + +The main agent file that sets up and coordinates the weather worker: + +```python +from game_sdk.game.agent import Agent +from game_sdk.game.worker_config import WorkerConfig + +# Create and configure the agent +agent = create_weather_agent() + +# Run tests +test_weather_agent(agent) +``` + +### Weather Worker (`example_weather_worker.py`) + +The worker that handles weather-related tasks: + +```python +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.worker import Worker + +# Create worker configuration +config = create_weather_worker_config(api_key) + +# Create worker instance +worker = Worker(config) + +# Execute weather function +result = worker.execute_function("get_weather", {"city": "New York"}) +``` + +## Key Concepts + +### Worker Configuration + +The worker is configured using a `WorkerConfig` object that defines: + +1. Available functions (action space) +2. State management +3. Worker description and instructions + +```python +worker_config = WorkerConfig( + id="weather_worker", + worker_description="Provides weather information and recommendations", + get_state_fn=get_state, + action_space=[weather_fn], + instruction="Fetch weather data and provide recommendations" +) +``` + +### State Management + +The worker maintains state between function calls: + +```python +def get_state(function_result, current_state): + if current_state is None: + return {'requests': 0, 'successes': 0, 'failures': 0} + + if function_result and function_result.get('status') == 'success': + current_state['successes'] += 1 + elif function_result: + current_state['failures'] += 1 + + current_state['requests'] += 1 + return current_state +``` + +### Error Handling + +The example demonstrates proper error handling: + +```python +try: + response = requests.get('https://weather-api.com/data') + response.raise_for_status() + # Process response... +except requests.RequestException as e: + logger.error(f"Failed to get weather: {e}") + return { + 'status': 'error', + 'message': f"Failed to get weather for {city}", + 'data': None + } +``` + +## Testing + +The example includes comprehensive tests: + +```python +def test_weather_agent(agent): + test_cases = ["New York", "Miami", "Boston"] + + for city in test_cases: + worker = agent.get_worker("weather_worker") + result = worker.execute_function("get_weather", {"city": city}) + + # Verify result + assert result['status'] == 'success' + assert 'temperature' in result['data'] + assert 'clothing' in result['data'] diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 00000000..089d26f5 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,171 @@ +# Getting Started with GAME SDK + +This guide will help you get started with the GAME SDK and create your first agent. + +## Installation + +1. Install via pip: +```bash +pip install game_sdk +``` + +2. Or install from source: +```bash +git clone https://github.com/game-by-virtuals/game-python.git +cd game-python +pip install -e . +``` + +## API Key Setup + +1. Get your API key from the [Game Console](https://console.game.virtuals.io/) + +2. Set your API key: +```bash +export VIRTUALS_API_KEY="your_api_key" +``` + +## Quick Start + +Here's a simple example to create a weather reporting agent: + +```python +from game_sdk.game.agent import Agent +from game_sdk.game.worker import Worker +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function, Argument + +# 1. Create a function +def get_weather(city: str): + """Get weather for a city.""" + return { + 'status': 'success', + 'data': { + 'temperature': '20°C', + 'condition': 'Sunny' + } + } + +# 2. Create function definition +weather_fn = Function( + fn_name="get_weather", + fn_description="Get weather for a city", + executable=get_weather, + args=[ + Argument( + name="city", + type="string", + description="City name" + ) + ] +) + +# 3. Create worker config +worker_config = WorkerConfig( + id="weather_worker", + worker_description="Provides weather information", + get_state_fn=lambda x, y: {'requests': 0}, + action_space=[weather_fn], + instruction="Fetch weather data" +) + +# 4. Create agent +agent = Agent( + goal="Provide weather updates", + description="Weather reporting system" +) + +# 5. Add worker and run +agent.add_worker(worker_config) +agent.compile() +agent.run() +``` + +## Core Concepts + +### 1. Agent + +The high-level planner that: +- Takes a goal and description +- Manages workers +- Makes decisions + +### 2. Worker + +The low-level planner that: +- Takes a description +- Executes functions +- Manages state + +### 3. Function + +The action executor that: +- Takes parameters +- Performs actions +- Returns results + +## Next Steps + +1. **Explore Examples** + - Check the [examples](examples/) directory + - Try modifying example code + - Create your own examples + +2. **Read Documentation** + - [SDK Overview](sdk_overview.md) + - [API Documentation](api/) + - [GAME Framework](https://whitepaper.virtuals.io/developer-documents/game-framework) + +3. **Join Community** + - Join our [Discord](https://discord.gg/virtuals) + - Follow us on [Twitter](https://twitter.com/VirtualsHQ) + - Read our [Blog](https://blog.virtuals.io) + +## Common Issues + +### API Key Not Found +```python +os.environ.get('VIRTUALS_API_KEY') is None +``` +Solution: Set your API key in your environment + +### Worker Not Found +```python +KeyError: 'worker_id' +``` +Solution: Ensure worker is added to agent before running + +### Function Error +```python +ValueError: Function not found +``` +Solution: Check function name matches worker's action space + +## Best Practices + +1. **Clear Organization** + - Separate concerns + - Use meaningful names + - Document code + +2. **Error Handling** + - Handle errors gracefully + - Log errors + - Provide feedback + +3. **Testing** + - Write unit tests + - Test edge cases + - Verify results + +## Getting Help + +If you need help: +1. Check documentation +2. Search issues +3. Ask on Discord +4. Contact support + +## Contributing + +Want to help? See our [Contribution Guide](../CONTRIBUTION_GUIDE.md) diff --git a/docs/sdk_overview.md b/docs/sdk_overview.md new file mode 100644 index 00000000..c68425b1 --- /dev/null +++ b/docs/sdk_overview.md @@ -0,0 +1,154 @@ +# GAME SDK Overview + +## Architecture + +The GAME SDK is built on three main components: + +1. **Agent (High Level Planner)** + - Takes a Goal and Description + - Creates and manages tasks + - Coordinates multiple workers + - Makes high-level decisions + +2. **Worker (Low Level Planner)** + - Takes a Description + - Executes specific tasks + - Manages state + - Calls functions + +3. **Function** + - Takes a Description + - Executes specific actions + - Returns results + - Handles errors + +![New SDK Visual](imgs/new_sdk_visual.png) + +## Key Features + +- **Custom Agent Development**: Build agents for any application +- **Description-Based Control**: Control agents and workers via prompts +- **State Management**: Full control over agent state +- **Function Customization**: Create complex function chains +- **Error Handling**: Robust error handling throughout +- **Type Safety**: Strong typing for better development + +## Component Descriptions + +### Agent + +The Agent serves as the high-level planner: + +```python +from game_sdk.game.agent import Agent + +agent = Agent( + goal="Handle weather reporting", + description="A weather reporting system that provides updates and recommendations" +) +``` + +### Worker + +Workers handle specific tasks: + +```python +from game_sdk.game.worker import Worker +from game_sdk.game.worker_config import WorkerConfig + +worker_config = WorkerConfig( + id="weather_worker", + description="Provides weather information", + action_space=[weather_function] +) + +worker = Worker(worker_config) +``` + +### Function + +Functions execute specific actions: + +```python +from game_sdk.game.custom_types import Function, Argument + +weather_function = Function( + fn_name="get_weather", + fn_description="Get weather for a city", + executable=get_weather, + args=[ + Argument( + name="city", + type="string", + description="City name" + ) + ] +) +``` + +## Best Practices + +1. **Clear Documentation** + - Document all components thoroughly + - Include usage examples + - Explain parameters clearly + +2. **Error Handling** + - Handle errors at all levels + - Provide meaningful error messages + - Log errors appropriately + +3. **State Management** + - Keep state minimal and focused + - Handle state updates cleanly + - Document state structure + +4. **Testing** + - Test all components + - Cover error cases + - Verify state management + +## Examples + +See the [examples](examples/) directory for complete examples: + +- Weather Agent: Complete weather reporting system +- Task Manager: Task management system +- Twitter Bot: Social media interaction + +## Getting Started + +1. Install the SDK: +```bash +pip install game_sdk +``` + +2. Set up your API key: +```bash +export VIRTUALS_API_KEY="your_api_key" +``` + +3. Create your first agent: +```python +from game_sdk.game.agent import Agent +from game_sdk.game.worker import Worker + +# Create agent +agent = Agent( + goal="Your goal", + description="Your description" +) + +# Add workers +agent.add_worker(worker_config) + +# Run agent +agent.run() +``` + +## Further Reading + +- [API Documentation](api/) +- [Examples](examples/) +- [Contributing Guide](../CONTRIBUTION_GUIDE.md) +- [GAME Framework](https://whitepaper.virtuals.io/developer-documents/game-framework) diff --git a/examples/example_weather_agent.py b/examples/example_weather_agent.py new file mode 100644 index 00000000..4d64e2a8 --- /dev/null +++ b/examples/example_weather_agent.py @@ -0,0 +1,268 @@ +""" +Example Weather Agent for the GAME SDK. + +This script demonstrates how to create and test an agent that provides weather +information and recommendations using the GAME SDK. It showcases: + +1. Creating a weather agent with a custom worker +2. Configuring the worker with weather-related functions +3. Testing the agent with different cities +4. Handling API responses and state management + +The weather agent can: +- Fetch current weather for a given city +- Provide clothing recommendations based on weather +- Handle multiple cities in sequence + +Example: + $ export VIRTUALS_API_KEY="your_api_key" + $ python examples/example_weather_agent.py + +Note: + This example requires a valid Virtuals API key to be set in the + environment variable VIRTUALS_API_KEY. +""" + +import logging +import os +from datetime import datetime +from typing import Dict, Any, Optional, Tuple +import requests + +from game_sdk.game.agent import Agent +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function, Argument +from game_sdk.game.exceptions import ValidationError, APIError + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def get_weather(city: str) -> Dict[str, Any]: + """Get weather information for a given city. + + This function makes an API call to fetch current weather data + and provides clothing recommendations based on conditions. + + Args: + city (str): Name of the city to get weather for + + Returns: + Dict[str, Any]: Weather information including: + - status: API call status + - message: Formatted weather message + - data: Detailed weather data + + Raises: + requests.RequestException: If the API call fails + + Example: + >>> result = get_weather("New York") + >>> print(result['message']) + 'Current weather in New York: Sunny, 15°C, 60% humidity' + """ + try: + # Simulate API call (replace with actual weather API in production) + response = requests.get('https://dylanburkey.com/assets/weather.json') + response.raise_for_status() + + # Process response + weather_data = { + 'New York': {'temp': '15°C', 'condition': 'Sunny', 'humidity': '60%'}, + 'Miami': {'temp': '28°C', 'condition': 'Sunny', 'humidity': '70%'}, + 'Boston': {'temp': '10°C', 'condition': 'Cloudy', 'humidity': '65%'} + }.get(city, {'temp': '20°C', 'condition': 'Clear', 'humidity': '50%'}) + + # Determine clothing recommendation + temp = int(weather_data['temp'].rstrip('°C')) + if temp > 25: + clothing = 'Shorts and t-shirt' + elif temp > 15: + clothing = 'Light jacket' + else: + clothing = 'Sweater' + + return { + 'status': 'success', + 'message': f"Current weather in {city}: {weather_data['condition']}, " + f"{weather_data['temp']}°F, {weather_data['humidity']} humidity", + 'data': { + 'city': city, + 'temperature': weather_data['temp'], + 'condition': weather_data['condition'], + 'humidity': weather_data['humidity'], + 'clothing': clothing + } + } + + except requests.RequestException as e: + logger.error(f"Failed to get weather: {e}") + return { + 'status': 'error', + 'message': f"Failed to get weather for {city}", + 'data': None + } + +def get_state(function_result: Optional[Dict], current_state: Optional[Dict]) -> Dict[str, Any]: + """Get the current state of the weather agent. + + This function maintains the agent's state between function calls, + tracking successful and failed requests. + + Args: + function_result: Result of the last executed function + current_state: Current agent state + + Returns: + Dict[str, Any]: Updated agent state + + Example: + >>> state = get_state(None, None) + >>> print(state) + {'requests': 0, 'successes': 0, 'failures': 0} + """ + if current_state is None: + return {'requests': 0, 'successes': 0, 'failures': 0} + + if function_result and function_result.get('status') == 'success': + current_state['successes'] += 1 + elif function_result: + current_state['failures'] += 1 + + current_state['requests'] += 1 + return current_state + +def create_weather_agent() -> Agent: + """Create and configure a weather agent. + + This function sets up an agent with a weather worker that can + fetch weather information and provide recommendations. + + Returns: + Agent: Configured weather agent + + Raises: + ValueError: If VIRTUALS_API_KEY is not set + ValidationError: If worker configuration is invalid + APIError: If agent creation fails + + Example: + >>> agent = create_weather_agent() + >>> agent.compile() + """ + # Get API key + api_key = os.getenv('VIRTUALS_API_KEY') + if not api_key: + raise ValueError("VIRTUALS_API_KEY not set") + + logger.debug("Starting weather reporter creation") + logger.debug(f"Creating agent with API key: {api_key[:8]}...") + + # Create weather function + weather_fn = Function( + fn_name="get_weather", + fn_description="Get weather information for a city", + executable=get_weather, + args=[ + Argument( + name="city", + type="string", + description="City to get weather for" + ) + ] + ) + + # Create worker config + worker_config = WorkerConfig( + id="weather_worker", + worker_description="Provides weather information and recommendations", + get_state_fn=get_state, + action_space=[weather_fn], + instruction="Fetch weather data and provide clothing recommendations", + api_key=api_key + ) + + # Create agent + agent = Agent( + api_key=api_key, + name="Weather Reporter", + agent_description="Reports weather and provides recommendations", + agent_goal="Help users prepare for weather conditions", + get_agent_state_fn=get_state, + workers=[worker_config] + ) + + # Compile agent + agent.compile() + logger.info("Created weather reporter agent") + return agent + +def test_weather_agent(agent: Agent): + """Test the weather agent with different cities. + + This function runs a series of tests to verify that the agent + can correctly fetch and process weather information. + + Args: + agent (Agent): Weather agent to test + + Raises: + AssertionError: If any test fails + + Example: + >>> agent = create_weather_agent() + >>> test_weather_agent(agent) + ✨ All weather reporter tests passed! + """ + logger.debug("Starting weather reporter tests") + + # Test cities + test_cases = ["New York", "Miami", "Boston"] + + for city in test_cases: + logger.info(f"\nExecuting: Getting weather for {city}") + + # Get worker + worker = agent.get_worker("weather_worker") + + # Execute function + result = worker.execute_function("get_weather", {"city": city}) + logger.info(f"Result: {result}") + + # Verify result + assert result['status'] == 'success', f"Failed to get weather for {city}" + assert result['data']['city'] == city, f"Wrong city in response" + assert 'temperature' in result['data'], "No temperature in response" + assert 'condition' in result['data'], "No condition in response" + assert 'humidity' in result['data'], "No humidity in response" + assert 'clothing' in result['data'], "No clothing recommendation" + + logger.info("✓ Test passed") + + logger.info("\n✨ All weather reporter tests passed!") + +def main(): + """Main entry point for the weather agent example. + + This function creates a weather agent and runs tests to verify + its functionality. + + Raises: + ValueError: If VIRTUALS_API_KEY is not set + ValidationError: If agent configuration is invalid + APIError: If agent creation or API calls fail + """ + try: + # Create and test agent + agent = create_weather_agent() + test_weather_agent(agent) + + except Exception as e: + logger.error(f"Error running weather agent: {e}") + raise + +if __name__ == "__main__": + main() diff --git a/examples/example_weather_worker.py b/examples/example_weather_worker.py new file mode 100644 index 00000000..b30c7676 --- /dev/null +++ b/examples/example_weather_worker.py @@ -0,0 +1,234 @@ +""" +Example Weather Worker for the GAME SDK. + +This module demonstrates how to create a standalone worker that provides weather +information and recommendations. It shows how to: + +1. Define a worker's action space with custom functions +2. Handle API responses and errors gracefully +3. Provide helpful clothing recommendations +4. Maintain clean and testable code structure + +The weather worker supports: +- Getting current weather conditions +- Providing temperature in Celsius +- Suggesting appropriate clothing +- Handling multiple cities + +Example: + >>> from game_sdk.game.worker_config import WorkerConfig + >>> from game_sdk.game.worker import Worker + >>> + >>> worker_config = create_weather_worker_config("your_api_key") + >>> worker = Worker(worker_config) + >>> result = worker.execute_function("get_weather", {"city": "New York"}) + >>> print(result["message"]) + 'Current weather in New York: Sunny, 15°C, 60% humidity' + +Note: + This example uses a mock weather API for demonstration. In a production + environment, you would want to use a real weather service API. +""" + +import logging +from typing import Dict, Any, Optional +import requests +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function, Argument + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def get_weather(city: str) -> Dict[str, Any]: + """Get weather information for a given city. + + This function makes an API call to fetch current weather data and provides + clothing recommendations based on the temperature and conditions. + + Args: + city (str): Name of the city to get weather for + + Returns: + Dict[str, Any]: Weather information including: + - status: API call status ('success' or 'error') + - message: Human-readable weather description + - data: Detailed weather information including: + - city: City name + - temperature: Temperature in Celsius + - condition: Weather condition (e.g., 'Sunny', 'Cloudy') + - humidity: Humidity percentage + - clothing: Recommended clothing based on conditions + + Raises: + requests.RequestException: If the API call fails + + Example: + >>> result = get_weather("New York") + >>> print(result["data"]["clothing"]) + 'Light jacket' + """ + try: + # Make API call (using mock API for example) + response = requests.get('https://dylanburkey.com/assets/weather.json') + response.raise_for_status() + + # Process weather data (mock data for example) + weather_data = { + 'New York': {'temp': '15°C', 'condition': 'Sunny', 'humidity': '60%'}, + 'Miami': {'temp': '28°C', 'condition': 'Sunny', 'humidity': '70%'}, + 'Boston': {'temp': '10°C', 'condition': 'Cloudy', 'humidity': '65%'} + }.get(city, {'temp': '20°C', 'condition': 'Clear', 'humidity': '50%'}) + + # Get temperature as number for clothing recommendation + temp = int(weather_data['temp'].rstrip('°C')) + + # Determine appropriate clothing + if temp > 25: + clothing = 'Shorts and t-shirt' + elif temp > 15: + clothing = 'Light jacket' + else: + clothing = 'Sweater' + + return { + 'status': 'success', + 'message': f"Current weather in {city}: {weather_data['condition']}, " + f"{weather_data['temp']}°F, {weather_data['humidity']} humidity", + 'data': { + 'city': city, + 'temperature': weather_data['temp'], + 'condition': weather_data['condition'], + 'humidity': weather_data['humidity'], + 'clothing': clothing + } + } + + except requests.RequestException as e: + logger.error(f"Failed to get weather: {e}") + return { + 'status': 'error', + 'message': f"Failed to get weather for {city}", + 'data': None + } + +def get_worker_state(function_result: Optional[Dict], current_state: Optional[Dict]) -> Dict[str, Any]: + """Get the current state of the weather worker. + + This function maintains the worker's state between function calls, + tracking the number of requests and their outcomes. + + Args: + function_result: Result of the last executed function + current_state: Current worker state + + Returns: + Dict[str, Any]: Updated worker state including: + - requests: Total number of requests made + - successes: Number of successful requests + - failures: Number of failed requests + + Example: + >>> state = get_worker_state(None, None) + >>> print(state) + {'requests': 0, 'successes': 0, 'failures': 0} + """ + if current_state is None: + return {'requests': 0, 'successes': 0, 'failures': 0} + + if function_result and function_result.get('status') == 'success': + current_state['successes'] += 1 + elif function_result: + current_state['failures'] += 1 + + current_state['requests'] += 1 + return current_state + +def create_weather_worker_config(api_key: str) -> WorkerConfig: + """Create a configuration for the weather worker. + + This function sets up a WorkerConfig object that defines the worker's + behavior, available actions, and state management. + + Args: + api_key (str): API key for worker authentication + + Returns: + WorkerConfig: Configured weather worker + + Example: + >>> config = create_weather_worker_config("your_api_key") + >>> print(config.worker_description) + 'Provides weather information and clothing recommendations' + """ + # Create weather function + weather_fn = Function( + fn_name="get_weather", + fn_description="Get weather information and recommendations for a city", + executable=get_weather, + args=[ + Argument( + name="city", + type="string", + description="City to get weather for (e.g., New York, Miami, Boston)" + ) + ] + ) + + # Create and return worker config + return WorkerConfig( + id="weather_worker", + worker_description="Provides weather information and clothing recommendations", + get_state_fn=get_worker_state, + action_space=[weather_fn], + instruction="Fetch weather data and provide appropriate clothing recommendations", + api_key=api_key + ) + +def main(): + """Run the weather worker example.""" + try: + # Get API key from environment + api_key = os.getenv("VIRTUALS_API_KEY") + if not api_key: + raise ValueError("VIRTUALS_API_KEY environment variable not set") + + # Create the agent + agent = Agent( + api_key=api_key, + name="Weather Assistant", + agent_goal="provide weather information and recommendations", + agent_description="A helpful weather assistant that provides weather information and clothing recommendations", + get_agent_state_fn=lambda x, y: {"status": "ready"} + ) + + # Add the weather worker + worker_config = create_weather_worker_config(api_key) + agent.add_worker(worker_config) + agent.compile() + + # Example: Test the worker with a query + logger.info("🌤️ Testing weather worker...") + worker = agent.get_worker(worker_config.id) + + # Test with New York + result = worker.execute_function( + "get_weather", + {"city": "New York"} + ) + + if result: + logger.info("✅ Worker response received") + logger.info(f"Response: {result}") + else: + logger.error("❌ No response received from worker") + + except Exception as e: + logger.error(f"❌ Error running weather worker: {e}") + raise + +if __name__ == "__main__": + main() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..b3f34bcb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=7.0.0 +responses>=0.23.0 +pytest-cov>=4.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..8379f0fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests>=2.26.0 +pydantic>=2.10.5 +typing-extensions>=4.0.0 +tenacity>=8.0.0 diff --git a/src/game_sdk/game/agent.py b/src/game_sdk/game/agent.py index 31300b80..a2c002b4 100644 --- a/src/game_sdk/game/agent.py +++ b/src/game_sdk/game/agent.py @@ -1,248 +1,277 @@ -from typing import List, Optional, Callable, Dict +""" +Agent module for the GAME SDK. + +This module provides the core Agent and supporting classes for creating and managing +GAME agents. It handles agent state management, worker coordination, and session tracking. + +Key Components: +- Session: Manages agent session state +- Agent: Main agent class that coordinates workers and handles state + +Example: + # Create a simple agent + agent = Agent( + api_key="your_api_key", + name="My Agent", + agent_description="A helpful agent", + agent_goal="To assist users", + get_agent_state_fn=lambda x, y: {"status": "ready"} + ) +""" + +from typing import List, Optional, Callable, Dict, Any import uuid from game_sdk.game.worker import Worker +from game_sdk.game.worker_config import WorkerConfig from game_sdk.game.custom_types import Function, FunctionResult, FunctionResultStatus, ActionResponse, ActionType -from game_sdk.game.api import GAMEClient -from game_sdk.game.api_v2 import GAMEClientV2 +from game_sdk.game.utils import create_agent, create_workers, post +from game_sdk.game.exceptions import ValidationError + class Session: + """Manages agent session state. + + A Session represents a single interaction context with an agent. + It maintains session-specific data like IDs and function results. + + Attributes: + id (str): Unique session identifier + function_result (Optional[FunctionResult]): Result of the last executed function + + Example: + session = Session() + print(f"Session ID: {session.id}") + """ + def __init__(self): + """Initialize a new session with a unique ID.""" self.id = str(uuid.uuid4()) self.function_result: Optional[FunctionResult] = None def reset(self): + """Reset the session state. + + Creates a new session ID and clears any existing function results. + """ self.id = str(uuid.uuid4()) self.function_result = None -class WorkerConfig: - def __init__(self, - id: str, - worker_description: str, - get_state_fn: Callable, - action_space: List[Function], - instruction: Optional[str] = "", - ): - - self.id = id # id or name of the worker - # worker description for the TASK GENERATOR (to give appropriate tasks) [NOT FOR THE WORKER ITSELF - WORKER WILL STILL USE AGENT DESCRIPTION] - self.worker_description = worker_description - self.instruction = instruction - self.get_state_fn = get_state_fn - - # setup get state function with the instructions - self.get_state_fn = lambda function_result, current_state: { - "instructions": self.instruction, # instructions are set up in the state - # places the rest of the output of the get_state_fn in the state - **get_state_fn(function_result, current_state), - } - - self.action_space: Dict[str, Function] = { - f.get_function_def()["fn_name"]: f for f in action_space - } - - class Agent: - def __init__(self, - api_key: str, - name: str, - agent_goal: str, - agent_description: str, - get_agent_state_fn: Callable, - workers: Optional[List[WorkerConfig]] = None, - ): - - if api_key.startswith("apt-"): - self.client = GAMEClientV2(api_key) - else: - self.client = GAMEClient(api_key) + """Main agent class for the GAME SDK. + + The Agent class coordinates workers and manages the overall agent state. + It handles agent creation, worker management, and state transitions. + + Attributes: + name (str): Agent name + agent_goal (str): Primary goal of the agent + agent_description (str): Description of agent capabilities + workers (Dict[str, WorkerConfig]): Configured workers + agent_state (dict): Current agent state + agent_id (str): Unique identifier for the agent + + Args: + api_key (str): API key for authentication + name (str): Agent name + agent_goal (str): Primary goal of the agent + agent_description (str): Description of agent capabilities + get_agent_state_fn (Callable): Function to get agent state + workers (Optional[List[WorkerConfig]]): List of worker configurations + + Raises: + ValueError: If API key is not set + ValidationError: If state function returns invalid data + APIError: If agent creation fails + AuthenticationError: If API key is invalid + + Example: + agent = Agent( + api_key="your_api_key", + name="Support Agent", + agent_goal="Help users with issues", + agent_description="A helpful support agent", + get_agent_state_fn=get_state + ) + """ + def __init__( + self, + api_key: str, + name: str, + agent_goal: str, + agent_description: str, + get_agent_state_fn: Callable, + workers: Optional[List[WorkerConfig]] = None, + ): + self._base_url: str = "https://api.virtuals.io" self._api_key: str = api_key - # checks + # Validate API key if not self._api_key: raise ValueError("API key not set") - # initialize session + # Initialize session self._session = Session() + # Set basic agent properties self.name = name self.agent_goal = agent_goal self.agent_description = agent_description - # set up workers + # Set up workers if workers is not None: self.workers = {w.id: w for w in workers} else: self.workers = {} self.current_worker_id = None - # get agent/task generator state function + # Set up agent state function self.get_agent_state_fn = get_agent_state_fn - # initialize and set up agent states - self.agent_state = self.get_agent_state_fn(None, None) - - # create agent - self.agent_id = self.client.create_agent( - self.name, self.agent_description, self.agent_goal + # Validate state function + initial_state = self.get_agent_state_fn(None, None) + if not isinstance(initial_state, dict): + raise ValidationError("State function must return a dictionary") + + # Initialize agent state + self.agent_state = initial_state + + # Create agent instance + self.agent_id = create_agent( + self._base_url, + self._api_key, + self.name, + self.agent_description, + self.agent_goal ) def compile(self): - """ Compile the workers for the agent - i.e. set up task generator""" - if not self.workers: - raise ValueError("No workers added to the agent") - - workers_list = list(self.workers.values()) + """Compile the agent by setting up its workers. - self._map_id = self.client.create_workers(workers_list) - self.current_worker_id = next(iter(self.workers.values())).id + This method initializes all workers and creates the necessary + task generator configurations. - # initialize and set up worker states - worker_states = {} - for worker in workers_list: - dummy_function_result = FunctionResult( - action_id="", - action_status=FunctionResultStatus.DONE, - feedback_message="", - info={}, - ) - worker_states[worker.id] = worker.get_state_fn( - dummy_function_result, self.agent_state) + Raises: + ValueError: If no workers are configured + ValidationError: If worker state functions return invalid data + APIError: If worker creation fails - self.worker_states = worker_states + Example: + agent.compile() + """ + if not self.workers: + raise ValueError("No workers configured") - return self._map_id + # Create worker instances + create_workers( + self._base_url, + self._api_key, + list(self.workers.values()) + ) def reset(self): - """ Reset the agent session""" + """Reset the agent session. + + Creates a new session ID and clears any existing function results. + """ self._session.reset() def add_worker(self, worker_config: WorkerConfig): - """Add worker to worker dict for the agent""" + """Add a worker to the agent's worker dictionary. + + Args: + worker_config (WorkerConfig): Worker configuration to add + + Returns: + Dict[str, WorkerConfig]: Updated worker dictionary + """ self.workers[worker_config.id] = worker_config return self.workers def get_worker_config(self, worker_id: str): - """Get worker config from worker dict""" + """Get a worker configuration from the agent's worker dictionary. + + Args: + worker_id (str): ID of the worker to retrieve + + Returns: + WorkerConfig: Worker configuration for the given ID + """ return self.workers[worker_id] def get_worker(self, worker_id: str): - """Initialize a working interactable standalone worker""" + """Get a worker instance from the agent's worker dictionary. + + Args: + worker_id (str): ID of the worker to retrieve + + Returns: + Worker: Worker instance for the given ID + """ worker_config = self.get_worker_config(worker_id) - return Worker( - api_key=self._api_key, - # THIS DESCRIPTION IS THE AGENT DESCRIPTION/CHARACTER CARD - WORKER DESCRIPTION IS ONLY USED FOR THE TASK GENERATOR - description=self.agent_description, - instruction=worker_config.instruction, - get_state_fn=worker_config.get_state_fn, - action_space=worker_config.action_space, - ) + return Worker(worker_config) def _get_action( self, function_result: Optional[FunctionResult] = None - ) -> ActionResponse: - - # dummy function result if None is provided - for get_state_fn to take the same input all the time - if function_result is None: - function_result = FunctionResult( - action_id="", - action_status=FunctionResultStatus.DONE, - feedback_message="", - info={}, - ) - - # set up payload - data = { - "location": self.current_worker_id, - "map_id": self._map_id, - "environment": self.worker_states[self.current_worker_id], - "functions": [ - f.get_function_def() - for f in self.workers[self.current_worker_id].action_space.values() - ], - "events": {}, - "agent_state": self.agent_state, - "current_action": ( - function_result.model_dump( - exclude={'info'}) if function_result else None - ), - "version": "v2", - } - - # make API call - response = self.client.get_agent_action( - agent_id=self.agent_id, - data=data, + ): + """Get the next action from the GAME API. + + Args: + function_result (Optional[FunctionResult]): Result of the last executed function + + Returns: + ActionResponse: Next action from the GAME API + """ + # Update agent state + self.agent_state = self.get_agent_state_fn(function_result, self.agent_state) + + # Get next action from API + response = post( + self._base_url, + self._api_key, + endpoint="/v2/actions", + data={ + "agent_id": self.agent_id, + "session_id": self._session.id, + "state": self.agent_state, + "function_result": function_result.to_dict() if function_result else None + } ) - return ActionResponse.model_validate(response) + return ActionResponse(**response) def step(self): - - # get next task/action from GAME API - action_response = self._get_action(self._session.function_result) - action_type = action_response.action_type - - print("#" * 50) - print("STEP") - print(f"Current Task: {action_response.agent_state.current_task}") - print(f"Action response: {action_response}") - print(f"Action type: {action_type}") - - # if new task is updated/generated - if ( - action_response.agent_state.hlp - and action_response.agent_state.hlp.change_indicator - ): - print("New task generated") - print(f"Task: {action_response.agent_state.current_task}") - - # execute action - if action_type in [ - ActionType.CALL_FUNCTION, - ActionType.CONTINUE_FUNCTION, - ]: - print(f"Action Selected: {action_response.action_args['fn_name']}") - print(f"Action Args: {action_response.action_args['args']}") - - if not action_response.action_args: - raise ValueError("No function information provided by GAME") - - self._session.function_result = ( - self.workers[self.current_worker_id] - .action_space[action_response.action_args["fn_name"]] - .execute(**action_response.action_args) - ) - - print(f"Function result: {self._session.function_result}") - - # update worker states - updated_worker_state = self.workers[self.current_worker_id].get_state_fn( - self._session.function_result, self.worker_states[self.current_worker_id]) - self.worker_states[self.current_worker_id] = updated_worker_state - - elif action_response.action_type == ActionType.WAIT: - print("Task ended completed or ended (not possible wiht current actions)") - - elif action_response.action_type == ActionType.GO_TO: - if not action_response.action_args: - raise ValueError("No location information provided by GAME") - - next_worker = action_response.action_args["location_id"] - print(f"Next worker selected: {next_worker}") - self.current_worker_id = next_worker - - else: - raise ValueError( - f"Unknown action type: {action_response.action_type}") - - # update agent state - self.agent_state = self.get_agent_state_fn( - self._session.function_result, self.agent_state) + """Take a step in the agent's workflow. + + This method gets the next action from the GAME API, executes it, + and updates the agent's state. + """ + # Get next action + action = self._get_action(self._session.function_result) + + # Execute action + if action.action_type == ActionType.FUNCTION: + # Get worker for function execution + if action.worker_id: + worker = self.get_worker(action.worker_id) + if not worker: + raise ValueError(f"Worker {action.worker_id} not found") + + # Execute function + function_result = worker.execute_function( + action.function_name, + action.function_args + ) + + # Update session with function result + self._session.function_result = function_result def run(self): - self._session = Session() + """Run the agent's workflow. + + This method starts the agent's workflow and continues until stopped. + """ while True: self.step() diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py new file mode 100644 index 00000000..5fc710f8 --- /dev/null +++ b/src/game_sdk/game/api_client.py @@ -0,0 +1,155 @@ +""" +API client module for the GAME SDK. + +This module provides a dedicated API client for making requests to the GAME API, +handling authentication, errors, and response parsing consistently. +""" + +import requests +from typing import Dict, Any, Optional +from game_sdk.game.config import config +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError +from game_sdk.game.custom_types import ActionResponse, FunctionResult +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception + + +def should_retry(exception): + """Determine if we should retry the request based on the exception type.""" + if isinstance(exception, (AuthenticationError, ValidationError)): + return False + return isinstance(exception, (APIError, requests.exceptions.RequestException)) + + +class GameAPIClient: + """Client for interacting with the GAME API. + + This class handles all API communication, including authentication, + request retries, and error handling. + + Attributes: + api_key (str): API key for authentication + base_url (str): Base URL for API requests + session (requests.Session): Reusable session for API requests + """ + + def __init__(self, api_key: Optional[str] = None): + """Initialize the API client. + + Args: + api_key (str): API key for authentication + + Raises: + ValueError: If API key is not provided + """ + if not api_key: + raise ValueError("API key is required") + + self.api_key = api_key + self.base_url = config.api_url + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }) + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=4, max=10), + retry=retry_if_exception(should_retry) + ) + def make_request( + self, + method: str, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Make an HTTP request to the API. + + Args: + method (str): HTTP method (GET, POST, etc.) + endpoint (str): API endpoint + data (Optional[Dict[str, Any]], optional): Request body. Defaults to None. + params (Optional[Dict[str, Any]], optional): Query parameters. Defaults to None. + + Raises: + AuthenticationError: If authentication fails + ValidationError: If request validation fails + APIError: For other API-related errors + + Returns: + Dict[str, Any]: API response data + """ + url = f"{self.base_url}/{endpoint.lstrip('/')}" + + try: + response = self.session.request( + method=method, + url=url, + json=data, + params=params + ) + + response.raise_for_status() + return response.json() + + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + # Don't retry auth errors + raise AuthenticationError("Authentication failed") from e + elif response.status_code == 422: + # Don't retry validation errors + raise ValidationError("Invalid request data") from e + else: + # Retry other HTTP errors + raise APIError(f"API request failed: {str(e)}") from e + except requests.exceptions.RequestException as e: + # Retry network errors + raise APIError(f"Request failed: {str(e)}") from e + + def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make a GET request. + + Args: + endpoint (str): API endpoint + params (Optional[Dict[str, Any]], optional): Query parameters. Defaults to None. + + Returns: + Dict[str, Any]: API response data + """ + return self.make_request("GET", endpoint, params=params) + + def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Make a POST request. + + Args: + endpoint (str): API endpoint + data (Dict[str, Any]): Request body + + Returns: + Dict[str, Any]: API response data + """ + return self.make_request("POST", endpoint, data=data) + + def put(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Make a PUT request. + + Args: + endpoint (str): API endpoint + data (Dict[str, Any]): Request body + + Returns: + Dict[str, Any]: API response data + """ + return self.make_request("PUT", endpoint, data=data) + + def delete(self, endpoint: str) -> Dict[str, Any]: + """Make a DELETE request. + + Args: + endpoint (str): API endpoint + + Returns: + Dict[str, Any]: API response data + """ + return self.make_request("DELETE", endpoint) diff --git a/src/game_sdk/game/config.py b/src/game_sdk/game/config.py new file mode 100644 index 00000000..043bc57a --- /dev/null +++ b/src/game_sdk/game/config.py @@ -0,0 +1,29 @@ +""" +Configuration module for the GAME SDK. + +This module provides centralized configuration management for the SDK. +""" + +from dataclasses import dataclass + + +@dataclass +class Config: + """Configuration class for the GAME SDK.""" + api_url: str = "https://api.virtuals.io" + version: str = "v2" + default_timeout: int = 30 + + @property + def base_url(self) -> str: + """Get the base URL for API calls.""" + return self.api_url + + @property + def version_prefix(self) -> str: + """Get the versioned API prefix.""" + return f"{self.api_url}/{self.version}" + + +# Global configuration instance +config = Config() diff --git a/src/game_sdk/game/custom_types.py b/src/game_sdk/game/custom_types.py index 9b369b45..795b994f 100644 --- a/src/game_sdk/game/custom_types.py +++ b/src/game_sdk/game/custom_types.py @@ -1,126 +1,195 @@ -from typing import Any, Dict, Optional, List, Union, Sequence, Callable, Tuple -from pydantic import BaseModel, Field +"""Custom types and data structures for the GAME SDK. + +This module defines core data structures and types used in the SDK.""" + from enum import Enum -from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union, Callable +from pydantic import BaseModel class Argument(BaseModel): + """Defines an argument for a function. + + Attributes: + name: Name of the argument + description: Description of what the argument does + type: Type of the argument (string, integer, etc.) + optional: Whether the argument is optional + """ name: str description: str - type: Optional[Union[List[str], str]] = None - optional: Optional[bool] = False + type: Union[str, List[str]] + optional: bool = False + class FunctionResultStatus(str, Enum): + """Status of a function execution. + + Attributes: + DONE: Function completed successfully + FAILED: Function failed to complete + """ DONE = "done" FAILED = "failed" + def __str__(self) -> str: + """Convert enum value to string.""" + return self.value + + class FunctionResult(BaseModel): + """Represents the result of a function execution. + + Attributes: + action_id: Unique identifier for the action + action_status: Status of the function execution + feedback_message: Human-readable message about the execution + info: Additional information about the execution + """ action_id: str action_status: FunctionResultStatus - feedback_message: Optional[str] = None - info: Optional[Dict[str, Any]] = None + feedback_message: str + info: Dict[str, Any] + class Function(BaseModel): + """Defines a function that can be executed by a worker. + + Attributes: + fn_name: Name of the function + fn_description: Description of what the function does + args: List of arguments the function accepts + executable: Optional callable that implements the function + """ fn_name: str fn_description: str args: List[Argument] - hint: Optional[str] = None - - # Make executable required but with a default value - executable: Callable[..., Tuple[FunctionResultStatus, str, dict]] = Field( - default_factory=lambda: Function._default_executable - ) - - def get_function_def(self): - return self.model_dump(exclude={'executable'}) - - @staticmethod - def _default_executable(**kwargs) -> Tuple[FunctionResultStatus, str]: - """Default executable that does nothing""" - return FunctionResultStatus.DONE, "Default implementation - no action taken", {} - - def execute(self, **kwds: Any) -> FunctionResult: - """Execute the function using arguments from GAME action.""" - fn_id = kwds.get('fn_id') - args = kwds.get('args', {}) + executable: Optional[Callable] = None - try: - # Extract values from the nested dictionary structure - processed_args = {} - for arg_name, arg_value in args.items(): - if isinstance(arg_value, dict) and 'value' in arg_value: - processed_args[arg_name] = arg_value['value'] - else: - processed_args[arg_name] = arg_value - - # print("Processed args: ", processed_args) - # execute the function provided - status, feedback, info = self.executable(**processed_args) + def execute(self, fn_id: str, args: Dict[str, Any]) -> FunctionResult: + """Execute the function using provided arguments. + Args: + fn_id: Unique identifier for this function execution + args: Dictionary of argument names to values + + Returns: + FunctionResult containing execution status and output + """ + if not self.executable: + return FunctionResult( + action_id=fn_id, + action_status=FunctionResultStatus.FAILED, + feedback_message="No executable defined for function", + info={} + ) + + try: + status, msg, info = self.executable(**args) return FunctionResult( action_id=fn_id, action_status=status, - feedback_message=feedback, - info=info, + feedback_message=msg, + info=info ) except Exception as e: return FunctionResult( action_id=fn_id, action_status=FunctionResultStatus.FAILED, feedback_message=f"Error executing function: {str(e)}", - info={}, + info={} ) -# Different ActionTypes returned by the GAME API -class ActionType(Enum): + +class ActionType(str, Enum): + """Type of action returned by the GAME API. + + Attributes: + CALL_FUNCTION: Execute a function + CONTINUE_FUNCTION: Continue executing a function + WAIT: Wait for a condition + GO_TO: Navigate to a different state + """ CALL_FUNCTION = "call_function" CONTINUE_FUNCTION = "continue_function" WAIT = "wait" GO_TO = "go_to" -## set of different data structures required by the ActionResponse returned from GAME ## -@dataclass(frozen=True) -class HLPResponse: +class HLPResponse(BaseModel): + """High-level planning response from the GAME API. + + Attributes: + plan_id: Unique identifier for the plan + observation_reflection: Reflection on current observations + plan: List of planned steps + plan_reasoning: Reasoning behind the plan + current_state_of_execution: Current execution state + change_indicator: Indicator of plan changes + log: Log of events + """ plan_id: str observation_reflection: str - plan: Sequence[str] + plan: List[str] plan_reasoning: str current_state_of_execution: str change_indicator: Optional[str] = None - log: Sequence[dict] = field(default_factory=list) + log: Optional[List[Dict[str, Any]]] = None + +class LLPResponse(BaseModel): + """Low-level planning response from the GAME API. -@dataclass(frozen=True) -class LLPResponse: + Attributes: + plan_id: Unique identifier for the plan + plan_reasoning: Reasoning behind the plan + situation_analysis: Analysis of the current situation + plan: List of planned steps + change_indicator: Indicator of plan changes + reflection: Reflection on the plan + """ plan_id: str plan_reasoning: str situation_analysis: str - plan: Sequence[str] + plan: List[str] change_indicator: Optional[str] = None reflection: Optional[str] = None -@dataclass(frozen=True) -class CurrentTaskResponse: +class CurrentTaskResponse(BaseModel): + """Response containing information about the current task. + + Attributes: + task: Description of the current task + task_reasoning: Reasoning behind the task + location_id: Identifier for the task location + llp: Low-level planning response + """ task: str task_reasoning: str - location_id: str = field(default="*not provided*") + location_id: Optional[str] = None llp: Optional[LLPResponse] = None -@dataclass(frozen=True) -class AgentStateResponse: +class AgentStateResponse(BaseModel): + """Response containing the current state of an agent. + + Attributes: + hlp: High-level planning state + current_task: Current task state + """ hlp: Optional[HLPResponse] = None current_task: Optional[CurrentTaskResponse] = None -# ActionResponse format returned from GAME API call + class ActionResponse(BaseModel): - """ - Response format from the GAME API when selecting an Action + """Response containing an action to be taken. + + Attributes: + action_type: Type of action to take + agent_state: Current state of the agent + action_args: Arguments for the action """ action_type: ActionType agent_state: AgentStateResponse - action_args: Optional[Dict[str, Any]] = None - + action_args: Dict[str, Any] diff --git a/src/game_sdk/game/exceptions.py b/src/game_sdk/game/exceptions.py new file mode 100644 index 00000000..a854299f --- /dev/null +++ b/src/game_sdk/game/exceptions.py @@ -0,0 +1,98 @@ +""" +Custom exceptions for the GAME SDK. + +This module defines the exception hierarchy used throughout the GAME SDK. +All exceptions inherit from the base GameSDKError class, providing a consistent +error handling interface. + +Exception Hierarchy: + GameSDKError + ├── ValidationError + ├── APIError + │ └── AuthenticationError + └── StateError + +Example: + try: + agent = Agent(api_key="invalid_key", ...) + except AuthenticationError as e: + print(f"Authentication failed: {e}") + except ValidationError as e: + print(f"Validation failed: {e}") + except APIError as e: + print(f"API error: {e}") +""" + +class GameSDKError(Exception): + """Base exception class for all GAME SDK errors. + + This is the parent class for all custom exceptions in the SDK. + It inherits from the built-in Exception class and serves as a + way to catch all SDK-specific exceptions. + + Example: + try: + # SDK operation + except GameSDKError as e: + print(f"SDK operation failed: {e}") + """ + pass + +class ValidationError(GameSDKError): + """Raised when input validation fails. + + This exception is raised when input parameters fail validation, + such as empty strings, invalid types, or invalid formats. + + Args: + message (str): Human-readable error description + errors (dict, optional): Dictionary containing validation errors + + Example: + raise ValidationError("Name cannot be empty", {"name": "required"}) + """ + def __init__(self, message="Validation failed", errors=None): + super().__init__(message) + self.errors = errors or {} + +class APIError(GameSDKError): + """Raised when API requests fail. + + This exception is raised for any API-related errors, including network + issues, server errors, and invalid responses. + + Args: + message (str): Human-readable error description + status_code (int, optional): HTTP status code if applicable + response_json (dict, optional): Raw response data from the API + + Example: + raise APIError("Failed to create agent", status_code=500) + """ + def __init__(self, message="API request failed", status_code=None, response_json=None): + super().__init__(message) + self.status_code = status_code + self.response_json = response_json + +class AuthenticationError(APIError): + """Raised when authentication fails. + + This exception is raised for authentication-specific failures, + such as invalid API keys or expired tokens. + + Example: + raise AuthenticationError("Invalid API key") + """ + pass + +class StateError(GameSDKError): + """Raised when there are issues with state management. + + This exception is raised when there are problems with agent or + worker state management, such as invalid state transitions or + corrupted state data. + + Example: + raise StateError("Invalid state transition") + """ + pass diff --git a/src/game_sdk/game/utils.py b/src/game_sdk/game/utils.py new file mode 100644 index 00000000..41a7b867 --- /dev/null +++ b/src/game_sdk/game/utils.py @@ -0,0 +1,286 @@ +""" +Utility functions for the GAME SDK. + +This module provides core utility functions for interacting with the GAME API, +including authentication, agent creation, and worker management. + +The module handles: +- API authentication and token management +- HTTP request handling with proper error handling +- Agent and worker creation +- Response parsing and validation + +Example: + # Create an agent + agent_id = create_agent( + base_url="https://api.virtuals.io", + api_key="your_api_key", + name="My Agent", + description="A helpful agent", + goal="To assist users" + ) +""" + +import json +import requests +from typing import Dict, Any, Optional, List +from requests.exceptions import ConnectionError, Timeout, JSONDecodeError +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError + + +def post( + base_url: str, + api_key: str, + endpoint: str, + data: Dict[str, Any] = None, + params: Dict[str, Any] = None, + timeout: int = 30 +) -> Dict[str, Any]: + """Make a POST request to the GAME API. + + This function handles all POST requests to the API, including proper + error handling, response validation, and authentication. + + Args: + base_url (str): Base URL for the API + api_key (str): API key for authentication + endpoint (str): API endpoint to call + data (Dict[str, Any], optional): Request payload + params (Dict[str, Any], optional): URL parameters + timeout (int, optional): Request timeout in seconds. Defaults to 30. + + Returns: + Dict[str, Any]: Parsed response data from the API + + Raises: + AuthenticationError: If API key is invalid + ValidationError: If request data is invalid + APIError: For other API-related errors including network issues + + Example: + response = post( + base_url="https://api.virtuals.io", + api_key="your_api_key", + endpoint="/v2/agents", + data={"name": "My Agent"} + ) + """ + try: + response = requests.post( + f"{base_url}{endpoint}", + json=data, + params=params, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout + ) + + if response.status_code == 401: + raise AuthenticationError("Invalid API key") + elif response.status_code == 400: + raise ValidationError(response.json().get("error", {}).get("message", "Invalid request")) + elif response.status_code == 429: + raise APIError("Rate limit exceeded", status_code=429) + elif response.status_code >= 500: + raise APIError("Server error", status_code=response.status_code) + elif response.status_code == 204: + # Handle no content response + return {"id": data.get("name", "default_id")} + + try: + if response.text: + return response.json().get("data", {}) + return {} + except json.JSONDecodeError: + raise APIError("Invalid JSON response") + + except requests.exceptions.ConnectionError as e: + raise APIError(f"Connection failed: {str(e)}") + except requests.exceptions.Timeout as e: + raise APIError(f"Connection timeout: {str(e)}") + except requests.exceptions.RequestException as e: + raise APIError(f"Request failed: {str(e)}") + + +def create_agent( + base_url: str, + api_key: str, + name: str, + description: str, + goal: str +) -> str: + """Create a new agent instance. + + This function creates a new agent with the specified parameters. + An agent can be either a standalone agent or one with a task generator. + + Args: + base_url (str): Base URL for the API + api_key (str): API key for authentication + name (str): Name of the agent + description (str): Description of the agent's capabilities + goal (str): The agent's primary goal or purpose + + Returns: + str: ID of the created agent + + Raises: + ValidationError: If name is empty or invalid + APIError: If agent creation fails + AuthenticationError: If API key is invalid + + Example: + agent_id = create_agent( + base_url="https://api.virtuals.io", + api_key="your_api_key", + name="Support Agent", + description="Helps users with support requests", + goal="To provide excellent customer support" + ) + """ + if not isinstance(name, str) or not name.strip(): + raise ValidationError("Name cannot be empty") + + create_agent_response = post( + base_url, + api_key, + endpoint="/v2/agents", + data={ + "name": name, + "description": description, + "goal": goal, + } + ) + + agent_id = create_agent_response.get("id") + if not agent_id: + raise APIError("Failed to create agent: missing id in response") + + return agent_id + + +def create_workers( + base_url: str, + api_key: str, + workers: List[Any] +) -> str: + """Create worker instances for an agent. + + This function creates one or more workers that can be assigned tasks + by the agent. Each worker has its own description and action space. + + Args: + base_url (str): Base URL for the API + api_key (str): API key for authentication + workers (List[Any]): List of worker configurations + + Returns: + str: ID of the created worker map + + Raises: + APIError: If worker creation fails + ValidationError: If worker configuration is invalid + AuthenticationError: If API key is invalid + + Example: + worker_map_id = create_workers( + base_url="https://api.virtuals.io", + api_key="your_api_key", + workers=[worker_config1, worker_config2] + ) + """ + locations = [] + for worker in workers: + location = { + "name": worker.id, + "description": worker.worker_description, + "functions": [ + { + "name": fn.fn_name, + "description": fn.fn_description, + "args": fn.args + } + for fn in worker.functions + ] + } + locations.append(location) + + response = post( + base_url, + api_key, + endpoint="/v2/maps", + data={"locations": locations} + ) + + return response["id"] + + +def validate_response(response: Dict[str, Any]) -> None: + """Validate API response format. + + Args: + response (Dict[str, Any]): Response from API + + Raises: + ValueError: If response is invalid + """ + if response is None: + raise ValueError("Response cannot be None") + if not isinstance(response, dict): + raise ValueError("Response must be a dictionary") + if not response: + raise ValueError("Response cannot be empty") + if "status" in response and response["status"] == "error": + raise ValueError("Response indicates error status") + if "data" in response and response["data"] is None: + raise ValueError("Response data cannot be None") + + +def format_endpoint(endpoint: str) -> str: + """Format API endpoint. + + Args: + endpoint (str): Endpoint to format + + Returns: + str: Formatted endpoint + """ + endpoint = endpoint.strip("/") + return f"/{endpoint}" if endpoint else "/" + + +def merge_params( + base_params: Optional[Dict[str, Any]] = None, + additional_params: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Merge two parameter dictionaries. + + Args: + base_params (Optional[Dict[str, Any]], optional): Base parameters. Defaults to None. + additional_params (Optional[Dict[str, Any]], optional): Additional parameters. Defaults to None. + + Returns: + Dict[str, Any]: Merged parameters + """ + params = base_params.copy() if base_params else {} + if additional_params: + params.update(additional_params) + return params + + +def parse_api_error(error_response: Dict[str, Any]) -> str: + """Parse error message from API response. + + Args: + error_response (Dict[str, Any]): Error response from API + + Returns: + str: Parsed error message + """ + if "error" in error_response: + error = error_response["error"] + if isinstance(error, dict): + return error.get("message") or error.get("detail", "Unknown error") + return str(error) + elif "message" in error_response: + return str(error_response["message"]) + return "Unknown error" diff --git a/src/game_sdk/game/worker.py b/src/game_sdk/game/worker.py index a605ef41..fb0ab1a3 100644 --- a/src/game_sdk/game/worker.py +++ b/src/game_sdk/game/worker.py @@ -1,166 +1,87 @@ -from typing import Any, Callable, Dict, Optional, List -from game_sdk.game.custom_types import Function, FunctionResult, FunctionResultStatus, ActionResponse, ActionType -from game_sdk.game.api import GAMEClient -from game_sdk.game.api_v2 import GAMEClientV2 +"""Worker module for the GAME SDK. -class Worker: - """ - A interactable worker agent, that can autonomously complete tasks with its available functions when given a task - """ +Provides the Worker class that executes functions and manages state. +""" - def __init__( - self, - api_key: str, - description: str, # description of the worker/character card (PROMPT) - get_state_fn: Callable, - action_space: List[Function], - # specific additional instruction for the worker (PROMPT) - instruction: Optional[str] = "", - ): - - if api_key.startswith("apt-"): - self.client = GAMEClientV2(api_key) - else: - self.client = GAMEClient(api_key) - - self._api_key: str = api_key - - # checks - if not self._api_key: - raise ValueError("API key not set") - - self.description: str = description - self.instruction: str = instruction - - # setup get state function and initial state - self.get_state_fn = lambda function_result, current_state: { - "instructions": self.instruction, # instructions are set up in the state - # places the rest of the output of the get_state_fn in the state - **get_state_fn(function_result, current_state), - } - dummy_function_result = FunctionResult( - action_id="", - action_status=FunctionResultStatus.DONE, - feedback_message="", - info={}, - ) - # get state - self.state = self.get_state_fn(dummy_function_result, None) - - # # setup action space (functions/tools available to the worker) - # check action space type - if not a dict - if not isinstance(action_space, dict): - self.action_space = { - f.get_function_def()["fn_name"]: f for f in action_space} - else: - self.action_space = action_space - - # initialize an agent instance for the worker - self._agent_id: str = self.client.create_agent( - "StandaloneWorker", self.description, "N/A" - ) +from typing import Dict, Any +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import FunctionResult, FunctionResultStatus - # persistent variables that is maintained through the worker running - # task ID for everytime you provide/update the task (i.e. ask the agent to do something) - self._submission_id: Optional[str] = None - # current response from the Agent - self._function_result: Optional[FunctionResult] = None - def set_task(self, task: str): - """ - Sets the task for the agent - """ - set_task_response = self.client.set_worker_task(self._agent_id, task) - # response_json = set_task_response.json() +class Worker: + """ + A worker that can execute functions and manage state. - # if set_task_response.status_code != 200: - # raise ValueError(f"Failed to assign task: {response_json}") + Attributes: + config: Configuration for the worker + state: Current state of the worker + """ - # task ID - self._submission_id = set_task_response["submission_id"] + def __init__(self, config: WorkerConfig): + """ + Initialize a new Worker instance. - return self._submission_id + Args: + config: Configuration defining worker behavior + """ + self.config = config + self.state = config.state or {} - def _get_action( + def execute_function( self, - # results of the previous action (if any) - function_result: Optional[FunctionResult] = None - ) -> ActionResponse: + fn_name: str, + fn_id: str, + args: Dict[str, Any] + ) -> Dict[str, Any]: """ - Gets the agent action from the GAME API - """ - # dummy function result if None is provided - for get_state_fn to take the same input all the time - if function_result is None: - function_result = FunctionResult( - action_id="", - action_status=FunctionResultStatus.DONE, - feedback_message="", - info={}, - ) - # set up data payload - data = { - "environment": self.state, # state (updated state) - "functions": [ - f.get_function_def() for f in self.action_space.values() # functions available - ], - "action_result": ( - function_result.model_dump( - exclude={'info'}) if function_result else None - ), - } + Execute a function and update worker state. - # make API call - response = self.client.get_worker_action( - self._agent_id, - self._submission_id, - data - ) - - return ActionResponse.model_validate(response) + Args: + fn_name: Name of function to execute + fn_id: Unique identifier for this execution + args: Arguments to pass to the function - def step(self): + Returns: + Dict containing execution result and updated state """ - Execute the next step in the task - requires a task ID (i.e. task ID) - """ - if not self._submission_id: - raise ValueError("No task set") - - # get action from GAME API (Agent) - action_response = self._get_action(self._function_result) - action_type = action_response.action_type - - print(f"Action response: {action_response}") - print(f"Action type: {action_type}") - - # execute action - if action_type == ActionType.CALL_FUNCTION: - if not action_response.action_args: - raise ValueError("No function information provided by GAME") - - self._function_result = self.action_space[ - action_response.action_args["fn_name"] - ].execute(**action_response.action_args) - - print(f"Function result: {self._function_result}") - - # update state - self.state = self.get_state_fn(self._function_result, self.state) - - elif action_response.action_type == ActionType.WAIT: - print("Task completed or ended (not possible)") - self._submission_id = None + # Get function from config + fn = next( + (f for f in self.config.functions if f.fn_name == fn_name), + None + ) + if not fn: + return { + "result": FunctionResult( + action_id=fn_id, + action_status=FunctionResultStatus.FAILED, + feedback_message=f"Function {fn_name} not found", + info={} + ), + "state": self.state + } + + # Execute function + result = fn.execute(fn_id=fn_id, args=args) + + # Update state + self.state = self._update_state(result) + + return { + "result": result, + "state": self.state + } - else: - raise ValueError( - f"Unexpected action type: {action_response.action_type}") + def _update_state(self, result: FunctionResult) -> Dict[str, Any]: + """ + Update worker state based on function result. - return action_response, self._function_result.model_copy() + Args: + result: Result from function execution - def run(self, task: str): - """ - Gets the agent to complete the task on its own autonomously + Returns: + Updated state dictionary """ - - self.set_task(task) - while self._submission_id: - self.step() + return { + **self.state, + "last_result": result.model_dump() + } diff --git a/src/game_sdk/game/worker_config.py b/src/game_sdk/game/worker_config.py new file mode 100644 index 00000000..72407f49 --- /dev/null +++ b/src/game_sdk/game/worker_config.py @@ -0,0 +1,28 @@ +""" +Worker configuration module for the GAME SDK. + +This module defines the configuration classes used to set up workers. +""" + +from typing import Dict, List, Optional +import uuid +from pydantic import BaseModel, Field +from game_sdk.game.custom_types import Function + + +class WorkerConfig(BaseModel): + """ + Configuration for a worker in the GAME SDK. + + Attributes: + id: Unique identifier for the worker + worker_name: Name of the worker + worker_description: Description of what the worker does + functions: List of functions the worker can execute + state: Optional initial state for the worker + """ + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + worker_name: str + worker_description: str + functions: List[Function] + state: Optional[Dict] = None diff --git a/tests/game/test_agent.py b/tests/game/test_agent.py new file mode 100644 index 00000000..54238d3c --- /dev/null +++ b/tests/game/test_agent.py @@ -0,0 +1,303 @@ +"""Tests for the Agent module.""" + +import pytest +from unittest.mock import patch, MagicMock +from game_sdk.game.agent import Agent, Session +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import ( + Function, + Argument, + FunctionResult, + FunctionResultStatus, + ActionResponse, + ActionType, + AgentStateResponse +) +from game_sdk.game.exceptions import ValidationError + + +def test_session_initialization(): + """Test Session initialization.""" + session = Session() + assert isinstance(session.id, str) + assert session.function_result is None + + +def test_session_reset(): + """Test Session reset.""" + session = Session() + original_id = session.id + + # Add a function result + session.function_result = FunctionResult( + action_id="test", + action_status=FunctionResultStatus.DONE, + feedback_message="Test", + info={} + ) + + # Reset session + session.reset() + assert session.id != original_id + assert session.function_result is None + + +def get_test_state(result, state): + """Mock state function for testing.""" + return {"status": "ready"} + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_initialization(mock_create_agent): + """Test Agent initialization.""" + mock_create_agent.return_value = "test_agent_id" + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + assert agent.name == "Test Agent" + assert agent.agent_goal == "Test Goal" + assert agent.agent_description == "Test Description" + assert agent.agent_id == "test_agent_id" + assert isinstance(agent._session, Session) + assert agent.workers == {} + assert agent.current_worker_id is None + assert agent.agent_state == {"status": "ready"} + + mock_create_agent.assert_called_once_with( + "https://api.virtuals.io", + "test_key", + "Test Agent", + "Test Description", + "Test Goal" + ) + + +def test_agent_initialization_no_api_key(): + """Test Agent initialization with no API key.""" + with pytest.raises(ValueError, match="API key not set"): + Agent( + api_key="", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + +def test_agent_initialization_invalid_state_fn(): + """Test Agent initialization with invalid state function.""" + def invalid_state_fn(result, state): + return "not a dict" + + with pytest.raises(ValidationError, match="State function must return a dictionary"): + Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=invalid_state_fn + ) + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_with_workers(mock_create_agent): + """Test Agent initialization with workers.""" + mock_create_agent.return_value = "test_agent_id" + + worker_config = WorkerConfig( + worker_name="Test Worker", + worker_description="Test Worker Description", + functions=[] + ) + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state, + workers=[worker_config] + ) + + assert len(agent.workers) == 1 + assert worker_config.id in agent.workers + assert agent.workers[worker_config.id] == worker_config + + +@patch('game_sdk.game.agent.create_agent') +@patch('game_sdk.game.agent.create_workers') +def test_agent_compile(mock_create_workers, mock_create_agent): + """Test agent compilation.""" + mock_create_agent.return_value = "test_agent_id" + + worker_config = WorkerConfig( + worker_name="Test Worker", + worker_description="Test Worker Description", + functions=[] + ) + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state, + workers=[worker_config] + ) + + agent.compile() + mock_create_workers.assert_called_once_with( + "https://api.virtuals.io", + "test_key", + [worker_config] + ) + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_compile_no_workers(mock_create_agent): + """Test agent compilation with no workers.""" + mock_create_agent.return_value = "test_agent_id" + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + with pytest.raises(ValueError, match="No workers configured"): + agent.compile() + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_reset(mock_create_agent): + """Test agent reset.""" + mock_create_agent.return_value = "test_agent_id" + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + original_session_id = agent._session.id + agent.reset() + assert agent._session.id != original_session_id + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_add_worker(mock_create_agent): + """Test adding a worker to an agent.""" + mock_create_agent.return_value = "test_agent_id" + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + worker_config = WorkerConfig( + worker_name="Test Worker", + worker_description="Test Worker Description", + functions=[] + ) + + workers = agent.add_worker(worker_config) + assert len(workers) == 1 + assert worker_config.id in workers + assert workers[worker_config.id] == worker_config + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_get_worker_config(mock_create_agent): + """Test getting a worker configuration.""" + mock_create_agent.return_value = "test_agent_id" + + worker_config = WorkerConfig( + worker_name="Test Worker", + worker_description="Test Worker Description", + functions=[] + ) + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state, + workers=[worker_config] + ) + + retrieved_config = agent.get_worker_config(worker_config.id) + assert retrieved_config == worker_config + + +@patch('game_sdk.game.agent.create_agent') +def test_agent_get_worker(mock_create_agent): + """Test getting a worker instance.""" + mock_create_agent.return_value = "test_agent_id" + + worker_config = WorkerConfig( + worker_name="Test Worker", + worker_description="Test Worker Description", + functions=[] + ) + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state, + workers=[worker_config] + ) + + worker = agent.get_worker(worker_config.id) + assert worker.config == worker_config + + +@patch('game_sdk.game.agent.create_agent') +@patch('game_sdk.game.agent.post') +def test_agent_get_action(mock_post, mock_create_agent): + """Test getting next action from API.""" + mock_create_agent.return_value = "test_agent_id" + mock_post.return_value = ActionResponse( + action_type=ActionType.CALL_FUNCTION, + agent_state=AgentStateResponse(), + action_args={} + ).model_dump() + + agent = Agent( + api_key="test_key", + name="Test Agent", + agent_goal="Test Goal", + agent_description="Test Description", + get_agent_state_fn=get_test_state + ) + + action = agent._get_action() + assert isinstance(action, ActionResponse) + assert action.action_type == ActionType.CALL_FUNCTION + + mock_post.assert_called_once_with( + "https://api.virtuals.io", + "test_key", + endpoint="/v2/actions", + data={ + "agent_id": "test_agent_id", + "session_id": agent._session.id, + "state": {"status": "ready"}, + "function_result": None + } + ) diff --git a/tests/game/test_api.py b/tests/game/test_api.py new file mode 100644 index 00000000..1e33b34d --- /dev/null +++ b/tests/game/test_api.py @@ -0,0 +1,183 @@ +"""Tests for the GAME API client.""" + +import unittest +from unittest.mock import patch, MagicMock +from game_sdk.game.api import GAMEClient +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function + + +class TestGAMEClient(unittest.TestCase): + """Test cases for the GAMEClient class.""" + + def setUp(self): + """Set up test fixtures.""" + self.api_key = "test_api_key" + self.client = GAMEClient(self.api_key) + + def test_initialization(self): + """Test client initialization.""" + self.assertEqual(self.client.api_key, self.api_key) + self.assertEqual(self.client.base_url, "https://game.virtuals.io") + + @patch('game_sdk.game.api.requests.post') + def test_get_access_token_success(self, mock_post): + """Test successful access token retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"accessToken": "test_token"} + } + mock_post.return_value = mock_response + + token = self.client._get_access_token() + self.assertEqual(token, "test_token") + + mock_post.assert_called_once_with( + "https://api.virtuals.io/api/accesses/tokens", + json={"data": {}}, + headers={"x-api-key": self.api_key} + ) + + @patch('game_sdk.game.api.requests.post') + def test_get_access_token_failure(self, mock_post): + """Test failed access token retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.json.return_value = {"error": "Invalid API key"} + mock_post.return_value = mock_response + + with self.assertRaises(ValueError): + self.client._get_access_token() + + @patch('game_sdk.game.api.GAMEClient._get_access_token') + @patch('game_sdk.game.api.requests.post') + def test_post_success(self, mock_post, mock_get_token): + """Test successful post request.""" + mock_get_token.return_value = "test_token" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"result": "success"} + } + mock_post.return_value = mock_response + + result = self.client._post("/test", {"key": "value"}) + self.assertEqual(result, {"result": "success"}) + + mock_post.assert_called_once_with( + f"{self.client.base_url}/prompts", + json={ + "data": { + "method": "post", + "headers": { + "Content-Type": "application/json", + }, + "route": "/test", + "data": {"key": "value"}, + }, + }, + headers={"Authorization": "Bearer test_token"}, + ) + + @patch('game_sdk.game.api.GAMEClient._post') + def test_create_agent(self, mock_post): + """Test agent creation.""" + mock_post.return_value = {"id": "test_agent_id"} + + agent_id = self.client.create_agent( + name="Test Agent", + description="Test Description", + goal="Test Goal" + ) + + self.assertEqual(agent_id, "test_agent_id") + mock_post.assert_called_once_with( + endpoint="/v2/agents", + data={ + "name": "Test Agent", + "description": "Test Description", + "goal": "Test Goal", + } + ) + + @patch('game_sdk.game.api.GAMEClient._post') + def test_create_workers(self, mock_post): + """Test workers creation.""" + mock_post.return_value = {"id": "test_map_id"} + + workers = [ + WorkerConfig( + worker_name="Test Worker", + worker_description="Test Description", + functions=[ + Function( + fn_name="test_fn", + fn_description="Test Function", + args=[] + ) + ] + ) + ] + + map_id = self.client.create_workers(workers) + self.assertEqual(map_id, "test_map_id") + + mock_post.assert_called_once() + call_args = mock_post.call_args[1] + self.assertEqual(call_args["endpoint"], "/v2/maps") + self.assertEqual(len(call_args["data"]["locations"]), 1) + self.assertEqual(call_args["data"]["locations"][0]["name"], workers[0].id) + + @patch('game_sdk.game.api.GAMEClient._post') + def test_set_worker_task(self, mock_post): + """Test setting worker task.""" + mock_post.return_value = {"task": "test_task"} + + result = self.client.set_worker_task( + agent_id="test_agent", + task="test_task" + ) + + self.assertEqual(result, {"task": "test_task"}) + mock_post.assert_called_once_with( + endpoint="/v2/agents/test_agent/tasks", + data={"task": "test_task"} + ) + + @patch('game_sdk.game.api.GAMEClient._post') + def test_get_worker_action(self, mock_post): + """Test getting worker action.""" + mock_post.return_value = {"action": "test_action"} + + result = self.client.get_worker_action( + agent_id="test_agent", + submission_id="test_submission", + data={"state": "test_state"} + ) + + self.assertEqual(result, {"action": "test_action"}) + mock_post.assert_called_once_with( + endpoint="/v2/agents/test_agent/tasks/test_submission/next", + data={"state": "test_state"} + ) + + @patch('game_sdk.game.api.GAMEClient._post') + def test_get_agent_action(self, mock_post): + """Test getting agent action.""" + mock_post.return_value = {"action": "test_action"} + + result = self.client.get_agent_action( + agent_id="test_agent", + data={"state": "test_state"} + ) + + self.assertEqual(result, {"action": "test_action"}) + mock_post.assert_called_once_with( + endpoint="/v2/agents/test_agent/actions", + data={"state": "test_state"} + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/game/test_api_client.py b/tests/game/test_api_client.py new file mode 100644 index 00000000..cad37438 --- /dev/null +++ b/tests/game/test_api_client.py @@ -0,0 +1,174 @@ +"""Tests for the GAME API client.""" + +import unittest +from unittest.mock import patch, MagicMock +import requests +import tenacity +from game_sdk.game.api_client import GameAPIClient, should_retry +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError + + +class TestGameAPIClient(unittest.TestCase): + """Test cases for the GameAPIClient class.""" + + def setUp(self): + """Set up test fixtures.""" + self.api_key = "test_api_key" + self.client = GameAPIClient(api_key=self.api_key) + + def test_initialization(self): + """Test client initialization.""" + self.assertEqual(self.client.api_key, self.api_key) + self.assertIsInstance(self.client.session, requests.Session) + self.assertEqual( + self.client.session.headers["Authorization"], + f"Bearer {self.api_key}" + ) + self.assertEqual( + self.client.session.headers["Content-Type"], + "application/json" + ) + + def test_initialization_no_api_key(self): + """Test initialization without API key.""" + with self.assertRaises(ValueError): + GameAPIClient(api_key=None) + + @patch('requests.Session.request') + def test_make_request_success(self, mock_request): + """Test successful API request.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + mock_request.return_value = mock_response + + result = self.client.make_request( + method="GET", + endpoint="/test", + data={"key": "value"}, + params={"param": "value"} + ) + + self.assertEqual(result, {"data": "test"}) + mock_request.assert_called_once_with( + method="GET", + url=f"{self.client.base_url}/test", + json={"key": "value"}, + params={"param": "value"} + ) + + @patch('requests.Session.request') + def test_make_request_auth_error(self, mock_request): + """Test authentication error handling.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_request.return_value = mock_response + + with self.assertRaises(AuthenticationError): + self.client.make_request("GET", "/test") + + @patch('requests.Session.request') + def test_make_request_validation_error(self, mock_request): + """Test validation error handling.""" + mock_response = MagicMock() + mock_response.status_code = 422 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_request.return_value = mock_response + + with self.assertRaises(ValidationError): + self.client.make_request("GET", "/test") + + @patch('requests.Session.request') + def test_make_request_api_error(self, mock_request): + """Test API error handling.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_request.return_value = mock_response + + with self.assertRaises(APIError): + try: + self.client.make_request("GET", "/test") + except tenacity.RetryError as e: + raise e.last_attempt.result() + + @patch('requests.Session.request') + def test_make_request_network_error(self, mock_request): + """Test network error handling.""" + mock_request.side_effect = requests.exceptions.ConnectionError() + + with self.assertRaises(APIError): + try: + self.client.make_request("GET", "/test") + except tenacity.RetryError as e: + raise e.last_attempt.result() + + @patch('game_sdk.game.api_client.GameAPIClient.make_request') + def test_get_request(self, mock_make_request): + """Test GET request.""" + mock_make_request.return_value = {"data": "test"} + result = self.client.get("/test", params={"param": "value"}) + + self.assertEqual(result, {"data": "test"}) + mock_make_request.assert_called_once_with( + "GET", + "/test", + params={"param": "value"} + ) + + @patch('game_sdk.game.api_client.GameAPIClient.make_request') + def test_post_request(self, mock_make_request): + """Test POST request.""" + mock_make_request.return_value = {"data": "test"} + result = self.client.post("/test", data={"key": "value"}) + + self.assertEqual(result, {"data": "test"}) + mock_make_request.assert_called_once_with( + "POST", + "/test", + data={"key": "value"} + ) + + @patch('game_sdk.game.api_client.GameAPIClient.make_request') + def test_put_request(self, mock_make_request): + """Test PUT request.""" + mock_make_request.return_value = {"data": "test"} + result = self.client.put("/test", data={"key": "value"}) + + self.assertEqual(result, {"data": "test"}) + mock_make_request.assert_called_once_with( + "PUT", + "/test", + data={"key": "value"} + ) + + @patch('game_sdk.game.api_client.GameAPIClient.make_request') + def test_delete_request(self, mock_make_request): + """Test DELETE request.""" + mock_make_request.return_value = {"data": "test"} + result = self.client.delete("/test") + + self.assertEqual(result, {"data": "test"}) + mock_make_request.assert_called_once_with( + "DELETE", + "/test" + ) + + def test_should_retry(self): + """Test retry condition function.""" + # Should retry on APIError + self.assertTrue(should_retry(APIError("test"))) + + # Should retry on RequestException + self.assertTrue(should_retry(requests.exceptions.RequestException())) + + # Should not retry on AuthenticationError + self.assertFalse(should_retry(AuthenticationError("test"))) + + # Should not retry on ValidationError + self.assertFalse(should_retry(ValidationError("test"))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/game/test_api_v2.py b/tests/game/test_api_v2.py new file mode 100644 index 00000000..6e7608f2 --- /dev/null +++ b/tests/game/test_api_v2.py @@ -0,0 +1,248 @@ +"""Tests for the GAME API V2 client.""" + +import unittest +from unittest.mock import patch, MagicMock +from game_sdk.game.api_v2 import GAMEClientV2 +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function + + +class TestGAMEClientV2(unittest.TestCase): + """Test cases for the GAMEClientV2 class.""" + + def setUp(self): + """Set up test fixtures.""" + self.api_key = "test_api_key" + self.client = GAMEClientV2(api_key=self.api_key) + + def test_initialization(self): + """Test client initialization.""" + self.assertEqual(self.client.api_key, self.api_key) + self.assertEqual(self.client.base_url, "https://sdk.game.virtuals.io") + self.assertEqual(self.client.headers, { + "Content-Type": "application/json", + "x-api-key": self.api_key + }) + + @patch('game_sdk.game.api_v2.requests.post') + def test_create_agent_success(self, mock_post): + """Test successful agent creation.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"id": "test_agent_id"} + } + mock_post.return_value = mock_response + + agent_id = self.client.create_agent( + name="Test Agent", + description="Test Description", + goal="Test Goal" + ) + + self.assertEqual(agent_id, "test_agent_id") + mock_post.assert_called_once_with( + f"{self.client.base_url}/agents", + headers=self.client.headers, + json={ + "data": { + "name": "Test Agent", + "goal": "Test Goal", + "description": "Test Description" + } + } + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_create_agent_failure(self, mock_post): + """Test failed agent creation.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Invalid data"} + mock_post.return_value = mock_response + + with self.assertRaises(ValueError): + self.client.create_agent( + name="Test Agent", + description="Test Description", + goal="Test Goal" + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_create_workers_success(self, mock_post): + """Test successful workers creation.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"id": "test_map_id"} + } + mock_post.return_value = mock_response + + workers = [ + WorkerConfig( + worker_name="Test Worker", + worker_description="Test Description", + functions=[ + Function( + fn_name="test_fn", + fn_description="Test Function", + args=[] + ) + ] + ) + ] + + map_id = self.client.create_workers(workers) + self.assertEqual(map_id, "test_map_id") + + mock_post.assert_called_once() + call_args = mock_post.call_args[1] + self.assertEqual(call_args["headers"], self.client.headers) + self.assertEqual(len(call_args["json"]["data"]["locations"]), 1) + self.assertEqual( + call_args["json"]["data"]["locations"][0]["name"], + workers[0].id + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_create_workers_failure(self, mock_post): + """Test failed workers creation.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Invalid data"} + mock_post.return_value = mock_response + + workers = [ + WorkerConfig( + worker_name="Test Worker", + worker_description="Test Description", + functions=[ + Function( + fn_name="test_fn", + fn_description="Test Function", + args=[] + ) + ] + ) + ] + + with self.assertRaises(ValueError): + self.client.create_workers(workers) + + @patch('game_sdk.game.api_v2.requests.post') + def test_set_worker_task_success(self, mock_post): + """Test successful worker task setting.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"task": "test_task"} + } + mock_post.return_value = mock_response + + result = self.client.set_worker_task( + agent_id="test_agent", + task="test_task" + ) + + self.assertEqual(result, {"task": "test_task"}) + mock_post.assert_called_once_with( + f"{self.client.base_url}/agents/test_agent/tasks", + headers=self.client.headers, + json={ + "data": { + "task": "test_task" + } + } + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_set_worker_task_failure(self, mock_post): + """Test failed worker task setting.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Invalid data"} + mock_post.return_value = mock_response + + with self.assertRaises(ValueError): + self.client.set_worker_task( + agent_id="test_agent", + task="test_task" + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_get_worker_action_success(self, mock_post): + """Test successful worker action retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"action": "test_action"} + } + mock_post.return_value = mock_response + + result = self.client.get_worker_action( + agent_id="test_agent", + submission_id="test_submission", + data={"state": "test_state"} + ) + + self.assertEqual(result, {"action": "test_action"}) + mock_post.assert_called_once_with( + f"{self.client.base_url}/agents/test_agent/tasks/test_submission/next", + headers=self.client.headers, + json={"state": "test_state"} + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_get_worker_action_failure(self, mock_post): + """Test failed worker action retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Invalid data"} + mock_post.return_value = mock_response + + with self.assertRaises(ValueError): + self.client.get_worker_action( + agent_id="test_agent", + submission_id="test_submission", + data={"state": "test_state"} + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_get_agent_action_success(self, mock_post): + """Test successful agent action retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"action": "test_action"} + } + mock_post.return_value = mock_response + + result = self.client.get_agent_action( + agent_id="test_agent", + data={"state": "test_state"} + ) + + self.assertEqual(result, {"action": "test_action"}) + mock_post.assert_called_once_with( + f"{self.client.base_url}/agents/test_agent/actions", + headers=self.client.headers, + json={"state": "test_state"} + ) + + @patch('game_sdk.game.api_v2.requests.post') + def test_get_agent_action_failure(self, mock_post): + """Test failed agent action retrieval.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Invalid data"} + mock_post.return_value = mock_response + + with self.assertRaises(ValueError): + self.client.get_agent_action( + agent_id="test_agent", + data={"state": "test_state"} + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/game/test_config.py b/tests/game/test_config.py new file mode 100644 index 00000000..b7575bcf --- /dev/null +++ b/tests/game/test_config.py @@ -0,0 +1,53 @@ +"""Tests for the GAME SDK configuration.""" + +import unittest +from game_sdk.game.config import Config, config + + +class TestConfig(unittest.TestCase): + """Test cases for the Config class.""" + + def test_default_values(self): + """Test default configuration values.""" + config = Config() + self.assertEqual(config.api_url, "https://api.virtuals.io") + self.assertEqual(config.version, "v2") + self.assertEqual(config.default_timeout, 30) + + def test_custom_values(self): + """Test custom configuration values.""" + config = Config( + api_url="https://custom.api.com", + version="v3", + default_timeout=60 + ) + self.assertEqual(config.api_url, "https://custom.api.com") + self.assertEqual(config.version, "v3") + self.assertEqual(config.default_timeout, 60) + + def test_base_url_property(self): + """Test base_url property.""" + config = Config(api_url="https://custom.api.com") + self.assertEqual(config.base_url, "https://custom.api.com") + + def test_version_prefix_property(self): + """Test version_prefix property.""" + config = Config( + api_url="https://custom.api.com", + version="v3" + ) + self.assertEqual( + config.version_prefix, + "https://custom.api.com/v3" + ) + + def test_global_config_instance(self): + """Test global configuration instance.""" + self.assertIsInstance(config, Config) + self.assertEqual(config.api_url, "https://api.virtuals.io") + self.assertEqual(config.version, "v2") + self.assertEqual(config.default_timeout, 30) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/game/test_custom_types.py b/tests/game/test_custom_types.py new file mode 100644 index 00000000..7fcbb3db --- /dev/null +++ b/tests/game/test_custom_types.py @@ -0,0 +1,231 @@ +""" +Tests for custom types and data structures in the GAME SDK. + +This module contains comprehensive tests for all custom types defined in +game_sdk.game.custom_types. +""" + +import pytest +from typing import Dict, Any +from game_sdk.game.custom_types import ( + Argument, + Function, + FunctionResult, + FunctionResultStatus, + ActionType, + HLPResponse, + LLPResponse, + CurrentTaskResponse, + AgentStateResponse, + ActionResponse +) + + +def test_argument_creation(): + """Test creating an Argument with various configurations.""" + # Test required fields + arg = Argument( + name="city", + description="City name", + type="string" + ) + assert arg.name == "city" + assert arg.description == "City name" + assert arg.type == "string" + assert not arg.optional + + # Test optional argument + optional_arg = Argument( + name="country", + description="Country name", + type="string", + optional=True + ) + assert optional_arg.optional + + # Test list type + multi_type_arg = Argument( + name="temperature", + description="Temperature value", + type=["integer", "float"] + ) + assert isinstance(multi_type_arg.type, list) + assert "integer" in multi_type_arg.type + assert "float" in multi_type_arg.type + + +def test_function_result_status(): + """Test FunctionResultStatus enum values.""" + assert FunctionResultStatus.DONE == "done" + assert FunctionResultStatus.FAILED == "failed" + + # Test string conversion + assert str(FunctionResultStatus.DONE) == "done" + assert str(FunctionResultStatus.FAILED) == "failed" + + +def test_function_result(): + """Test FunctionResult creation and attributes.""" + result = FunctionResult( + action_id="test_123", + action_status=FunctionResultStatus.DONE, + feedback_message="Test completed", + info={"value": 42} + ) + + assert result.action_id == "test_123" + assert result.action_status == FunctionResultStatus.DONE + assert result.feedback_message == "Test completed" + assert result.info == {"value": 42} + + +def get_test_value(value: Dict[str, Any]) -> Dict[str, Any]: + """Helper function for testing.""" + return FunctionResultStatus.DONE, f"Got value: {value['value']}", {"value": value['value']} + + +def test_function(): + """Test Function creation and execution.""" + # Create test function + fn = Function( + fn_name="get_value", + fn_description="Get a value", + args=[ + Argument( + name="value", + description="Value to get", + type="string" + ) + ], + executable=get_test_value + ) + + # Test function definition + assert fn.fn_name == "get_value" + assert fn.fn_description == "Get a value" + assert len(fn.args) == 1 + + # Test function execution + result = fn.execute( + fn_id="test_123", + args={"value": {"value": "test"}} + ) + assert result.action_status == FunctionResultStatus.DONE + assert result.info == {"value": "test"} + + # Test error handling + result = fn.execute( + fn_id="test_456", + args={"invalid": "value"} + ) + assert result.action_status == FunctionResultStatus.FAILED + assert "Error executing function" in result.feedback_message + + +def test_action_type(): + """Test ActionType enum values.""" + assert ActionType.CALL_FUNCTION == "call_function" + assert ActionType.CONTINUE_FUNCTION == "continue_function" + assert ActionType.WAIT == "wait" + assert ActionType.GO_TO == "go_to" + + +def test_hlp_response(): + """Test HLPResponse creation and attributes.""" + hlp = HLPResponse( + plan_id="test_123", + observation_reflection="Test reflection", + plan=["step1", "step2"], + plan_reasoning="Test reasoning", + current_state_of_execution="Running", + change_indicator="Changed", + log=[{"event": "start"}] + ) + + assert hlp.plan_id == "test_123" + assert hlp.observation_reflection == "Test reflection" + assert len(hlp.plan) == 2 + assert hlp.plan_reasoning == "Test reasoning" + assert hlp.current_state_of_execution == "Running" + assert hlp.change_indicator == "Changed" + assert len(hlp.log) == 1 + + +def test_llp_response(): + """Test LLPResponse creation and attributes.""" + llp = LLPResponse( + plan_id="test_123", + plan_reasoning="Test reasoning", + situation_analysis="Test analysis", + plan=["step1", "step2"], + change_indicator="Changed", + reflection="Test reflection" + ) + + assert llp.plan_id == "test_123" + assert llp.plan_reasoning == "Test reasoning" + assert llp.situation_analysis == "Test analysis" + assert len(llp.plan) == 2 + assert llp.change_indicator == "Changed" + assert llp.reflection == "Test reflection" + + +def test_current_task_response(): + """Test CurrentTaskResponse creation and attributes.""" + llp = LLPResponse( + plan_id="test_123", + plan_reasoning="Test reasoning", + situation_analysis="Test analysis", + plan=["step1"] + ) + + task = CurrentTaskResponse( + task="Test task", + task_reasoning="Test reasoning", + location_id="test_loc", + llp=llp + ) + + assert task.task == "Test task" + assert task.task_reasoning == "Test reasoning" + assert task.location_id == "test_loc" + assert task.llp == llp + + +def test_agent_state_response(): + """Test AgentStateResponse creation and attributes.""" + hlp = HLPResponse( + plan_id="test_123", + observation_reflection="Test reflection", + plan=["step1"], + plan_reasoning="Test reasoning", + current_state_of_execution="Running" + ) + + task = CurrentTaskResponse( + task="Test task", + task_reasoning="Test reasoning" + ) + + state = AgentStateResponse( + hlp=hlp, + current_task=task + ) + + assert state.hlp == hlp + assert state.current_task == task + + +def test_action_response(): + """Test ActionResponse creation and attributes.""" + state = AgentStateResponse() + + response = ActionResponse( + action_type=ActionType.CALL_FUNCTION, + agent_state=state, + action_args={"function": "test"} + ) + + assert response.action_type == ActionType.CALL_FUNCTION + assert response.agent_state == state + assert response.action_args == {"function": "test"} diff --git a/tests/game/test_utils.py b/tests/game/test_utils.py new file mode 100644 index 00000000..1562ce86 --- /dev/null +++ b/tests/game/test_utils.py @@ -0,0 +1,262 @@ +"""Tests for the GAME SDK utilities.""" + +import unittest +from unittest.mock import patch, MagicMock +import requests +from game_sdk.game.utils import ( + post, + create_agent, + create_workers, + validate_response, + format_endpoint, + merge_params, + parse_api_error +) +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import Function + + +class TestUtils(unittest.TestCase): + """Test cases for utility functions.""" + + def setUp(self): + """Set up test fixtures.""" + self.base_url = "https://api.virtuals.io" + self.api_key = "test_api_key" + + @patch('game_sdk.game.utils.requests.post') + def test_post_success(self, mock_post): + """Test successful POST request.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"result": "success"}} + mock_post.return_value = mock_response + + result = post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test", + data={"key": "value"} + ) + + self.assertEqual(result, {"result": "success"}) + mock_post.assert_called_once_with( + f"{self.base_url}/test", + json={"key": "value"}, + params=None, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=30 + ) + + @patch('game_sdk.game.utils.requests.post') + def test_post_auth_error(self, mock_post): + """Test authentication error handling.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_post.return_value = mock_response + + with self.assertRaises(AuthenticationError): + post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test" + ) + + @patch('game_sdk.game.utils.requests.post') + def test_post_validation_error(self, mock_post): + """Test validation error handling.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "error": {"message": "Invalid data"} + } + mock_post.return_value = mock_response + + with self.assertRaises(ValidationError): + post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test" + ) + + @patch('game_sdk.game.utils.requests.post') + def test_post_rate_limit_error(self, mock_post): + """Test rate limit error handling.""" + mock_response = MagicMock() + mock_response.status_code = 429 + mock_post.return_value = mock_response + + with self.assertRaises(APIError) as context: + post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test" + ) + self.assertEqual(str(context.exception), "Rate limit exceeded") + + @patch('game_sdk.game.utils.requests.post') + def test_post_server_error(self, mock_post): + """Test server error handling.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_post.return_value = mock_response + + with self.assertRaises(APIError) as context: + post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test" + ) + self.assertEqual(str(context.exception), "Server error") + + @patch('game_sdk.game.utils.requests.post') + def test_post_connection_error(self, mock_post): + """Test connection error handling.""" + mock_post.side_effect = requests.exceptions.ConnectionError("Connection failed") + + with self.assertRaises(APIError) as context: + post( + base_url=self.base_url, + api_key=self.api_key, + endpoint="/test" + ) + self.assertTrue("Connection failed" in str(context.exception)) + + @patch('game_sdk.game.utils.post') + def test_create_agent_success(self, mock_post): + """Test successful agent creation.""" + mock_post.return_value = {"id": "test_agent_id"} + + agent_id = create_agent( + base_url=self.base_url, + api_key=self.api_key, + name="Test Agent", + description="Test Description", + goal="Test Goal" + ) + + self.assertEqual(agent_id, "test_agent_id") + mock_post.assert_called_once_with( + self.base_url, + self.api_key, + endpoint="/v2/agents", + data={ + "name": "Test Agent", + "description": "Test Description", + "goal": "Test Goal" + } + ) + + @patch('game_sdk.game.utils.post') + def test_create_workers_success(self, mock_post): + """Test successful workers creation.""" + mock_post.return_value = {"id": "test_map_id"} + + workers = [ + WorkerConfig( + worker_name="Test Worker", + worker_description="Test Description", + functions=[ + Function( + fn_name="test_fn", + fn_description="Test Function", + args=[] + ) + ] + ) + ] + + map_id = create_workers( + base_url=self.base_url, + api_key=self.api_key, + workers=workers + ) + + self.assertEqual(map_id, "test_map_id") + mock_post.assert_called_once() + call_args = mock_post.call_args + self.assertEqual(call_args[0], (self.base_url, self.api_key)) + self.assertEqual(call_args[1]["endpoint"], "/v2/maps") + self.assertEqual(len(call_args[1]["data"]["locations"]), 1) + self.assertEqual( + call_args[1]["data"]["locations"][0]["name"], + workers[0].id + ) + + def test_validate_response_success(self): + """Test successful response validation.""" + response = { + "status": "success", + "data": {"result": "test"} + } + validate_response(response) # Should not raise any exception + + def test_validate_response_failure(self): + """Test failed response validation.""" + invalid_responses = [ + None, + {}, + {"status": "error"}, + {"data": None} + ] + for response in invalid_responses: + with self.assertRaises(ValueError): + validate_response(response) + + def test_format_endpoint(self): + """Test endpoint formatting.""" + test_cases = [ + ("test", "/test"), + ("/test", "/test"), + ("//test", "/test"), + ("test/", "/test"), + ("/test/", "/test"), + ("", "/"), + ("/", "/") + ] + for input_endpoint, expected_output in test_cases: + self.assertEqual(format_endpoint(input_endpoint), expected_output) + + def test_merge_params(self): + """Test parameter merging.""" + test_cases = [ + (None, None, {}), + ({}, {}, {}), + ({"a": 1}, None, {"a": 1}), + (None, {"b": 2}, {"b": 2}), + ({"a": 1}, {"b": 2}, {"a": 1, "b": 2}), + ({"a": 1}, {"a": 2}, {"a": 2}) # Additional params override base params + ] + for base_params, additional_params, expected_output in test_cases: + self.assertEqual( + merge_params(base_params, additional_params), + expected_output + ) + + def test_parse_api_error(self): + """Test API error parsing.""" + test_cases = [ + ( + {"error": {"message": "Test error"}}, + "Test error" + ), + ( + {"error": {"detail": "Test detail"}}, + "Test detail" + ), + ( + {"message": "Direct message"}, + "Direct message" + ), + ( + {}, + "Unknown error" + ) + ] + for error_response, expected_message in test_cases: + self.assertEqual(parse_api_error(error_response), expected_message) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/game/test_worker.py b/tests/game/test_worker.py new file mode 100644 index 00000000..17520e05 --- /dev/null +++ b/tests/game/test_worker.py @@ -0,0 +1,140 @@ +"""Tests for the Worker class.""" + +import pytest +from game_sdk.game.worker import Worker +from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import ( + Function, + Argument, + FunctionResult, + FunctionResultStatus +) + + +def test_worker_initialization(): + """Test worker initialization with config.""" + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[], + state={"initial": "state"} + ) + worker = Worker(config) + assert worker.config == config + assert worker.state == {"initial": "state"} + + +def test_worker_initialization_default_state(): + """Test worker initialization with default state.""" + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[] + ) + worker = Worker(config) + assert worker.config == config + assert worker.state == {} + + +def test_execute_function_not_found(): + """Test executing a non-existent function.""" + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[] + ) + worker = Worker(config) + result = worker.execute_function("nonexistent", "123", {}) + + assert isinstance(result, dict) + assert "result" in result + assert "state" in result + assert result["result"].action_id == "123" + assert result["result"].action_status == FunctionResultStatus.FAILED + assert "not found" in result["result"].feedback_message + + +def test_execute_function_success(): + """Test successful function execution.""" + def test_fn(**kwargs): + return FunctionResultStatus.DONE, "Success", {"output": "test"} + + fn = Function( + fn_name="test_fn", + fn_description="Test function", + args=[ + Argument( + name="input", + description="Test input", + type="string" + ) + ], + executable=test_fn + ) + + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[fn] + ) + worker = Worker(config) + result = worker.execute_function("test_fn", "123", {"input": "test"}) + + assert isinstance(result, dict) + assert "result" in result + assert "state" in result + assert result["result"].action_id == "123" + assert result["result"].action_status == FunctionResultStatus.DONE + assert result["result"].feedback_message == "Success" + assert result["result"].info == {"output": "test"} + + +def test_execute_function_error(): + """Test function execution with error.""" + def test_fn(**kwargs): + raise ValueError("Test error") + + fn = Function( + fn_name="test_fn", + fn_description="Test function", + args=[], + executable=test_fn + ) + + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[fn] + ) + worker = Worker(config) + result = worker.execute_function("test_fn", "123", {}) + + assert isinstance(result, dict) + assert "result" in result + assert "state" in result + assert result["result"].action_id == "123" + assert result["result"].action_status == FunctionResultStatus.FAILED + assert "Test error" in result["result"].feedback_message + + +def test_update_state(): + """Test state updates after function execution.""" + config = WorkerConfig( + worker_name="test_worker", + worker_description="Test worker", + functions=[], + state={"initial": "state"} + ) + worker = Worker(config) + + result = FunctionResult( + action_id="123", + action_status=FunctionResultStatus.DONE, + feedback_message="Success", + info={"output": "test"} + ) + + new_state = worker._update_state(result) + assert new_state["initial"] == "state" + assert "last_result" in new_state + assert new_state["last_result"]["action_id"] == "123" diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 00000000..4f108583 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,215 @@ +""" +Tests for the GAME SDK API client. + +This module contains tests for the GameAPIClient class, including error handling, +retry logic, and HTTP method wrappers. +""" + +import pytest +import responses +import tenacity +from requests.exceptions import HTTPError, RequestException +from game_sdk.game.api_client import GameAPIClient +from game_sdk.game.config import config +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError + + +@pytest.fixture +def api_client(): + """Create a test API client instance.""" + return GameAPIClient("test_api_key") + + +@pytest.fixture +def mock_responses(): + """Set up mock responses for testing.""" + with responses.RequestsMock() as rsps: + yield rsps + + +def test_init_with_valid_api_key(api_client): + """Test client initialization with valid API key.""" + assert api_client.api_key == "test_api_key" + assert api_client.base_url == config.api_url + assert api_client.session.headers["Authorization"] == "Bearer test_api_key" + assert api_client.session.headers["Content-Type"] == "application/json" + + +def test_init_without_api_key(): + """Test client initialization without API key raises error.""" + with pytest.raises(ValueError, match="API key is required"): + GameAPIClient("") + + +def test_get_request_success(api_client, mock_responses): + """Test successful GET request.""" + expected_response = {"data": "test_data"} + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + response = api_client.get("test") + assert response == expected_response + + +def test_post_request_success(api_client, mock_responses): + """Test successful POST request.""" + request_data = {"key": "value"} + expected_response = {"data": "created"} + mock_responses.add( + responses.POST, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + response = api_client.post("test", request_data) + assert response == expected_response + + +def test_put_request_success(api_client, mock_responses): + """Test successful PUT request.""" + request_data = {"key": "updated_value"} + expected_response = {"data": "updated"} + mock_responses.add( + responses.PUT, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + response = api_client.put("test", request_data) + assert response == expected_response + + +def test_delete_request_success(api_client, mock_responses): + """Test successful DELETE request.""" + expected_response = {"data": "deleted"} + mock_responses.add( + responses.DELETE, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + response = api_client.delete("test") + assert response == expected_response + + +def test_authentication_error(api_client, mock_responses): + """Test authentication error handling.""" + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json={"error": "Unauthorized"}, + status=401 + ) + + with pytest.raises(AuthenticationError, match="Authentication failed"): + api_client.get("test") + + +def test_validation_error(api_client, mock_responses): + """Test validation error handling.""" + mock_responses.add( + responses.POST, + f"{config.api_url}/test", + json={"error": "Invalid data"}, + status=422 + ) + + with pytest.raises(ValidationError, match="Invalid request data"): + api_client.post("test", {}) + + +def test_api_error(api_client, mock_responses): + """Test general API error handling.""" + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json={"error": "Server error"}, + status=500 + ) + + with pytest.raises(tenacity.RetryError): + api_client.get("test") + + +def test_network_error(api_client, mock_responses): + """Test network error handling.""" + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + body=RequestException("Network error") + ) + + with pytest.raises(tenacity.RetryError): + api_client.get("test") + + +@pytest.mark.parametrize("status_code", [500, 502, 503, 504]) +def test_retry_on_server_error(api_client, mock_responses, status_code): + """Test retry logic on server errors.""" + # First two requests fail, third succeeds + expected_response = {"data": "success"} + + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json={"error": "Server error"}, + status=status_code + ) + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json={"error": "Server error"}, + status=status_code + ) + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + response = api_client.get("test") + assert response == expected_response + assert len(mock_responses.calls) == 3 # Verify retry happened + + +def test_request_with_params(api_client, mock_responses): + """Test request with query parameters.""" + expected_response = {"data": "filtered"} + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + + params = {"filter": "value"} + response = api_client.get("test", params=params) + assert response == expected_response + assert "filter=value" in mock_responses.calls[0].request.url + + +def test_endpoint_path_handling(api_client, mock_responses): + """Test proper handling of endpoint paths with/without leading slash.""" + expected_response = {"data": "test"} + + # Test with leading slash + mock_responses.add( + responses.GET, + f"{config.api_url}/test", + json=expected_response, + status=200 + ) + response = api_client.get("/test") + assert response == expected_response + + # Test without leading slash + response = api_client.get("test") + assert response == expected_response diff --git a/tests/test_weather_worker.py b/tests/test_weather_worker.py new file mode 100644 index 00000000..86cbe4f7 --- /dev/null +++ b/tests/test_weather_worker.py @@ -0,0 +1,113 @@ +""" +Test module for the weather worker functionality. +""" + +import unittest +from unittest.mock import Mock, patch +import json +from datetime import datetime + +from game_sdk.game.agent import Agent +from game_sdk.game.custom_types import FunctionResult, FunctionResultStatus +from examples.weather_worker import create_weather_worker, get_weather_handler + +class TestWeatherWorker(unittest.TestCase): + """Test cases for the weather worker functionality.""" + + @patch('game_sdk.game.utils.post') + def setUp(self, mock_post): + """Set up test fixtures before each test method.""" + # Mock API responses + mock_post.return_value = {"id": "test_agent_id"} + + # Mock API key for testing + self.api_key = "test_api_key" + + # Create a mock agent + self.agent = Agent( + api_key=self.api_key, + name="Test Weather Assistant", + agent_description="Test weather reporter", + agent_goal="Test weather reporting functionality", + get_agent_state_fn=lambda x, y: {"status": "ready"} + ) + + # Create and add the weather worker + self.worker_config = create_weather_worker(self.api_key) + self.agent.add_worker(self.worker_config) + + def test_worker_creation(self): + """Test if worker is created correctly.""" + self.assertIsNotNone(self.worker_config) + self.assertTrue(self.worker_config.id.startswith("weather_reporter_")) + + # Check action space + actions = {fn.fn_name: fn for fn in self.worker_config.action_space} + self.assertIn("get_weather", actions) + + # Check get_weather function + get_weather = actions["get_weather"] + self.assertEqual(len(get_weather.args), 1) + self.assertEqual(get_weather.args[0].name, "query") + + @patch('requests.get') + def test_get_weather_success(self, mock_get): + """Test successful weather retrieval.""" + # Mock weather API response + mock_weather_data = { + "weather": [ + { + "location": "New York", + "temperature": 72, + "condition": "sunny", + "humidity": 45, + "clothing": "light jacket" + } + ] + } + mock_get.return_value.json.return_value = mock_weather_data + mock_get.return_value.raise_for_status.return_value = None + + # Test the handler + result = get_weather_handler("What's the weather like in New York?") + + # Verify results + self.assertEqual(result["status"], "success") + self.assertIn("New York", result["message"]) + self.assertEqual(result["data"]["temperature"], 72) + self.assertEqual(result["data"]["condition"], "sunny") + self.assertEqual(result["data"]["humidity"], 45) + self.assertEqual(result["data"]["clothing"], "light jacket") + + @patch('requests.get') + def test_get_weather_invalid_city(self, mock_get): + """Test handling of invalid city.""" + # Mock weather API response + mock_weather_data = {"weather": []} + mock_get.return_value.json.return_value = mock_weather_data + mock_get.return_value.raise_for_status.return_value = None + + # Test the handler + result = get_weather_handler("What's the weather like in InvalidCity?") + + # Verify results + self.assertEqual(result["status"], "error") + self.assertIn("No weather data available", result["message"]) + self.assertIn("error", result) + + @patch('requests.get') + def test_get_weather_api_error(self, mock_get): + """Test handling of API errors.""" + # Mock API error + mock_get.side_effect = Exception("API Error") + + # Test the handler + result = get_weather_handler("What's the weather like in New York?") + + # Verify results + self.assertEqual(result["status"], "error") + self.assertIn("Failed to fetch weather data", result["message"]) + self.assertIn("error", result) + +if __name__ == '__main__': + unittest.main()