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:
Matthew Zhou 2025-01-30 16:19:08 -10:00 committed by GitHub
parent 27deb578a6
commit aba1756ef4
8 changed files with 419 additions and 42 deletions

View File

@ -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

View File

@ -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

View 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}")

View File

@ -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:

View File

@ -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

View File

@ -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()

View File

@ -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)}")

View File

@ -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()