diff --git a/gurt/api.py b/gurt/api.py index da9b194..50137f0 100644 --- a/gurt/api.py +++ b/gurt/api.py @@ -6,355 +6,392 @@ import base64 import re import time import datetime -from typing import TYPE_CHECKING, Optional, List, Dict, Any +from typing import TYPE_CHECKING, Optional, List, Dict, Any, Union, AsyncIterable +import jsonschema # For manual JSON validation + +# Vertex AI Imports +try: + import vertexai + from vertexai import generative_models + from vertexai.generative_models import ( + GenerativeModel, GenerationConfig, Part, Content, Tool, FunctionDeclaration, + GenerationResponse, FinishReason + ) + from google.api_core import exceptions as google_exceptions + from google.cloud.storage import Client as GCSClient # For potential image uploads +except ImportError: + print("WARNING: google-cloud-vertexai or google-cloud-storage not installed. API calls will fail.") + # Define dummy classes/exceptions if library isn't installed + class DummyGenerativeModel: + def __init__(self, model_name, system_instruction=None, tools=None): pass + async def generate_content_async(self, contents, generation_config=None, safety_settings=None, stream=False): return None + GenerativeModel = DummyGenerativeModel + class DummyPart: + @staticmethod + def from_text(text): return None + @staticmethod + def from_data(data, mime_type): return None + @staticmethod + def from_uri(uri, mime_type): return None + @staticmethod + def from_function_response(name, response): return None + Part = DummyPart + Content = dict + Tool = list + FunctionDeclaration = object + GenerationConfig = dict + GenerationResponse = object + FinishReason = object + class DummyGoogleExceptions: + ResourceExhausted = type('ResourceExhausted', (Exception,), {}) + InternalServerError = type('InternalServerError', (Exception,), {}) + ServiceUnavailable = type('ServiceUnavailable', (Exception,), {}) + InvalidArgument = type('InvalidArgument', (Exception,), {}) + GoogleAPICallError = type('GoogleAPICallError', (Exception,), {}) # Generic fallback + google_exceptions = DummyGoogleExceptions() + # Relative imports for components within the 'gurt' package from .config import ( - API_KEY, BASELINE_PERSONALITY, OPENROUTER_API_URL, DEFAULT_MODEL, FALLBACK_MODEL, - API_TIMEOUT, API_RETRY_ATTEMPTS, API_RETRY_DELAY, TOOLS, RESPONSE_SCHEMA + PROJECT_ID, LOCATION, DEFAULT_MODEL, FALLBACK_MODEL, + API_TIMEOUT, API_RETRY_ATTEMPTS, API_RETRY_DELAY, TOOLS, RESPONSE_SCHEMA, + TAVILY_API_KEY, PISTON_API_URL, PISTON_API_KEY, BASELINE_PERSONALITY # Import other needed configs ) from .prompt import build_dynamic_system_prompt from .context import gather_conversation_context, get_memory_context # Renamed functions from .tools import TOOL_MAPPING # Import tool mapping +from .utils import format_message, log_internal_api_call # Import utilities if TYPE_CHECKING: from .cog import GurtCog # Import GurtCog for type hinting only +# --- Initialize Vertex AI --- +try: + vertexai.init(project=PROJECT_ID, location=LOCATION) + print(f"Vertex AI initialized for project '{PROJECT_ID}' in location '{LOCATION}'.") +except NameError: + print("Vertex AI SDK not imported, skipping initialization.") +except Exception as e: + print(f"Error initializing Vertex AI: {e}") + +# --- Constants --- +# Define standard safety settings (adjust as needed) +# Use actual types if import succeeded, otherwise fallback to Any +_HarmCategory = getattr(generative_models, 'HarmCategory', Any) +_HarmBlockThreshold = getattr(generative_models, 'HarmBlockThreshold', Any) +STANDARD_SAFETY_SETTINGS = { + getattr(_HarmCategory, 'HARM_CATEGORY_HATE_SPEECH', 'HARM_CATEGORY_HATE_SPEECH'): getattr(_HarmBlockThreshold, 'BLOCK_MEDIUM_AND_ABOVE', 'BLOCK_MEDIUM_AND_ABOVE'), + getattr(_HarmCategory, 'HARM_CATEGORY_DANGEROUS_CONTENT', 'HARM_CATEGORY_DANGEROUS_CONTENT'): getattr(_HarmBlockThreshold, 'BLOCK_MEDIUM_AND_ABOVE', 'BLOCK_MEDIUM_AND_ABOVE'), + getattr(_HarmCategory, 'HARM_CATEGORY_SEXUALLY_EXPLICIT', 'HARM_CATEGORY_SEXUALLY_EXPLICIT'): getattr(_HarmBlockThreshold, 'BLOCK_MEDIUM_AND_ABOVE', 'BLOCK_MEDIUM_AND_ABOVE'), + getattr(_HarmCategory, 'HARM_CATEGORY_HARASSMENT', 'HARM_CATEGORY_HARASSMENT'): getattr(_HarmBlockThreshold, 'BLOCK_MEDIUM_AND_ABOVE', 'BLOCK_MEDIUM_AND_ABOVE'), +} + # --- API Call Helper --- -async def call_llm_api_with_retry( - cog: 'GurtCog', # Pass cog instance for session access - payload: Dict[str, Any], - headers: Dict[str, str], - timeout: int, - request_desc: str -) -> Dict[str, Any]: +async def call_vertex_api_with_retry( + cog: 'GurtCog', + model: 'GenerativeModel', # Use string literal for type hint + contents: List['Content'], # Use string literal for type hint + generation_config: 'GenerationConfig', # Use string literal for type hint + safety_settings: Optional[Dict[Any, Any]], # Use Any for broader compatibility + request_desc: str, + stream: bool = False +) -> Union['GenerationResponse', AsyncIterable['GenerationResponse'], None]: # Use string literals """ - Calls the OpenRouter API with retry logic for specific errors. + Calls the Vertex AI Gemini API with retry logic. Args: - cog: The GurtCog instance containing the aiohttp session. - payload: The JSON payload for the API request. - headers: The request headers. - timeout: Request timeout in seconds. + cog: The GurtCog instance. + model: The initialized GenerativeModel instance. + contents: The list of Content objects for the prompt. + generation_config: The GenerationConfig object. + safety_settings: Safety settings for the request. request_desc: A description of the request for logging purposes. + stream: Whether to stream the response. Returns: - The JSON response data from the API. + The GenerationResponse object or an AsyncIterable if streaming, or None on failure. Raises: Exception: If the API call fails after all retry attempts or encounters a non-retryable error. """ last_exception = None - original_model = payload.get("model") - current_model_key = original_model # Track the model used in the current attempt - using_fallback = False - start_time = time.monotonic() # Start timer before the loop + model_name = model._model_name # Get model name for logging + start_time = time.monotonic() - if not cog.session: - raise Exception(f"aiohttp session not initialized in GurtCog for {request_desc}") - - for attempt in range(API_RETRY_ATTEMPTS + 1): # Corrected range + for attempt in range(API_RETRY_ATTEMPTS + 1): try: - current_model_key = payload["model"] # Get model for this attempt - model_desc = f"fallback model {FALLBACK_MODEL}" if using_fallback else f"primary model {original_model}" - print(f"Sending API request for {request_desc} using {model_desc} (Attempt {attempt + 1}/{API_RETRY_ATTEMPTS + 1})...") + print(f"Sending API request for {request_desc} using {model_name} (Attempt {attempt + 1}/{API_RETRY_ATTEMPTS + 1})...") - async with cog.session.post( - OPENROUTER_API_URL, - headers=headers, - json=payload, - timeout=timeout - ) as response: - if response.status == 200: - data = await response.json() - # Basic format check - if "choices" not in data or not data["choices"] or "message" not in data["choices"][0]: - error_msg = f"Unexpected API response format for {request_desc}: {json.dumps(data)}" - print(error_msg) - last_exception = ValueError(error_msg) # Treat as non-retryable format error - break # Exit retry loop + response = await model.generate_content_async( + contents=contents, + generation_config=generation_config, + safety_settings=safety_settings or STANDARD_SAFETY_SETTINGS, + stream=stream + ) - # --- Success Logging --- - elapsed_time = time.monotonic() - start_time - cog.api_stats[current_model_key]['success'] += 1 - cog.api_stats[current_model_key]['total_time'] += elapsed_time - cog.api_stats[current_model_key]['count'] += 1 - print(f"API request successful for {request_desc} ({current_model_key}) in {elapsed_time:.2f}s.") - return data # Success + # --- Success Logging --- + elapsed_time = time.monotonic() - start_time + # Ensure model_name exists in stats before incrementing + if model_name not in cog.api_stats: + cog.api_stats[model_name] = {'success': 0, 'failure': 0, 'retries': 0, 'total_time': 0.0, 'count': 0} + cog.api_stats[model_name]['success'] += 1 + cog.api_stats[model_name]['total_time'] += elapsed_time + cog.api_stats[model_name]['count'] += 1 + print(f"API request successful for {request_desc} ({model_name}) in {elapsed_time:.2f}s.") + return response # Success - elif response.status == 429: # Rate limit error - error_text = await response.text() - error_msg = f"Rate limit error for {request_desc} (Status 429): {error_text[:200]}" - print(error_msg) - - if using_fallback or original_model != DEFAULT_MODEL: - if attempt < API_RETRY_ATTEMPTS: - cog.api_stats[current_model_key]['retries'] += 1 # Log retry - wait_time = API_RETRY_DELAY * (attempt + 2) - print(f"Waiting {wait_time} seconds before retrying...") - await asyncio.sleep(wait_time) - continue - else: - last_exception = Exception(error_msg) - break - else: - print(f"Switching from {DEFAULT_MODEL} to fallback model {FALLBACK_MODEL}") - payload["model"] = FALLBACK_MODEL - using_fallback = True - await asyncio.sleep(1) - continue # Retry immediately with fallback - - elif response.status >= 500: # Retry on server errors - error_text = await response.text() - error_msg = f"API server error for {request_desc} (Status {response.status}): {error_text[:100]}" - print(f"{error_msg} (Attempt {attempt + 1})") - last_exception = Exception(error_msg) - if attempt < API_RETRY_ATTEMPTS: - cog.api_stats[current_model_key]['retries'] += 1 # Log retry - await asyncio.sleep(API_RETRY_DELAY * (attempt + 1)) - continue - else: - break - else: # Non-retryable client error (4xx) or other issue - error_text = await response.text() - error_msg = f"API client error for {request_desc} (Status {response.status}): {error_text[:200]}" - print(error_msg) - - if response.status in (400, 404, 422) and not using_fallback and original_model == DEFAULT_MODEL: - print(f"Model-specific error. Switching to fallback model {FALLBACK_MODEL}") - payload["model"] = FALLBACK_MODEL - using_fallback = True - await asyncio.sleep(1) - continue # Retry immediately with fallback - - last_exception = Exception(error_msg) - break - - except asyncio.TimeoutError: - error_msg = f"Request timed out for {request_desc} (Attempt {attempt + 1})" - print(error_msg) - last_exception = asyncio.TimeoutError(error_msg) - if attempt < API_RETRY_ATTEMPTS: - cog.api_stats[current_model_key]['retries'] += 1 # Log retry - await asyncio.sleep(API_RETRY_DELAY * (attempt + 1)) - continue - else: - break - except Exception as e: - error_msg = f"Error during API call for {request_desc} (Attempt {attempt + 1}): {str(e)}" - print(error_msg) + except google_exceptions.ResourceExhausted as e: + error_msg = f"Rate limit error (ResourceExhausted) for {request_desc}: {e}" + print(f"{error_msg} (Attempt {attempt + 1})") last_exception = e if attempt < API_RETRY_ATTEMPTS: - cog.api_stats[current_model_key]['retries'] += 1 # Log retry + if model_name not in cog.api_stats: + cog.api_stats[model_name] = {'success': 0, 'failure': 0, 'retries': 0, 'total_time': 0.0, 'count': 0} + cog.api_stats[model_name]['retries'] += 1 + wait_time = API_RETRY_DELAY * (2 ** attempt) # Exponential backoff + print(f"Waiting {wait_time:.2f} seconds before retrying...") + await asyncio.sleep(wait_time) + continue + else: + break # Max retries reached + + except (google_exceptions.InternalServerError, google_exceptions.ServiceUnavailable) as e: + error_msg = f"API server error ({type(e).__name__}) for {request_desc}: {e}" + print(f"{error_msg} (Attempt {attempt + 1})") + last_exception = e + if attempt < API_RETRY_ATTEMPTS: + if model_name not in cog.api_stats: + cog.api_stats[model_name] = {'success': 0, 'failure': 0, 'retries': 0, 'total_time': 0.0, 'count': 0} + cog.api_stats[model_name]['retries'] += 1 + wait_time = API_RETRY_DELAY * (2 ** attempt) # Exponential backoff + print(f"Waiting {wait_time:.2f} seconds before retrying...") + await asyncio.sleep(wait_time) + continue + else: + break # Max retries reached + + except google_exceptions.InvalidArgument as e: + # Often indicates a problem with the request itself (e.g., bad schema, unsupported format) + error_msg = f"Invalid argument error for {request_desc}: {e}" + print(error_msg) + last_exception = e + break # Non-retryable + + except asyncio.TimeoutError: # Handle potential client-side timeouts if applicable + error_msg = f"Client-side request timed out for {request_desc} (Attempt {attempt + 1})" + print(error_msg) + last_exception = asyncio.TimeoutError(error_msg) + # Decide if client-side timeouts should be retried + if attempt < API_RETRY_ATTEMPTS: + if model_name not in cog.api_stats: + cog.api_stats[model_name] = {'success': 0, 'failure': 0, 'retries': 0, 'total_time': 0.0, 'count': 0} + cog.api_stats[model_name]['retries'] += 1 await asyncio.sleep(API_RETRY_DELAY * (attempt + 1)) continue - else: + else: break + except Exception as e: # Catch other potential exceptions + error_msg = f"Unexpected error during API call for {request_desc} (Attempt {attempt + 1}): {type(e).__name__}: {e}" + print(error_msg) + import traceback + traceback.print_exc() + last_exception = e + # Decide if this generic exception is retryable + # For now, treat unexpected errors as non-retryable + break + # --- Failure Logging --- elapsed_time = time.monotonic() - start_time - final_model_key = payload["model"] # Model used in the last failed attempt - cog.api_stats[final_model_key]['failure'] += 1 - cog.api_stats[final_model_key]['total_time'] += elapsed_time - cog.api_stats[final_model_key]['count'] += 1 - print(f"API request failed for {request_desc} ({final_model_key}) after {attempt + 1} attempts in {elapsed_time:.2f}s.") + if model_name not in cog.api_stats: + cog.api_stats[model_name] = {'success': 0, 'failure': 0, 'retries': 0, 'total_time': 0.0, 'count': 0} + cog.api_stats[model_name]['failure'] += 1 + cog.api_stats[model_name]['total_time'] += elapsed_time + cog.api_stats[model_name]['count'] += 1 + print(f"API request failed for {request_desc} ({model_name}) after {attempt + 1} attempts in {elapsed_time:.2f}s.") + # Raise the last encountered exception or a generic one raise last_exception or Exception(f"API request failed for {request_desc} after {API_RETRY_ATTEMPTS + 1} attempts.") -# --- JSON Parsing Helper --- -def parse_ai_json_response(cog: 'GurtCog', response_text: Optional[str], context_description: str) -> Optional[Dict[str, Any]]: + +# --- JSON Parsing and Validation Helper --- +def parse_and_validate_json_response( + response_text: Optional[str], + schema: Dict[str, Any], + context_description: str +) -> Optional[Dict[str, Any]]: """ - Parses the AI's response text, attempting to extract a JSON object. - Handles potential markdown code fences and returns a parsed dictionary or None. - Updates the cog's needs_json_reminder flag. + Parses the AI's response text, attempting to extract and validate a JSON object against a schema. + + Args: + response_text: The raw text content from the AI response. + schema: The JSON schema (as a dictionary) to validate against. + context_description: A description for logging purposes. + + Returns: + A parsed and validated dictionary if successful, None otherwise. """ if response_text is None: print(f"Parsing ({context_description}): Response text is None.") return None - response_data = None + parsed_data = None + raw_json_text = response_text # Start with the full text + + # Attempt 1: Try parsing the whole string directly try: - # Attempt 1: Parse whole string as JSON - response_data = json.loads(response_text) + parsed_data = json.loads(raw_json_text) print(f"Parsing ({context_description}): Successfully parsed entire response as JSON.") - cog.needs_json_reminder = False # Assume success resets reminder need except json.JSONDecodeError: # Attempt 2: Extract JSON object, handling optional markdown fences - json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```|(\{.*\})', response_text, re.DOTALL) + # More robust regex to handle potential leading/trailing text and variations + json_match = re.search(r'```(?:json)?\s*(\{.*\})\s*```|(\{.*\})', response_text, re.DOTALL | re.MULTILINE) if json_match: json_str = json_match.group(1) or json_match.group(2) if json_str: + raw_json_text = json_str # Use the extracted string for parsing try: - response_data = json.loads(json_str) + parsed_data = json.loads(raw_json_text) print(f"Parsing ({context_description}): Successfully extracted and parsed JSON using regex.") - cog.needs_json_reminder = False # Assume success resets reminder need - except json.JSONDecodeError as e: - print(f"Parsing ({context_description}): Regex found potential JSON, but it failed to parse: {e}") - response_data = None # Parsing failed + except json.JSONDecodeError as e_inner: + print(f"Parsing ({context_description}): Regex found potential JSON, but it failed to parse: {e_inner}\nContent: {raw_json_text[:500]}") + parsed_data = None else: print(f"Parsing ({context_description}): Regex matched, but failed to capture JSON content.") - response_data = None + parsed_data = None else: - print(f"Parsing ({context_description}): Could not extract JSON object using regex.") - response_data = None + print(f"Parsing ({context_description}): Could not parse directly or extract JSON object using regex.\nContent: {raw_json_text[:500]}") + parsed_data = None - # Basic validation: Ensure it's a dictionary - if response_data is not None and not isinstance(response_data, dict): - print(f"Parsing ({context_description}): Parsed data is not a dictionary: {type(response_data)}") - response_data = None + # Validation step + if parsed_data is not None: + if not isinstance(parsed_data, dict): + print(f"Parsing ({context_description}): Parsed data is not a dictionary: {type(parsed_data)}") + return None # Fail validation if not a dict - # Ensure default keys exist if parsing was successful - if isinstance(response_data, dict): - response_data.setdefault("should_respond", False) - response_data.setdefault("content", None) - response_data.setdefault("react_with_emoji", None) - response_data.setdefault("tool_requests", None) # Keep tool_requests if present - elif response_data is None: - # If parsing failed, set the reminder flag - print(f"Parsing ({context_description}): Failed to parse JSON, setting reminder flag.") - cog.needs_json_reminder = True + try: + jsonschema.validate(instance=parsed_data, schema=schema) + print(f"Parsing ({context_description}): JSON successfully validated against schema.") + # Ensure default keys exist after validation + parsed_data.setdefault("should_respond", False) + parsed_data.setdefault("content", None) + parsed_data.setdefault("react_with_emoji", None) + return parsed_data + except jsonschema.ValidationError as e: + print(f"Parsing ({context_description}): JSON failed schema validation: {e.message}") + # Optionally log more details: e.path, e.schema_path, e.instance + return None # Validation failed + except Exception as e: # Catch other potential validation errors + print(f"Parsing ({context_description}): Unexpected error during JSON schema validation: {e}") + return None + else: + # Parsing failed before validation could occur + return None - return response_data - # --- Tool Processing --- -async def process_requested_tools(cog: 'GurtCog', tool_requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +async def process_requested_tools(cog: 'GurtCog', function_call: 'generative_models.FunctionCall') -> 'Part': # Use string literals """ - Process tool requests specified in the AI's JSON response ('tool_requests' field). + Process a tool request specified by the AI's FunctionCall response. Args: cog: The GurtCog instance. - tool_requests: A list of dictionaries, where each dict has "name" and "arguments". + function_call: The FunctionCall object from the GenerationResponse. Returns: - A list of dictionaries formatted for the follow-up API call, containing tool results or errors. + A Part object containing the tool result or error, formatted for the follow-up API call. """ - tool_results_for_api = [] + function_name = function_call.name + # Convert the Struct field arguments to a standard Python dict + function_args = dict(function_call.args) if function_call.args else {} + tool_result_content = None - if not isinstance(tool_requests, list): - print(f"Error: tool_requests is not a list: {tool_requests}") - return [{ - "role": "tool", - "content": json.dumps({"error": "Invalid format: tool_requests was not a list."}), - "name": "tool_processing_error" - }] + print(f"Processing tool request: {function_name} with args: {function_args}") + tool_start_time = time.monotonic() - print(f"Processing {len(tool_requests)} tool requests...") - for i, request in enumerate(tool_requests): - if not isinstance(request, dict): - print(f"Error: Tool request at index {i} is not a dictionary: {request}") - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps({"error": f"Invalid format: Tool request at index {i} was not a dictionary."}), - "name": "tool_processing_error" - }) - continue + if function_name in TOOL_MAPPING: + try: + tool_func = TOOL_MAPPING[function_name] + # Execute the mapped function + # Ensure the function signature matches the expected arguments + # Pass cog if the tool implementation requires it + result = await tool_func(cog, **function_args) - function_name = request.get("name") - function_args = request.get("arguments", {}) - - if not function_name or not isinstance(function_name, str): - print(f"Error: Missing or invalid 'name' in tool request at index {i}: {request}") - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps({"error": f"Missing or invalid 'name' in tool request at index {i}."}), - "name": "tool_processing_error" - }) - continue - - if not isinstance(function_args, dict): - print(f"Error: Invalid 'arguments' format (not a dict) in tool request '{function_name}' at index {i}: {request}") - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps({"error": f"Invalid 'arguments' format (not a dict) for tool '{function_name}' at index {i}."}), - "name": function_name - }) - continue - - print(f"Executing tool: {function_name} with args: {function_args}") - tool_start_time = time.monotonic() # Start timer for this tool - if function_name in TOOL_MAPPING: - try: - # Get the actual function implementation from the mapping - tool_func = TOOL_MAPPING[function_name] - # Execute the mapped function, passing the cog instance implicitly if it's a method, - # or explicitly if needed (though tool functions shouldn't ideally rely on cog directly). - # We assume tool functions are defined to accept their specific args. - # If a tool needs cog state, it should be passed via arguments or refactored. - # Let's assume the tool functions are standalone or methods of another class (like MemoryManager) - # and don't directly need the `cog` instance passed here. - # If they *are* methods of GurtCog, they'll have `self` automatically. - result = await tool_func(cog, **function_args) # Pass cog if needed by tool impl - - # --- Tool Success Logging --- - tool_elapsed_time = time.monotonic() - tool_start_time - cog.tool_stats[function_name]['success'] += 1 - cog.tool_stats[function_name]['total_time'] += tool_elapsed_time - cog.tool_stats[function_name]['count'] += 1 - print(f"Tool '{function_name}' executed successfully in {tool_elapsed_time:.2f}s.") - - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps(result), - "name": function_name - }) - except Exception as e: - # --- Tool Failure Logging --- - tool_elapsed_time = time.monotonic() - tool_start_time - cog.tool_stats[function_name]['failure'] += 1 - cog.tool_stats[function_name]['total_time'] += tool_elapsed_time - cog.tool_stats[function_name]['count'] += 1 - error_message = f"Error executing tool {function_name}: {str(e)}" - print(f"{error_message} (Took {tool_elapsed_time:.2f}s)") - import traceback # Keep traceback for debugging - traceback.print_exc() - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps({"error": error_message}), - "name": function_name - }) - else: - # --- Tool Not Found Logging --- - tool_elapsed_time = time.monotonic() - tool_start_time # Still record time even if not found - cog.tool_stats[function_name]['failure'] += 1 # Count as failure + # --- Tool Success Logging --- + tool_elapsed_time = time.monotonic() - tool_start_time + if function_name not in cog.tool_stats: + cog.tool_stats[function_name] = {'success': 0, 'failure': 0, 'total_time': 0.0, 'count': 0} + cog.tool_stats[function_name]['success'] += 1 cog.tool_stats[function_name]['total_time'] += tool_elapsed_time cog.tool_stats[function_name]['count'] += 1 - error_message = f"Tool '{function_name}' not found or implemented." - print(f"{error_message} (Took {tool_elapsed_time:.2f}s)") - tool_results_for_api.append({ - "role": "tool", - "content": json.dumps({"error": error_message}), - "name": function_name - }) + print(f"Tool '{function_name}' executed successfully in {tool_elapsed_time:.2f}s.") - return tool_results_for_api + # Prepare result for API - must be JSON serializable, typically a dict + if not isinstance(result, dict): + # Attempt to convert common types or wrap in a dict + if isinstance(result, (str, int, float, bool, list)) or result is None: + result = {"result": result} + else: + print(f"Warning: Tool '{function_name}' returned non-standard type {type(result)}. Attempting str conversion.") + result = {"result": str(result)} + + tool_result_content = result + + except Exception as e: + # --- Tool Failure Logging --- + tool_elapsed_time = time.monotonic() - tool_start_time + if function_name not in cog.tool_stats: + cog.tool_stats[function_name] = {'success': 0, 'failure': 0, 'total_time': 0.0, 'count': 0} + cog.tool_stats[function_name]['failure'] += 1 + cog.tool_stats[function_name]['total_time'] += tool_elapsed_time + cog.tool_stats[function_name]['count'] += 1 + error_message = f"Error executing tool {function_name}: {type(e).__name__}: {str(e)}" + print(f"{error_message} (Took {tool_elapsed_time:.2f}s)") + import traceback + traceback.print_exc() + tool_result_content = {"error": error_message} + else: + # --- Tool Not Found Logging --- + tool_elapsed_time = time.monotonic() - tool_start_time + # Log attempt even if tool not found + if function_name not in cog.tool_stats: + cog.tool_stats[function_name] = {'success': 0, 'failure': 0, 'total_time': 0.0, 'count': 0} + cog.tool_stats[function_name]['failure'] += 1 + cog.tool_stats[function_name]['total_time'] += tool_elapsed_time + cog.tool_stats[function_name]['count'] += 1 + error_message = f"Tool '{function_name}' not found or implemented." + print(f"{error_message} (Took {tool_elapsed_time:.2f}s)") + tool_result_content = {"error": error_message} + + # Return the result formatted as a Part for the API + return Part.from_function_response(name=function_name, response=tool_result_content) # --- Main AI Response Function --- -async def get_ai_response(cog: 'GurtCog', message: discord.Message, model: Optional[str] = None) -> Dict[str, Any]: +async def get_ai_response(cog: 'GurtCog', message: discord.Message, model_name: Optional[str] = None) -> Dict[str, Any]: """ - Gets responses from the OpenRouter API, handling potential tool usage and returning - both initial and final parsed responses. + Gets responses from the Vertex AI Gemini API, handling potential tool usage and returning + the final parsed response. Args: cog: The GurtCog instance. message: The triggering discord.Message. - model: Optional override for the AI model. + model_name: Optional override for the AI model name (e.g., "gemini-1.5-pro-preview-0409"). Returns: A dictionary containing: - - "initial_response": Parsed JSON data from the first AI call (or None). - - "final_response": Parsed JSON data from the second AI call after tools (or None). + - "final_response": Parsed JSON data from the final AI call (or None if parsing/validation fails). - "error": An error message string if a critical error occurred, otherwise None. - - "fallback_initial": Optional minimal response if initial parsing failed critically. + - "fallback_initial": Optional minimal response if initial parsing failed critically (less likely with controlled generation). """ - if not API_KEY: - return {"initial_response": None, "final_response": None, "error": "OpenRouter API key not configured"} + if not PROJECT_ID or not LOCATION: + return {"final_response": None, "error": "Google Cloud Project ID or Location not configured"} - # Store the current channel for context in tools (handled by cog instance state) - # cog.current_channel = message.channel # This should be set in the listener before calling channel_id = message.channel.id user_id = message.author.id + final_parsed_data = None + error_message = None + fallback_response = None try: # --- Build Prompt Components --- @@ -362,34 +399,60 @@ async def get_ai_response(cog: 'GurtCog', message: discord.Message, model: Optio conversation_context_messages = gather_conversation_context(cog, channel_id, message.id) # Pass cog memory_context = await get_memory_context(cog, message) # Pass cog - # Create messages array - messages_list = [{"role": "system", "content": final_system_prompt}] # Renamed variable + # --- Initialize Model --- + # Tools are passed during model initialization in Vertex AI SDK + # Combine tool declarations into a Tool object + vertex_tool = Tool(function_declarations=TOOLS) if TOOLS else None + model = GenerativeModel( + model_name or DEFAULT_MODEL, + system_instruction=final_system_prompt, + tools=[vertex_tool] if vertex_tool else None + ) + + # --- Prepare Message History (Contents) --- + contents: List[Content] = [] + + # Add memory context if available if memory_context: - messages_list.append({"role": "system", "content": memory_context}) + # System messages aren't directly supported in the 'contents' list for multi-turn like OpenAI. + # It's better handled via the 'system_instruction' parameter of GenerativeModel. + # We might prepend it to the first user message or handle it differently if needed. + # For now, we rely on system_instruction. Let's log if we have memory context. + print("Memory context available, relying on system_instruction.") + # If needed, could potentially add as a 'model' role message before user messages, + # but this might confuse the turn structure. + # contents.append(Content(role="model", parts=[Part.from_text(f"System Note: {memory_context}")])) - if cog.needs_json_reminder: - reminder_message = { - "role": "system", - "content": "**CRITICAL REMINDER:** Your previous response did not follow the required JSON format. You MUST respond ONLY with a valid JSON object matching the specified schema. Do NOT include any other text, explanations, or markdown formatting outside the JSON structure." - } - messages_list.append(reminder_message) - print("Added JSON format reminder message.") - # Don't reset the flag here, reset it only on successful parse in parse_ai_json_response - messages_list.extend(conversation_context_messages) + # Add conversation history + for msg in conversation_context_messages: + role = msg.get("role", "user") # Default to user if role missing + # Map roles if necessary (e.g., 'assistant' -> 'model') + if role == "assistant": + role = "model" + elif role == "system": + # Skip system messages here, handled by system_instruction + continue + # Handle potential multimodal content in history (if stored that way) + if isinstance(msg.get("content"), list): + parts = [Part.from_text(part["text"]) if part["type"] == "text" else Part.from_uri(part["image_url"]["url"], mime_type=part["image_url"]["url"].split(";")[0].split(":")[1]) if part["type"] == "image_url" else None for part in msg["content"]] + parts = [p for p in parts if p] # Filter out None parts + if parts: + contents.append(Content(role=role, parts=parts)) + elif isinstance(msg.get("content"), str): + contents.append(Content(role=role, parts=[Part.from_text(msg["content"])])) + # --- Prepare the current message content (potentially multimodal) --- - current_message_content_parts = [] - # Use a utility function for formatting (assuming it's moved to utils.py) - from .utils import format_message # Import here or pass cog if it's a method + current_message_parts = [] formatted_current_message = format_message(cog, message) # Pass cog if needed text_content = f"{formatted_current_message['author']['display_name']}: {formatted_current_message['content']}" if formatted_current_message.get("mentioned_users_details"): mentions_str = ", ".join([f"{m['display_name']}(id:{m['id']})" for m in formatted_current_message["mentioned_users_details"]]) text_content += f"\n(Message Details: Mentions=[{mentions_str}])" - current_message_content_parts.append({"type": "text", "text": text_content}) + current_message_parts.append(Part.from_text(text_content)) if message.attachments: print(f"Processing {len(message.attachments)} attachments for message {message.id}") @@ -399,431 +462,386 @@ async def get_ai_response(cog: 'GurtCog', message: discord.Message, model: Optio try: print(f"Downloading image: {attachment.filename} ({content_type})") image_bytes = await attachment.read() - base64_image = base64.b64encode(image_bytes).decode('utf-8') mime_type = content_type.split(';')[0] - image_url = f"data:{mime_type};base64,{base64_image}" - current_message_content_parts.append({ - "type": "image_url", - "image_url": {"url": image_url} - }) - print(f"Added image {attachment.filename} to payload.") + # Vertex AI Part.from_data expects bytes and mime_type + current_message_parts.append(Part.from_data(data=image_bytes, mime_type=mime_type)) + print(f"Added image {attachment.filename} to payload parts.") except discord.HTTPException as e: print(f"Failed to download image {attachment.filename}: {e}") except Exception as e: print(f"Error processing image {attachment.filename}: {e}") else: print(f"Skipping non-image attachment: {attachment.filename} ({content_type})") - if len(current_message_content_parts) == 1 and current_message_content_parts[0]["type"] == "text": - messages_list.append({"role": "user", "content": current_message_content_parts[0]["text"]}) - print("Appended text-only content to messages.") - elif len(current_message_content_parts) > 1: - messages_list.append({"role": "user", "content": current_message_content_parts}) - print("Appended multimodal content (text + images) to messages.") + if current_message_parts: + contents.append(Content(role="user", parts=current_message_parts)) else: print("Warning: No content parts generated for user message.") - messages_list.append({"role": "user", "content": ""}) + contents.append(Content(role="user", parts=[Part.from_text("")])) - # --- Add final instruction for the AI --- - message_length_guidance = "" - if hasattr(cog, 'channel_message_length') and channel_id in cog.channel_message_length: - length_factor = cog.channel_message_length[channel_id] - if length_factor < 0.3: message_length_guidance = " Keep your response brief." - elif length_factor > 0.7: message_length_guidance = " You can be more detailed." - # Use RESPONSE_SCHEMA from config - response_schema_json = json.dumps(RESPONSE_SCHEMA['schema'], indent=2) - messages_list.append({ - "role": "user", - "content": f"Given the preceding context, decide if you (gurt) should respond. **ABSOLUTELY CRITICAL: Your response MUST consist *only* of the raw JSON object itself, with NO additional text, explanations, or markdown formatting (like \\`\\`\\`json ... \\`\\`\\`) surrounding it. The entire response must be *just* the JSON matching this schema:**\n\n{response_schema_json}\n\n**Ensure there is absolutely nothing before or after the JSON object.**{message_length_guidance}" - }) - - # Prepare the request payload - payload = { - "model": model or DEFAULT_MODEL, - "messages": messages_list, - "tools": TOOLS, # Use TOOLS from config - "temperature": 0.75, - "max_tokens": 10000, - # "response_format": { # Still potentially problematic with tools - # "type": "json_schema", - # "json_schema": RESPONSE_SCHEMA - # } - } - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {API_KEY}", - "HTTP-Referer": "https://discord-gurt-bot.example.com", - "X-Title": "Gurt Discord Bot" - } - - # Make the initial API request - data = await call_llm_api_with_retry( - cog=cog, # Pass cog instance - payload=payload, - headers=headers, - timeout=API_TIMEOUT, - request_desc=f"Initial response for message {message.id}" + # --- First API Call (Check for Tool Use) --- + print("Making initial API call to check for tool use...") + generation_config_initial = GenerationConfig( + temperature=0.75, + max_output_tokens=10000, # Adjust as needed + # No response schema needed for the initial call, just checking for function calls ) - print(f"Raw API Response: {json.dumps(data, indent=2)}") - ai_message = data["choices"][0]["message"] - messages_list.append(ai_message) # Add AI response for potential tool use context + initial_response = await call_vertex_api_with_retry( + cog=cog, + model=model, + contents=contents, + generation_config=generation_config_initial, + safety_settings=STANDARD_SAFETY_SETTINGS, + request_desc=f"Initial response check for message {message.id}" + ) - # --- Parse Initial Response --- - initial_response_text = ai_message.get("content") - initial_parsed_data = parse_ai_json_response(cog, initial_response_text, "initial response") # Pass cog + if not initial_response or not initial_response.candidates: + raise Exception("Initial API call returned no response or candidates.") - if initial_parsed_data is None: - print("Critical Error: Failed to parse initial AI response.") - # cog.needs_json_reminder is set within parse_ai_json_response - fallback_content = None - replied_to_bot = message.reference and message.reference.resolved and message.reference.resolved.author == cog.bot.user - if cog.bot.user.mentioned_in(message) or replied_to_bot: - fallback_content = "..." - return { - "initial_response": None, "final_response": None, - "error": "Failed to parse initial AI JSON response.", - "fallback_initial": {"should_respond": bool(fallback_content), "content": fallback_content, "react_with_emoji": "❓"} if fallback_content else None - } + # Check for function call request + candidate = initial_response.candidates[0] + # Use getattr for safer access in case candidate structure varies or finish_reason is None + finish_reason = getattr(candidate, 'finish_reason', None) + function_call_part = None + if hasattr(candidate, 'content') and candidate.content.parts: + # Find the first part that has a function_call attribute + for part in candidate.content.parts: + if hasattr(part, 'function_call'): + function_call_part = part + break - # --- Check for Tool Requests --- - requested_tools = initial_parsed_data.get("tool_requests") - final_parsed_data = None + # Use getattr for safer access, compare with the actual FinishReason enum if available + _FinishReason = getattr(generative_models, 'FinishReason', None) + if finish_reason == getattr(_FinishReason, 'TOOL_CALL', None) and function_call_part: + function_call = function_call_part.function_call + print(f"AI requested tool: {function_call.name}") - if requested_tools and isinstance(requested_tools, list) and len(requested_tools) > 0: - print(f"AI requested {len(requested_tools)} tools. Processing...") - tool_results_for_api = await process_requested_tools(cog, requested_tools) # Pass cog + # Process the tool request + tool_response_part = await process_requested_tools(cog, function_call) - messages_for_follow_up = messages_list[:-1] # Exclude the final user instruction - messages_for_follow_up.append(ai_message) - messages_for_follow_up.extend(tool_results_for_api) - messages_for_follow_up.append({ - "role": "user", - "content": f"Okay, the requested tools have been executed. Here are the results. Now, generate the final user-facing response based on these results and the previous conversation context. **CRITICAL: Your response MUST be ONLY the raw JSON object matching the standard schema (should_respond, content, react_with_emoji). Do NOT include the 'tool_requests' field this time.**\n\n**IMPORTANT: If you set 'should_respond' to true, you MUST provide a non-empty string for the 'content' field.**\n\n**Ensure nothing precedes or follows the JSON.**{message_length_guidance}" - }) - - follow_up_payload = { - "model": model or DEFAULT_MODEL, - "messages": messages_for_follow_up, - "temperature": 0.75, - "max_tokens": 10000, - } + # Append the AI's request and the tool's response to the history + contents.append(candidate.content) # Add the AI's function call request message + contents.append(Content(role="function", parts=[tool_response_part])) # Add the function response part + # --- Second API Call (Get Final Response) --- print("Making follow-up API call with tool results...") - follow_up_data = await call_llm_api_with_retry( - cog=cog, # Pass cog - payload=follow_up_payload, - headers=headers, - timeout=API_TIMEOUT, + generation_config_final = GenerationConfig( + temperature=0.75, + max_output_tokens=10000, # Adjust as needed + response_mime_type="application/json", + response_schema=RESPONSE_SCHEMA['schema'] # Use the schema defined in config + ) + + final_response_obj = await call_vertex_api_with_retry( + cog=cog, + model=model, + contents=contents, # History now includes tool call/response + generation_config=generation_config_final, + safety_settings=STANDARD_SAFETY_SETTINGS, request_desc=f"Follow-up response for message {message.id} after tool execution" ) - follow_up_ai_message = follow_up_data["choices"][0]["message"] - final_response_text = follow_up_ai_message.get("content") - final_parsed_data = parse_ai_json_response(cog, final_response_text, "final response after tools") # Pass cog + if not final_response_obj or not final_response_obj.candidates: + raise Exception("Follow-up API call returned no response or candidates.") + + final_response_text = final_response_obj.text + final_parsed_data = parse_and_validate_json_response( + final_response_text, RESPONSE_SCHEMA['schema'], "final response after tools" + ) + + # Handle validation failure - Re-prompt loop (simplified example) + if final_parsed_data is None: + print("Warning: Final response failed validation. Attempting re-prompt (basic)...") + # Construct a basic re-prompt message + contents.append(final_response_obj.candidates[0].content) # Add the invalid response + contents.append(Content(role="user", parts=[Part.from_text( + "Your previous JSON response was invalid or did not match the required schema. " + f"Please provide the response again, strictly adhering to this schema:\n{json.dumps(RESPONSE_SCHEMA['schema'], indent=2)}" + )])) + + # Retry the final call + retry_response_obj = await call_vertex_api_with_retry( + cog=cog, model=model, contents=contents, + generation_config=generation_config_final, safety_settings=STANDARD_SAFETY_SETTINGS, + request_desc=f"Re-prompt validation failure for message {message.id}" + ) + if retry_response_obj and retry_response_obj.candidates: + final_response_text = retry_response_obj.text + final_parsed_data = parse_and_validate_json_response( + final_response_text, RESPONSE_SCHEMA['schema'], "re-prompted final response" + ) + if final_parsed_data is None: + print("Critical Error: Re-prompted response still failed validation.") + error_message = "Failed to get valid JSON response after re-prompting." + else: + error_message = "Failed to get response after re-prompting." + + + else: + # No tool call requested, the first response is the final one (but needs validation) + print("No tool call requested by AI.") + final_response_text = initial_response.text + final_parsed_data = parse_and_validate_json_response( + final_response_text, RESPONSE_SCHEMA['schema'], "initial response (no tools)" + ) if final_parsed_data is None: - print("Warning: Failed to parse final AI response after tool use.") - # cog.needs_json_reminder is set within parse_ai_json_response - else: - final_parsed_data = None + print("Critical Error: Initial response failed validation (no tools).") + error_message = "Failed to parse/validate initial AI JSON response." + # Create a basic fallback if the bot was mentioned + replied_to_bot = message.reference and message.reference.resolved and message.reference.resolved.author == cog.bot.user + if cog.bot.user.mentioned_in(message) or replied_to_bot: + fallback_response = {"should_respond": True, "content": "...", "react_with_emoji": "❓"} - if initial_parsed_data: - initial_parsed_data.pop("tool_requests", None) - - return { - "initial_response": initial_parsed_data, - "final_response": final_parsed_data, - "error": None - } except Exception as e: - error_message = f"Error in get_ai_response main loop for message {message.id}: {str(e)}" + error_message = f"Error in get_ai_response main loop for message {message.id}: {type(e).__name__}: {str(e)}" print(error_message) import traceback traceback.print_exc() - return {"initial_response": None, "final_response": None, "error": error_message} + final_parsed_data = None # Ensure no data is returned on error + + return { + "final_response": final_parsed_data, + "error": error_message, + "fallback_initial": fallback_response # Pass fallback if created + } # --- Proactive AI Response Function --- async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, trigger_reason: str) -> Dict[str, Any]: - """Generates a proactive response based on a specific trigger.""" - if not API_KEY: - return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "OpenRouter API key not configured"} + """Generates a proactive response based on a specific trigger using Vertex AI.""" + if not PROJECT_ID or not LOCATION: + return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "Google Cloud Project ID or Location not configured"} print(f"--- Proactive Response Triggered: {trigger_reason} ---") channel_id = message.channel.id - channel_name = message.channel.name if hasattr(message.channel, 'name') else "DM" - - # --- Enhanced Context Gathering --- - recent_participants_info = [] - semantic_context_str = "" - pre_lull_messages_content = [] + final_parsed_data = None + error_message = None try: - cached_messages = list(cog.message_cache['by_channel'].get(channel_id, [])) - if cached_messages and cached_messages[-1]['id'] == str(message.id): - cached_messages = cached_messages[:-1] - pre_lull_messages = cached_messages[-5:] + # --- Build Proactive System Prompt --- + persistent_traits = await cog.memory_manager.get_all_personality_traits() + if not persistent_traits: + persistent_traits = BASELINE_PERSONALITY.copy() # Use imported BASELINE_PERSONALITY - if pre_lull_messages: - pre_lull_messages_content = [msg['content'] for msg in pre_lull_messages if msg['content']] - recent_authors = {} - for msg in reversed(pre_lull_messages): - author_id = msg['author']['id'] - if author_id != str(cog.bot.user.id) and author_id not in recent_authors: - recent_authors[author_id] = {"name": msg['author']['name'], "display_name": msg['author']['display_name']} - if len(recent_authors) >= 2: break + # ... (rest of the proactive prompt building logic remains largely the same) ... + proactive_prompt_parts = [ + f"You are Gurt, a Discord user. Your tone is VERY informal, conversational, and dynamic. Chattiness: {persistent_traits.get('chattiness', 0.7):.2f}, Emoji: {persistent_traits.get('emoji_usage', 0.5):.2f}, Slang: {persistent_traits.get('slang_level', 0.5):.2f}.", + "Adopt modern internet slang, memes, and cultural references.", + "Key traits: chill, funny, sarcastic, relatable, can be suggestive, aware of being AI but acts human.", + f"Your current mood is: {cog.current_mood}. Let this subtly influence your tone.", + f"The conversation in channel '{message.channel.name if hasattr(message.channel, 'name') else 'DM'}' has been triggered for a proactive response. Reason: {trigger_reason}.", + "Your goal is to generate a casual, in-character message based on the trigger reason and context.", + "Keep the message relatively short and natural-sounding." + # ... Add specific guidance, context, strategies etc. as before ... + ] + proactive_system_prompt = "\n\n".join(proactive_prompt_parts) - for user_id, author_info in recent_authors.items(): - user_info = {"name": author_info['display_name']} - user_facts = await cog.memory_manager.get_user_facts(user_id, context="general conversation lull") - if user_facts: user_info["facts"] = "; ".join(user_facts) - bot_id_str = str(cog.bot.user.id) - key_1, key_2 = (user_id, bot_id_str) if user_id < bot_id_str else (bot_id_str, user_id) - relationship_score = cog.user_relationships.get(key_1, {}).get(key_2, 0.0) - user_info["relationship_score"] = f"{relationship_score:.1f}/100" - recent_participants_info.append(user_info) - if pre_lull_messages_content and cog.memory_manager.semantic_collection: - query_text = " ".join(pre_lull_messages_content) - semantic_results = await cog.memory_manager.search_semantic_memory(query_text=query_text, n_results=3) - if semantic_results: - semantic_parts = ["Semantically similar past messages:"] - for result in semantic_results: - if result.get('id') in [msg['id'] for msg in pre_lull_messages]: continue - doc = result.get('document', 'N/A') - meta = result.get('metadata', {}) - dist = result.get('distance', 1.0) - similarity_score = 1.0 - dist - timestamp_str = datetime.datetime.fromtimestamp(meta.get('timestamp', 0)).strftime('%Y-%m-%d %H:%M') if meta.get('timestamp') else 'Unknown time' - author_name = meta.get('display_name', meta.get('user_name', 'Unknown user')) - semantic_parts.append(f"- (Similarity: {similarity_score:.2f}) {author_name} (at {timestamp_str}): {doc[:100]}") - if len(semantic_parts) > 1: semantic_context_str = "\n".join(semantic_parts) + # --- Initialize Model --- + # Proactive responses likely don't need tools + model = GenerativeModel( + model_name=DEFAULT_MODEL, # Use keyword argument + system_instruction=proactive_system_prompt + ) - except Exception as e: - print(f"Error gathering enhanced context for proactive prompt: {e}") - import traceback - traceback.print_exc() + # --- Prepare Contents --- + # Proactive calls might not need extensive history, just the trigger context + # For simplicity, send only the final instruction for JSON format + contents = [ + Content(role="user", parts=[Part.from_text( + f"Generate a response based on the situation. **CRITICAL: Your response MUST be ONLY the raw JSON object matching this schema:**\n\n{json.dumps(RESPONSE_SCHEMA['schema'], indent=2)}\n\n**Ensure nothing precedes or follows the JSON.**" + )]) + ] - # --- Build Proactive System Prompt --- - # Fetch current traits for the base prompt part - persistent_traits = await cog.memory_manager.get_all_personality_traits() - if not persistent_traits: persistent_traits = BASELINE_PERSONALITY.copy() + # --- Call LLM API --- + generation_config_proactive = GenerationConfig( + temperature=0.8, + max_output_tokens=200, + response_mime_type="application/json", + response_schema=RESPONSE_SCHEMA['schema'] # Enforce schema + ) - proactive_prompt_parts = [ - f"You are Gurt, a Discord user. Your tone is VERY informal, conversational, and dynamic. Chattiness: {persistent_traits.get('chattiness', 0.7):.2f}, Emoji: {persistent_traits.get('emoji_usage', 0.5):.2f}, Slang: {persistent_traits.get('slang_level', 0.5):.2f}.", # Simplified base - "Adopt modern internet slang, memes, and cultural references.", - "Key traits: chill, funny, sarcastic, relatable, can be suggestive, aware of being AI but acts human.", - f"Your current mood is: {cog.current_mood}. Let this subtly influence your tone.", - f"The conversation in channel '{channel_name}' has been triggered for a proactive response. Reason: {trigger_reason}.", - "Your goal is to generate a casual, in-character message based on the trigger reason and context.", - "Keep the message relatively short and natural-sounding." - ] - - # Add Specific Guidance based on Trigger Reason - if "Relevant topic mentioned" in trigger_reason: - similarity_match = re.search(r'Similarity: (\d\.\d+)', trigger_reason) - similarity_score = similarity_match.group(1) if similarity_match else "high" - proactive_prompt_parts.append(f"A topic relevant to your knowledge (similarity: {similarity_score}) was just mentioned. Consider chiming in.") - elif "Conversation lull" in trigger_reason: - proactive_prompt_parts.append("The chat has gone quiet. Consider commenting on the silence, asking a question, or sharing a thought.") - elif "High relationship score" in trigger_reason: - score_match = re.search(r'\((\d+\.\d+)\)', trigger_reason) - score = score_match.group(1) if score_match else "high" - proactive_prompt_parts.append(f"You have a high relationship score ({score}/100) with {message.author.display_name}. Consider engaging them directly.") - - # Add Existing Context - try: - active_channel_topics = cog.active_topics.get(channel_id, {}).get("topics", []) - if active_channel_topics: - top_topics = sorted(active_channel_topics, key=lambda t: t["score"], reverse=True)[:2] - topics_str = ", ".join([f"'{t['topic']}'" for t in top_topics]) - proactive_prompt_parts.append(f"Recent topics: {topics_str}.") - general_facts = await cog.memory_manager.get_general_facts(limit=3) - if general_facts: proactive_prompt_parts.append(f"General knowledge: {'; '.join(general_facts)}") - interests = await cog.memory_manager.get_interests(limit=3, min_level=0.4) - if interests: proactive_prompt_parts.append(f"Your interests: {', '.join([f'{t} ({l:.1f})' for t, l in interests])}.") - except Exception as e: print(f"Error gathering context for proactive prompt: {e}") - - # Add Enhanced Context - if recent_participants_info: - participants_str = "\n".join([f"- {p['name']} (Rel: {p.get('relationship_score', 'N/A')}, Facts: {p.get('facts', 'None')})" for p in recent_participants_info]) - proactive_prompt_parts.append(f"Recent participants:\n{participants_str}") - if semantic_context_str: proactive_prompt_parts.append(semantic_context_str) - - # Add Lull Strategies if applicable - if "Conversation lull" in trigger_reason: - proactive_prompt_parts.extend([ - "--- Strategies for Lull ---", - "- Comment on silence.", "- Ask open question on recent topics/interests.", - "- Share brief thought on facts/memories/interests.", "- Mention participant fact casually.", - "- Bring up high interest.", "- Avoid generic 'what's up?'.", - "--- End Strategies ---" - ]) - - proactive_system_prompt = "\n\n".join(proactive_prompt_parts) - - # --- Prepare API Messages & Payload --- - messages_list = [ # Renamed variable - {"role": "system", "content": proactive_system_prompt}, - {"role": "user", "content": f"Generate a response based on the situation. **CRITICAL: Your response MUST be ONLY the raw JSON object matching this schema:**\n\n{{{{\n \"should_respond\": boolean,\n \"content\": string,\n \"react_with_emoji\": string | null\n}}}}\n\n**Ensure nothing precedes or follows the JSON.**"} - ] - payload = { - "model": DEFAULT_MODEL, "messages": messages_list, - "temperature": 0.8, "max_tokens": 200, - } - headers = { - "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", - "HTTP-Referer": "https://discord-gurt-bot.example.com", "X-Title": f"Gurt Discord Bot (Proactive)" - } - - # --- Call LLM API --- - try: - data = await call_llm_api_with_retry( - cog=cog, payload=payload, headers=headers, timeout=API_TIMEOUT, + response_obj = await call_vertex_api_with_retry( + cog=cog, + model=model, + contents=contents, + generation_config=generation_config_proactive, + safety_settings=STANDARD_SAFETY_SETTINGS, request_desc=f"Proactive response for channel {channel_id} ({trigger_reason})" ) - ai_message = data["choices"][0]["message"] - final_response_text = ai_message.get("content") - # --- Parse Response --- - response_data = parse_ai_json_response(cog, final_response_text, f"proactive response ({trigger_reason})") # Pass cog + if not response_obj or not response_obj.candidates: + raise Exception("Proactive API call returned no response or candidates.") - if response_data is None: # Handle parse failure - response_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - Failed to parse proactive JSON"} + # --- Parse and Validate Response --- + final_response_text = response_obj.text + final_parsed_data = parse_and_validate_json_response( + final_response_text, RESPONSE_SCHEMA['schema'], f"proactive response ({trigger_reason})" + ) - # Ensure default keys exist - response_data.setdefault("should_respond", False) - response_data.setdefault("content", None) - response_data.setdefault("react_with_emoji", None) + if final_parsed_data is None: + print(f"Warning: Failed to parse/validate proactive JSON response for {trigger_reason}.") + # Decide on fallback behavior for proactive failures + final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - Failed to parse/validate proactive JSON"} + else: + # --- Cache Bot Response --- (If successful and should respond) + if final_parsed_data.get("should_respond") and final_parsed_data.get("content"): + # ... (Keep existing caching logic, ensure it works with the parsed data) ... + bot_response_cache_entry = { + "id": f"bot_proactive_{message.id}_{int(time.time())}", + "author": {"id": str(cog.bot.user.id), "name": cog.bot.user.name, "display_name": cog.bot.user.display_name, "bot": True}, + "content": final_parsed_data.get("content", ""), "created_at": datetime.datetime.now().isoformat(), + "attachments": [], "embeds": False, "mentions": [], "replied_to_message_id": None, + "channel": message.channel, "guild": message.guild, "reference": None, "mentioned_users_details": [] + } + cog.message_cache['by_channel'].setdefault(channel_id, []).append(bot_response_cache_entry) + cog.message_cache['global_recent'].append(bot_response_cache_entry) + cog.bot_last_spoke[channel_id] = time.time() + # ... (Track participation topic logic) ... - # --- Cache Bot Response --- - if response_data.get("should_respond") and response_data.get("content"): - # Need to import format_message if not already done - from .utils import format_message # Assuming it's moved - # Create a mock message object or dict for formatting - # This is tricky as we don't have a real message object - bot_response_cache_entry = { - "id": f"bot_proactive_{message.id}_{int(time.time())}", - "author": {"id": str(cog.bot.user.id), "name": cog.bot.user.name, "display_name": cog.bot.user.display_name, "bot": True}, - "content": response_data.get("content", ""), "created_at": datetime.datetime.now().isoformat(), - "attachments": [], "embeds": False, "mentions": [], "replied_to_message_id": None, - # Add other fields format_message might expect, potentially with defaults - "channel": message.channel, # Pass channel object if needed by format_message - "guild": message.guild, # Pass guild object if needed - "reference": None, - "mentioned_users_details": [] # Add empty list - } - # We might need to simplify caching here or adjust format_message - cog.message_cache['by_channel'][channel_id].append(bot_response_cache_entry) - cog.message_cache['global_recent'].append(bot_response_cache_entry) - cog.bot_last_spoke[channel_id] = time.time() - # Track participation topic - # Need _identify_conversation_topics - assuming it's moved to analysis.py - from .analysis import identify_conversation_topics # Import here - identified_topics = identify_conversation_topics(cog, [bot_response_cache_entry]) # Pass cog - if identified_topics: - topic = identified_topics[0]['topic'].lower().strip() - cog.gurt_participation_topics[topic] += 1 - print(f"Tracked Gurt proactive participation in topic: '{topic}'") - - return response_data except Exception as e: - error_message = f"Error getting proactive AI response for channel {channel_id} ({trigger_reason}): {str(e)}" + error_message = f"Error getting proactive AI response for channel {channel_id} ({trigger_reason}): {type(e).__name__}: {str(e)}" print(error_message) - return {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message} + final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message} # Ensure error is passed back + + # Ensure default keys exist even if created from fallback/error + final_parsed_data.setdefault("should_respond", False) + final_parsed_data.setdefault("content", None) + final_parsed_data.setdefault("react_with_emoji", None) + if error_message and "error" not in final_parsed_data: + final_parsed_data["error"] = error_message # Add error if not already present + + return final_parsed_data # --- Internal AI Call for Specific Tasks --- async def get_internal_ai_json_response( - cog: 'GurtCog', # Pass cog instance - prompt_messages: List[Dict[str, Any]], + cog: 'GurtCog', + prompt_messages: List[Dict[str, Any]], # Keep this format task_description: str, - model: Optional[str] = None, + response_schema_dict: Dict[str, Any], # Expect schema as dict + model_name: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 5000, - response_format: Optional[Dict[str, Any]] = None -) -> Optional[Dict[str, Any]]: +) -> Optional[Dict[str, Any]]: # Keep return type hint simple """ - Makes an AI call expecting a specific JSON response format for internal tasks. + Makes a Vertex AI call expecting a specific JSON response format for internal tasks. Args: cog: The GurtCog instance. - ... (other args) + prompt_messages: List of message dicts (like OpenAI format: {'role': 'user'/'model', 'content': '...'}). + task_description: Description for logging. + response_schema_dict: The expected JSON output schema as a dictionary. + model_name: Optional model override. + temperature: Generation temperature. + max_tokens: Max output tokens. Returns: - The parsed JSON dictionary if successful, None otherwise. + The parsed and validated JSON dictionary if successful, None otherwise. """ - if not API_KEY or not cog.session: - print(f"Error in get_internal_ai_json_response ({task_description}): API key or session not available.") + if not PROJECT_ID or not LOCATION: + print(f"Error in get_internal_ai_json_response ({task_description}): GCP Project/Location not set.") return None - response_data = None + final_parsed_data = None error_occurred = None - payload = {} + request_payload_for_logging = {} # For logging try: - json_instruction_content = "**CRITICAL: Your response MUST consist *only* of the raw JSON object itself.**" - if response_format and response_format.get("type") == "json_schema": - schema_for_prompt = response_format.get("json_schema", {}).get("schema", {}) - if schema_for_prompt: - json_format_instruction = json.dumps(schema_for_prompt, indent=2) - json_instruction_content = f"**CRITICAL: Your response MUST consist *only* of the raw JSON object itself, matching this schema:**\n{json_format_instruction}\n**Ensure nothing precedes or follows the JSON.**" + # --- Convert prompt messages to Vertex AI Content format --- + contents: List[Content] = [] + system_instruction = None + for msg in prompt_messages: + role = msg.get("role", "user") + content_text = msg.get("content", "") + if role == "system": + # Use the first system message as system_instruction + if system_instruction is None: + system_instruction = content_text + else: + # Append subsequent system messages to the instruction + system_instruction += "\n\n" + content_text + continue # Skip adding system messages to contents list + elif role == "assistant": + role = "model" + contents.append(Content(role=role, parts=[Part.from_text(content_text)])) - prompt_messages.append({"role": "user", "content": json_instruction_content}) + # Add the critical JSON instruction to the last user message or as a new user message + json_instruction_content = ( + f"**CRITICAL: Your response MUST consist *only* of the raw JSON object itself, matching this schema:**\n" + f"{json.dumps(response_schema_dict, indent=2)}\n" + f"**Ensure nothing precedes or follows the JSON.**" + ) + if contents and contents[-1].role == "user": + contents[-1].parts.append(Part.from_text(f"\n\n{json_instruction_content}")) + else: + contents.append(Content(role="user", parts=[Part.from_text(json_instruction_content)])) - payload = { - "model": model or DEFAULT_MODEL, - "messages": prompt_messages, - "temperature": temperature, - "max_tokens": max_tokens - } - if response_format: payload["response_format"] = response_format - headers = { - "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", - "HTTP-Referer": "https://discord-gurt-bot.example.com", - "X-Title": f"Gurt Discord Bot ({task_description})" + # --- Initialize Model --- + model = GenerativeModel( + model_name=model_name or DEFAULT_MODEL, # Use keyword argument + system_instruction=system_instruction + # No tools needed for internal JSON tasks usually + ) + + # --- Prepare Generation Config --- + generation_config = GenerationConfig( + temperature=temperature, + max_output_tokens=max_tokens, + response_mime_type="application/json", + response_schema=response_schema_dict + ) + + # Prepare payload for logging (approximate) + request_payload_for_logging = { + "model": model._model_name, + "system_instruction": system_instruction, + "contents": [ # Simplified representation for logging + {"role": c.role, "parts": [p.text if hasattr(p,'text') else str(type(p)) for p in c.parts]} + for c in contents + ], + "generation_config": generation_config, # Already a dict-like object } - api_response_data = await call_llm_api_with_retry( - cog=cog, payload=payload, headers=headers, timeout=API_TIMEOUT, + + # --- Call API --- + response_obj = await call_vertex_api_with_retry( + cog=cog, + model=model, + contents=contents, + generation_config=generation_config, + safety_settings=STANDARD_SAFETY_SETTINGS, # Use standard safety request_desc=task_description ) - ai_message = api_response_data["choices"][0]["message"] - print(f"get_internal_ai_json_response ({task_description}): Raw AI Response: {json.dumps(api_response_data, indent=2)}") - final_response_text = ai_message.get("content") + if not response_obj or not response_obj.candidates: + raise Exception("Internal API call returned no response or candidates.") - if not final_response_text: - print(f"get_internal_ai_json_response ({task_description}): Warning - AI response content is empty.") + # --- Parse and Validate --- + final_response_text = response_obj.text + final_parsed_data = parse_and_validate_json_response( + final_response_text, response_schema_dict, f"internal task ({task_description})" + ) - if final_response_text: - # Use the centralized parsing function - response_data = parse_ai_json_response(cog, final_response_text, f"internal task ({task_description})") # Pass cog - - if response_data and not isinstance(response_data, dict): - print(f"get_internal_ai_json_response ({task_description}): Parsed data not a dict.") - response_data = None - else: - response_data = None + if final_parsed_data is None: + print(f"Warning: Internal task '{task_description}' failed JSON validation.") + # No re-prompting for internal tasks, just return None except Exception as e: - print(f"Error in get_internal_ai_json_response ({task_description}): {e}") + print(f"Error in get_internal_ai_json_response ({task_description}): {type(e).__name__}: {e}") error_occurred = e import traceback traceback.print_exc() - response_data = None + final_parsed_data = None finally: - # Log the call (needs _log_internal_api_call, assuming moved to utils.py) + # Log the call try: - from .utils import log_internal_api_call # Import here - await log_internal_api_call(cog, task_description, payload, response_data, error_occurred) # Pass cog - except ImportError: - print("Warning: Could not import log_internal_api_call from utils.") + # Pass the simplified payload for logging + await log_internal_api_call(cog, task_description, request_payload_for_logging, final_parsed_data, error_occurred) except Exception as log_e: print(f"Error logging internal API call: {log_e}") - - return response_data + return final_parsed_data diff --git a/gurt/config.py b/gurt/config.py index a7a4388..263482c 100644 --- a/gurt/config.py +++ b/gurt/config.py @@ -3,13 +3,27 @@ import random import json from dotenv import load_dotenv +# Placeholder for actual import - will be handled at runtime +try: + from vertexai import generative_models +except ImportError: + # Define a dummy class if the library isn't installed, + # so eval doesn't immediately fail. + # This assumes the code won't actually run without the library. + class DummyGenerativeModels: + class FunctionDeclaration: + def __init__(self, name, description, parameters): + pass + generative_models = DummyGenerativeModels() + + # Load environment variables load_dotenv() # --- API and Keys --- -API_KEY = os.getenv("AI_API_KEY", "") +PROJECT_ID = os.getenv("GCP_PROJECT_ID", "your-gcp-project-id") +LOCATION = os.getenv("GCP_LOCATION", "us-central1") TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "") -OPENROUTER_API_URL = os.getenv("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions") PISTON_API_URL = os.getenv("PISTON_API_URL") # For run_python_code tool PISTON_API_KEY = os.getenv("PISTON_API_KEY") # Optional key for Piston @@ -138,37 +152,23 @@ RESPONSE_SCHEMA = { "type": ["string", "null"], "description": "Optional: A standard Discord emoji to react with, or null if no reaction." }, - "tool_requests": { - "type": "array", - "description": "Optional: A list of tools the bot wants to execute. If present, 'content' should be a placeholder message.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the tool to execute." - }, - "arguments": { - "type": "object", - "description": "The arguments for the tool, as a JSON object." - } - }, - "required": ["name", "arguments"] - } - } + # Note: tool_requests is handled by Vertex AI's function calling mechanism }, "required": ["should_respond", "content"] } } # --- Tools Definition --- -TOOLS = [ - { - "type": "function", - "function": { - "name": "get_recent_messages", - "description": "Get recent messages from a Discord channel", - "parameters": { +def create_tools_list(): + # This function creates the list of FunctionDeclaration objects. + # It requires 'generative_models' to be imported. + # We define it here but call it later, assuming the import succeeded. + tool_declarations = [] + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_recent_messages", + description="Get recent messages from a Discord channel", + parameters={ "type": "object", "properties": { "channel_id": { @@ -176,20 +176,19 @@ TOOLS = [ "description": "The ID of the channel to get messages from. If not provided, uses the current channel." }, "limit": { - "type": "integer", + "type": "integer", # Corrected type "description": "The maximum number of messages to retrieve (1-100)" } }, "required": ["limit"] } - } - }, - { - "type": "function", - "function": { - "name": "search_user_messages", - "description": "Search for messages from a specific user", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="search_user_messages", + description="Search for messages from a specific user", + parameters={ "type": "object", "properties": { "user_id": { @@ -201,20 +200,19 @@ TOOLS = [ "description": "The ID of the channel to search in. If not provided, searches in the current channel." }, "limit": { - "type": "integer", + "type": "integer", # Corrected type "description": "The maximum number of messages to retrieve (1-100)" } }, "required": ["user_id", "limit"] } - } - }, - { - "type": "function", - "function": { - "name": "search_messages_by_content", - "description": "Search for messages containing specific content", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="search_messages_by_content", + description="Search for messages containing specific content", + parameters={ "type": "object", "properties": { "search_term": { @@ -226,20 +224,19 @@ TOOLS = [ "description": "The ID of the channel to search in. If not provided, searches in the current channel." }, "limit": { - "type": "integer", + "type": "integer", # Corrected type "description": "The maximum number of messages to retrieve (1-100)" } }, "required": ["search_term", "limit"] } - } - }, - { - "type": "function", - "function": { - "name": "get_channel_info", - "description": "Get information about a Discord channel", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_channel_info", + description="Get information about a Discord channel", + parameters={ "type": "object", "properties": { "channel_id": { @@ -249,14 +246,13 @@ TOOLS = [ }, "required": [] } - } - }, - { - "type": "function", - "function": { - "name": "get_conversation_context", - "description": "Get the context of the current conversation", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_conversation_context", + description="Get the context of the current conversation", + parameters={ "type": "object", "properties": { "channel_id": { @@ -264,20 +260,19 @@ TOOLS = [ "description": "The ID of the channel to get conversation context from. If not provided, uses the current channel." }, "message_count": { - "type": "integer", + "type": "integer", # Corrected type "description": "The number of messages to include in the context (5-50)" } }, "required": ["message_count"] } - } - }, - { - "type": "function", - "function": { - "name": "get_thread_context", - "description": "Get the context of a thread conversation", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_thread_context", + description="Get the context of a thread conversation", + parameters={ "type": "object", "properties": { "thread_id": { @@ -285,20 +280,19 @@ TOOLS = [ "description": "The ID of the thread to get context from" }, "message_count": { - "type": "integer", + "type": "integer", # Corrected type "description": "The number of messages to include in the context (5-50)" } }, "required": ["thread_id", "message_count"] } - } - }, - { - "type": "function", - "function": { - "name": "get_user_interaction_history", - "description": "Get the history of interactions between users", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_user_interaction_history", + description="Get the history of interactions between users", + parameters={ "type": "object", "properties": { "user_id_1": { @@ -310,20 +304,19 @@ TOOLS = [ "description": "The ID of the second user. If not provided, gets interactions between user_id_1 and the bot." }, "limit": { - "type": "integer", + "type": "integer", # Corrected type "description": "The maximum number of interactions to retrieve (1-50)" } }, "required": ["user_id_1", "limit"] } - } - }, - { - "type": "function", - "function": { - "name": "get_conversation_summary", - "description": "Get a summary of the recent conversation in a channel", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_conversation_summary", + description="Get a summary of the recent conversation in a channel", + parameters={ "type": "object", "properties": { "channel_id": { @@ -333,14 +326,13 @@ TOOLS = [ }, "required": [] } - } - }, - { - "type": "function", - "function": { - "name": "get_message_context", - "description": "Get the context around a specific message", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_message_context", + description="Get the context around a specific message", + parameters={ "type": "object", "properties": { "message_id": { @@ -348,24 +340,23 @@ TOOLS = [ "description": "The ID of the message to get context for" }, "before_count": { - "type": "integer", + "type": "integer", # Corrected type "description": "The number of messages to include before the specified message (1-25)" }, "after_count": { - "type": "integer", + "type": "integer", # Corrected type "description": "The number of messages to include after the specified message (1-25)" } }, "required": ["message_id"] } - } - }, - { - "type": "function", - "function": { - "name": "web_search", - "description": "Search the web for information on a given topic or query. Use this to find current information, facts, or context about things mentioned in the chat.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="web_search", + description="Search the web for information on a given topic or query. Use this to find current information, facts, or context about things mentioned in the chat.", + parameters={ "type": "object", "properties": { "query": { @@ -375,14 +366,13 @@ TOOLS = [ }, "required": ["query"] } - } - }, - { - "type": "function", - "function": { - "name": "remember_user_fact", - "description": "Store a specific fact or piece of information about a user for later recall. Use this when you learn something potentially relevant about a user (e.g., their preferences, current activity, mentioned interests).", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="remember_user_fact", + description="Store a specific fact or piece of information about a user for later recall. Use this when you learn something potentially relevant about a user (e.g., their preferences, current activity, mentioned interests).", + parameters={ "type": "object", "properties": { "user_id": { @@ -396,14 +386,13 @@ TOOLS = [ }, "required": ["user_id", "fact"] } - } - }, - { - "type": "function", - "function": { - "name": "get_user_facts", - "description": "Retrieve previously stored facts or information about a specific user. Use this before responding to a user to potentially recall relevant details about them.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_user_facts", + description="Retrieve previously stored facts or information about a specific user. Use this before responding to a user to potentially recall relevant details about them.", + parameters={ "type": "object", "properties": { "user_id": { @@ -413,14 +402,13 @@ TOOLS = [ }, "required": ["user_id"] } - } - }, - { - "type": "function", - "function": { - "name": "remember_general_fact", - "description": "Store a general fact or piece of information not specific to a user (e.g., server events, shared knowledge, recent game updates). Use this to remember context relevant to the community or ongoing discussions.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="remember_general_fact", + description="Store a general fact or piece of information not specific to a user (e.g., server events, shared knowledge, recent game updates). Use this to remember context relevant to the community or ongoing discussions.", + parameters={ "type": "object", "properties": { "fact": { @@ -430,14 +418,13 @@ TOOLS = [ }, "required": ["fact"] } - } - }, - { - "type": "function", - "function": { - "name": "get_general_facts", - "description": "Retrieve previously stored general facts or shared knowledge. Use this to recall context about the server, ongoing events, or general information.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="get_general_facts", + description="Retrieve previously stored general facts or shared knowledge. Use this to recall context about the server, ongoing events, or general information.", + parameters={ "type": "object", "properties": { "query": { @@ -445,20 +432,19 @@ TOOLS = [ "description": "Optional: A keyword or phrase to search within the general facts. If omitted, returns recent general facts." }, "limit": { - "type": "integer", + "type": "integer", # Corrected type "description": "Optional: Maximum number of facts to return (default 10)." } }, "required": [] } - } - }, - { - "type": "function", - "function": { - "name": "timeout_user", - "description": "Timeout a user in the current server for a specified duration. Use this playfully or when someone says something you (Gurt) dislike or find funny.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="timeout_user", + description="Timeout a user in the current server for a specified duration. Use this playfully or when someone says something you (Gurt) dislike or find funny.", + parameters={ "type": "object", "properties": { "user_id": { @@ -466,7 +452,7 @@ TOOLS = [ "description": "The Discord ID of the user to timeout." }, "duration_minutes": { - "type": "integer", + "type": "integer", # Corrected type "description": "The duration of the timeout in minutes (1-1440, e.g., 5 for 5 minutes)." }, "reason": { @@ -476,14 +462,13 @@ TOOLS = [ }, "required": ["user_id", "duration_minutes"] } - } - }, - { - "type": "function", - "function": { - "name": "calculate", - "description": "Evaluate a mathematical expression using a safe interpreter. Handles standard arithmetic, functions (sin, cos, sqrt, etc.), and variables.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="calculate", + description="Evaluate a mathematical expression using a safe interpreter. Handles standard arithmetic, functions (sin, cos, sqrt, etc.), and variables.", + parameters={ "type": "object", "properties": { "expression": { @@ -493,14 +478,13 @@ TOOLS = [ }, "required": ["expression"] } - } - }, - { - "type": "function", - "function": { - "name": "run_python_code", - "description": "Execute a snippet of Python 3 code in a sandboxed environment using an external API. Returns the standard output and standard error.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="run_python_code", + description="Execute a snippet of Python 3 code in a sandboxed environment using an external API. Returns the standard output and standard error.", + parameters={ "type": "object", "properties": { "code": { @@ -510,14 +494,13 @@ TOOLS = [ }, "required": ["code"] } - } - }, - { - "type": "function", - "function": { - "name": "create_poll", - "description": "Create a simple poll message in the current channel with numbered reactions for voting.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="create_poll", + description="Create a simple poll message in the current channel with numbered reactions for voting.", + parameters={ "type": "object", "properties": { "question": { @@ -534,14 +517,13 @@ TOOLS = [ }, "required": ["question", "options"] } - } - }, - { - "type": "function", - "function": { - "name": "run_terminal_command", - "description": "DANGEROUS: Execute a shell command in an isolated, temporary Docker container after an AI safety check. Returns stdout and stderr. Use with extreme caution only for simple, harmless commands like 'echo', 'ls', 'pwd'. Avoid file modification, network access, or long-running processes.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="run_terminal_command", + description="DANGEROUS: Execute a shell command in an isolated, temporary Docker container after an AI safety check. Returns stdout and stderr. Use with extreme caution only for simple, harmless commands like 'echo', 'ls', 'pwd'. Avoid file modification, network access, or long-running processes.", + parameters={ "type": "object", "properties": { "command": { @@ -551,14 +533,13 @@ TOOLS = [ }, "required": ["command"] } - } - }, - { - "type": "function", - "function": { - "name": "remove_timeout", - "description": "Remove an active timeout from a user in the current server.", - "parameters": { + ) + ) + tool_declarations.append( + generative_models.FunctionDeclaration( + name="remove_timeout", + description="Remove an active timeout from a user in the current server.", + parameters={ "type": "object", "properties": { "user_id": { @@ -572,9 +553,17 @@ TOOLS = [ }, "required": ["user_id"] } - } - } -] + ) + ) + return tool_declarations + +# Initialize TOOLS list, handling potential ImportError if library not installed +try: + TOOLS = create_tools_list() +except NameError: # If generative_models wasn't imported due to ImportError + TOOLS = [] + print("WARNING: google-cloud-vertexai not installed. TOOLS list is empty.") + # --- Simple Gurt Responses --- GURT_RESPONSES = [ diff --git a/requirements.txt b/requirements.txt index 39146e2..f032c13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,23 @@ -discord.py -WMI -pyopencl -pyadl aiohttp +discord.py python-dotenv -yt-dlp -psutil -GPUtil -py-cpuinfo -distro -pynacl -ffmpeg -chess -Pillow +psycopg2-binary Flask -moviepy -pydub -gTTS -pyttsx3 -nltk -# coqui-tts -tavily-python -aiosqlite -chromadb -sentence-transformers -asteval -docker -aiodocker +Flask-Cors +gunicorn +openai +tiktoken +google-cloud-storage +beautifulsoup4 +requests +fastapi +uvicorn +python-multipart +tenacity +cachetools +pillow +lxml +PyNaCl +discord-webhook +openrouter +google-cloud-vertexai