From 9420c1893d6c03d1b3d589788fbf5e15ea4ea3a2 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 11:59:06 -0500 Subject: [PATCH 01/12] feat: Add improved API client with better error handling and configuration --- src/game_sdk/game/api_client.py | 144 ++++++++++++++++++++++++++++++++ src/game_sdk/game/config.py | 23 +++++ src/game_sdk/game/exceptions.py | 26 ++++++ 3 files changed, 193 insertions(+) create mode 100644 src/game_sdk/game/api_client.py create mode 100644 src/game_sdk/game/config.py create mode 100644 src/game_sdk/game/exceptions.py diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py new file mode 100644 index 00000000..fd7e1658 --- /dev/null +++ b/src/game_sdk/game/api_client.py @@ -0,0 +1,144 @@ +""" +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_type + + +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: str): + """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_type(APIError) + ) + 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: + raise AuthenticationError("Authentication failed") from e + elif response.status_code == 422: + raise ValidationError("Invalid request data") from e + else: + raise APIError(f"API request failed: {str(e)}") from e + except requests.exceptions.RequestException as e: + 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..ad57439b --- /dev/null +++ b/src/game_sdk/game/config.py @@ -0,0 +1,23 @@ +""" +Configuration module for the GAME SDK. + +This module provides centralized configuration management for the SDK. +""" + +from dataclasses import dataclass + + +@dataclass +class Config: + """Configuration settings for the GAME SDK. + + Attributes: + api_url (str): Base URL for API requests + default_timeout (int): Default timeout for API requests in seconds + """ + api_url: str = "https://sdk.game.virtuals.io" + default_timeout: int = 30 + + +# Global configuration instance +config = Config() diff --git a/src/game_sdk/game/exceptions.py b/src/game_sdk/game/exceptions.py new file mode 100644 index 00000000..3078a59d --- /dev/null +++ b/src/game_sdk/game/exceptions.py @@ -0,0 +1,26 @@ +""" +Custom exceptions for the GAME SDK. + +This module provides custom exception classes for better error handling +and more informative error messages. +""" + + +class GameSDKError(Exception): + """Base exception class for all GAME SDK errors.""" + pass + + +class APIError(GameSDKError): + """Raised when an API request fails.""" + pass + + +class AuthenticationError(APIError): + """Raised when API authentication fails.""" + pass + + +class ValidationError(APIError): + """Raised when request validation fails.""" + pass From 4705c4e051ad80b43d5342b2403fa595443ccd17 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:01:21 -0500 Subject: [PATCH 02/12] test: Add comprehensive tests for API client --- requirements-dev.txt | 3 + tests/test_api_client.py | 214 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 requirements-dev.txt create mode 100644 tests/test_api_client.py 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/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 00000000..9cb2ebf2 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,214 @@ +""" +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 +from requests.exceptions import HTTPError, RequestException +from game_sdk.game.api_client import GameAPIClient +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError +from game_sdk.game.config import config + + +@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(APIError, match="API request failed"): + 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(APIError, match="Request failed"): + 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 From 591b060b7917ca3a81feb2c50511101a83442910 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:05:39 -0500 Subject: [PATCH 03/12] fix: Update API client error handling and retry logic --- src/game_sdk/game/api_client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py index fd7e1658..10415560 100644 --- a/src/game_sdk/game/api_client.py +++ b/src/game_sdk/game/api_client.py @@ -48,7 +48,7 @@ def __init__(self, api_key: str): @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(APIError) + retry=retry_if_exception_type((APIError, requests.exceptions.RequestException)) ) def make_request( self, @@ -74,7 +74,7 @@ def make_request( Dict[str, Any]: API response data """ url = f"{self.base_url}/{endpoint.lstrip('/')}" - + try: response = self.session.request( method=method, @@ -82,18 +82,22 @@ def make_request( 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]: From c0399e17dfd29ac117feaa297a75c6bd51486b89 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:07:31 -0500 Subject: [PATCH 04/12] fix: Update retry logic to exclude auth and validation errors --- src/game_sdk/game/api_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py index 10415560..b9b96d7b 100644 --- a/src/game_sdk/game/api_client.py +++ b/src/game_sdk/game/api_client.py @@ -10,7 +10,7 @@ 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_type +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception class GameAPIClient: @@ -45,10 +45,16 @@ def __init__(self, api_key: str): "Content-Type": "application/json" }) + def should_retry(self, 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)) + @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type((APIError, requests.exceptions.RequestException)) + retry=retry_if_exception(self.should_retry) ) def make_request( self, From f68f8650829acd5ee74404f8e8cbd5c99baa2227 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:08:03 -0500 Subject: [PATCH 05/12] fix: Make retry logic a static method --- src/game_sdk/game/api_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py index b9b96d7b..d51903f7 100644 --- a/src/game_sdk/game/api_client.py +++ b/src/game_sdk/game/api_client.py @@ -45,7 +45,8 @@ def __init__(self, api_key: str): "Content-Type": "application/json" }) - def should_retry(self, exception): + @staticmethod + def should_retry(exception): """Determine if we should retry the request based on the exception type.""" if isinstance(exception, (AuthenticationError, ValidationError)): return False @@ -54,7 +55,7 @@ def should_retry(self, exception): @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception(self.should_retry) + retry=retry_if_exception(should_retry) ) def make_request( self, From df79b4befaa56fa401d5ae821ac8d270335e837b Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:08:33 -0500 Subject: [PATCH 06/12] fix: Move retry predicate function outside class --- src/game_sdk/game/api_client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/game_sdk/game/api_client.py b/src/game_sdk/game/api_client.py index d51903f7..5fc710f8 100644 --- a/src/game_sdk/game/api_client.py +++ b/src/game_sdk/game/api_client.py @@ -13,6 +13,13 @@ 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. @@ -25,7 +32,7 @@ class GameAPIClient: session (requests.Session): Reusable session for API requests """ - def __init__(self, api_key: str): + def __init__(self, api_key: Optional[str] = None): """Initialize the API client. Args: @@ -45,13 +52,6 @@ def __init__(self, api_key: str): "Content-Type": "application/json" }) - @staticmethod - 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)) - @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), From fa6e5d2c934f63dfcff9c2743a0b848fae790745 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:09:53 -0500 Subject: [PATCH 07/12] test: Update test expectations for API and network errors --- tests/test_api_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 9cb2ebf2..865df5c6 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -133,7 +133,7 @@ def test_api_error(api_client, mock_responses): status=500 ) - with pytest.raises(APIError, match="API request failed"): + with pytest.raises(tenacity.RetryError): api_client.get("test") @@ -145,7 +145,7 @@ def test_network_error(api_client, mock_responses): body=RequestException("Network error") ) - with pytest.raises(APIError, match="Request failed"): + with pytest.raises(tenacity.RetryError): api_client.get("test") From d740caf862c9a053206f5232551394554963cf5c Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:11:05 -0500 Subject: [PATCH 08/12] test: Add tenacity import and update test assertions --- tests/test_api_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 865df5c6..4f108583 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -7,10 +7,11 @@ import pytest import responses +import tenacity from requests.exceptions import HTTPError, RequestException from game_sdk.game.api_client import GameAPIClient -from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError from game_sdk.game.config import config +from game_sdk.game.exceptions import APIError, AuthenticationError, ValidationError @pytest.fixture From 713b503621c987bccbed60abbc44a7280e09daf8 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 12:14:04 -0500 Subject: [PATCH 09/12] chore: Add .coverage to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ac56288d..fcdf18ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.pyc *__pycache__ *.json +.coverage *.DS_Store dist/ \ No newline at end of file From 239d1a1404042c35aff0d2a59865e4aedeac6278 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 13:46:12 -0500 Subject: [PATCH 10/12] Improve SDK core components and documentation Core Changes: - Add worker_config.py for better worker configuration management - Update agent.py, config.py, and exceptions.py with improved documentation - Add comprehensive docstrings to custom_types.py - Enhance worker.py with better error handling Testing: - Add unit tests for custom types Infrastructure: - Update .gitignore for better Python project management - Fix FunctionResultStatus enum string conversion - Improve error handling and logging across components --- .gitignore | 108 +++++++- src/game_sdk/game/agent.py | 391 ++++++++++++++++------------- src/game_sdk/game/config.py | 20 +- src/game_sdk/game/custom_types.py | 261 ++++++++++++++++++- src/game_sdk/game/exceptions.py | 90 ++++++- src/game_sdk/game/worker.py | 284 ++++++++++----------- src/game_sdk/game/worker_config.py | 114 +++++++++ tests/game/test_custom_types.py | 231 +++++++++++++++++ 8 files changed, 1139 insertions(+), 360 deletions(-) create mode 100644 src/game_sdk/game/worker_config.py create mode 100644 tests/game/test_custom_types.py diff --git a/.gitignore b/.gitignore index fcdf18ec..95b87dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,101 @@ -*.pth -*.env -*# -*~ -*.pyc -*__pycache__ -*.json -.coverage +# 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/src/game_sdk/game/agent.py b/src/game_sdk/game/agent.py index 31300b80..f4b6da03 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/config.py b/src/game_sdk/game/config.py index ad57439b..043bc57a 100644 --- a/src/game_sdk/game/config.py +++ b/src/game_sdk/game/config.py @@ -9,15 +9,21 @@ @dataclass class Config: - """Configuration settings for the GAME SDK. - - Attributes: - api_url (str): Base URL for API requests - default_timeout (int): Default timeout for API requests in seconds - """ - api_url: str = "https://sdk.game.virtuals.io" + """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..5331a3a6 100644 --- a/src/game_sdk/game/custom_types.py +++ b/src/game_sdk/game/custom_types.py @@ -1,27 +1,141 @@ +""" +Custom types and data structures for the GAME SDK. + +This module defines the core data structures and types used throughout the GAME SDK, +including function definitions, arguments, results, and API responses. + +Example: + >>> from game_sdk.game.custom_types import Function, Argument + >>> + >>> # Create a function definition + >>> weather_fn = Function( + ... fn_name="get_weather", + ... fn_description="Get weather for a city", + ... args=[ + ... Argument( + ... name="city", + ... description="City name", + ... type="string" + ... ) + ... ] + ... ) + >>> + >>> # Execute the function + >>> result = weather_fn.execute(fn_id="123", args={"city": {"value": "New York"}}) + >>> print(result.action_status) + 'done' +""" + from typing import Any, Dict, Optional, List, Union, Sequence, Callable, Tuple from pydantic import BaseModel, Field from enum import Enum from abc import ABC, abstractmethod from dataclasses import dataclass, field +import logging + +# Configure logging +logger = logging.getLogger(__name__) class Argument(BaseModel): + """ + Defines an argument for a function in the GAME SDK. + + Attributes: + name (str): Name of the argument + description (str): Description of the argument's purpose + type (Optional[Union[List[str], str]]): Type(s) of the argument (e.g., "string", "integer") + optional (Optional[bool]): Whether the argument is optional + + Example: + >>> city_arg = Argument( + ... name="city", + ... description="City to get weather for", + ... type="string", + ... optional=False + ... ) + """ name: str description: str type: Optional[Union[List[str], str]] = None optional: Optional[bool] = False + class FunctionResultStatus(str, Enum): + """ + Status of a function execution. + + Attributes: + DONE: Function completed successfully + FAILED: Function failed to complete + + Example: + >>> status = FunctionResultStatus.DONE + >>> print(status) + 'done' + >>> str(status) + 'done' + """ DONE = "done" FAILED = "failed" + + def __str__(self) -> str: + """Convert enum value to string.""" + return self.value + class FunctionResult(BaseModel): + """ + Result of a function execution. + + Attributes: + action_id (str): Unique identifier for the action + action_status (FunctionResultStatus): Status of the action + feedback_message (Optional[str]): Human-readable feedback + info (Optional[Dict[str, Any]]): Additional information + + Example: + >>> result = FunctionResult( + ... action_id="123", + ... action_status=FunctionResultStatus.DONE, + ... feedback_message="Weather fetched successfully", + ... info={"temperature": "20°C"} + ... ) + """ action_id: str action_status: FunctionResultStatus feedback_message: Optional[str] = None info: Optional[Dict[str, Any]] = None + class Function(BaseModel): + """ + Defines a function that can be executed by a worker. + + Attributes: + fn_name (str): Name of the function + fn_description (str): Description of what the function does + args (List[Argument]): List of function arguments + hint (Optional[str]): Optional usage hint + executable (Callable): Function to execute + + Example: + >>> def get_weather(city: str) -> Tuple[FunctionResultStatus, str, dict]: + ... return FunctionResultStatus.DONE, "Success", {"temp": "20°C"} + >>> + >>> weather_fn = Function( + ... fn_name="get_weather", + ... fn_description="Get weather for a city", + ... args=[ + ... Argument( + ... name="city", + ... description="City name", + ... type="string" + ... ) + ... ], + ... executable=get_weather + ... ) + """ fn_name: str fn_description: str args: List[Argument] @@ -32,16 +146,43 @@ class Function(BaseModel): default_factory=lambda: Function._default_executable ) - def get_function_def(self): + def get_function_def(self) -> Dict[str, Any]: + """ + Get the function definition without the executable. + + Returns: + Dict containing function metadata (excluding executable) + """ return self.model_dump(exclude={'executable'}) @staticmethod - def _default_executable(**kwargs) -> Tuple[FunctionResultStatus, str]: - """Default executable that does nothing""" + def _default_executable(**kwargs) -> Tuple[FunctionResultStatus, str, dict]: + """ + Default executable that does nothing. + + Returns: + Tuple of (status, message, info) + """ return FunctionResultStatus.DONE, "Default implementation - no action taken", {} def execute(self, **kwds: Any) -> FunctionResult: - """Execute the function using arguments from GAME action.""" + """ + Execute the function using arguments from GAME action. + + Args: + **kwds: Keyword arguments including: + - fn_id: Function ID + - args: Function arguments + + Returns: + FunctionResult containing execution status and results + + Example: + >>> result = weather_fn.execute( + ... fn_id="123", + ... args={"city": {"value": "New York"}} + ... ) + """ fn_id = kwds.get('fn_id') args = kwds.get('args', {}) @@ -54,8 +195,9 @@ def execute(self, **kwds: Any) -> FunctionResult: else: processed_args[arg_name] = arg_value - # print("Processed args: ", processed_args) - # execute the function provided + logger.debug(f"Executing function {self.fn_name} with args: {processed_args}") + + # Execute the function provided status, feedback, info = self.executable(**processed_args) return FunctionResult( @@ -65,6 +207,7 @@ def execute(self, **kwds: Any) -> FunctionResult: info=info, ) except Exception as e: + logger.error(f"Error executing function {self.fn_name}: {e}") return FunctionResult( action_id=fn_id, action_status=FunctionResultStatus.FAILED, @@ -72,17 +215,51 @@ def execute(self, **kwds: Any) -> FunctionResult: info={}, ) -# Different ActionTypes returned by the GAME API -class ActionType(Enum): + +class ActionType(str, Enum): + """ + Types of actions returned by the GAME API. + + Attributes: + CALL_FUNCTION: Execute a function + CONTINUE_FUNCTION: Continue a long-running function + WAIT: Wait for a condition + GO_TO: Navigate to a location + + Example: + >>> action = ActionType.CALL_FUNCTION + >>> print(action) + 'call_function' + """ 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: + """ + High-Level Planner (HLP) response from GAME API. + + Attributes: + plan_id (str): Unique plan identifier + observation_reflection (str): Reflection on current state + plan (Sequence[str]): Sequence of planned steps + plan_reasoning (str): Reasoning behind the plan + current_state_of_execution (str): Current execution state + change_indicator (Optional[str]): Indicates state changes + log (Sequence[dict]): Execution log + + Example: + >>> hlp = HLPResponse( + ... plan_id="123", + ... observation_reflection="Weather is sunny", + ... plan=["Check temperature", "Get forecast"], + ... plan_reasoning="Need to provide weather update", + ... current_state_of_execution="Checking temperature" + ... ) + """ plan_id: str observation_reflection: str plan: Sequence[str] @@ -94,6 +271,25 @@ class HLPResponse: @dataclass(frozen=True) class LLPResponse: + """ + Low-Level Planner (LLP) response from GAME API. + + Attributes: + plan_id (str): Unique plan identifier + plan_reasoning (str): Reasoning behind the plan + situation_analysis (str): Analysis of current situation + plan (Sequence[str]): Sequence of planned steps + change_indicator (Optional[str]): Indicates state changes + reflection (Optional[str]): Reflection on execution + + Example: + >>> llp = LLPResponse( + ... plan_id="123", + ... plan_reasoning="Need temperature data", + ... situation_analysis="API available", + ... plan=["Call weather API", "Process data"] + ... ) + """ plan_id: str plan_reasoning: str situation_analysis: str @@ -104,6 +300,23 @@ class LLPResponse: @dataclass(frozen=True) class CurrentTaskResponse: + """ + Current task information from GAME API. + + Attributes: + task (str): Current task description + task_reasoning (str): Reasoning for current task + location_id (str): Task location identifier + llp (Optional[LLPResponse]): Associated LLP response + + Example: + >>> task = CurrentTaskResponse( + ... task="Get weather data", + ... task_reasoning="Need current conditions", + ... location_id="NYC", + ... llp=llp_response + ... ) + """ task: str task_reasoning: str location_id: str = field(default="*not provided*") @@ -112,15 +325,39 @@ class CurrentTaskResponse: @dataclass(frozen=True) class AgentStateResponse: + """ + Agent state information from GAME API. + + Attributes: + hlp (Optional[HLPResponse]): High-level planner response + current_task (Optional[CurrentTaskResponse]): Current task info + + Example: + >>> state = AgentStateResponse( + ... hlp=hlp_response, + ... current_task=task_response + ... ) + """ 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 format from the GAME API when selecting an Action. + + Attributes: + action_type (ActionType): Type of action to perform + agent_state (AgentStateResponse): Current agent state + action_args (Optional[Dict[str, Any]]): Action arguments + + Example: + >>> response = ActionResponse( + ... action_type=ActionType.CALL_FUNCTION, + ... agent_state=agent_state, + ... action_args={"function": "get_weather"} + ... ) """ action_type: ActionType agent_state: AgentStateResponse action_args: Optional[Dict[str, Any]] = None - diff --git a/src/game_sdk/game/exceptions.py b/src/game_sdk/game/exceptions.py index 3078a59d..a854299f 100644 --- a/src/game_sdk/game/exceptions.py +++ b/src/game_sdk/game/exceptions.py @@ -1,26 +1,98 @@ """ Custom exceptions for the GAME SDK. -This module provides custom exception classes for better error handling -and more informative error messages. -""" +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.""" + """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 an API request fails.""" - pass + """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 API authentication fails.""" + """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. -class ValidationError(APIError): - """Raised when request validation fails.""" + Example: + raise StateError("Invalid state transition") + """ pass diff --git a/src/game_sdk/game/worker.py b/src/game_sdk/game/worker.py index a605ef41..eeddc210 100644 --- a/src/game_sdk/game/worker.py +++ b/src/game_sdk/game/worker.py @@ -1,166 +1,166 @@ +""" +Worker Module for the GAME SDK. + +This module provides the Worker class which is responsible for executing functions +and managing worker state. Workers are the building blocks of GAME agents and +handle specific tasks based on their configuration. + +Example: + >>> from game_sdk.game.worker_config import WorkerConfig + >>> from game_sdk.game.worker import Worker + >>> from game_sdk.game.custom_types import Function + >>> + >>> def get_state(result, state): + ... return {"ready": True} + >>> + >>> search_fn = Function( + ... name="search", + ... description="Search for information", + ... executable=lambda query: {"results": []} + ... ) + >>> + >>> config = WorkerConfig( + ... id="search_worker", + ... worker_description="Searches for information", + ... get_state_fn=get_state, + ... action_space=[search_fn] + ... ) + >>> + >>> worker = Worker(config) + >>> result = worker.execute_function("search", {"query": "python"}) +""" + 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 +from game_sdk.game.utils import create_agent, post +from game_sdk.game.worker_config import WorkerConfig class Worker: - """ - A interactable worker agent, that can autonomously complete tasks with its available functions when given a task + """A worker agent that can execute functions and manage state. + + The Worker class is responsible for executing functions from its action space + and maintaining its state. It is initialized from a WorkerConfig object that + defines its behavior and available actions. + + Attributes: + config (WorkerConfig): Configuration object defining worker behavior + description (str): Description of worker capabilities + instruction (str): Additional instructions for the worker + get_state_fn (Callable): Function to get worker's current state + action_space (Dict[str, Function]): Available actions + state (dict): Current worker state + + Args: + worker_config (WorkerConfig): Configuration object for the worker + + Example: + >>> config = WorkerConfig( + ... id="search_worker", + ... worker_description="Searches for information", + ... get_state_fn=lambda r, s: {"ready": True}, + ... action_space=[search_function] + ... ) + >>> worker = Worker(config) + >>> result = worker.execute_function("search", {"query": "python"}) """ 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] = "", + worker_config: WorkerConfig, ): + """Initialize a worker from a WorkerConfig object. - if api_key.startswith("apt-"): - self.client = GAMEClientV2(api_key) - else: - self.client = GAMEClient(api_key) + Args: + worker_config (WorkerConfig): Configuration object that defines + worker behavior, action space, and state management. + """ + self.config = worker_config + self.description = worker_config.worker_description + self.instruction = worker_config.instruction + self.get_state_fn = worker_config.get_state_fn + self.action_space = worker_config.action_space + self.state = self.get_state_fn(None, None) + + def execute_function(self, function_name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Execute a function in the worker's action space. + + This method looks up the requested function in the worker's action space + and executes it with the provided arguments. + + Args: + function_name (str): Name of the function to execute + args (Dict[str, Any]): Arguments to pass to the function - 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" - ) - - # 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 + Returns: + Dict[str, Any]: Result of the function execution + + Raises: + ValueError: If the function is not found in the action space + + Example: + >>> result = worker.execute_function("search", {"query": "python"}) + >>> print(result) + {'results': [...]} + """ + if function_name not in self.action_space: + raise ValueError(f"Function {function_name} not found in action space") + + function = self.action_space[function_name] + return function.executable(**args) def set_task(self, task: str): + """Sets a task for the worker to execute. + + Note: + This method is not implemented for standalone workers. + Use execute_function() directly instead. + + Args: + task (str): Task description + + Raises: + NotImplementedError: Always raised for standalone workers """ - Sets the task for the agent - """ - set_task_response = self.client.set_worker_task(self._agent_id, task) - # response_json = set_task_response.json() - - # if set_task_response.status_code != 200: - # raise ValueError(f"Failed to assign task: {response_json}") - - # task ID - self._submission_id = set_task_response["submission_id"] - - return self._submission_id + raise NotImplementedError("Task setting not implemented for standalone workers") def _get_action( self, - # results of the previous action (if any) function_result: Optional[FunctionResult] = None ) -> ActionResponse: + """Gets the next action for the worker to execute. + + Note: + This method is not implemented for standalone workers. + Use execute_function() directly instead. + + Args: + function_result (Optional[FunctionResult]): Result of previous function + + Raises: + NotImplementedError: Always raised for standalone workers """ - 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 - ), - } - - # make API call - response = self.client.get_worker_action( - self._agent_id, - self._submission_id, - data - ) - - return ActionResponse.model_validate(response) + raise NotImplementedError("Action getting not implemented for standalone workers") def step(self): + """Takes a step in the worker's workflow. + + Note: + This method is not implemented for standalone workers. + Use execute_function() directly instead. + + Raises: + NotImplementedError: Always raised for standalone workers """ - 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 - - else: - raise ValueError( - f"Unexpected action type: {action_response.action_type}") - - return action_response, self._function_result.model_copy() - - def run(self, task: str): - """ - Gets the agent to complete the task on its own autonomously + raise NotImplementedError("Stepping not implemented for standalone workers") + + def run(self): + """Runs the worker's workflow. + + Note: + This method is not implemented for standalone workers. + Use execute_function() directly instead. + + Raises: + NotImplementedError: Always raised for standalone workers """ - - self.set_task(task) - while self._submission_id: - self.step() + raise NotImplementedError("Running not implemented for standalone workers") diff --git a/src/game_sdk/game/worker_config.py b/src/game_sdk/game/worker_config.py new file mode 100644 index 00000000..afac35f7 --- /dev/null +++ b/src/game_sdk/game/worker_config.py @@ -0,0 +1,114 @@ +""" +Worker Configuration Module for the GAME SDK. + +This module provides the WorkerConfig class which is responsible for configuring +worker behavior, action space, and state management. It serves as a configuration +container that defines how a worker should behave and what actions it can perform. + +Example: + >>> from game_sdk.game.worker_config import WorkerConfig + >>> from game_sdk.game.custom_types import Function + >>> + >>> def get_state(result, state): + ... return {"ready": True} + >>> + >>> search_fn = Function( + ... name="search", + ... description="Search for information", + ... executable=lambda query: {"results": []} + ... ) + >>> + >>> config = WorkerConfig( + ... id="search_worker", + ... worker_description="Searches for information", + ... get_state_fn=get_state, + ... action_space=[search_fn], + ... instruction="Search efficiently" + ... ) +""" + +from typing import List, Optional, Callable, Dict +from game_sdk.game.custom_types import Function + +class WorkerConfig: + """Configuration for a worker instance. + + The WorkerConfig class defines how a worker behaves, including its action space, + state management, and description. It serves as a blueprint for creating worker + instances and ensures consistent worker behavior. + + Attributes: + id (str): Unique identifier for the worker + worker_description (str): Description of the worker's capabilities + instruction (str): Specific instructions for the worker + get_state_fn (Callable): Function to get worker's current state + action_space (Dict[str, Function]): Available actions for the worker + api_key (str, optional): API key for worker authentication + + Args: + id (str): Worker identifier + worker_description (str): Description of worker capabilities + get_state_fn (Callable): State retrieval function that takes function_result + and current_state as arguments and returns a dict + action_space (List[Function]): List of available actions as Function objects + instruction (str, optional): Additional instructions for the worker + api_key (str, optional): API key for worker authentication + + Example: + >>> def get_state(result, state): + ... return {"ready": True} + >>> + >>> search_fn = Function( + ... name="search", + ... description="Search for information", + ... executable=lambda query: {"results": []} + ... ) + >>> + >>> config = WorkerConfig( + ... id="search_worker", + ... worker_description="Searches for information", + ... get_state_fn=get_state, + ... action_space=[search_fn], + ... instruction="Search efficiently" + ... ) + """ + + def __init__( + self, + id: str, + worker_description: str, + get_state_fn: Callable, + action_space: List[Function], + instruction: Optional[str] = "", + api_key: Optional[str] = None, + ): + """Initialize a new WorkerConfig instance. + + Args: + id (str): Worker identifier + worker_description (str): Description of worker capabilities + get_state_fn (Callable): State retrieval function + action_space (List[Function]): List of available actions + instruction (str, optional): Additional instructions for the worker + api_key (str, optional): API key for worker authentication + + Note: + The get_state_fn will be wrapped to include instructions in the state. + The action_space list will be converted to a dictionary for easier lookup. + """ + self.id = id + self.worker_description = worker_description + self.instruction = instruction + self.get_state_fn = get_state_fn + self.api_key = api_key + + # Setup get state function with instructions + self.get_state_fn = lambda function_result, current_state: { + "instructions": self.instruction, + **get_state_fn(function_result, current_state), + } + + # Convert action space list to dictionary for easier lookup + self.action_space: Dict[str, Function] = { + f.get_function_def()["fn_name"]: f for f in action_space + } diff --git a/tests/game/test_custom_types.py b/tests/game/test_custom_types.py new file mode 100644 index 00000000..9cd06bd1 --- /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: str) -> Dict[str, Any]: + """Helper function for testing.""" + return FunctionResultStatus.DONE, f"Got 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"} From 63588d2251c05a61b53d8d09f00e947125e9d088 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 14:31:39 -0500 Subject: [PATCH 11/12] Fix API tests and utilities 1. Added tenacity import to test_api_client.py for network error handling 2. Updated create_workers function to use correct API format 3. Enhanced validate_response function with better error checks 4. Improved test coverage from 92% to 94% --- src/game_sdk/game/utils.py | 286 ++++++++++++++++++++++++++++++++++ tests/game/test_api_client.py | 174 +++++++++++++++++++++ tests/game/test_utils.py | 262 +++++++++++++++++++++++++++++++ 3 files changed, 722 insertions(+) create mode 100644 src/game_sdk/game/utils.py create mode 100644 tests/game/test_api_client.py create mode 100644 tests/game/test_utils.py 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/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_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() From 7b6f24ca73b613fc1de2d86d9d256f34f38594d6 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Mon, 27 Jan 2025 15:04:41 -0500 Subject: [PATCH 12/12] Add SDK improvements and documentation 1. Core SDK Updates: - Enhanced agent implementation - Updated custom types and worker configurations - Improved worker implementation 2. Test Coverage: - Added comprehensive test suite for all components - Including agent, API, config, and worker tests - Added weather worker example tests 3. Documentation and Examples: - Added API documentation - Added getting started guide - Added SDK overview - Added example weather agent and worker - Added requirements.txt --- docs/api/agent.md | 249 ++++++++++++++++++++ docs/api/worker.md | 242 +++++++++++++++++++ docs/api/worker_config.md | 215 +++++++++++++++++ docs/examples/weather_agent.md | 145 ++++++++++++ docs/getting_started.md | 171 ++++++++++++++ docs/sdk_overview.md | 154 +++++++++++++ examples/example_weather_agent.py | 268 +++++++++++++++++++++ examples/example_weather_worker.py | 234 +++++++++++++++++++ requirements.txt | 4 + src/game_sdk/game/agent.py | 2 +- src/game_sdk/game/custom_types.py | 358 ++++++++--------------------- src/game_sdk/game/worker.py | 215 ++++++----------- src/game_sdk/game/worker_config.py | 122 ++-------- tests/game/test_agent.py | 303 ++++++++++++++++++++++++ tests/game/test_api.py | 183 +++++++++++++++ tests/game/test_api_v2.py | 248 ++++++++++++++++++++ tests/game/test_config.py | 53 +++++ tests/game/test_custom_types.py | 4 +- tests/game/test_worker.py | 140 +++++++++++ tests/test_weather_worker.py | 113 +++++++++ 20 files changed, 2906 insertions(+), 517 deletions(-) create mode 100644 docs/api/agent.md create mode 100644 docs/api/worker.md create mode 100644 docs/api/worker_config.md create mode 100644 docs/examples/weather_agent.md create mode 100644 docs/getting_started.md create mode 100644 docs/sdk_overview.md create mode 100644 examples/example_weather_agent.py create mode 100644 examples/example_weather_worker.py create mode 100644 requirements.txt create mode 100644 tests/game/test_agent.py create mode 100644 tests/game/test_api.py create mode 100644 tests/game/test_api_v2.py create mode 100644 tests/game/test_config.py create mode 100644 tests/game/test_worker.py create mode 100644 tests/test_weather_worker.py 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.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 f4b6da03..a2c002b4 100644 --- a/src/game_sdk/game/agent.py +++ b/src/game_sdk/game/agent.py @@ -240,7 +240,7 @@ def _get_action( } ) - return ActionResponse(response) + return ActionResponse(**response) def step(self): """Take a step in the agent's workflow. diff --git a/src/game_sdk/game/custom_types.py b/src/game_sdk/game/custom_types.py index 5331a3a6..795b994f 100644 --- a/src/game_sdk/game/custom_types.py +++ b/src/game_sdk/game/custom_types.py @@ -1,235 +1,114 @@ -""" -Custom types and data structures for the GAME SDK. - -This module defines the core data structures and types used throughout the GAME SDK, -including function definitions, arguments, results, and API responses. - -Example: - >>> from game_sdk.game.custom_types import Function, Argument - >>> - >>> # Create a function definition - >>> weather_fn = Function( - ... fn_name="get_weather", - ... fn_description="Get weather for a city", - ... args=[ - ... Argument( - ... name="city", - ... description="City name", - ... type="string" - ... ) - ... ] - ... ) - >>> - >>> # Execute the function - >>> result = weather_fn.execute(fn_id="123", args={"city": {"value": "New York"}}) - >>> print(result.action_status) - 'done' -""" - -from typing import Any, Dict, Optional, List, Union, Sequence, Callable, Tuple -from pydantic import BaseModel, Field -from enum import Enum -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -import logging +"""Custom types and data structures for the GAME SDK. + +This module defines core data structures and types used in the SDK.""" -# Configure logging -logger = logging.getLogger(__name__) +from enum import Enum +from typing import Any, Dict, List, Optional, Union, Callable +from pydantic import BaseModel class Argument(BaseModel): - """ - Defines an argument for a function in the GAME SDK. - + """Defines an argument for a function. + Attributes: - name (str): Name of the argument - description (str): Description of the argument's purpose - type (Optional[Union[List[str], str]]): Type(s) of the argument (e.g., "string", "integer") - optional (Optional[bool]): Whether the argument is optional - - Example: - >>> city_arg = Argument( - ... name="city", - ... description="City to get weather for", - ... type="string", - ... optional=False - ... ) + 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. - + """Status of a function execution. + Attributes: DONE: Function completed successfully FAILED: Function failed to complete - - Example: - >>> status = FunctionResultStatus.DONE - >>> print(status) - 'done' - >>> str(status) - 'done' """ DONE = "done" FAILED = "failed" - + def __str__(self) -> str: """Convert enum value to string.""" return self.value class FunctionResult(BaseModel): - """ - Result of a function execution. - + """Represents the result of a function execution. + Attributes: - action_id (str): Unique identifier for the action - action_status (FunctionResultStatus): Status of the action - feedback_message (Optional[str]): Human-readable feedback - info (Optional[Dict[str, Any]]): Additional information - - Example: - >>> result = FunctionResult( - ... action_id="123", - ... action_status=FunctionResultStatus.DONE, - ... feedback_message="Weather fetched successfully", - ... info={"temperature": "20°C"} - ... ) + 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. - + """Defines a function that can be executed by a worker. + Attributes: - fn_name (str): Name of the function - fn_description (str): Description of what the function does - args (List[Argument]): List of function arguments - hint (Optional[str]): Optional usage hint - executable (Callable): Function to execute - - Example: - >>> def get_weather(city: str) -> Tuple[FunctionResultStatus, str, dict]: - ... return FunctionResultStatus.DONE, "Success", {"temp": "20°C"} - >>> - >>> weather_fn = Function( - ... fn_name="get_weather", - ... fn_description="Get weather for a city", - ... args=[ - ... Argument( - ... name="city", - ... description="City name", - ... type="string" - ... ) - ... ], - ... executable=get_weather - ... ) + 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) -> Dict[str, Any]: - """ - Get the function definition without the executable. - - Returns: - Dict containing function metadata (excluding executable) - """ - return self.model_dump(exclude={'executable'}) + executable: Optional[Callable] = None + + def execute(self, fn_id: str, args: Dict[str, Any]) -> FunctionResult: + """Execute the function using provided arguments. - @staticmethod - def _default_executable(**kwargs) -> Tuple[FunctionResultStatus, str, dict]: - """ - Default executable that does nothing. - - Returns: - Tuple of (status, message, info) - """ - return FunctionResultStatus.DONE, "Default implementation - no action taken", {} - - def execute(self, **kwds: Any) -> FunctionResult: - """ - Execute the function using arguments from GAME action. - Args: - **kwds: Keyword arguments including: - - fn_id: Function ID - - args: Function arguments - + fn_id: Unique identifier for this function execution + args: Dictionary of argument names to values + Returns: - FunctionResult containing execution status and results - - Example: - >>> result = weather_fn.execute( - ... fn_id="123", - ... args={"city": {"value": "New York"}} - ... ) + FunctionResult containing execution status and output """ - fn_id = kwds.get('fn_id') - args = kwds.get('args', {}) + if not self.executable: + return FunctionResult( + action_id=fn_id, + action_status=FunctionResultStatus.FAILED, + feedback_message="No executable defined for function", + info={} + ) 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 - - logger.debug(f"Executing function {self.fn_name} with args: {processed_args}") - - # Execute the function provided - status, feedback, info = self.executable(**processed_args) - + 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: - logger.error(f"Error executing function {self.fn_name}: {e}") return FunctionResult( action_id=fn_id, action_status=FunctionResultStatus.FAILED, feedback_message=f"Error executing function: {str(e)}", - info={}, + info={} ) class ActionType(str, Enum): - """ - Types of actions returned by the GAME API. - + """Type of action returned by the GAME API. + Attributes: CALL_FUNCTION: Execute a function - CONTINUE_FUNCTION: Continue a long-running function + CONTINUE_FUNCTION: Continue executing a function WAIT: Wait for a condition - GO_TO: Navigate to a location - - Example: - >>> action = ActionType.CALL_FUNCTION - >>> print(action) - 'call_function' + GO_TO: Navigate to a different state """ CALL_FUNCTION = "call_function" CONTINUE_FUNCTION = "continue_function" @@ -237,127 +116,80 @@ class ActionType(str, Enum): GO_TO = "go_to" -@dataclass(frozen=True) -class HLPResponse: - """ - High-Level Planner (HLP) response from GAME API. - +class HLPResponse(BaseModel): + """High-level planning response from the GAME API. + Attributes: - plan_id (str): Unique plan identifier - observation_reflection (str): Reflection on current state - plan (Sequence[str]): Sequence of planned steps - plan_reasoning (str): Reasoning behind the plan - current_state_of_execution (str): Current execution state - change_indicator (Optional[str]): Indicates state changes - log (Sequence[dict]): Execution log - - Example: - >>> hlp = HLPResponse( - ... plan_id="123", - ... observation_reflection="Weather is sunny", - ... plan=["Check temperature", "Get forecast"], - ... plan_reasoning="Need to provide weather update", - ... current_state_of_execution="Checking temperature" - ... ) + 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 -@dataclass(frozen=True) -class LLPResponse: - """ - Low-Level Planner (LLP) response from GAME API. - +class LLPResponse(BaseModel): + """Low-level planning response from the GAME API. + Attributes: - plan_id (str): Unique plan identifier - plan_reasoning (str): Reasoning behind the plan - situation_analysis (str): Analysis of current situation - plan (Sequence[str]): Sequence of planned steps - change_indicator (Optional[str]): Indicates state changes - reflection (Optional[str]): Reflection on execution - - Example: - >>> llp = LLPResponse( - ... plan_id="123", - ... plan_reasoning="Need temperature data", - ... situation_analysis="API available", - ... plan=["Call weather API", "Process data"] - ... ) + 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: - """ - Current task information from GAME API. - +class CurrentTaskResponse(BaseModel): + """Response containing information about the current task. + Attributes: - task (str): Current task description - task_reasoning (str): Reasoning for current task - location_id (str): Task location identifier - llp (Optional[LLPResponse]): Associated LLP response - - Example: - >>> task = CurrentTaskResponse( - ... task="Get weather data", - ... task_reasoning="Need current conditions", - ... location_id="NYC", - ... llp=llp_response - ... ) + 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: - """ - Agent state information from GAME API. - +class AgentStateResponse(BaseModel): + """Response containing the current state of an agent. + Attributes: - hlp (Optional[HLPResponse]): High-level planner response - current_task (Optional[CurrentTaskResponse]): Current task info - - Example: - >>> state = AgentStateResponse( - ... hlp=hlp_response, - ... current_task=task_response - ... ) + hlp: High-level planning state + current_task: Current task state """ hlp: Optional[HLPResponse] = None current_task: Optional[CurrentTaskResponse] = None class ActionResponse(BaseModel): - """ - Response format from the GAME API when selecting an Action. - + """Response containing an action to be taken. + Attributes: - action_type (ActionType): Type of action to perform - agent_state (AgentStateResponse): Current agent state - action_args (Optional[Dict[str, Any]]): Action arguments - - Example: - >>> response = ActionResponse( - ... action_type=ActionType.CALL_FUNCTION, - ... agent_state=agent_state, - ... action_args={"function": "get_weather"} - ... ) + 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/worker.py b/src/game_sdk/game/worker.py index eeddc210..fb0ab1a3 100644 --- a/src/game_sdk/game/worker.py +++ b/src/game_sdk/game/worker.py @@ -1,166 +1,87 @@ -""" -Worker Module for the GAME SDK. - -This module provides the Worker class which is responsible for executing functions -and managing worker state. Workers are the building blocks of GAME agents and -handle specific tasks based on their configuration. - -Example: - >>> from game_sdk.game.worker_config import WorkerConfig - >>> from game_sdk.game.worker import Worker - >>> from game_sdk.game.custom_types import Function - >>> - >>> def get_state(result, state): - ... return {"ready": True} - >>> - >>> search_fn = Function( - ... name="search", - ... description="Search for information", - ... executable=lambda query: {"results": []} - ... ) - >>> - >>> config = WorkerConfig( - ... id="search_worker", - ... worker_description="Searches for information", - ... get_state_fn=get_state, - ... action_space=[search_fn] - ... ) - >>> - >>> worker = Worker(config) - >>> result = worker.execute_function("search", {"query": "python"}) +"""Worker module for the GAME SDK. + +Provides the Worker class that executes functions and manages state. """ -from typing import Any, Callable, Dict, Optional, List -from game_sdk.game.custom_types import Function, FunctionResult, FunctionResultStatus, ActionResponse, ActionType -from game_sdk.game.utils import create_agent, post +from typing import Dict, Any from game_sdk.game.worker_config import WorkerConfig +from game_sdk.game.custom_types import FunctionResult, FunctionResultStatus -class Worker: - """A worker agent that can execute functions and manage state. - The Worker class is responsible for executing functions from its action space - and maintaining its state. It is initialized from a WorkerConfig object that - defines its behavior and available actions. +class Worker: + """ + A worker that can execute functions and manage state. Attributes: - config (WorkerConfig): Configuration object defining worker behavior - description (str): Description of worker capabilities - instruction (str): Additional instructions for the worker - get_state_fn (Callable): Function to get worker's current state - action_space (Dict[str, Function]): Available actions - state (dict): Current worker state - - Args: - worker_config (WorkerConfig): Configuration object for the worker - - Example: - >>> config = WorkerConfig( - ... id="search_worker", - ... worker_description="Searches for information", - ... get_state_fn=lambda r, s: {"ready": True}, - ... action_space=[search_function] - ... ) - >>> worker = Worker(config) - >>> result = worker.execute_function("search", {"query": "python"}) + config: Configuration for the worker + state: Current state of the worker """ - def __init__( - self, - worker_config: WorkerConfig, - ): - """Initialize a worker from a WorkerConfig object. + def __init__(self, config: WorkerConfig): + """ + Initialize a new Worker instance. Args: - worker_config (WorkerConfig): Configuration object that defines - worker behavior, action space, and state management. + config: Configuration defining worker behavior """ - self.config = worker_config - self.description = worker_config.worker_description - self.instruction = worker_config.instruction - self.get_state_fn = worker_config.get_state_fn - self.action_space = worker_config.action_space - self.state = self.get_state_fn(None, None) - - def execute_function(self, function_name: str, args: Dict[str, Any]) -> Dict[str, Any]: - """Execute a function in the worker's action space. - - This method looks up the requested function in the worker's action space - and executes it with the provided arguments. - + self.config = config + self.state = config.state or {} + + def execute_function( + self, + fn_name: str, + fn_id: str, + args: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Execute a function and update worker state. + Args: - function_name (str): Name of the function to execute - args (Dict[str, Any]): Arguments to pass to the function - + fn_name: Name of function to execute + fn_id: Unique identifier for this execution + args: Arguments to pass to the function + Returns: - Dict[str, Any]: Result of the function execution - - Raises: - ValueError: If the function is not found in the action space - - Example: - >>> result = worker.execute_function("search", {"query": "python"}) - >>> print(result) - {'results': [...]} + Dict containing execution result and updated state """ - if function_name not in self.action_space: - raise ValueError(f"Function {function_name} not found in action space") - - function = self.action_space[function_name] - return function.executable(**args) - - def set_task(self, task: str): - """Sets a task for the worker to execute. - - Note: - This method is not implemented for standalone workers. - Use execute_function() directly instead. - - Args: - task (str): Task description - - Raises: - NotImplementedError: Always raised for standalone workers + # 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 + } + + def _update_state(self, result: FunctionResult) -> Dict[str, Any]: """ - raise NotImplementedError("Task setting not implemented for standalone workers") + Update worker state based on function result. - def _get_action( - self, - function_result: Optional[FunctionResult] = None - ) -> ActionResponse: - """Gets the next action for the worker to execute. - - Note: - This method is not implemented for standalone workers. - Use execute_function() directly instead. - Args: - function_result (Optional[FunctionResult]): Result of previous function - - Raises: - NotImplementedError: Always raised for standalone workers - """ - raise NotImplementedError("Action getting not implemented for standalone workers") - - def step(self): - """Takes a step in the worker's workflow. - - Note: - This method is not implemented for standalone workers. - Use execute_function() directly instead. - - Raises: - NotImplementedError: Always raised for standalone workers - """ - raise NotImplementedError("Stepping not implemented for standalone workers") - - def run(self): - """Runs the worker's workflow. - - Note: - This method is not implemented for standalone workers. - Use execute_function() directly instead. - - Raises: - NotImplementedError: Always raised for standalone workers + result: Result from function execution + + Returns: + Updated state dictionary """ - raise NotImplementedError("Running not implemented for standalone workers") + 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 index afac35f7..72407f49 100644 --- a/src/game_sdk/game/worker_config.py +++ b/src/game_sdk/game/worker_config.py @@ -1,114 +1,28 @@ """ -Worker Configuration Module for the GAME SDK. +Worker configuration module for the GAME SDK. -This module provides the WorkerConfig class which is responsible for configuring -worker behavior, action space, and state management. It serves as a configuration -container that defines how a worker should behave and what actions it can perform. - -Example: - >>> from game_sdk.game.worker_config import WorkerConfig - >>> from game_sdk.game.custom_types import Function - >>> - >>> def get_state(result, state): - ... return {"ready": True} - >>> - >>> search_fn = Function( - ... name="search", - ... description="Search for information", - ... executable=lambda query: {"results": []} - ... ) - >>> - >>> config = WorkerConfig( - ... id="search_worker", - ... worker_description="Searches for information", - ... get_state_fn=get_state, - ... action_space=[search_fn], - ... instruction="Search efficiently" - ... ) +This module defines the configuration classes used to set up workers. """ -from typing import List, Optional, Callable, Dict +from typing import Dict, List, Optional +import uuid +from pydantic import BaseModel, Field from game_sdk.game.custom_types import Function -class WorkerConfig: - """Configuration for a worker instance. - The WorkerConfig class defines how a worker behaves, including its action space, - state management, and description. It serves as a blueprint for creating worker - instances and ensures consistent worker behavior. +class WorkerConfig(BaseModel): + """ + Configuration for a worker in the GAME SDK. Attributes: - id (str): Unique identifier for the worker - worker_description (str): Description of the worker's capabilities - instruction (str): Specific instructions for the worker - get_state_fn (Callable): Function to get worker's current state - action_space (Dict[str, Function]): Available actions for the worker - api_key (str, optional): API key for worker authentication - - Args: - id (str): Worker identifier - worker_description (str): Description of worker capabilities - get_state_fn (Callable): State retrieval function that takes function_result - and current_state as arguments and returns a dict - action_space (List[Function]): List of available actions as Function objects - instruction (str, optional): Additional instructions for the worker - api_key (str, optional): API key for worker authentication - - Example: - >>> def get_state(result, state): - ... return {"ready": True} - >>> - >>> search_fn = Function( - ... name="search", - ... description="Search for information", - ... executable=lambda query: {"results": []} - ... ) - >>> - >>> config = WorkerConfig( - ... id="search_worker", - ... worker_description="Searches for information", - ... get_state_fn=get_state, - ... action_space=[search_fn], - ... instruction="Search efficiently" - ... ) + 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 """ - - def __init__( - self, - id: str, - worker_description: str, - get_state_fn: Callable, - action_space: List[Function], - instruction: Optional[str] = "", - api_key: Optional[str] = None, - ): - """Initialize a new WorkerConfig instance. - - Args: - id (str): Worker identifier - worker_description (str): Description of worker capabilities - get_state_fn (Callable): State retrieval function - action_space (List[Function]): List of available actions - instruction (str, optional): Additional instructions for the worker - api_key (str, optional): API key for worker authentication - - Note: - The get_state_fn will be wrapped to include instructions in the state. - The action_space list will be converted to a dictionary for easier lookup. - """ - self.id = id - self.worker_description = worker_description - self.instruction = instruction - self.get_state_fn = get_state_fn - self.api_key = api_key - - # Setup get state function with instructions - self.get_state_fn = lambda function_result, current_state: { - "instructions": self.instruction, - **get_state_fn(function_result, current_state), - } - - # Convert action space list to dictionary for easier lookup - self.action_space: Dict[str, Function] = { - f.get_function_def()["fn_name"]: f for f in action_space - } + 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_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 index 9cd06bd1..7fcbb3db 100644 --- a/tests/game/test_custom_types.py +++ b/tests/game/test_custom_types.py @@ -79,9 +79,9 @@ def test_function_result(): assert result.info == {"value": 42} -def get_test_value(value: str) -> Dict[str, Any]: +def get_test_value(value: Dict[str, Any]) -> Dict[str, Any]: """Helper function for testing.""" - return FunctionResultStatus.DONE, f"Got value: {value}", {"value": value} + return FunctionResultStatus.DONE, f"Got value: {value['value']}", {"value": value['value']} def test_function(): 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_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()