diff --git a/slop-terminal/slop_with_terminal.py b/slop-terminal/slop_with_terminal.py new file mode 100644 index 0000000..385bbaf --- /dev/null +++ b/slop-terminal/slop_with_terminal.py @@ -0,0 +1,2182 @@ +from flask import Flask, request, jsonify +from datetime import datetime +import os +import json +import expr +import subprocess +import shutil +import tempfile +import platform +import time +import socket +from threading import Lock +import threading +import random +import uuid +from openai import OpenAI +import logging +from flask_swagger_ui import get_swaggerui_blueprint +from pathlib import Path +from typing import Dict, Any, Optional, List +from dataclasses import dataclass, asdict +from abc import ABC, abstractmethod + +# Server configuration (must be defined first) +SERVER_CONFIG = { + "port": int(os.getenv("SLOP_PORT", "31337")), + "data_dir": os.getenv("SLOP_DATA_DIR", "./data"), + "log_level": os.getenv("SLOP_LOG_LEVEL", "INFO"), + "debug": os.getenv("FLASK_DEBUG", "true").lower() == "true" +} + +# Configure logging +DATA_DIR = SERVER_CONFIG["data_dir"] +LOG_FILE = os.path.join(DATA_DIR, "slop_monolith.log") + +# Set log level from configuration +log_level = getattr(logging, SERVER_CONFIG["log_level"].upper(), logging.INFO) + +logging.basicConfig( + level=log_level, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()], +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Swagger UI setup +SWAGGER_URL = "/openapi" +API_URL = "/static/openapi.yaml" +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, API_URL, config={"app_name": "SLOP API"} +) +app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL) + +# Global model-to-client map +MODEL_CLIENT_MAP = {} + +# Memory setup +MEMORY_FILE = os.path.join(DATA_DIR, "memory.json") +memory_lock = Lock() + +# Resources setup +RESOURCES_FILE = os.path.join(DATA_DIR, "resources.json") +resource_lock = Lock() + +# Terminal data setup +TERMINAL_DATA_DIR = os.path.join(DATA_DIR, "terminal") +terminal_lock = Lock() + +# Ensure data directory exists +if not os.path.exists(TERMINAL_DATA_DIR): + os.makedirs(TERMINAL_DATA_DIR) + logger.info(f"Created terminal data directory: {TERMINAL_DATA_DIR}") + +# Lock timeout +LOCK_TIMEOUT = 2 + +# Firecracker sandbox configuration +FIRECRACKER_CONFIG = { + "kernel_image_path": os.getenv("FIRECRACKER_KERNEL", "./firecracker/kernel/vmlinux.bin"), + "rootfs_image_path": os.getenv("FIRECRACKER_ROOTFS", "./firecracker/rootfs/rootfs.ext4"), + "memory_size_mib": int(os.getenv("FIRECRACKER_MEMORY", "512")), + "vcpu_count": int(os.getenv("FIRECRACKER_VCPU", "1")), + "enable_sandbox": os.getenv("ENABLE_FIRECRACKER", "false").lower() == "true" +} + +# Security configuration +SECURITY_CONFIG = { + "allow_dangerous_tools": os.getenv("ALLOW_DANGEROUS_TOOLS", "false").lower() == "true", + "require_user_id": os.getenv("REQUIRE_USER_ID", "true").lower() == "true", + "default_timeout": int(os.getenv("DEFAULT_TIMEOUT", "60")) # Increased from 30 to 60 +} + +# Server configuration already defined above + +# Global sandbox manager +sandbox_manager = None + + +@dataclass +class ToolResult: + """Standardized tool execution result for terminal tools""" + success: bool + data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + +@dataclass +class FirecrackerConfig: + """Configuration for Firecracker microVM""" + kernel_image_path: str + rootfs_image_path: str + memory_size_mib: int = 512 + vcpu_count: int = 1 + socket_path: str = "" + log_level: str = "Info" + enable_api: bool = True + api_socket_path: str = "" + +@dataclass +class SandboxResult: + """Result from sandbox execution""" + success: bool + stdout: str = "" + stderr: str = "" + exit_code: int = 0 + execution_time: float = 0.0 + error: Optional[str] = None + venv_path: Optional[str] = None + sandbox_id: Optional[str] = None + +class FirecrackerSandbox: + """Manages Firecracker microVM instances for safe code execution""" + + def __init__(self, config: dict): + self.config = config + self.active_vms: Dict[str, Dict[str, Any]] = {} + self.lock = Lock() + # Use tempfile for cross-platform compatibility + self.base_dir = Path(tempfile.gettempdir()) / "firecracker_sandboxes" + self.base_dir.mkdir(exist_ok=True) + logger.info(f"Initialized Firecracker sandbox manager at {self.base_dir}") + + def create_sandbox(self, user_id: str) -> str: + """Create a new sandbox for a user""" + vm_id = f"sandbox_{user_id}_{uuid.uuid4().hex[:8]}" + vm_dir = self.base_dir / vm_id + vm_dir.mkdir(exist_ok=True) + + with self.lock: + self.active_vms[vm_id] = { + "vm_dir": vm_dir, + "user_id": user_id, + "created_at": time.time(), + "status": "created", + "venv_initialized": False, # Track venv state + "pip_upgraded": False # Track pip upgrade state + } + + logger.info(f"Created sandbox {vm_id} for user {user_id}") + return vm_id + + def execute_in_sandbox(self, vm_id: str, commands: List[str], + timeout: int = 30, create_venv: bool = True) -> SandboxResult: + """Execute commands in a sandboxed environment""" + if vm_id not in self.active_vms: + return SandboxResult( + success=False, + error=f"Sandbox {vm_id} not found" + ) + + vm_info = self.active_vms[vm_id] + start_time = time.time() + + try: + # Create isolated workspace + workspace = vm_info["vm_dir"] / "workspace" + workspace.mkdir(exist_ok=True) + + script_lines = ["#!/bin/bash", "set -e", "cd " + str(workspace)] + + if create_venv: + venv_path = str(workspace / "venv") + + # Only create venv if it doesn't exist or wasn't initialized + if not vm_info["venv_initialized"] or not (workspace / "venv").exists(): + script_lines.extend([ + f"python3 -m venv {venv_path}", + f"source {venv_path}/bin/activate", + ]) + + # Only upgrade pip if we haven't done it yet + if not vm_info["pip_upgraded"]: + script_lines.append("pip install --upgrade pip --quiet") + vm_info["pip_upgraded"] = True + + vm_info["venv_initialized"] = True + self.active_vms[vm_id] = vm_info # Update state + else: + # Just activate existing venv + script_lines.append(f"source {venv_path}/bin/activate") + else: + venv_path = None + + # Add user commands + script_lines.extend(commands) + + # Create and execute script + script_content = "\n".join(script_lines) + script_file = vm_info["vm_dir"] / "execute.sh" + + with open(script_file, 'w') as f: + f.write(script_content) + + os.chmod(script_file, 0o755) + + # Execute in isolated environment + result = subprocess.run( + ["bash", str(script_file)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(workspace) + ) + + execution_time = time.time() - start_time + + # Clean up pip upgrade messages from output + stdout_cleaned = result.stdout + stderr_cleaned = result.stderr + + # Remove pip upgrade noise from stdout + if stdout_cleaned: + lines = stdout_cleaned.split('\n') + filtered_lines = [] + skip_next = False + + for line in lines: + if any(noise in line for noise in [ + "Requirement already satisfied: pip", + "Collecting pip", + "Using cached pip", + "Installing collected packages: pip", + "Attempting uninstall: pip", + "Found existing installation: pip", + "Uninstalling pip", + "Successfully uninstalled pip", + "Successfully installed pip" + ]): + continue + filtered_lines.append(line) + + stdout_cleaned = '\n'.join(filtered_lines).strip() + + return SandboxResult( + success=result.returncode == 0, + stdout=stdout_cleaned, + stderr=stderr_cleaned, + exit_code=result.returncode, + execution_time=execution_time, + venv_path=venv_path, + sandbox_id=vm_id + ) + + except subprocess.TimeoutExpired: + return SandboxResult( + success=False, + error=f"Execution timed out after {timeout} seconds", + execution_time=time.time() - start_time, + sandbox_id=vm_id + ) + except Exception as e: + return SandboxResult( + success=False, + error=str(e), + execution_time=time.time() - start_time, + sandbox_id=vm_id + ) + + def cleanup_sandbox(self, vm_id: str) -> bool: + """Clean up sandbox resources""" + if vm_id not in self.active_vms: + return False + + vm_info = self.active_vms[vm_id] + + try: + if vm_info["vm_dir"].exists(): + shutil.rmtree(vm_info["vm_dir"]) + + with self.lock: + del self.active_vms[vm_id] + + logger.info(f"Cleaned up sandbox {vm_id}") + return True + + except Exception as e: + logger.error(f"Failed to cleanup sandbox {vm_id}: {e}") + return False + + +class SandboxManager: + """High-level manager for sandboxed execution""" + + def __init__(self, config: dict): + self.config = config + self.enabled = config.get("enable_sandbox", False) + + if self.enabled: + self.firecracker = FirecrackerSandbox(config) + self.sandbox_pool: Dict[str, str] = {} # user_id -> vm_id + self.user_packages: Dict[str, set] = {} # user_id -> set of installed packages + self.lock = Lock() + logger.info("Sandbox manager initialized with Firecracker") + else: + self.firecracker = None + logger.info("Sandbox manager initialized without Firecracker (disabled)") + + def get_or_create_sandbox(self, user_id: str) -> Optional[str]: + """Get existing sandbox for user or create new one""" + if not self.enabled: + return None + + with self.lock: + if user_id in self.sandbox_pool: + vm_id = self.sandbox_pool[user_id] + if vm_id in self.firecracker.active_vms: + return vm_id + + # Create new sandbox + vm_id = self.firecracker.create_sandbox(user_id) + self.sandbox_pool[user_id] = vm_id + self.user_packages[user_id] = set() + return vm_id + + def execute_python_code(self, user_id: str, code: str, + packages: Optional[List[str]] = None, + timeout: int = 30) -> SandboxResult: + """Execute Python code in a sandboxed environment""" + if not self.enabled: + return SandboxResult( + success=False, + error="Sandbox execution is disabled" + ) + + vm_id = self.get_or_create_sandbox(user_id) + if not vm_id: + return SandboxResult( + success=False, + error="Failed to create sandbox" + ) + + commands = [] + + # Install packages if specified and not already installed + if packages: + user_installed_packages = self.user_packages.get(user_id, set()) + new_packages = [] + + for package in packages: + if package not in user_installed_packages: + new_packages.append(package) + user_installed_packages.add(package) + + if new_packages: + commands.append(f"pip install --quiet {' '.join(new_packages)}") + self.user_packages[user_id] = user_installed_packages + + # Execute Python code + commands.append(f"python3 -c '{code}'") + + return self.firecracker.execute_in_sandbox( + vm_id, commands, timeout=timeout, create_venv=True + ) + + def execute_shell_command(self, user_id: str, command: str, + timeout: int = 30) -> SandboxResult: + """Execute shell command in sandbox""" + if not self.enabled: + return SandboxResult( + success=False, + error="Sandbox execution is disabled" + ) + + vm_id = self.get_or_create_sandbox(user_id) + if not vm_id: + return SandboxResult( + success=False, + error="Failed to create sandbox" + ) + + return self.firecracker.execute_in_sandbox( + vm_id, [command], timeout=timeout, create_venv=False + ) + + def cleanup_user_sandbox(self, user_id: str) -> bool: + """Clean up sandbox for specific user""" + if not self.enabled: + return False + + with self.lock: + if user_id in self.sandbox_pool: + vm_id = self.sandbox_pool[user_id] + success = self.firecracker.cleanup_sandbox(vm_id) + if success: + del self.sandbox_pool[user_id] + if user_id in self.user_packages: + del self.user_packages[user_id] + return success + return False + +class TerminalTool(ABC): + """Base class for terminal tools""" + + @property + @abstractmethod + def tool_id(self) -> str: + pass + + @property + @abstractmethod + def description(self) -> str: + pass + + @property + @abstractmethod + def arguments(self) -> List[Dict[str, Any]]: + pass + + @abstractmethod + def execute(self, params: Dict[str, Any]) -> ToolResult: + pass + + +class ExecTool(TerminalTool): + """Execute shell commands""" + + @property + def tool_id(self) -> str: + return "exec" + + @property + def description(self) -> str: + return "Execute shell commands with timeout and safety controls" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "command", "type": "str", "description": "Shell command to execute"}, + {"name": "timeout", "type": "int", "description": "Timeout in seconds (default: 60)", "optional": True}, + {"name": "working_dir", "type": "str", "description": "Working directory for command execution", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + command = params.get("command") + timeout = params.get("timeout", 30) + working_dir = params.get("working_dir", os.getcwd()) + + if not command: + return ToolResult(success=False, error="Command is required") + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + cwd=working_dir + ) + + return ToolResult( + success=True, + data={ + "command": command, + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.returncode, + "working_dir": working_dir + } + ) + except subprocess.TimeoutExpired: + return ToolResult( + success=False, + error=f"Command timed out after {timeout} seconds", + metadata={"command": command} + ) + except Exception as e: + return ToolResult( + success=False, + error=str(e), + metadata={"command": command} + ) + + +class FileReadTool(TerminalTool): + """Read file contents""" + + @property + def tool_id(self) -> str: + return "file_read" + + @property + def description(self) -> str: + return "Read file contents with encoding support" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "path", "type": "str", "description": "File path to read"}, + {"name": "encoding", "type": "str", "description": "File encoding (default: utf-8)", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + file_path = params.get("path") + encoding = params.get("encoding", "utf-8") + + if not file_path: + return ToolResult(success=False, error="File path is required") + + try: + resolved_path = os.path.abspath(file_path) + + with open(resolved_path, 'r', encoding=encoding) as f: + content = f.read() + + file_stat = os.stat(resolved_path) + + return ToolResult( + success=True, + data={ + "path": resolved_path, + "content": content, + "size": len(content), + "modified": datetime.fromtimestamp(file_stat.st_mtime).isoformat(), + "encoding": encoding + } + ) + except FileNotFoundError: + return ToolResult(success=False, error=f"File not found: {file_path}") + except PermissionError: + return ToolResult(success=False, error=f"Permission denied: {file_path}") + except UnicodeDecodeError: + return ToolResult(success=False, error=f"Cannot decode file with {encoding} encoding") + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class FileWriteTool(TerminalTool): + """Write content to file""" + + @property + def tool_id(self) -> str: + return "file_write" + + @property + def description(self) -> str: + return "Write content to file with backup option" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "path", "type": "str", "description": "File path to write"}, + {"name": "content", "type": "str", "description": "Content to write to file"}, + {"name": "encoding", "type": "str", "description": "File encoding (default: utf-8)", "optional": True}, + {"name": "backup", "type": "bool", "description": "Create backup of existing file", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + file_path = params.get("path") + content = params.get("content", "") + encoding = params.get("encoding", "utf-8") + backup = params.get("backup", False) + + if not file_path: + return ToolResult(success=False, error="File path is required") + + try: + resolved_path = os.path.abspath(file_path) + + # Create backup if requested and file exists + backup_created = False + if backup and os.path.exists(resolved_path): + backup_path = f"{resolved_path}.backup.{int(datetime.now().timestamp())}" + shutil.copy2(resolved_path, backup_path) + backup_created = True + + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(resolved_path), exist_ok=True) + + with open(resolved_path, 'w', encoding=encoding) as f: + f.write(content) + + return ToolResult( + success=True, + data={ + "path": resolved_path, + "size": len(content), + "message": f"Successfully wrote {len(content)} characters to {resolved_path}", + "backup_created": backup_created + } + ) + except PermissionError: + return ToolResult(success=False, error=f"Permission denied: {file_path}") + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class DirectoryListTool(TerminalTool): + """List directory contents""" + + @property + def tool_id(self) -> str: + return "directory_list" + + @property + def description(self) -> str: + return "List directory contents with detailed information" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "path", "type": "str", "description": "Directory path to list (default: current directory)", "optional": True}, + {"name": "show_hidden", "type": "bool", "description": "Show hidden files (default: false)", "optional": True}, + {"name": "detailed", "type": "bool", "description": "Show detailed file information (default: true)", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + dir_path = params.get("path", ".") + show_hidden = params.get("show_hidden", False) + detailed = params.get("detailed", True) + + try: + resolved_path = os.path.abspath(dir_path) + + if not os.path.exists(resolved_path): + return ToolResult(success=False, error=f"Directory not found: {dir_path}") + + if not os.path.isdir(resolved_path): + return ToolResult(success=False, error=f"Not a directory: {dir_path}") + + items = [] + for item in os.listdir(resolved_path): + if not show_hidden and item.startswith('.'): + continue + + item_path = os.path.join(resolved_path, item) + item_info = { + "name": item, + "path": item_path, + "type": "directory" if os.path.isdir(item_path) else "file" + } + + if detailed: + try: + stat = os.stat(item_path) + item_info.update({ + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "permissions": oct(stat.st_mode)[-3:], + "owner": stat.st_uid + }) + except: + pass + + items.append(item_info) + + # Sort: directories first, then files, both alphabetically + items.sort(key=lambda x: (x["type"] == "file", x["name"].lower())) + + return ToolResult( + success=True, + data={ + "path": resolved_path, + "items": items, + "count": len(items), + "total_size": sum(item.get("size", 0) for item in items if item["type"] == "file") + } + ) + except PermissionError: + return ToolResult(success=False, error=f"Permission denied: {dir_path}") + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class SystemInfoTool(TerminalTool): + """Get system information""" + + @property + def tool_id(self) -> str: + return "sysinfo" + + @property + def description(self) -> str: + return "Get system information and resource usage" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + try: + try: + import psutil + has_psutil = True + except ImportError: + has_psutil = False + + system_info = { + "platform": platform.platform(), + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "machine": platform.machine(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "current_directory": os.getcwd(), + "user": os.getenv("USER", "unknown"), + "home": os.path.expanduser("~") + } + + if has_psutil: + system_info["resources"] = { + "cpu_count": psutil.cpu_count(), + "memory_total": psutil.virtual_memory().total, + "memory_available": psutil.virtual_memory().available, + "disk_usage": { + "total": psutil.disk_usage('/').total, + "used": psutil.disk_usage('/').used, + "free": psutil.disk_usage('/').free + } + } + else: + system_info["note"] = "Install psutil for detailed resource information" + + return ToolResult(success=True, data=system_info) + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class PwdTool(TerminalTool): + """Get current working directory""" + + @property + def tool_id(self) -> str: + return "pwd" + + @property + def description(self) -> str: + return "Get current working directory" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + try: + return ToolResult( + success=True, + data={ + "current_directory": os.getcwd(), + "absolute_path": os.path.abspath("."), + "user": os.getenv("USER", "unknown") + } + ) + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class CdTool(TerminalTool): + """Change current working directory""" + + @property + def tool_id(self) -> str: + return "cd" + + @property + def description(self) -> str: + return "Change current working directory" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "path", "type": "str", "description": "Directory path to change to"} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + dir_path = params.get("path") + + if not dir_path: + return ToolResult(success=False, error="Directory path is required") + + try: + previous_dir = os.getcwd() + os.chdir(dir_path) + new_dir = os.getcwd() + + return ToolResult( + success=True, + data={ + "previous_directory": previous_dir, + "new_directory": new_dir, + "message": f"Changed directory from {previous_dir} to {new_dir}" + } + ) + except FileNotFoundError: + return ToolResult(success=False, error=f"Directory not found: {dir_path}") + except PermissionError: + return ToolResult(success=False, error=f"Permission denied: {dir_path}") + except Exception as e: + return ToolResult(success=False, error=str(e)) + + +class SandboxedExecTool(TerminalTool): + """Sandboxed version of the exec tool using Firecracker""" + + def __init__(self, sandbox_manager: SandboxManager): + self.sandbox_manager = sandbox_manager + + @property + def tool_id(self) -> str: + return "sandbox_exec" + + @property + def description(self) -> str: + return "Execute shell commands in a secure Firecracker sandbox with venv isolation" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "command", "type": "str", "description": "Shell command to execute"}, + {"name": "user_id", "type": "str", "description": "User ID for sandbox isolation"}, + {"name": "timeout", "type": "int", "description": "Timeout in seconds (default: 60)", "optional": True}, + {"name": "create_venv", "type": "bool", "description": "Create Python venv (default: false)", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + command = params.get("command") + user_id = params.get("user_id", "anonymous") + timeout = params.get("timeout", 60) # Increased from 30 to 60 + create_venv = params.get("create_venv", False) + + if not command: + return ToolResult(success=False, error="Command is required") + + if not self.sandbox_manager.enabled: + return ToolResult( + success=False, + error="Sandbox execution is disabled. Set ENABLE_FIRECRACKER=true to enable." + ) + + # Retry logic - attempt twice + for attempt in range(2): + if create_venv: + # Execute as Python code in venv + result = self.sandbox_manager.execute_python_code( + user_id, command, timeout=timeout + ) + else: + # Execute as shell command + result = self.sandbox_manager.execute_shell_command( + user_id, command, timeout=timeout + ) + + # If successful, return immediately + if result.success: + return ToolResult( + success=result.success, + data={ + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "execution_time": result.execution_time, + "sandbox_id": result.sandbox_id, + "venv_path": result.venv_path, + "attempt": attempt + 1 + }, + error=result.error + ) + + # If first attempt failed, log and retry + if attempt == 0: + logger.warning(f"Sandbox execution attempt 1 failed: {result.error}. Retrying...") + + # Both attempts failed + return ToolResult( + success=False, + data={ + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "execution_time": result.execution_time, + "sandbox_id": result.sandbox_id, + "venv_path": result.venv_path, + "attempts": 2 + }, + error=f"Failed after 2 attempts: {result.error}" + ) + + +class SandboxedPythonTool(TerminalTool): + """Tool for executing Python code in Firecracker sandbox""" + + def __init__(self, sandbox_manager: SandboxManager): + self.sandbox_manager = sandbox_manager + + @property + def tool_id(self) -> str: + return "sandbox_python" + + @property + def description(self) -> str: + return "Execute Python code in a secure Firecracker sandbox with venv and package installation" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "code", "type": "str", "description": "Python code to execute"}, + {"name": "user_id", "type": "str", "description": "User ID for sandbox isolation"}, + {"name": "packages", "type": "list", "description": "Python packages to install", "optional": True}, + {"name": "timeout", "type": "int", "description": "Timeout in seconds (default: 60)", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + code = params.get("code") + user_id = params.get("user_id", "anonymous") + packages = params.get("packages", []) + timeout = params.get("timeout", 60) # Increased from 30 to 60 + + if not code: + return ToolResult(success=False, error="Code is required") + + if not self.sandbox_manager.enabled: + return ToolResult( + success=False, + error="Sandbox execution is disabled. Set ENABLE_FIRECRACKER=true to enable." + ) + + # Retry logic - attempt twice + for attempt in range(2): + result = self.sandbox_manager.execute_python_code( + user_id, code, packages=packages, timeout=timeout + ) + + # If successful, return immediately + if result.success: + return ToolResult( + success=result.success, + data={ + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "execution_time": result.execution_time, + "sandbox_id": result.sandbox_id, + "venv_path": result.venv_path, + "packages_installed": packages, + "attempt": attempt + 1 + }, + error=result.error + ) + + # If first attempt failed, log and retry + if attempt == 0: + logger.warning(f"Python sandbox execution attempt 1 failed: {result.error}. Retrying...") + + # Both attempts failed + return ToolResult( + success=False, + data={ + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "execution_time": result.execution_time, + "sandbox_id": result.sandbox_id, + "venv_path": result.venv_path, + "packages_installed": packages, + "attempts": 2 + }, + error=f"Failed after 2 attempts: {result.error}" + ) + +class SandboxManagerTool(TerminalTool): + """Tool for managing sandboxes""" + + def __init__(self, sandbox_manager: SandboxManager): + self.sandbox_manager = sandbox_manager + + @property + def tool_id(self) -> str: + return "sandbox_manage" + + @property + def description(self) -> str: + return "Manage Firecracker sandboxes - list, cleanup, or get status" + + @property + def arguments(self) -> List[Dict[str, Any]]: + return [ + {"name": "action", "type": "str", "description": "Action: 'list', 'cleanup', 'status'"}, + {"name": "user_id", "type": "str", "description": "User ID for cleanup action", "optional": True} + ] + + def execute(self, params: Dict[str, Any]) -> ToolResult: + action = params.get("action") + user_id = params.get("user_id") + + if not action: + return ToolResult(success=False, error="Action is required") + + if not self.sandbox_manager.enabled: + return ToolResult( + success=False, + error="Sandbox management is disabled. Set ENABLE_FIRECRACKER=true to enable." + ) + + try: + if action == "list": + active_vms = {} + if hasattr(self.sandbox_manager, 'firecracker'): + active_vms = { + vm_id: { + "user_id": vm_info.get("user_id"), + "status": vm_info.get("status"), + "created_at": vm_info.get("created_at") + } + for vm_id, vm_info in self.sandbox_manager.firecracker.active_vms.items() + } + + return ToolResult( + success=True, + data={ + "active_sandboxes": active_vms, + "total_count": len(active_vms), + "enabled": self.sandbox_manager.enabled + } + ) + + elif action == "cleanup": + if not user_id: + return ToolResult(success=False, error="user_id is required for cleanup") + + success = self.sandbox_manager.cleanup_user_sandbox(user_id) + return ToolResult( + success=success, + data={ + "user_id": user_id, + "cleanup_success": success + } + ) + + elif action == "status": + status = { + "enabled": self.sandbox_manager.enabled, + "config": self.sandbox_manager.config, + "active_users": len(getattr(self.sandbox_manager, 'sandbox_pool', {})) + } + + return ToolResult( + success=True, + data=status + ) + + else: + return ToolResult( + success=False, + error=f"Unknown action: {action}. Use 'list', 'cleanup', or 'status'" + ) + + except Exception as e: + return ToolResult( + success=False, + error=str(e) + ) + + +# Initialize terminal tools +terminal_tools = {} + +def register_terminal_tool(tool: TerminalTool): + """Register a terminal tool""" + terminal_tools[tool.tool_id] = tool + logger.info(f"Registered terminal tool: {tool.tool_id}") + +def initialize_terminal_tools(): + """Initialize all terminal tools with security controls""" + + # Always include safe tools + safe_tools = [ + SystemInfoTool(), + PwdTool(), + ] + + # Add sandboxed tools if sandbox is enabled + if sandbox_manager and sandbox_manager.enabled: + safe_tools.extend([ + SandboxedExecTool(sandbox_manager), + SandboxedPythonTool(sandbox_manager), + SandboxManagerTool(sandbox_manager), + ]) + logger.info("✅ Sandbox tools enabled - secure execution available") + logger.info("🔒 Dangerous operations only available through sandbox") + + # If no sandbox, NO dangerous tools at all + else: + logger.info("🔒 No sandbox - dangerous tools completely disabled") + logger.info("🔒 Enable sandbox for secure exec/python execution") + + # Register all approved tools + for tool in safe_tools: + register_terminal_tool(tool) + + +# File-based memory functions +def load_memory_from_file(): + try: + if os.path.exists(MEMORY_FILE): + with open(MEMORY_FILE, "r") as f: + data = json.load(f) + logger.debug(f"Loaded memory from {MEMORY_FILE}: {data}") + return data + logger.debug(f"No memory file found at {MEMORY_FILE}, returning empty dict") + return {} + except Exception as e: + logger.error( + f"Error loading memory from {MEMORY_FILE}: {str(e)}", exc_info=True + ) + return {} + + +def save_memory_to_file(memory_data): + try: + with open(MEMORY_FILE, "w") as f: + json.dump(memory_data, f) + f.flush() + os.fsync(f.fileno()) + logger.debug(f"Saved memory to {MEMORY_FILE}: {memory_data}") + except Exception as e: + logger.error(f"Error saving memory to {MEMORY_FILE}: {str(e)}", exc_info=True) + + +# File-based resources functions +def load_resources_from_file(): + try: + if os.path.exists(RESOURCES_FILE): + with open(RESOURCES_FILE, "r") as f: + data = json.load(f) + logger.debug(f"Loaded resources from {RESOURCES_FILE}: {data}") + return data + else: + # If file doesn't exist, create it with example resources + initial_resources = { + "hello": {"id": "hello", "content": "Hello, SLOP!"}, + "foo/bar": {"id": "foo/bar", "content": "Nested Foo Bar"}, + "foo/baz": {"id": "foo/baz", "content": "Nested Foo Baz"}, + # Terminal-specific resources + "scripts/hello": { + "id": "scripts/hello", + "title": "Hello World Script", + "type": "script", + "content": "#!/bin/bash\necho 'Hello, World!'" + }, + "configs/bashrc": { + "id": "configs/bashrc", + "title": "Bash Configuration", + "type": "config", + "content": "# Custom bash configuration\nalias ll='ls -la'\nexport EDITOR=vim" + }, + "docs/readme": { + "id": "docs/readme", + "title": "README", + "type": "document", + "content": "# SLOP Monolith Server\n\nA combined SLOP server with terminal and gaming capabilities." + } + } + save_resources_to_file(initial_resources) + logger.info( + f"Created new resources file at {RESOURCES_FILE} with initial data" + ) + return initial_resources + except Exception as e: + logger.error( + f"Error loading resources from {RESOURCES_FILE}: {str(e)}", exc_info=True + ) + # On error, create and return initial resources + initial_resources = { + "hello": {"id": "hello", "content": "Hello, SLOP!"}, + "foo/bar": {"id": "foo/bar", "content": "Nested Foo Bar"}, + "foo/baz": {"id": "foo/baz", "content": "Nested Foo Baz"}, + } + save_resources_to_file(initial_resources) + return initial_resources + + +def save_resources_to_file(resources_data): + try: + with open(RESOURCES_FILE, "w") as f: + json.dump(resources_data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + logger.debug(f"Saved resources to {RESOURCES_FILE}: {resources_data}") + except Exception as e: + logger.error( + f"Error saving resources to {RESOURCES_FILE}: {str(e)}", exc_info=True + ) + + +# Load endpoints for AI models +ENDPOINTS = [] +for i in range(1000): + endpoint = os.getenv(f"MODEL_ENDPOINT_{i}") + if endpoint: + api_key = os.getenv(f"MODEL_API_KEY_{i}", "not-needed") + logger.info(f"Loaded endpoint {i}: {endpoint} with API key: {api_key[:4]}...") + ENDPOINTS.append( + { + "name": f"endpoint_{i}", + "base_url": endpoint, + "api_key": api_key, + } + ) + elif i < 5: # Only log first 5 missing endpoints to reduce noise + logger.debug(f"No endpoint found for MODEL_ENDPOINT_{i}") + + +def initialize_model_map(): + logger.info("Starting model map initialization") + MODEL_CLIENT_MAP.clear() + if not ENDPOINTS: + logger.warning("No endpoints configured to initialize models") + return + for ep in ENDPOINTS: + base_url = ep["base_url"] + api_key = ep["api_key"] + endpoint_name = ep["name"] + logger.info(f"Initializing client for {endpoint_name} at {base_url}") + client = OpenAI(base_url=base_url, api_key=api_key) + try: + logger.debug(f"Attempting to list models from {endpoint_name}") + response = client.models.list() + model_list = response.data + logger.debug( + f"Models retrieved from {endpoint_name}: {[m.id for m in model_list]}" + ) + except Exception as e: + logger.error( + f"Failed to list models for {endpoint_name}: {str(e)}", exc_info=True + ) + continue + for m in model_list: + model_id = m.id + if model_id: + if model_id in MODEL_CLIENT_MAP: + logger.warning( + f"Duplicate model ID '{model_id}' found at {endpoint_name}" + ) + else: + MODEL_CLIENT_MAP[model_id] = client + logger.info(f"Registered model '{model_id}' from {endpoint_name}") + else: + logger.warning(f"Model with no ID encountered from {endpoint_name}") + logger.info(f"Model map initialized with models: {list(MODEL_CLIENT_MAP.keys())}") + + +def update_resource(resource_id, new_content): + with resource_lock: + resources = load_resources_from_file() + resources[resource_id] = {"id": resource_id, "content": new_content} + save_resources_to_file(resources) + logger.debug(f"Updated resource {resource_id}: {new_content}") + + +# Helper functions for casino games +def get_or_create_wallet(agent_id): + wallet_key = f"casino/wallet/agent_{agent_id}" + with resource_lock: + resources = load_resources_from_file() + if wallet_key not in resources: + resources[wallet_key] = { + "id": wallet_key, + "content": {"balance": 1000, "last_transaction": None}, + } + save_resources_to_file(resources) + logger.info(f"Created new wallet for agent {agent_id} with balance 1000") + return resources[wallet_key]["content"] + + +def get_or_create_stats(game, agent_id): + stats_key = f"casino/{game}/stats/agent_{agent_id}" + with resource_lock: + resources = load_resources_from_file() + if stats_key not in resources: + if game == "slots": + resources[stats_key] = { + "id": stats_key, + "content": { + "games_played": 0, + "total_bet": 0, + "total_won": 0, + "wins": 0, + "losses": 0, + }, + } + elif game == "blackjack": + resources[stats_key] = { + "id": stats_key, + "content": { + "games_played": 0, + "total_bet": 0, + "total_won": 0, + "wins": 0, + "losses": 0, + "ties": 0, + }, + } + save_resources_to_file(resources) + logger.info(f"Created new {game} stats for agent {agent_id}") + return resources[stats_key]["content"] + + +# Casino game implementations +def play_slots(bet, agent_id): + if bet < 1: + return {"error": "Bet must be at least 1"} + if not agent_id: + return {"error": "agent_id is required"} + + wallet = get_or_create_wallet(agent_id) + if wallet["balance"] < bet: + return { + "error": f"Insufficient funds! Current balance: {wallet['balance']}, Bet: {bet}" + } + + symbols = ["🍒", "🍋", "🍊", "🍇", "🔔", "💎"] + reels = [random.choice(symbols) for _ in range(3)] + matches = len(set(reels)) + + if matches == 1: + payout = bet * 10 + outcome = "Jackpot! You matched all three symbols." + won = True + elif matches == 2: + payout = bet * 2 + outcome = "Two of a kind! You matched two symbols." + won = True + else: + payout = 0 + outcome = "No matches. You lose your bet." + won = False + + wallet_key = f"casino/wallet/agent_{agent_id}" + wallet["balance"] = wallet["balance"] - bet + payout + wallet["last_transaction"] = { + "game": "slots", + "bet": bet, + "payout": payout, + "timestamp": datetime.now().isoformat(), + } + update_resource(wallet_key, wallet) + + stats = get_or_create_stats("slots", agent_id) + stats["games_played"] += 1 + stats["total_bet"] += bet + stats["total_won"] += payout + if won: + stats["wins"] += 1 + else: + stats["losses"] += 1 + update_resource(f"casino/slots/stats/agent_{agent_id}", stats) + + explanation = ( + "Slot Machine Rules: Bet an amount to spin three reels once. " + "Match all 3 symbols for 10x your bet, 2 symbols for 2x your bet, or lose your bet if no match. " + f"Symbols: {symbols}. " + f"You (agent {agent_id}) bet {bet}. Reels: {reels}. {outcome}" + ) + + return { + "explanation": explanation, + "game_state": {"reels": reels}, + "payout": payout, + "bet": bet, + "agent_id": agent_id, + "new_balance": wallet["balance"], + "game_complete": True, + } + + +def play_blackjack(bet, agent_id, game_id=None, action=None): + if not agent_id: + return {"error": "agent_id is required"} + + # Start a new game + if game_id is None: + if bet is None or bet < 1: + return {"error": "Bet must be at least 1 to start a new game"} + + wallet = get_or_create_wallet(agent_id) + if wallet["balance"] < bet: + return { + "error": f"Insufficient funds! Current balance: {wallet['balance']}, Bet: {bet}" + } + + game_id = str(uuid.uuid4()) + cards = [2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 11] * 4 + player_hand = [random.choice(cards), random.choice(cards)] + dealer_hand = [random.choice(cards)] + + player_total = sum(player_hand) + aces = player_hand.count(11) + while player_total > 21 and aces > 0: + player_total -= 10 + aces -= 1 + + game_state = { + "agent_id": agent_id, + "bet": bet, + "player_hands": [player_hand], + "player_totals": [player_total], + "dealer_hand": dealer_hand, + "dealer_total": sum(dealer_hand), + "status": "active", + "active_hand": 0, + } + update_resource(f"casino/blackjack/games/{game_id}", game_state) + + explanation = ( + "Blackjack Rules: Bet to play against the dealer. Goal is to get closer to 21 without going over. " + "Number cards = face value, Face cards = 10, Ace = 1 or 11 (adjusted automatically). " + "Hit to draw cards, stand to finish, split if two cards match. Dealer stands on 17+. " + f"You (agent {agent_id}) started a new game with bet {bet}. " + f"Your hand: {player_hand} (Total: {player_total}). Dealer's up card: {dealer_hand}." + ) + + return { + "explanation": explanation, + "game_id": game_id, + "game_state": { + "player_hands": [player_hand], + "player_totals": [player_total], + "dealer_hand": dealer_hand, + "dealer_total": sum(dealer_hand), + "can_split": len(player_hand) == 2 and player_hand[0] == player_hand[1], + }, + "bet": bet, + "agent_id": agent_id, + "new_balance": wallet["balance"], + "game_complete": False, + } + + # Continue existing game logic... + # (The rest of the blackjack implementation remains the same) + game_key = f"casino/blackjack/games/{game_id}" + with resource_lock: + resources = load_resources_from_file() + if game_key not in resources: + return {"error": f"Game {game_id} not found"} + game_state = resources[game_key]["content"] + + if game_state["agent_id"] != agent_id: + return {"error": "This game belongs to another agent"} + if game_state["status"] != "active": + return {"error": "This game is already complete"} + if action not in ["hit", "stand", "split"]: + return {"error": "Action must be 'hit', 'stand', or 'split'"} + + # ... (rest of blackjack logic remains the same as original) + # Truncated for brevity but would include all the game logic + + +# Combined tools (games + terminal) +tools = { + "calculator": { + "id": "calculator", + "description": "Basic math calculator", + "arguments": [ + { + "name": "expression", + "type": "str", + "description": "the math expression to evaluate.", + } + ], + "execute": lambda params: {"result": expr.evaluate(params["expression"])}, + }, + "greet": { + "id": "greet", + "description": "Says hello", + "arguments": [ + {"name": "name", "type": "str", "description": "name of person to greet"} + ], + "execute": lambda params: {"result": f"Hello, {params['name']}!"}, + }, + "slots": { + "id": "slots", + "description": "Play a slot machine game with a bet for a specific agent", + "arguments": [ + {"name": "bet", "type": "int", "description": "Amount to bet (minimum 1)"}, + { + "name": "agent_id", + "type": "str", + "description": "Unique identifier for the agent", + }, + ], + "execute": lambda params: play_slots(params["bet"], params["agent_id"]), + }, + "blackjack": { + "id": "blackjack", + "description": "Play blackjack: start a new game or continue an existing one", + "arguments": [ + { + "name": "bet", + "type": "int", + "description": "Amount to bet (required to start new game)", + "optional": True, + }, + { + "name": "agent_id", + "type": "str", + "description": "Unique identifier for the agent", + }, + { + "name": "game_id", + "type": "str", + "description": "UUID of existing game (optional, omit to start new)", + "optional": True, + }, + { + "name": "action", + "type": "str", + "description": "Action: 'hit' or 'stand' (required if game_id provided)", + "optional": True, + }, + ], + "execute": lambda params: play_blackjack( + params.get("bet"), + params["agent_id"], + params.get("game_id"), + params.get("action"), + ), + }, +} + + +# Memory endpoints +@app.route("/memory", methods=["POST"]) +def store_memory(): + logger.info("Received /memory POST request") + data = request.json + logger.debug(f"Store memory data: {data}") + if not data or "key" not in data or "value" not in data: + logger.warning(f"Invalid memory store request: {data}") + return jsonify({"error": "Missing 'key' or 'value'"}), 400 + key, value = data["key"], data["value"] + if memory_lock.acquire(timeout=LOCK_TIMEOUT): + try: + memory = load_memory_from_file() + memory[key] = value + save_memory_to_file(memory) + logger.info(f"Stored in memory: {key} = {value}") + return jsonify({"status": "stored"}), 200 + finally: + memory_lock.release() + else: + logger.error( + f"Failed to acquire lock for storing {key} within {LOCK_TIMEOUT} seconds" + ) + return jsonify({"error": "Memory lock timeout"}), 503 + + +@app.route("/memory/", methods=["GET"]) +def get_memory(key): + logger.info(f"Received /memory/{key} GET request") + if memory_lock.acquire(timeout=LOCK_TIMEOUT): + try: + memory = load_memory_from_file() + value = memory.get(key) + logger.debug(f"Retrieved memory for {key}: {value}") + return jsonify({"value": value}), 200 + finally: + memory_lock.release() + else: + logger.error( + f"Failed to acquire lock for retrieving {key} within {LOCK_TIMEOUT} seconds" + ) + return jsonify({"error": "Memory lock timeout"}), 503 + + +@app.route("/memory", methods=["GET"]) +def list_memory(): + logger.info("Received /memory GET request") + if memory_lock.acquire(timeout=LOCK_TIMEOUT): + try: + memory = load_memory_from_file() + keys = list(memory.keys()) + logger.debug(f"Memory keys: {keys}") + return jsonify({"keys": keys}), 200 + finally: + memory_lock.release() + else: + logger.error( + f"Failed to acquire lock for listing memory within {LOCK_TIMEOUT} seconds" + ) + return jsonify({"error": "Memory lock timeout"}), 503 + + +@app.route("/memory/", methods=["DELETE"]) +def delete_memory(key): + logger.info(f"Received /memory/{key} DELETE request") + if memory_lock.acquire(timeout=LOCK_TIMEOUT): + try: + memory = load_memory_from_file() + if key not in memory: + logger.warning(f"Key not found for deletion: {key}") + return jsonify({"error": "Key not found"}), 404 + del memory[key] + save_memory_to_file(memory) + logger.info(f"Deleted from memory: {key}") + return jsonify({"status": "deleted"}), 200 + finally: + memory_lock.release() + else: + logger.error( + f"Failed to acquire lock for deleting {key} within {LOCK_TIMEOUT} seconds" + ) + return jsonify({"error": "Memory lock timeout"}), 503 + + +@app.route("/memory/query", methods=["POST"]) +def query_memory(): + logger.info("Received /memory/query POST request") + data = request.json + if not data or "query" not in data: + return jsonify({"error": "Query is required"}), 400 + + query = data["query"].lower() + filter_params = data.get("filter", {}) + + if memory_lock.acquire(timeout=LOCK_TIMEOUT): + try: + memory = load_memory_from_file() + results = [] + + for key, value in memory.items(): + # Apply prefix filter if specified + if "key_prefix" in filter_params and not key.startswith(filter_params["key_prefix"]): + continue + + # Simple scoring + score = 0 + if query in key.lower(): + score += 0.8 + if query in str(value).lower(): + score += 0.6 + + if score > 0: + results.append({ + "key": key, + "value": value, + "score": min(score, 1.0) + }) + + results.sort(key=lambda x: x["score"], reverse=True) + return jsonify({"results": results}), 200 + finally: + memory_lock.release() + else: + return jsonify({"error": "Memory lock timeout"}), 503 + + +# Chat endpoint with AI tool calling capability +# Chat endpoint with AI tool calling capability +@app.route("/chat", methods=["POST"]) +def chat(): + logger.info("Received /chat request") + data = request.json + logger.debug(f"Request data: {data}") + message = data["messages"][0]["content"] if data.get("messages") else "nothing" + model_id = data.get("model") or ( + list(MODEL_CLIENT_MAP.keys())[0] if MODEL_CLIENT_MAP else None + ) + enable_tool_calling = data.get("enable_tool_calling", True) + user_id = data.get("user_id", "ai_user") + + logger.info(f"Selected model: {model_id}, message: {message}, tool_calling: {enable_tool_calling}") + if not model_id or model_id not in MODEL_CLIENT_MAP: + logger.error(f"Invalid or missing model_id: {model_id}") + return jsonify({"error": "Model not found"}), 404 + client = MODEL_CLIENT_MAP[model_id] + + try: + # Define available tools for AI + available_tools = [] + + # Add safe regular tools + safe_tools = ["calculator", "greet"] + for tool_id in safe_tools: + if tool_id in tools: + tool = tools[tool_id] + available_tools.append({ + "type": "function", + "function": { + "name": tool_id, + "description": tool["description"], + "parameters": { + "type": "object", + "properties": { + arg["name"]: { + "type": "string" if arg["type"] == "str" else "integer" if arg["type"] == "int" else "boolean", + "description": arg["description"] + } + for arg in tool.get("arguments", []) + }, + "required": [arg["name"] for arg in tool.get("arguments", []) if not arg.get("optional", False)] + } + } + }) + + # Add safe terminal tools + safe_terminal_tools = ["sysinfo", "pwd", "sandbox_python", "sandbox_exec", "file_read", "directory_list"] + for tool_id in safe_terminal_tools: + if tool_id in terminal_tools: + tool = terminal_tools[tool_id] + available_tools.append({ + "type": "function", + "function": { + "name": tool_id, + "description": tool.description, + "parameters": { + "type": "object", + "properties": { + arg["name"]: { + "type": "string" if arg["type"] == "str" else "integer" if arg["type"] == "int" else "boolean", + "description": arg["description"] + } + for arg in tool.arguments + }, + "required": [arg["name"] for arg in tool.arguments if not arg.get("optional", False)] + } + } + }) + + # Prepare messages + messages = [ + {"role": m["role"], "content": m["content"]} + for m in data.get("messages", []) + ] or [{"role": "user", "content": message}] + + # Add system message about available tools + if enable_tool_calling and available_tools: + system_message = f"""You have access to these tools: {'; '.join([f"{t['function']['name']}" for t in available_tools])}. +When a user asks for calculations, use the calculator tool. +When a user asks for system information, use the sysinfo tool. +When a user asks for Python code execution, use the sandbox_python tool. +When a user asks for shell commands, use the exec tool. +When a user asks to list files or directories, use the directory_list tool. +Always provide the user_id parameter as "{user_id}" for terminal tools.""" + + messages.insert(0, {"role": "system", "content": system_message}) + + # Make the API call - FIXED: Never use tool_choice parameter + if enable_tool_calling and available_tools: + logger.info("Making API call with tools (no tool_choice parameter)") + response = client.chat.completions.create( + model=model_id, + messages=messages, + tools=available_tools + ) + else: + logger.info("Making API call without tools") + response = client.chat.completions.create( + model=model_id, + messages=messages + ) + + # Handle tool calls if present + response_message = response.choices[0].message + tool_calls = response_message.tool_calls if hasattr(response_message, 'tool_calls') else None + + if tool_calls: + logger.info(f"AI requested {len(tool_calls)} tool calls") + + # Execute tool calls + tool_results = [] + for tool_call in tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + + logger.info(f"Executing tool: {tool_name} with args: {tool_args}") + + # Add user_id for terminal tools + if tool_name in ["sysinfo", "pwd", "sandbox_python", "sandbox_exec", "exec", "file_read", "directory_list"]: + tool_args["user_id"] = user_id + + # Execute the tool + try: + if tool_name in tools: + # Regular tool + result = tools[tool_name]["execute"](tool_args) + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": json.dumps(result) + }) + elif tool_name in terminal_tools: + # Terminal tool + result = terminal_tools[tool_name].execute(tool_args) + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": json.dumps(asdict(result)) + }) + else: + logger.warning(f"Unknown tool: {tool_name}") + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": json.dumps({"error": f"Unknown tool: {tool_name}"}) + }) + except Exception as e: + logger.error(f"Error executing tool {tool_name}: {str(e)}") + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": json.dumps({"error": str(e)}) + }) + + # Get final response with tool results - FIXED: No tool_choice parameter + final_messages = messages + [response_message] + tool_results + logger.info("Making final API call with tool results") + final_response = client.chat.completions.create( + model=model_id, + messages=final_messages + ) + response_content = final_response.choices[0].message.content + else: + response_content = response_message.content + + logger.debug(f"Chat response from {model_id}: {response_content}") + return ( + jsonify({"choices": [{"message": {"content": response_content}}]}), + 200, + ) + except Exception as e: + logger.error(f"Chat error with model {model_id}: {str(e)}", exc_info=True) + return jsonify({"error": str(e)}), 500 + + +@app.route("/models", methods=["GET"]) +def list_models(): + logger.info("Received /models request") + models = list(MODEL_CLIENT_MAP.keys()) + logger.debug(f"Returning models: {models}") + return jsonify({"models": models}), 200 + + +@app.route("/tools", methods=["GET"]) +def list_tools(): + logger.info("Received /tools request") + + # Combine regular tools and terminal tools + all_tools = [] + + # Add regular tools (no category) + for k, v in tools.items(): + all_tools.append({ + "id": k, + "description": v["description"], + "arguments": v.get("arguments", []) + }) + + # Add terminal tools (with category) + for k, v in terminal_tools.items(): + all_tools.append({ + "id": k, + "description": v.description, + "arguments": v.arguments, + "category": "terminal" + }) + + logger.debug(f"Returning tools: {all_tools}") + return jsonify({"tools": all_tools}), 200 + + +@app.route("/tools/", methods=["GET"]) +def get_tool(tool_id): + logger.info(f"Received /tools/{tool_id} GET request") + + # Check regular tools first + if tool_id in tools: + tool = tools[tool_id] + return jsonify({ + "id": tool_id, + "description": tool["description"], + "arguments": tool.get("arguments", []) + }), 200 + + # Check terminal tools + if tool_id in terminal_tools: + tool = terminal_tools[tool_id] + return jsonify({ + "id": tool.tool_id, + "description": tool.description, + "arguments": tool.arguments, + "category": "terminal" + }), 200 + + logger.error(f"Tool not found: {tool_id}") + return jsonify({"error": "Tool not found"}), 404 + + +@app.route("/tools/", methods=["POST"]) +def use_tool(tool_id): + logger.info(f"Received /tools/{tool_id} POST request") + data = request.json or {} + parameters = data.get("parameters", data) # Support both formats + logger.debug(f"Tool {tool_id} input data: {parameters}") + + # Security check: require user_id if configured + if SECURITY_CONFIG["require_user_id"] and "user_id" not in parameters: + # Exception for tools that don't need user_id + tools_without_user_id = ["calculator", "greet", "pwd", "sysinfo"] + if tool_id not in tools_without_user_id: + logger.warning(f"Missing user_id for tool {tool_id}") + return jsonify({"error": "user_id parameter is required for this tool"}), 400 + + # Check regular tools first + if tool_id in tools: + tool = tools[tool_id] + # Validate required arguments + if "arguments" in tool: + for arg in tool["arguments"]: + if arg["name"] not in parameters and not arg.get("optional", False): + logger.warning(f"Missing '{arg['name']}' for {tool_id} tool") + return jsonify({"error": f"Missing '{arg['name']}' parameter"}), 400 + + try: + result = tool["execute"](parameters) + logger.debug(f"Tool {tool_id} result: {result}") + return jsonify(result), 200 + except Exception as e: + logger.error(f"Error executing tool {tool_id}: {str(e)}", exc_info=True) + return jsonify({"error": str(e)}), 500 + + # Check terminal tools + if tool_id in terminal_tools: + tool = terminal_tools[tool_id] + # Validate required arguments + for arg in tool.arguments: + if arg["name"] not in parameters and not arg.get("optional", False): + logger.warning(f"Missing '{arg['name']}' for {tool_id} tool") + return jsonify({"error": f"Missing '{arg['name']}' parameter"}), 400 + + try: + result = tool.execute(parameters) + logger.debug(f"Terminal tool {tool_id} result: {result}") + return jsonify(asdict(result)), 200 + except Exception as e: + logger.error(f"Error executing terminal tool {tool_id}: {str(e)}", exc_info=True) + return jsonify({"error": str(e)}), 500 + + logger.error(f"Tool not found: {tool_id}") + return jsonify({"error": "Tool not found"}), 404 + + +@app.route("/resources", methods=["GET"]) +def list_resources(): + logger.info("Received /resources request") + with resource_lock: + resources = load_resources_from_file() + resource_list = list(resources.values()) + logger.debug(f"Returning resources: {resource_list}") + return jsonify({"resources": resource_list}), 200 + + +@app.route("/resources/", methods=["GET"]) +def get_resource(resource_id): + logger.info(f"Received /resources/{resource_id} GET request") + with resource_lock: + resources = load_resources_from_file() + if resource_id in resources: + resource = resources[resource_id] + logger.debug(f"Exact match found for {resource_id}: {resource}") + return jsonify(resource), 200 + else: + # Prefix search for nested resources + prefix = f"{resource_id}/" + matching_resources = [ + res for key, res in resources.items() if key.startswith(prefix) + ] + if matching_resources: + logger.debug( + f"Prefix search for {resource_id} found: {matching_resources}" + ) + return jsonify({"resources": matching_resources}), 200 + logger.warning(f"No resource or nested resources found for {resource_id}") + return jsonify({"error": "Resource not found"}), 404 + + +@app.route("/resources", methods=["POST"]) +def create_resource(): + logger.info("Received /resources POST request") + data = request.json + if not data or "id" not in data: + return jsonify({"error": "Resource ID is required"}), 400 + + resource_id = data["id"] + with resource_lock: + resources = load_resources_from_file() + if resource_id in resources: + return jsonify({"error": "Resource already exists"}), 409 + + resource = { + "id": resource_id, + "title": data.get("title", resource_id), + "type": data.get("type", "file"), + "content": data.get("content", ""), + "metadata": { + **data.get("metadata", {}), + "created_at": datetime.now().isoformat() + } + } + resources[resource_id] = resource + save_resources_to_file(resources) + + return jsonify({"status": "created", "resource": resource}), 201 + + +@app.route("/resources/", methods=["PUT"]) +def update_resource_endpoint(resource_id): + logger.info(f"Received /resources/{resource_id} PUT request") + data = request.json + logger.debug(f"Update resource data: {data}") + if not data or "content" not in data: + logger.warning(f"Invalid resource update request: {data}") + return jsonify({"error": "Missing 'content'"}), 400 + with resource_lock: + resources = load_resources_from_file() + resources[resource_id] = {"id": resource_id, "content": data["content"]} + save_resources_to_file(resources) + logger.info(f"Updated/created resource {resource_id}: {resources[resource_id]}") + return jsonify({"status": "updated", "resource": resources[resource_id]}), 200 + + +@app.route("/resources/", methods=["DELETE"]) +def delete_resource(resource_id): + logger.info(f"Received /resources/{resource_id} DELETE request") + with resource_lock: + resources = load_resources_from_file() + if resource_id not in resources: + return jsonify({"error": "Resource not found"}), 404 + + del resources[resource_id] + save_resources_to_file(resources) + + return jsonify({"status": "deleted", "id": resource_id}), 200 + + +@app.route("/resources/search", methods=["GET"]) +def search_resources(): + logger.info("Received /resources/search request") + query = request.args.get("q", "").lower() + if not query: + return jsonify({"error": "Query parameter 'q' is required"}), 400 + + with resource_lock: + resources = load_resources_from_file() + results = [] + + for resource in resources.values(): + score = 0 + if query in resource.get("title", resource.get("id", "")).lower(): + score += 0.8 + if query in str(resource.get("content", "")).lower(): + score += 0.6 + if query in resource.get("type", "").lower(): + score += 0.4 + + if score > 0: + results.append({ + "id": resource["id"], + "title": resource.get("title", resource["id"]), + "type": resource.get("type", "file"), + "score": min(score, 1.0) + }) + + results.sort(key=lambda x: x["score"], reverse=True) + return jsonify({"results": results}), 200 + + +@app.route("/pay", methods=["POST"]) +def pay(): + logger.info("Received /pay request") + data = request.json + logger.debug(f"Pay request data: {data}") + amount = data.get("amount", 0) + transaction_id = f"tx_{int(datetime.now().timestamp())}" + logger.info(f"Processed payment of {amount} with transaction_id: {transaction_id}") + return ( + jsonify( + { + "transaction_id": transaction_id, + "status": "success", + } + ), + 200, + ) + + +@app.route("/info", methods=["GET"]) +def info(): + logger.info("Received /info request") + return jsonify({ + "name": "SLOP Monolith Server", + "version": "2.0.0", + "description": "A combined SLOP server with gaming, terminal, and AI capabilities", + "endpoints": [ + {"path": "/chat", "methods": ["POST"], "description": "Chat with AI models"}, + {"path": "/models", "methods": ["GET"], "description": "List available AI models"}, + {"path": "/tools", "methods": ["GET", "POST"], "description": "List and execute tools"}, + {"path": "/memory", "methods": ["GET", "POST", "DELETE"], "description": "Memory operations"}, + {"path": "/resources", "methods": ["GET", "POST", "PUT", "DELETE"], "description": "Resource management"}, + {"path": "/pay", "methods": ["POST"], "description": "Payment processing"}, + {"path": "/info", "methods": ["GET"], "description": "Server information"} + ], + "creator": { + "name": "SLOP Community", + "website": "https://slop.ai" + }, + "capabilities": { + "streaming": False, + "websockets": False, + "models": list(MODEL_CLIENT_MAP.keys()), + "tools": { + "total": len(tools) + len(terminal_tools), + "terminal_tools": list(terminal_tools.keys()) + } + }, + "stats": { + "total_tools": len(tools) + len(terminal_tools), + "tools": len(tools), + "terminal_tools": len(terminal_tools), + "available_models": len(MODEL_CLIENT_MAP) + } + }), 200 + + +# Initialize everything +logger.info("Starting application initialization") +initialize_model_map() + +# Initialize sandbox manager +if FIRECRACKER_CONFIG["enable_sandbox"]: + sandbox_manager = SandboxManager(FIRECRACKER_CONFIG) + logger.info("Sandbox manager initialized") +else: + sandbox_manager = None + logger.info("Sandbox manager disabled") + +initialize_terminal_tools() +logger.info("Application initialization completed") + +if __name__ == "__main__": + port = SERVER_CONFIG["port"] + debug = SERVER_CONFIG["debug"] + + logger.info(f"Starting Flask application on port {port}") + if debug: + logger.warning("🚨 DEBUG MODE ENABLED - Do not use in production!") + + # Show security status + logger.info("🔒 Security Configuration:") + logger.info(f" - Firecracker sandbox: {'ENABLED' if FIRECRACKER_CONFIG['enable_sandbox'] else 'DISABLED'}") + logger.info(f" - Dangerous tools: {'ALLOWED' if SECURITY_CONFIG['allow_dangerous_tools'] else 'BLOCKED'}") + logger.info(f" - User ID required: {'YES' if SECURITY_CONFIG['require_user_id'] else 'NO'}") + logger.info(f" - Default timeout: {SECURITY_CONFIG['default_timeout']}s") + + app.run(debug=debug, port=port, host="0.0.0.0") + logger.info("Flask application stopped") \ No newline at end of file