import asyncio import time from typing import TYPE_CHECKING, Any, Dict, Optional from e2b.sandbox.commands.command_handle import CommandExitException from e2b_code_interpreter import AsyncSandbox from letta.log import get_logger from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState from letta.schemas.enums import SandboxType from letta.schemas.sandbox_config import SandboxConfig from letta.schemas.tool import Tool from letta.schemas.tool_execution_result import ToolExecutionResult from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort from letta.services.tool_sandbox.base import AsyncToolSandboxBase from letta.services.tool_sandbox.typescript_generator import parse_typescript_result from letta.types import JsonDict from letta.utils import get_friendly_error_msg logger = get_logger(__name__) if TYPE_CHECKING: from e2b_code_interpreter import Execution class AsyncToolSandboxE2B(AsyncToolSandboxBase): METADATA_CONFIG_STATE_KEY = "config_state" def __init__( self, tool_name: str, args: JsonDict, user, tool_id: str, agent_id: Optional[str] = None, project_id: Optional[str] = None, force_recreate: bool = True, tool_object: Optional[Tool] = None, sandbox_config: Optional[SandboxConfig] = None, sandbox_env_vars: Optional[Dict[str, Any]] = None, ): super().__init__( tool_name, args, user, tool_id=tool_id, agent_id=agent_id, project_id=project_id, tool_object=tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars, ) self.force_recreate = force_recreate @trace_method async def run( self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None, ) -> ToolExecutionResult: await self._init_async() if self.provided_sandbox_config: sbx_config = self.provided_sandbox_config else: sbx_config = await self.sandbox_config_manager.get_or_create_default_sandbox_config_async( sandbox_type=SandboxType.E2B, actor=self.user ) # TODO: So this defaults to force recreating always # TODO: Eventually, provision one sandbox PER agent, and that agent re-uses that one specifically e2b_sandbox = await self.create_e2b_sandbox_with_metadata_hash(sandbox_config=sbx_config) logger.info(f"E2B Sandbox configurations: {sbx_config}") logger.info(f"E2B Sandbox ID: {e2b_sandbox.sandbox_id}") # TODO: This only makes sense if we re-use sandboxes # # Since this sandbox was used, we extend its lifecycle by the timeout # await sbx.set_timeout(sbx_config.get_e2b_config().timeout) # Get environment variables for the sandbox envs = await self._gather_env_vars(agent_state, additional_env_vars, sbx_config.id, is_local=False) code = await self.generate_execution_script(agent_state=agent_state) # Determine language based on tool source_type is_typescript = self.is_typescript_tool() language = "ts" if is_typescript else None try: logger.info(f"E2B execution started for ID {e2b_sandbox.sandbox_id}: {self.tool_name} (language={language or 'python'})") log_event( "e2b_execution_started", { "tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "code": code, "env_vars": envs, "language": language or "python", }, ) start_time = time.perf_counter() try: execution = await e2b_sandbox.run_code(code, envs=envs, language=language) except asyncio.CancelledError: execution_time = time.perf_counter() - start_time logger.info(f"E2B execution cancelled for ID {e2b_sandbox.sandbox_id}: {self.tool_name} (took {execution_time:.2f}s)") log_event( "e2b_execution_cancelled", {"tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "execution_time_seconds": execution_time}, ) raise Exception("Execution cancelled. Transient failure, please retry.") execution_time = time.perf_counter() - start_time logger.info(f"E2B execution completed in {execution_time:.2f}s for sandbox {e2b_sandbox.sandbox_id}, tool: {self.tool_name}") if execution.results: # Use appropriate result parser based on language if is_typescript: func_return, agent_state = parse_typescript_result(execution.results[0].text) else: func_return, agent_state = parse_stdout_best_effort(execution.results[0].text) logger.info(f"E2B execution succeeded for ID {e2b_sandbox.sandbox_id}: {self.tool_name} (took {execution_time:.2f}s)") log_event( "e2b_execution_succeeded", { "tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "func_return": func_return, "execution_time_seconds": execution_time, }, ) elif execution.error: logger.warning(f"Tool {self.tool_name} raised a {execution.error.name}: {execution.error.value}") logger.warning(f"Traceback from e2b sandbox: \n{execution.error.traceback}") func_return = get_friendly_error_msg( function_name=self.tool_name, exception_name=execution.error.name, exception_message=execution.error.value ) execution.logs.stderr.append(execution.error.traceback) logger.warning(f"E2B execution failed for ID {e2b_sandbox.sandbox_id}: {self.tool_name} (took {execution_time:.2f}s)") log_event( "e2b_execution_failed", { "tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "error_type": execution.error.name, "error_message": execution.error.value, "func_return": func_return, "execution_time_seconds": execution_time, }, ) else: logger.warning(f"E2B execution empty for ID {e2b_sandbox.sandbox_id}: {self.tool_name} (took {execution_time:.2f}s)") log_event( "e2b_execution_empty", { "tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "status": "no_results_no_error", "execution_time_seconds": execution_time, }, ) raise ValueError(f"Tool {self.tool_name} returned execution with None") return ToolExecutionResult( func_return=func_return, agent_state=agent_state, stdout=execution.logs.stdout, stderr=execution.logs.stderr, status="error" if execution.error else "success", sandbox_config_fingerprint=sbx_config.fingerprint(), ) finally: logger.info(f"E2B sandbox {e2b_sandbox.sandbox_id} killed") await e2b_sandbox.kill() @staticmethod def parse_exception_from_e2b_execution(e2b_execution: "Execution") -> Exception: builtins_dict = __builtins__ if isinstance(__builtins__, dict) else vars(__builtins__) # Dynamically fetch the exception class from builtins, defaulting to Exception if not found exception_class = builtins_dict.get(e2b_execution.error.name, Exception) return exception_class(e2b_execution.error.value) @trace_method async def create_e2b_sandbox_with_metadata_hash(self, sandbox_config: SandboxConfig) -> "AsyncSandbox": state_hash = sandbox_config.fingerprint() e2b_config = sandbox_config.get_e2b_config() log_event( "e2b_sandbox_create_started", { "sandbox_fingerprint": state_hash, "e2b_config": e2b_config.model_dump(), }, ) if e2b_config.template: sbx = await AsyncSandbox.create(sandbox_config.get_e2b_config().template, metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}) else: sbx = await AsyncSandbox.create( metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}, **e2b_config.model_dump(exclude={"pip_requirements"}) ) log_event( "e2b_sandbox_create_finished", { "sandbox_id": sbx.sandbox_id, "sandbox_fingerprint": state_hash, }, ) if e2b_config.pip_requirements: for package in e2b_config.pip_requirements: log_event( "e2b_pip_install_started", { "sandbox_id": sbx.sandbox_id, "package": package, }, ) try: await sbx.commands.run(f"pip install {package}") log_event( "e2b_pip_install_finished", { "sandbox_id": sbx.sandbox_id, "package": package, }, ) except CommandExitException as e: error_msg = f"Failed to install sandbox pip requirement '{package}' in E2B sandbox. This may be due to package version incompatibility with the E2B environment. Error: {e}" logger.error(error_msg) log_event( "e2b_pip_install_failed", { "sandbox_id": sbx.sandbox_id, "package": package, "error": str(e), }, ) raise RuntimeError(error_msg) from e # Install tool-specific pip requirements if self.tool and self.tool.pip_requirements: for pip_requirement in self.tool.pip_requirements: package_str = str(pip_requirement) log_event( "tool_pip_install_started", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, }, ) try: await sbx.commands.run(f"pip install {package_str}") log_event( "tool_pip_install_finished", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, }, ) except CommandExitException as e: error_msg = f"Failed to install tool pip requirement '{package_str}' for tool '{self.tool.name}' in E2B sandbox. This may be due to package version incompatibility with the E2B environment. Consider updating the package version or removing the version constraint. Error: {e}" logger.error(error_msg) log_event( "tool_pip_install_failed", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, "error": str(e), }, ) # Kill sandbox to prevent resource leak await sbx.kill() raise RuntimeError(error_msg) from e # Install tool-specific npm requirements (for TypeScript tools) if self.tool and self.tool.npm_requirements: for npm_requirement in self.tool.npm_requirements: package_str = str(npm_requirement) log_event( "tool_npm_install_started", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, }, ) try: await sbx.commands.run(f"npm install {package_str}") log_event( "tool_npm_install_finished", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, }, ) except CommandExitException as e: error_msg = f"Failed to install tool npm requirement '{package_str}' for tool '{self.tool.name}' in E2B sandbox. This may be due to package version incompatibility. Error: {e}" logger.error(error_msg) log_event( "tool_npm_install_failed", { "sandbox_id": sbx.sandbox_id, "package": package_str, "tool_name": self.tool.name, "error": str(e), }, ) # Kill sandbox to prevent resource leak await sbx.kill() raise RuntimeError(error_msg) from e # Auto-install @letta-ai/letta-client for TypeScript tools (similar to letta_client for Python) if self.is_typescript_tool(): letta_client_package = "@letta-ai/letta-client" log_event( "letta_client_npm_install_started", { "sandbox_id": sbx.sandbox_id, "package": letta_client_package, "tool_name": self.tool.name if self.tool else None, }, ) try: await sbx.commands.run(f"npm install {letta_client_package}") log_event( "letta_client_npm_install_finished", { "sandbox_id": sbx.sandbox_id, "package": letta_client_package, "tool_name": self.tool.name if self.tool else None, }, ) except CommandExitException as e: # Log warning but don't fail - the client is optional for tools that don't need it logger.warning(f"Failed to install {letta_client_package} in E2B sandbox: {e}") log_event( "letta_client_npm_install_failed", { "sandbox_id": sbx.sandbox_id, "package": letta_client_package, "tool_name": self.tool.name if self.tool else None, "error": str(e), }, ) return sbx def use_top_level_await(self) -> bool: """ E2B sandboxes run in a Jupyter-like environment with an active event loop, so they support top-level await. """ return True @staticmethod async def list_running_e2b_sandboxes(): # List running sandboxes and access metadata. return await AsyncSandbox.list()