Swap the order of @trace_method and @raise_on_invalid_id decorators across all service managers so that @trace_method is always the first wrapper applied to the function (positioned directly above the method). This ensures the ID validation happens before tracing begins, which is the intended execution order. Files modified: - agent_manager.py (23 occurrences) - archive_manager.py (11 occurrences) - block_manager.py (7 occurrences) - file_manager.py (6 occurrences) - group_manager.py (9 occurrences) - identity_manager.py (10 occurrences) - job_manager.py (7 occurrences) - message_manager.py (2 occurrences) - provider_manager.py (3 occurrences) - sandbox_config_manager.py (7 occurrences) - source_manager.py (5 occurrences) - step_manager.py (13 occurrences)
356 lines
17 KiB
Python
356 lines
17 KiB
Python
from typing import Dict, List, Optional
|
|
|
|
from letta.constants import LETTA_TOOL_EXECUTION_DIR
|
|
from letta.log import get_logger
|
|
from letta.orm.errors import NoResultFound
|
|
from letta.orm.sandbox_config import SandboxConfig as SandboxConfigModel, SandboxEnvironmentVariable as SandboxEnvVarModel
|
|
from letta.otel.tracing import trace_method
|
|
from letta.schemas.enums import PrimitiveType, SandboxType
|
|
from letta.schemas.environment_variables import (
|
|
SandboxEnvironmentVariable as PydanticEnvVar,
|
|
SandboxEnvironmentVariableCreate,
|
|
SandboxEnvironmentVariableUpdate,
|
|
)
|
|
from letta.schemas.sandbox_config import (
|
|
LocalSandboxConfig,
|
|
SandboxConfig as PydanticSandboxConfig,
|
|
SandboxConfigCreate,
|
|
SandboxConfigUpdate,
|
|
)
|
|
from letta.schemas.user import User as PydanticUser
|
|
from letta.server.db import db_registry
|
|
from letta.utils import enforce_types, printd
|
|
from letta.validators import raise_on_invalid_id
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class SandboxConfigManager:
|
|
"""Manager class to handle business logic related to SandboxConfig and SandboxEnvironmentVariable."""
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
def get_or_create_default_sandbox_config(self, sandbox_type: SandboxType, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
sandbox_config = self.get_sandbox_config_by_type(sandbox_type, actor=actor)
|
|
if not sandbox_config:
|
|
logger.debug(f"Creating new sandbox config of type {sandbox_type}, none found for organization {actor.organization_id}.")
|
|
|
|
# TODO: Add more sandbox types later
|
|
if sandbox_type == SandboxType.E2B:
|
|
default_config = {} # Empty
|
|
else:
|
|
# TODO: May want to move this to environment variables v.s. persisting in database
|
|
default_local_sandbox_path = LETTA_TOOL_EXECUTION_DIR
|
|
default_config = LocalSandboxConfig(sandbox_dir=default_local_sandbox_path).model_dump(exclude_none=True)
|
|
|
|
sandbox_config = self.create_or_update_sandbox_config(SandboxConfigCreate(config=default_config), actor=actor)
|
|
return sandbox_config
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def get_or_create_default_sandbox_config_async(self, sandbox_type: SandboxType, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
sandbox_config = await self.get_sandbox_config_by_type_async(sandbox_type, actor=actor)
|
|
if not sandbox_config:
|
|
logger.debug(f"Creating new sandbox config of type {sandbox_type}, none found for organization {actor.organization_id}.")
|
|
|
|
# TODO: Add more sandbox types later
|
|
if sandbox_type == SandboxType.E2B:
|
|
default_config = {} # Empty
|
|
else:
|
|
# TODO: May want to move this to environment variables v.s. persisting in database
|
|
default_local_sandbox_path = LETTA_TOOL_EXECUTION_DIR
|
|
default_config = LocalSandboxConfig(sandbox_dir=default_local_sandbox_path).model_dump(exclude_none=True)
|
|
|
|
sandbox_config = await self.create_or_update_sandbox_config_async(SandboxConfigCreate(config=default_config), actor=actor)
|
|
return sandbox_config
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def create_or_update_sandbox_config_async(
|
|
self, sandbox_config_create: SandboxConfigCreate, actor: PydanticUser
|
|
) -> PydanticSandboxConfig:
|
|
"""Create or update a sandbox configuration based on the PydanticSandboxConfig schema."""
|
|
config = sandbox_config_create.config
|
|
sandbox_type = config.type
|
|
sandbox_config = PydanticSandboxConfig(
|
|
type=sandbox_type, config=config.model_dump(exclude_none=True), organization_id=actor.organization_id
|
|
)
|
|
|
|
# Attempt to retrieve the existing sandbox configuration by type within the organization
|
|
db_sandbox = await self.get_sandbox_config_by_type_async(sandbox_config.type, actor=actor)
|
|
if db_sandbox:
|
|
# Prepare the update data, excluding fields that should not be reset
|
|
update_data = sandbox_config.model_dump(exclude_unset=True, exclude_none=True)
|
|
update_data = {key: value for key, value in update_data.items() if getattr(db_sandbox, key) != value}
|
|
|
|
# If there are changes, update the sandbox configuration
|
|
if update_data:
|
|
db_sandbox = await self.update_sandbox_config_async(db_sandbox.id, SandboxConfigUpdate(**update_data), actor)
|
|
else:
|
|
printd(
|
|
f"`create_or_update_sandbox_config` was called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
f"type={sandbox_config.type}, but found existing configuration with nothing to update."
|
|
)
|
|
|
|
return db_sandbox
|
|
else:
|
|
# If the sandbox configuration doesn't exist, create a new one
|
|
async with db_registry.async_session() as session:
|
|
db_sandbox = SandboxConfigModel(**sandbox_config.model_dump(exclude_none=True))
|
|
await db_sandbox.create_async(session, actor=actor)
|
|
return db_sandbox.to_pydantic()
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def update_sandbox_config_async(
|
|
self, sandbox_config_id: str, sandbox_update: SandboxConfigUpdate, actor: PydanticUser
|
|
) -> PydanticSandboxConfig:
|
|
"""Update an existing sandbox configuration."""
|
|
async with db_registry.async_session() as session:
|
|
sandbox = await SandboxConfigModel.read_async(db_session=session, identifier=sandbox_config_id, actor=actor)
|
|
# We need to check that the sandbox_update provided is the same type as the original sandbox
|
|
if sandbox.type != sandbox_update.config.type:
|
|
raise ValueError(
|
|
f"Mismatched type for sandbox config update: tried to update sandbox_config of type {sandbox.type} with config of type {sandbox_update.config.type}"
|
|
)
|
|
|
|
update_data = sandbox_update.model_dump(exclude_unset=True, exclude_none=True)
|
|
update_data = {key: value for key, value in update_data.items() if getattr(sandbox, key) != value}
|
|
|
|
if update_data:
|
|
for key, value in update_data.items():
|
|
setattr(sandbox, key, value)
|
|
await sandbox.update_async(db_session=session, actor=actor)
|
|
else:
|
|
printd(
|
|
f"`update_sandbox_config` called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
f"name={sandbox.type}, but nothing to update."
|
|
)
|
|
return sandbox.to_pydantic()
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def delete_sandbox_config_async(self, sandbox_config_id: str, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
"""Delete a sandbox configuration by its ID."""
|
|
async with db_registry.async_session() as session:
|
|
sandbox = await SandboxConfigModel.read_async(db_session=session, identifier=sandbox_config_id, actor=actor)
|
|
await sandbox.hard_delete_async(db_session=session, actor=actor)
|
|
return sandbox.to_pydantic()
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def list_sandbox_configs_async(
|
|
self,
|
|
actor: PydanticUser,
|
|
after: Optional[str] = None,
|
|
limit: Optional[int] = 50,
|
|
sandbox_type: Optional[SandboxType] = None,
|
|
) -> List[PydanticSandboxConfig]:
|
|
"""List all sandbox configurations with optional pagination."""
|
|
kwargs = {"organization_id": actor.organization_id}
|
|
if sandbox_type:
|
|
kwargs.update({"type": sandbox_type})
|
|
|
|
async with db_registry.async_session() as session:
|
|
sandboxes = await SandboxConfigModel.list_async(db_session=session, after=after, limit=limit, **kwargs)
|
|
return [sandbox.to_pydantic() for sandbox in sandboxes]
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def get_sandbox_config_by_type_async(
|
|
self, type: SandboxType, actor: Optional[PydanticUser] = None
|
|
) -> Optional[PydanticSandboxConfig]:
|
|
"""Retrieve a sandbox config by its type."""
|
|
async with db_registry.async_session() as session:
|
|
try:
|
|
sandboxes = await SandboxConfigModel.list_async(
|
|
db_session=session,
|
|
type=type,
|
|
organization_id=actor.organization_id,
|
|
limit=1,
|
|
)
|
|
if sandboxes:
|
|
return sandboxes[0].to_pydantic()
|
|
return None
|
|
except NoResultFound:
|
|
return None
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def create_sandbox_env_var_async(
|
|
self, env_var_create: SandboxEnvironmentVariableCreate, sandbox_config_id: str, actor: PydanticUser
|
|
) -> PydanticEnvVar:
|
|
"""Create a new sandbox environment variable."""
|
|
env_var = PydanticEnvVar(**env_var_create.model_dump(), sandbox_config_id=sandbox_config_id, organization_id=actor.organization_id)
|
|
|
|
db_env_var = await self.get_sandbox_env_var_by_key_and_sandbox_config_id_async(env_var.key, env_var.sandbox_config_id, actor=actor)
|
|
if db_env_var:
|
|
update_data = env_var.model_dump(exclude_unset=True, exclude_none=True)
|
|
update_data = {key: value for key, value in update_data.items() if getattr(db_env_var, key) != value}
|
|
# If there are changes, update the environment variable
|
|
if update_data:
|
|
db_env_var = await self.update_sandbox_env_var_async(db_env_var.id, SandboxEnvironmentVariableUpdate(**update_data), actor)
|
|
else:
|
|
printd(
|
|
f"`create_or_update_sandbox_env_var` was called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
f"key={env_var.key}, but found existing variable with nothing to update."
|
|
)
|
|
|
|
return db_env_var
|
|
else:
|
|
async with db_registry.async_session() as session:
|
|
# Encrypt the value before storing (only to value_enc, not plaintext)
|
|
from letta.schemas.secret import Secret
|
|
|
|
if env_var.value:
|
|
env_var.value_enc = Secret.from_plaintext(env_var.value)
|
|
env_var.value = "" # Don't store plaintext, use empty string for NOT NULL constraint
|
|
|
|
env_var = SandboxEnvVarModel(**env_var.model_dump(to_orm=True))
|
|
await env_var.create_async(session, actor=actor)
|
|
return await PydanticEnvVar.from_orm_async(env_var)
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def update_sandbox_env_var_async(
|
|
self, env_var_id: str, env_var_update: SandboxEnvironmentVariableUpdate, actor: PydanticUser
|
|
) -> PydanticEnvVar:
|
|
"""Update an existing sandbox environment variable."""
|
|
async with db_registry.async_session() as session:
|
|
env_var = await SandboxEnvVarModel.read_async(db_session=session, identifier=env_var_id, actor=actor)
|
|
update_data = env_var_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
|
|
|
|
# Handle encryption for value if provided
|
|
# Only re-encrypt if the value has actually changed
|
|
if "value" in update_data and update_data["value"] is not None:
|
|
from letta.schemas.secret import Secret
|
|
|
|
# Check if value changed by comparing with existing encrypted value
|
|
existing_value = None
|
|
if env_var.value_enc:
|
|
existing_secret = Secret.from_encrypted(env_var.value_enc)
|
|
existing_value = await existing_secret.get_plaintext_async()
|
|
|
|
# Only re-encrypt if different
|
|
if existing_value != update_data["value"]:
|
|
env_var.value_enc = Secret.from_plaintext(update_data["value"]).get_encrypted()
|
|
# Don't store plaintext anymore
|
|
|
|
# Remove from update_data since we set directly on env_var
|
|
update_data.pop("value", None)
|
|
update_data.pop("value_enc", None)
|
|
|
|
# Apply remaining updates
|
|
update_data = {key: value for key, value in update_data.items() if getattr(env_var, key) != value}
|
|
|
|
if update_data:
|
|
for key, value in update_data.items():
|
|
setattr(env_var, key, value)
|
|
await env_var.update_async(db_session=session, actor=actor)
|
|
else:
|
|
printd(
|
|
f"`update_sandbox_env_var` called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
f"key={env_var.key}, but nothing to update."
|
|
)
|
|
return await PydanticEnvVar.from_orm_async(env_var)
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def delete_sandbox_env_var_async(self, env_var_id: str, actor: PydanticUser) -> PydanticEnvVar:
|
|
"""Delete a sandbox environment variable by its ID."""
|
|
async with db_registry.async_session() as session:
|
|
env_var = await SandboxEnvVarModel.read_async(db_session=session, identifier=env_var_id, actor=actor)
|
|
await env_var.hard_delete_async(db_session=session, actor=actor)
|
|
return await PydanticEnvVar.from_orm_async(env_var)
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def list_sandbox_env_vars_async(
|
|
self,
|
|
sandbox_config_id: str,
|
|
actor: PydanticUser,
|
|
after: Optional[str] = None,
|
|
limit: Optional[int] = 50,
|
|
) -> List[PydanticEnvVar]:
|
|
"""List all sandbox environment variables with optional pagination."""
|
|
async with db_registry.async_session() as session:
|
|
env_vars = await SandboxEnvVarModel.list_async(
|
|
db_session=session,
|
|
after=after,
|
|
limit=limit,
|
|
organization_id=actor.organization_id,
|
|
sandbox_config_id=sandbox_config_id,
|
|
)
|
|
result = []
|
|
for env_var in env_vars:
|
|
result.append(await PydanticEnvVar.from_orm_async(env_var))
|
|
return result
|
|
|
|
@enforce_types
|
|
@trace_method
|
|
async def list_sandbox_env_vars_by_key_async(
|
|
self, key: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
|
|
) -> List[PydanticEnvVar]:
|
|
"""List all sandbox environment variables with optional pagination."""
|
|
async with db_registry.async_session() as session:
|
|
env_vars = await SandboxEnvVarModel.list_async(
|
|
db_session=session,
|
|
after=after,
|
|
limit=limit,
|
|
organization_id=actor.organization_id,
|
|
key=key,
|
|
)
|
|
result = []
|
|
for env_var in env_vars:
|
|
result.append(await PydanticEnvVar.from_orm_async(env_var))
|
|
return result
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
def get_sandbox_env_vars_as_dict(
|
|
self, sandbox_config_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
|
|
) -> Dict[str, str]:
|
|
env_vars = self.list_sandbox_env_vars(sandbox_config_id, actor, after, limit)
|
|
result = {}
|
|
for env_var in env_vars:
|
|
# Decrypt the value before returning
|
|
result[env_var.key] = env_var.value_enc.get_plaintext() if env_var.value_enc else None
|
|
return result
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def get_sandbox_env_vars_as_dict_async(
|
|
self, sandbox_config_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
|
|
) -> Dict[str, str]:
|
|
env_vars = await self.list_sandbox_env_vars_async(sandbox_config_id, actor, after, limit)
|
|
# Values are already decrypted via from_orm_async
|
|
return {env_var.key: env_var.value for env_var in env_vars}
|
|
|
|
@enforce_types
|
|
@raise_on_invalid_id(param_name="sandbox_config_id", expected_prefix=PrimitiveType.SANDBOX_CONFIG)
|
|
@trace_method
|
|
async def get_sandbox_env_var_by_key_and_sandbox_config_id_async(
|
|
self, key: str, sandbox_config_id: str, actor: Optional[PydanticUser] = None
|
|
) -> Optional[PydanticEnvVar]:
|
|
"""Retrieve a sandbox environment variable by its key and sandbox_config_id."""
|
|
async with db_registry.async_session() as session:
|
|
try:
|
|
env_var = await SandboxEnvVarModel.list_async(
|
|
db_session=session,
|
|
key=key,
|
|
sandbox_config_id=sandbox_config_id,
|
|
organization_id=actor.organization_id,
|
|
limit=1,
|
|
)
|
|
if env_var:
|
|
return await PydanticEnvVar.from_orm_async(env_var[0])
|
|
return None
|
|
except NoResultFound:
|
|
return None
|