mirror of
https://github.com/cpacker/MemGPT.git
synced 2025-06-03 04:30:22 +00:00
feat: Custom pip package installations when using local sandbox w/ venv (#867)
Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
This commit is contained in:
parent
27deb578a6
commit
aba1756ef4
@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Literal, Optional, Union
|
from typing import Any, Dict, List, Literal, Optional, Union
|
||||||
|
|
||||||
@ -25,18 +26,55 @@ class SandboxRunResult(BaseModel):
|
|||||||
sandbox_config_fingerprint: str = Field(None, description="The fingerprint of the config for the sandbox")
|
sandbox_config_fingerprint: str = Field(None, description="The fingerprint of the config for the sandbox")
|
||||||
|
|
||||||
|
|
||||||
|
class PipRequirement(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, description="Name of the pip package.")
|
||||||
|
version: Optional[str] = Field(None, description="Optional version of the package, following semantic versioning.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_version(cls, version: Optional[str]) -> Optional[str]:
|
||||||
|
if version is None:
|
||||||
|
return None
|
||||||
|
semver_pattern = re.compile(r"^\d+(\.\d+){0,2}(-[a-zA-Z0-9.]+)?$")
|
||||||
|
if not semver_pattern.match(version):
|
||||||
|
raise ValueError(f"Invalid version format: {version}. Must follow semantic versioning (e.g., 1.2.3, 2.0, 1.5.0-alpha).")
|
||||||
|
return version
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
self.version = self.validate_version(self.version)
|
||||||
|
|
||||||
|
|
||||||
class LocalSandboxConfig(BaseModel):
|
class LocalSandboxConfig(BaseModel):
|
||||||
sandbox_dir: str = Field(..., description="Directory for the sandbox environment.")
|
sandbox_dir: Optional[str] = Field(None, description="Directory for the sandbox environment.")
|
||||||
use_venv: bool = Field(False, description="Whether or not to use the venv, or run directly in the same run loop.")
|
use_venv: bool = Field(False, description="Whether or not to use the venv, or run directly in the same run loop.")
|
||||||
venv_name: str = Field(
|
venv_name: str = Field(
|
||||||
"venv",
|
"venv",
|
||||||
description="The name for the venv in the sandbox directory. We first search for an existing venv with this name, otherwise, we make it from the requirements.txt.",
|
description="The name for the venv in the sandbox directory. We first search for an existing venv with this name, otherwise, we make it from the requirements.txt.",
|
||||||
)
|
)
|
||||||
|
pip_requirements: List[PipRequirement] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of pip packages to install with mandatory name and optional version following semantic versioning. This only is considered when use_venv is True.",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> "SandboxType":
|
def type(self) -> "SandboxType":
|
||||||
return SandboxType.LOCAL
|
return SandboxType.LOCAL
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def set_default_sandbox_dir(cls, data):
|
||||||
|
# If `data` is not a dict (e.g., it's another Pydantic model), just return it
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if data.get("sandbox_dir") is None:
|
||||||
|
if tool_settings.local_sandbox_dir:
|
||||||
|
data["sandbox_dir"] = tool_settings.local_sandbox_dir
|
||||||
|
else:
|
||||||
|
data["sandbox_dir"] = "~/.letta"
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class E2BSandboxConfig(BaseModel):
|
class E2BSandboxConfig(BaseModel):
|
||||||
timeout: int = Field(5 * 60, description="Time limit for the sandbox (in seconds).")
|
timeout: int = Field(5 * 60, description="Time limit for the sandbox (in seconds).")
|
||||||
@ -53,6 +91,10 @@ class E2BSandboxConfig(BaseModel):
|
|||||||
"""
|
"""
|
||||||
Assign a default template value if the template field is not provided.
|
Assign a default template value if the template field is not provided.
|
||||||
"""
|
"""
|
||||||
|
# If `data` is not a dict (e.g., it's another Pydantic model), just return it
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
if data.get("template") is None:
|
if data.get("template") is None:
|
||||||
data["template"] = tool_settings.e2b_sandbox_template_id
|
data["template"] = tool_settings.e2b_sandbox_template_id
|
||||||
return data
|
return data
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from letta.log import get_logger
|
||||||
from letta.schemas.environment_variables import SandboxEnvironmentVariable as PydanticEnvVar
|
from letta.schemas.environment_variables import SandboxEnvironmentVariable as PydanticEnvVar
|
||||||
from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate
|
from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate
|
||||||
|
from letta.schemas.sandbox_config import LocalSandboxConfig
|
||||||
from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig
|
from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig
|
||||||
from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate, SandboxType
|
from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate, SandboxType
|
||||||
from letta.server.rest_api.utils import get_letta_server, get_user_id
|
from letta.server.rest_api.utils import get_letta_server, get_user_id
|
||||||
from letta.server.server import SyncServer
|
from letta.server.server import SyncServer
|
||||||
|
from letta.services.helpers.tool_execution_helper import create_venv_for_local_sandbox, install_pip_requirements_for_sandbox
|
||||||
|
|
||||||
router = APIRouter(prefix="/sandbox-config", tags=["sandbox-config"])
|
router = APIRouter(prefix="/sandbox-config", tags=["sandbox-config"])
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
### Sandbox Config Routes
|
### Sandbox Config Routes
|
||||||
|
|
||||||
@ -44,6 +50,34 @@ def create_default_local_sandbox_config(
|
|||||||
return server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=actor)
|
return server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/local", response_model=PydanticSandboxConfig)
|
||||||
|
def create_custom_local_sandbox_config(
|
||||||
|
local_sandbox_config: LocalSandboxConfig,
|
||||||
|
server: SyncServer = Depends(get_letta_server),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create or update a custom LocalSandboxConfig, including pip_requirements.
|
||||||
|
"""
|
||||||
|
# Ensure the incoming config is of type LOCAL
|
||||||
|
if local_sandbox_config.type != SandboxType.LOCAL:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Provided config must be of type '{SandboxType.LOCAL.value}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve the user (actor)
|
||||||
|
actor = server.user_manager.get_user_or_default(user_id=user_id)
|
||||||
|
|
||||||
|
# Wrap the LocalSandboxConfig into a SandboxConfigCreate
|
||||||
|
sandbox_config_create = SandboxConfigCreate(config=local_sandbox_config)
|
||||||
|
|
||||||
|
# Use the manager to create or update the sandbox config
|
||||||
|
sandbox_config = server.sandbox_config_manager.create_or_update_sandbox_config(sandbox_config_create, actor=actor)
|
||||||
|
|
||||||
|
return sandbox_config
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{sandbox_config_id}", response_model=PydanticSandboxConfig)
|
@router.patch("/{sandbox_config_id}", response_model=PydanticSandboxConfig)
|
||||||
def update_sandbox_config(
|
def update_sandbox_config(
|
||||||
sandbox_config_id: str,
|
sandbox_config_id: str,
|
||||||
@ -77,6 +111,49 @@ def list_sandbox_configs(
|
|||||||
return server.sandbox_config_manager.list_sandbox_configs(actor, limit=limit, after=after, sandbox_type=sandbox_type)
|
return server.sandbox_config_manager.list_sandbox_configs(actor, limit=limit, after=after, sandbox_type=sandbox_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/local/recreate-venv", response_model=PydanticSandboxConfig)
|
||||||
|
def force_recreate_local_sandbox_venv(
|
||||||
|
server: SyncServer = Depends(get_letta_server),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Forcefully recreate the virtual environment for the local sandbox.
|
||||||
|
Deletes and recreates the venv, then reinstalls required dependencies.
|
||||||
|
"""
|
||||||
|
actor = server.user_manager.get_user_or_default(user_id=user_id)
|
||||||
|
|
||||||
|
# Retrieve the local sandbox config
|
||||||
|
sbx_config = server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=actor)
|
||||||
|
|
||||||
|
local_configs = sbx_config.get_local_config()
|
||||||
|
sandbox_dir = os.path.expanduser(local_configs.sandbox_dir) # Expand tilde
|
||||||
|
venv_path = os.path.join(sandbox_dir, local_configs.venv_name)
|
||||||
|
|
||||||
|
# Check if venv exists, and delete if necessary
|
||||||
|
if os.path.isdir(venv_path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(venv_path)
|
||||||
|
logger.info(f"Deleted existing virtual environment at: {venv_path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to delete existing venv: {e}")
|
||||||
|
|
||||||
|
# Recreate the virtual environment
|
||||||
|
try:
|
||||||
|
create_venv_for_local_sandbox(sandbox_dir_path=sandbox_dir, venv_path=str(venv_path), env=os.environ.copy(), force_recreate=True)
|
||||||
|
logger.info(f"Successfully recreated virtual environment at: {venv_path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to recreate venv: {e}")
|
||||||
|
|
||||||
|
# Install pip requirements
|
||||||
|
try:
|
||||||
|
install_pip_requirements_for_sandbox(local_configs=local_configs, env=os.environ.copy())
|
||||||
|
logger.info(f"Successfully installed pip requirements for venv at: {venv_path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to install pip requirements: {e}")
|
||||||
|
|
||||||
|
return sbx_config
|
||||||
|
|
||||||
|
|
||||||
### Sandbox Environment Variable Routes
|
### Sandbox Environment Variable Routes
|
||||||
|
|
||||||
|
|
||||||
|
155
letta/services/helpers/tool_execution_helper.py
Normal file
155
letta/services/helpers/tool_execution_helper.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import venv
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from letta.log import get_logger
|
||||||
|
from letta.schemas.sandbox_config import LocalSandboxConfig
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def find_python_executable(local_configs: LocalSandboxConfig) -> str:
|
||||||
|
"""
|
||||||
|
Determines the Python executable path based on sandbox configuration and platform.
|
||||||
|
Resolves any '~' (tilde) paths to absolute paths.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Full path to the Python binary.
|
||||||
|
"""
|
||||||
|
sandbox_dir = os.path.expanduser(local_configs.sandbox_dir) # Expand tilde
|
||||||
|
|
||||||
|
if not local_configs.use_venv:
|
||||||
|
return "python.exe" if platform.system().lower().startswith("win") else "python3"
|
||||||
|
|
||||||
|
venv_path = os.path.join(sandbox_dir, local_configs.venv_name)
|
||||||
|
python_exec = (
|
||||||
|
os.path.join(venv_path, "Scripts", "python.exe")
|
||||||
|
if platform.system().startswith("Win")
|
||||||
|
else os.path.join(venv_path, "bin", "python3")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.isfile(python_exec):
|
||||||
|
raise FileNotFoundError(f"Python executable not found: {python_exec}. Ensure the virtual environment exists.")
|
||||||
|
|
||||||
|
return python_exec
|
||||||
|
|
||||||
|
|
||||||
|
def run_subprocess(command: list, env: Optional[Dict[str, str]] = None, fail_msg: str = "Command failed"):
|
||||||
|
"""
|
||||||
|
Helper to execute a subprocess with logging and error handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command (list): The command to run as a list of arguments.
|
||||||
|
env (dict, optional): The environment variables to use for the process.
|
||||||
|
fail_msg (str): The error message to log in case of failure.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the subprocess execution fails.
|
||||||
|
"""
|
||||||
|
logger.info(f"Running command: {' '.join(command)}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=True, capture_output=True, text=True, env=env)
|
||||||
|
logger.info(f"Command successful. Output:\n{result.stdout}")
|
||||||
|
return result.stdout
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"{fail_msg}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}")
|
||||||
|
raise RuntimeError(f"{fail_msg}: {e.stderr.strip()}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_pip_is_up_to_date(python_exec: str, env: Optional[Dict[str, str]] = None):
|
||||||
|
"""
|
||||||
|
Ensures pip, setuptools, and wheel are up to date before installing any other dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
python_exec (str): Path to the Python executable to use.
|
||||||
|
env (dict, optional): Environment variables to pass to subprocess.
|
||||||
|
"""
|
||||||
|
run_subprocess(
|
||||||
|
[python_exec, "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"],
|
||||||
|
env=env,
|
||||||
|
fail_msg="Failed to upgrade pip, setuptools, and wheel.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_pip_requirements_for_sandbox(
|
||||||
|
local_configs: LocalSandboxConfig,
|
||||||
|
upgrade: bool = True,
|
||||||
|
user_install_if_no_venv: bool = False,
|
||||||
|
env: Optional[Dict[str, str]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Installs the specified pip requirements inside the correct environment (venv or system).
|
||||||
|
"""
|
||||||
|
if not local_configs.pip_requirements:
|
||||||
|
logger.debug("No pip requirements specified; skipping installation.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sandbox_dir = os.path.expanduser(local_configs.sandbox_dir) # Expand tilde
|
||||||
|
local_configs.sandbox_dir = sandbox_dir # Update the object to store the absolute path
|
||||||
|
|
||||||
|
python_exec = find_python_executable(local_configs)
|
||||||
|
|
||||||
|
# If using a virtual environment, upgrade pip before installing dependencies.
|
||||||
|
if local_configs.use_venv:
|
||||||
|
ensure_pip_is_up_to_date(python_exec, env=env)
|
||||||
|
|
||||||
|
# Construct package list
|
||||||
|
packages = [f"{req.name}=={req.version}" if req.version else req.name for req in local_configs.pip_requirements]
|
||||||
|
|
||||||
|
# Construct pip install command
|
||||||
|
pip_cmd = [python_exec, "-m", "pip", "install"]
|
||||||
|
if upgrade:
|
||||||
|
pip_cmd.append("--upgrade")
|
||||||
|
pip_cmd += packages
|
||||||
|
|
||||||
|
if user_install_if_no_venv and not local_configs.use_venv:
|
||||||
|
pip_cmd.append("--user")
|
||||||
|
|
||||||
|
run_subprocess(pip_cmd, env=env, fail_msg=f"Failed to install packages: {', '.join(packages)}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_venv_for_local_sandbox(sandbox_dir_path: str, venv_path: str, env: Dict[str, str], force_recreate: bool):
|
||||||
|
"""
|
||||||
|
Creates a virtual environment for the sandbox. If force_recreate is True, deletes and recreates the venv.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox_dir_path (str): Path to the sandbox directory.
|
||||||
|
venv_path (str): Path to the virtual environment directory.
|
||||||
|
env (dict): Environment variables to use.
|
||||||
|
force_recreate (bool): If True, delete and recreate the virtual environment.
|
||||||
|
"""
|
||||||
|
sandbox_dir_path = os.path.expanduser(sandbox_dir_path)
|
||||||
|
venv_path = os.path.expanduser(venv_path)
|
||||||
|
|
||||||
|
# If venv exists and force_recreate is True, delete it
|
||||||
|
if force_recreate and os.path.isdir(venv_path):
|
||||||
|
logger.warning(f"Force recreating virtual environment at: {venv_path}")
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(venv_path)
|
||||||
|
|
||||||
|
# Create venv if it does not exist
|
||||||
|
if not os.path.isdir(venv_path):
|
||||||
|
logger.info(f"Creating new virtual environment at {venv_path}")
|
||||||
|
venv.create(venv_path, with_pip=True)
|
||||||
|
|
||||||
|
pip_path = os.path.join(venv_path, "bin", "pip")
|
||||||
|
try:
|
||||||
|
# Step 2: Upgrade pip
|
||||||
|
logger.info("Upgrading pip in the virtual environment...")
|
||||||
|
subprocess.run([pip_path, "install", "--upgrade", "pip"], env=env, check=True)
|
||||||
|
|
||||||
|
# Step 3: Install packages from requirements.txt if available
|
||||||
|
requirements_txt_path = os.path.join(sandbox_dir_path, "requirements.txt")
|
||||||
|
if os.path.isfile(requirements_txt_path):
|
||||||
|
logger.info(f"Installing packages from requirements file: {requirements_txt_path}")
|
||||||
|
subprocess.run([pip_path, "install", "-r", requirements_txt_path], env=env, check=True)
|
||||||
|
logger.info("Successfully installed packages from requirements.txt")
|
||||||
|
else:
|
||||||
|
logger.warning("No requirements.txt file found. Skipping package installation.")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Error while setting up the virtual environment: {e}")
|
||||||
|
raise RuntimeError(f"Failed to set up the virtual environment: {e}")
|
@ -9,7 +9,6 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
import venv
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from letta.log import get_logger
|
from letta.log import get_logger
|
||||||
@ -17,6 +16,11 @@ from letta.schemas.agent import AgentState
|
|||||||
from letta.schemas.sandbox_config import SandboxConfig, SandboxRunResult, SandboxType
|
from letta.schemas.sandbox_config import SandboxConfig, SandboxRunResult, SandboxType
|
||||||
from letta.schemas.tool import Tool
|
from letta.schemas.tool import Tool
|
||||||
from letta.schemas.user import User
|
from letta.schemas.user import User
|
||||||
|
from letta.services.helpers.tool_execution_helper import (
|
||||||
|
create_venv_for_local_sandbox,
|
||||||
|
find_python_executable,
|
||||||
|
install_pip_requirements_for_sandbox,
|
||||||
|
)
|
||||||
from letta.services.sandbox_config_manager import SandboxConfigManager
|
from letta.services.sandbox_config_manager import SandboxConfigManager
|
||||||
from letta.services.tool_manager import ToolManager
|
from letta.services.tool_manager import ToolManager
|
||||||
from letta.settings import tool_settings
|
from letta.settings import tool_settings
|
||||||
@ -38,7 +42,9 @@ class ToolExecutionSandbox:
|
|||||||
# We make this a long random string to avoid collisions with any variables in the user's code
|
# We make this a long random string to avoid collisions with any variables in the user's code
|
||||||
LOCAL_SANDBOX_RESULT_VAR_NAME = "result_ZQqiequkcFwRwwGQMqkt"
|
LOCAL_SANDBOX_RESULT_VAR_NAME = "result_ZQqiequkcFwRwwGQMqkt"
|
||||||
|
|
||||||
def __init__(self, tool_name: str, args: dict, user: User, force_recreate=True, tool_object: Optional[Tool] = None):
|
def __init__(
|
||||||
|
self, tool_name: str, args: dict, user: User, force_recreate=True, force_recreate_venv=False, tool_object: Optional[Tool] = None
|
||||||
|
):
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
self.args = args
|
self.args = args
|
||||||
self.user = user
|
self.user = user
|
||||||
@ -58,6 +64,7 @@ class ToolExecutionSandbox:
|
|||||||
|
|
||||||
self.sandbox_config_manager = SandboxConfigManager(tool_settings)
|
self.sandbox_config_manager = SandboxConfigManager(tool_settings)
|
||||||
self.force_recreate = force_recreate
|
self.force_recreate = force_recreate
|
||||||
|
self.force_recreate_venv = force_recreate_venv
|
||||||
|
|
||||||
def run(self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None) -> SandboxRunResult:
|
def run(self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None) -> SandboxRunResult:
|
||||||
"""
|
"""
|
||||||
@ -150,36 +157,41 @@ class ToolExecutionSandbox:
|
|||||||
|
|
||||||
def run_local_dir_sandbox_venv(self, sbx_config: SandboxConfig, env: Dict[str, str], temp_file_path: str) -> SandboxRunResult:
|
def run_local_dir_sandbox_venv(self, sbx_config: SandboxConfig, env: Dict[str, str], temp_file_path: str) -> SandboxRunResult:
|
||||||
local_configs = sbx_config.get_local_config()
|
local_configs = sbx_config.get_local_config()
|
||||||
venv_path = os.path.join(local_configs.sandbox_dir, local_configs.venv_name)
|
sandbox_dir = os.path.expanduser(local_configs.sandbox_dir) # Expand tilde
|
||||||
|
venv_path = os.path.join(sandbox_dir, local_configs.venv_name)
|
||||||
|
|
||||||
# Safety checks for the venv: verify that the venv path exists and is a directory
|
# Recreate venv if required
|
||||||
if not os.path.isdir(venv_path):
|
if self.force_recreate_venv or not os.path.isdir(venv_path):
|
||||||
logger.warning(f"Virtual environment directory does not exist at: {venv_path}, creating one now...")
|
logger.warning(f"Virtual environment directory does not exist at: {venv_path}, creating one now...")
|
||||||
self.create_venv_for_local_sandbox(sandbox_dir_path=local_configs.sandbox_dir, venv_path=venv_path, env=env)
|
create_venv_for_local_sandbox(
|
||||||
|
sandbox_dir_path=sandbox_dir, venv_path=venv_path, env=env, force_recreate=self.force_recreate_venv
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure the python interpreter exists in the virtual environment
|
install_pip_requirements_for_sandbox(local_configs, env=env)
|
||||||
python_executable = os.path.join(venv_path, "bin", "python3")
|
|
||||||
|
# Ensure Python executable exists
|
||||||
|
python_executable = find_python_executable(local_configs)
|
||||||
if not os.path.isfile(python_executable):
|
if not os.path.isfile(python_executable):
|
||||||
raise FileNotFoundError(f"Python executable not found in virtual environment: {python_executable}")
|
raise FileNotFoundError(f"Python executable not found in virtual environment: {python_executable}")
|
||||||
|
|
||||||
# Set up env for venv
|
# Set up environment variables
|
||||||
env["VIRTUAL_ENV"] = venv_path
|
env["VIRTUAL_ENV"] = venv_path
|
||||||
env["PATH"] = os.path.join(venv_path, "bin") + ":" + env["PATH"]
|
env["PATH"] = os.path.join(venv_path, "bin") + ":" + env["PATH"]
|
||||||
# Suppress all warnings
|
|
||||||
env["PYTHONWARNINGS"] = "ignore"
|
env["PYTHONWARNINGS"] = "ignore"
|
||||||
|
|
||||||
# Execute the code in a restricted subprocess
|
# Execute the code
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[os.path.join(venv_path, "bin", "python3"), temp_file_path],
|
[python_executable, temp_file_path],
|
||||||
env=env,
|
env=env,
|
||||||
cwd=local_configs.sandbox_dir, # Restrict execution to sandbox_dir
|
cwd=sandbox_dir,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
func_result, stdout = self.parse_out_function_results_markers(result.stdout)
|
func_result, stdout = self.parse_out_function_results_markers(result.stdout)
|
||||||
func_return, agent_state = self.parse_best_effort(func_result)
|
func_return, agent_state = self.parse_best_effort(func_result)
|
||||||
|
|
||||||
return SandboxRunResult(
|
return SandboxRunResult(
|
||||||
func_return=func_return,
|
func_return=func_return,
|
||||||
agent_state=agent_state,
|
agent_state=agent_state,
|
||||||
@ -260,29 +272,6 @@ class ToolExecutionSandbox:
|
|||||||
end_index = text.index(self.LOCAL_SANDBOX_RESULT_END_MARKER)
|
end_index = text.index(self.LOCAL_SANDBOX_RESULT_END_MARKER)
|
||||||
return text[start_index:end_index], text[: start_index - marker_len] + text[end_index + +marker_len :]
|
return text[start_index:end_index], text[: start_index - marker_len] + text[end_index + +marker_len :]
|
||||||
|
|
||||||
def create_venv_for_local_sandbox(self, sandbox_dir_path: str, venv_path: str, env: Dict[str, str]):
|
|
||||||
# Step 1: Create the virtual environment
|
|
||||||
venv.create(venv_path, with_pip=True)
|
|
||||||
|
|
||||||
pip_path = os.path.join(venv_path, "bin", "pip")
|
|
||||||
try:
|
|
||||||
# Step 2: Upgrade pip
|
|
||||||
logger.info("Upgrading pip in the virtual environment...")
|
|
||||||
subprocess.run([pip_path, "install", "--upgrade", "pip"], env=env, check=True)
|
|
||||||
|
|
||||||
# Step 3: Install packages from requirements.txt if provided
|
|
||||||
requirements_txt_path = os.path.join(sandbox_dir_path, self.REQUIREMENT_TXT_NAME)
|
|
||||||
if os.path.isfile(requirements_txt_path):
|
|
||||||
logger.info(f"Installing packages from requirements file: {requirements_txt_path}")
|
|
||||||
subprocess.run([pip_path, "install", "-r", requirements_txt_path], env=env, check=True)
|
|
||||||
logger.info("Successfully installed packages from requirements.txt")
|
|
||||||
else:
|
|
||||||
logger.warning("No requirements.txt file provided or the file does not exist. Skipping package installation.")
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.error(f"Error while setting up the virtual environment: {e}")
|
|
||||||
raise RuntimeError(f"Failed to set up the virtual environment: {e}")
|
|
||||||
|
|
||||||
# e2b sandbox specific functions
|
# e2b sandbox specific functions
|
||||||
|
|
||||||
def run_e2b_sandbox(self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None) -> SandboxRunResult:
|
def run_e2b_sandbox(self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None) -> SandboxRunResult:
|
||||||
|
@ -17,7 +17,14 @@ from letta.schemas.environment_variables import AgentEnvironmentVariable, Sandbo
|
|||||||
from letta.schemas.llm_config import LLMConfig
|
from letta.schemas.llm_config import LLMConfig
|
||||||
from letta.schemas.memory import ChatMemory
|
from letta.schemas.memory import ChatMemory
|
||||||
from letta.schemas.organization import Organization
|
from letta.schemas.organization import Organization
|
||||||
from letta.schemas.sandbox_config import E2BSandboxConfig, LocalSandboxConfig, SandboxConfigCreate, SandboxConfigUpdate, SandboxType
|
from letta.schemas.sandbox_config import (
|
||||||
|
E2BSandboxConfig,
|
||||||
|
LocalSandboxConfig,
|
||||||
|
PipRequirement,
|
||||||
|
SandboxConfigCreate,
|
||||||
|
SandboxConfigUpdate,
|
||||||
|
SandboxType,
|
||||||
|
)
|
||||||
from letta.schemas.tool import Tool, ToolCreate
|
from letta.schemas.tool import Tool, ToolCreate
|
||||||
from letta.schemas.user import User
|
from letta.schemas.user import User
|
||||||
from letta.services.organization_manager import OrganizationManager
|
from letta.services.organization_manager import OrganizationManager
|
||||||
@ -252,7 +259,10 @@ def custom_test_sandbox_config(test_user):
|
|||||||
|
|
||||||
# Set the sandbox to be within the external codebase path and use a venv
|
# Set the sandbox to be within the external codebase path and use a venv
|
||||||
external_codebase_path = str(Path(__file__).parent / "test_tool_sandbox" / "restaurant_management_system")
|
external_codebase_path = str(Path(__file__).parent / "test_tool_sandbox" / "restaurant_management_system")
|
||||||
local_sandbox_config = LocalSandboxConfig(sandbox_dir=external_codebase_path, use_venv=True)
|
# tqdm is used in this codebase, but NOT in the requirements.txt, this tests that we can successfully install pip requirements
|
||||||
|
local_sandbox_config = LocalSandboxConfig(
|
||||||
|
sandbox_dir=external_codebase_path, use_venv=True, pip_requirements=[PipRequirement(name="tqdm")]
|
||||||
|
)
|
||||||
|
|
||||||
# Create the sandbox configuration
|
# Create the sandbox configuration
|
||||||
config_create = SandboxConfigCreate(config=local_sandbox_config.model_dump())
|
config_create = SandboxConfigCreate(config=local_sandbox_config.model_dump())
|
||||||
@ -436,7 +446,7 @@ def test_local_sandbox_e2e_composio_star_github_without_setting_db_env_vars(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.local_sandbox
|
@pytest.mark.local_sandbox
|
||||||
def test_local_sandbox_external_codebase(mock_e2b_api_key_none, custom_test_sandbox_config, external_codebase_tool, test_user):
|
def test_local_sandbox_external_codebase_with_venv(mock_e2b_api_key_none, custom_test_sandbox_config, external_codebase_tool, test_user):
|
||||||
# Set the args
|
# Set the args
|
||||||
args = {"percentage": 10}
|
args = {"percentage": 10}
|
||||||
|
|
||||||
@ -470,6 +480,59 @@ def test_local_sandbox_with_venv_errors(mock_e2b_api_key_none, custom_test_sandb
|
|||||||
assert "ZeroDivisionError: This is an intentionally weird division!" in result.stderr[0], "stderr contains expected error"
|
assert "ZeroDivisionError: This is an intentionally weird division!" in result.stderr[0], "stderr contains expected error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2b_sandbox
|
||||||
|
def test_local_sandbox_with_venv_pip_installs_basic(mock_e2b_api_key_none, cowsay_tool, test_user):
|
||||||
|
manager = SandboxConfigManager(tool_settings)
|
||||||
|
config_create = SandboxConfigCreate(
|
||||||
|
config=LocalSandboxConfig(use_venv=True, pip_requirements=[PipRequirement(name="cowsay")]).model_dump()
|
||||||
|
)
|
||||||
|
config = manager.create_or_update_sandbox_config(config_create, test_user)
|
||||||
|
|
||||||
|
# Add an environment variable
|
||||||
|
key = "secret_word"
|
||||||
|
long_random_string = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20))
|
||||||
|
manager.create_sandbox_env_var(
|
||||||
|
SandboxEnvironmentVariableCreate(key=key, value=long_random_string), sandbox_config_id=config.id, actor=test_user
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox = ToolExecutionSandbox(cowsay_tool.name, {}, user=test_user, force_recreate_venv=True)
|
||||||
|
result = sandbox.run()
|
||||||
|
assert long_random_string in result.stdout[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2b_sandbox
|
||||||
|
def test_local_sandbox_with_venv_pip_installs_with_update(mock_e2b_api_key_none, cowsay_tool, test_user):
|
||||||
|
manager = SandboxConfigManager(tool_settings)
|
||||||
|
config_create = SandboxConfigCreate(config=LocalSandboxConfig(use_venv=True).model_dump())
|
||||||
|
config = manager.create_or_update_sandbox_config(config_create, test_user)
|
||||||
|
|
||||||
|
# Add an environment variable
|
||||||
|
key = "secret_word"
|
||||||
|
long_random_string = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20))
|
||||||
|
manager.create_sandbox_env_var(
|
||||||
|
SandboxEnvironmentVariableCreate(key=key, value=long_random_string), sandbox_config_id=config.id, actor=test_user
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox = ToolExecutionSandbox(cowsay_tool.name, {}, user=test_user, force_recreate_venv=True)
|
||||||
|
result = sandbox.run()
|
||||||
|
|
||||||
|
# Check that this should error
|
||||||
|
assert len(result.stdout) == 0
|
||||||
|
error_message = "No module named 'cowsay'"
|
||||||
|
assert error_message in result.stderr[0]
|
||||||
|
|
||||||
|
# Now update the SandboxConfig
|
||||||
|
config_create = SandboxConfigCreate(
|
||||||
|
config=LocalSandboxConfig(use_venv=True, pip_requirements=[PipRequirement(name="cowsay")]).model_dump()
|
||||||
|
)
|
||||||
|
manager.create_or_update_sandbox_config(config_create, test_user)
|
||||||
|
|
||||||
|
# Run it again WITHOUT force recreating the venv
|
||||||
|
sandbox = ToolExecutionSandbox(cowsay_tool.name, {}, user=test_user, force_recreate_venv=False)
|
||||||
|
result = sandbox.run()
|
||||||
|
assert long_random_string in result.stdout[0]
|
||||||
|
|
||||||
|
|
||||||
# E2B sandbox tests
|
# E2B sandbox tests
|
||||||
|
|
||||||
|
|
||||||
|
@ -2355,6 +2355,19 @@ def test_create_or_update_sandbox_config(server: SyncServer, default_user):
|
|||||||
assert created_config.organization_id == default_user.organization_id
|
assert created_config.organization_id == default_user.organization_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_local_sandbox_config_defaults(server: SyncServer, default_user):
|
||||||
|
sandbox_config_create = SandboxConfigCreate(
|
||||||
|
config=LocalSandboxConfig(),
|
||||||
|
)
|
||||||
|
created_config = server.sandbox_config_manager.create_or_update_sandbox_config(sandbox_config_create, actor=default_user)
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert created_config.type == SandboxType.LOCAL
|
||||||
|
assert created_config.get_local_config() == sandbox_config_create.config
|
||||||
|
assert created_config.get_local_config().sandbox_dir in {"~/.letta", tool_settings.local_sandbox_dir}
|
||||||
|
assert created_config.organization_id == default_user.organization_id
|
||||||
|
|
||||||
|
|
||||||
def test_default_e2b_settings_sandbox_config(server: SyncServer, default_user):
|
def test_default_e2b_settings_sandbox_config(server: SyncServer, default_user):
|
||||||
created_config = server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=default_user)
|
created_config = server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=default_user)
|
||||||
e2b_config = created_config.get_e2b_config()
|
e2b_config = created_config.get_e2b_config()
|
||||||
|
@ -8,6 +8,7 @@ def adjust_menu_prices(percentage: float) -> str:
|
|||||||
str: A formatted string summarizing the price adjustments.
|
str: A formatted string summarizing the price adjustments.
|
||||||
"""
|
"""
|
||||||
import cowsay
|
import cowsay
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
from core.menu import Menu, MenuItem # Import a class from the codebase
|
from core.menu import Menu, MenuItem # Import a class from the codebase
|
||||||
from core.utils import format_currency # Use a utility function to test imports
|
from core.utils import format_currency # Use a utility function to test imports
|
||||||
@ -23,7 +24,7 @@ def adjust_menu_prices(percentage: float) -> str:
|
|||||||
|
|
||||||
# Make adjustments and record
|
# Make adjustments and record
|
||||||
adjustments = []
|
adjustments = []
|
||||||
for item in menu.items:
|
for item in tqdm(menu.items):
|
||||||
old_price = item.price
|
old_price = item.price
|
||||||
item.price += item.price * (percentage / 100)
|
item.price += item.price * (percentage / 100)
|
||||||
adjustments.append(f"{item.name}: {format_currency(old_price)} -> {format_currency(item.price)}")
|
adjustments.append(f"{item.name}: {format_currency(old_price)} -> {format_currency(item.price)}")
|
||||||
|
@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
|
|||||||
from letta.orm.errors import NoResultFound
|
from letta.orm.errors import NoResultFound
|
||||||
from letta.schemas.block import Block, BlockUpdate, CreateBlock
|
from letta.schemas.block import Block, BlockUpdate, CreateBlock
|
||||||
from letta.schemas.message import UserMessage
|
from letta.schemas.message import UserMessage
|
||||||
|
from letta.schemas.sandbox_config import LocalSandboxConfig, PipRequirement, SandboxConfig
|
||||||
from letta.schemas.tool import ToolCreate, ToolUpdate
|
from letta.schemas.tool import ToolCreate, ToolUpdate
|
||||||
from letta.server.rest_api.app import app
|
from letta.server.rest_api.app import app
|
||||||
from letta.server.rest_api.utils import get_letta_server
|
from letta.server.rest_api.utils import get_letta_server
|
||||||
@ -480,3 +481,39 @@ def test_list_agents_for_block(client, mock_sync_server):
|
|||||||
block_id="block-abc",
|
block_id="block-abc",
|
||||||
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
|
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================================================================
|
||||||
|
# Sandbox Config Routes Tests
|
||||||
|
# ======================================================================================================================
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_local_sandbox_config():
|
||||||
|
"""Fixture for a sample LocalSandboxConfig object."""
|
||||||
|
return LocalSandboxConfig(
|
||||||
|
sandbox_dir="/custom/path",
|
||||||
|
use_venv=True,
|
||||||
|
venv_name="custom_venv_name",
|
||||||
|
pip_requirements=[
|
||||||
|
PipRequirement(name="numpy", version="1.23.0"),
|
||||||
|
PipRequirement(name="pandas"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_custom_local_sandbox_config(client, mock_sync_server, sample_local_sandbox_config):
|
||||||
|
"""Test creating or updating a LocalSandboxConfig."""
|
||||||
|
mock_sync_server.sandbox_config_manager.create_or_update_sandbox_config.return_value = SandboxConfig(
|
||||||
|
type="local", organization_id="org-123", config=sample_local_sandbox_config.model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post("/v1/sandbox-config/local", json=sample_local_sandbox_config.model_dump(), headers={"user_id": "test_user"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["type"] == "local"
|
||||||
|
assert response.json()["config"]["sandbox_dir"] == "/custom/path"
|
||||||
|
assert response.json()["config"]["pip_requirements"] == [
|
||||||
|
{"name": "numpy", "version": "1.23.0"},
|
||||||
|
{"name": "pandas", "version": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_sync_server.sandbox_config_manager.create_or_update_sandbox_config.assert_called_once()
|
||||||
|
Loading…
Reference in New Issue
Block a user