From 8114e5769d07f8646e2cef7762d0dac341349b76 Mon Sep 17 00:00:00 2001 From: Vladislav Sokolovskii Date: Wed, 10 Jun 2026 18:53:10 +0200 Subject: [PATCH 1/3] feat(code): add E2B sandbox provider --- libs/code/deepagents_code/app.py | 1 + libs/code/deepagents_code/extras_info.py | 4 +- .../integrations/sandbox_factory.py | 91 +++++++++- libs/code/deepagents_code/main.py | 15 +- libs/code/pyproject.toml | 3 +- libs/code/tests/unit_tests/test_app.py | 3 + .../tests/unit_tests/test_install_command.py | 1 + .../code/tests/unit_tests/test_launch_init.py | 7 +- .../tests/unit_tests/test_sandbox_factory.py | 167 +++++++++++++++++- libs/code/uv.lock | 53 +++++- 10 files changed, 330 insertions(+), 15 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index a8e926af4e..8a70c1abf9 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -1163,6 +1163,7 @@ def reset_thread(self) -> str: _SANDBOX_DISPLAY_NAMES: dict[str, str] = { "agentcore": "AgentCore", "daytona": "Daytona", + "e2b": "E2B", "langsmith": "LangSmith", "modal": "Modal", "runloop": "Runloop", diff --git a/libs/code/deepagents_code/extras_info.py b/libs/code/deepagents_code/extras_info.py index 0767e1dce1..8f2cd6c5c7 100644 --- a/libs/code/deepagents_code/extras_info.py +++ b/libs/code/deepagents_code/extras_info.py @@ -66,7 +66,9 @@ class ExtrasIntrospectionError(RuntimeError): Keep in sync with `[project.optional-dependencies]` in `pyproject.toml`. """ -SANDBOX_EXTRAS: frozenset[str] = frozenset({"agentcore", "daytona", "modal", "runloop"}) +SANDBOX_EXTRAS: frozenset[str] = frozenset( + {"agentcore", "daytona", "e2b", "modal", "runloop"} +) """Optional extras that add sandbox integrations.""" STANDALONE_EXTRAS: frozenset[str] = frozenset({"quickjs"}) diff --git a/libs/code/deepagents_code/integrations/sandbox_factory.py b/libs/code/deepagents_code/integrations/sandbox_factory.py index 318a8a7e96..185c1c93e3 100644 --- a/libs/code/deepagents_code/integrations/sandbox_factory.py +++ b/libs/code/deepagents_code/integrations/sandbox_factory.py @@ -73,6 +73,7 @@ def _run_sandbox_setup(backend: SandboxBackendProtocol, setup_script_path: str) _PROVIDER_TO_WORKING_DIR = { "agentcore": "/tmp", # noqa: S108 # AgentCore Code Interpreter working directory "daytona": "/home/daytona", + "e2b": "/home/user", "langsmith": "/root", # `$HOME` in the LangSmith sandbox "modal": "/workspace", "runloop": "/home/user", @@ -94,8 +95,8 @@ def create_sandbox( provider abstraction. Args: - provider: Sandbox provider (`'agentcore'`, `'daytona'`, `'langsmith'`, - `'modal'`, `'runloop'`) + provider: Sandbox provider (`'agentcore'`, `'daytona'`, `'e2b'`, + `'langsmith'`, `'modal'`, `'runloop'`) sandbox_id: Optional existing sandbox ID to reuse snapshot_name: Optional sandbox snapshot name to use or create. Honored by `'langsmith'` (snapshot) and `'runloop'` (blueprint); @@ -179,8 +180,8 @@ def get_default_working_dir(provider: str) -> str: """Get the default working directory for a given sandbox provider. Args: - provider: Sandbox provider name (`'agentcore'`, `'daytona'`, `'langsmith'`, - `'modal'`, `'runloop'`) + provider: Sandbox provider name (`'agentcore'`, `'daytona'`, `'e2b'`, + `'langsmith'`, `'modal'`, `'runloop'`) Returns: Default working directory path as string @@ -698,6 +699,81 @@ def delete(self, *, sandbox_id: str, **kwargs: Any) -> None: # noqa: ARG002 self._provider.delete(sandbox_id=sandbox_id) +class _E2BProvider(SandboxProvider): + """E2B sandbox provider — lifecycle management for E2B sandboxes.""" + + def __init__(self) -> None: + self._e2b = _import_provider_module( + "e2b", + provider="e2b", + package="langchain-e2b", + ) + + from deepagents_code.model_config import resolve_env_var + + api_key = resolve_env_var("E2B_API_KEY") + if not api_key: + msg = ( + "No E2B API key found. Set E2B_API_KEY or DEEPAGENTS_CODE_E2B_API_KEY." + ) + raise ValueError(msg) + self._api_key = api_key + self._template = resolve_env_var("E2B_TEMPLATE") + timeout_value = resolve_env_var("E2B_SANDBOX_TIMEOUT") + self._sandbox_timeout = int(timeout_value) if timeout_value else 60 * 30 + + def get_or_create( + self, + *, + sandbox_id: str | None = None, + timeout: int = 180, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> SandboxBackendProtocol: + """Get or create an E2B sandbox. + + Args: + sandbox_id: Existing sandbox ID, or None to create. + timeout: Unused compatibility argument for the provider interface. + **kwargs: Unused. + + Returns: + `E2BSandbox` instance. + + Raises: + RuntimeError: If the sandbox fails to connect or start. + SandboxNotFoundError: If `sandbox_id` does not exist. + """ + e2b_backend = _import_provider_module( + "langchain_e2b", + provider="e2b", + package="langchain-e2b", + ) + + if sandbox_id: + try: + sandbox = self._e2b.Sandbox.connect( + sandbox_id, + api_key=self._api_key, + ) + except Exception as exc: + if type(exc).__name__ == "SandboxNotFoundException": + raise SandboxNotFoundError(sandbox_id) from exc + msg = f"Failed to connect to existing E2B sandbox '{sandbox_id}': {exc}" + raise RuntimeError(msg) from exc + else: + sandbox = self._e2b.Sandbox.create( + timeout=self._sandbox_timeout, + api_key=self._api_key, + **({"template": self._template} if self._template else {}), + ) + + return e2b_backend.E2BSandbox(sandbox=sandbox) + + def delete(self, *, sandbox_id: str, **kwargs: Any) -> None: # noqa: ARG002 + """Kill an E2B sandbox by id.""" + self._e2b.Sandbox.kill(sandbox_id, api_key=self._api_key) + + class _AgentCoreProvider(SandboxProvider): """AgentCore Code Interpreter sandbox provider. @@ -829,8 +905,8 @@ def _get_provider(provider_name: str) -> SandboxProvider: """Get a `SandboxProvider` instance for the specified provider (internal). Args: - provider_name: Name of the provider (`'agentcore'`, `'daytona'`, `'langsmith'`, - `'modal'`, `'runloop'`) + provider_name: Name of the provider (`'agentcore'`, `'daytona'`, `'e2b'`, + `'langsmith'`, `'modal'`, `'runloop'`) Returns: `SandboxProvider` instance @@ -842,6 +918,8 @@ def _get_provider(provider_name: str) -> SandboxProvider: return _AgentCoreProvider() if provider_name == "daytona": return _DaytonaProvider() + if provider_name == "e2b": + return _E2BProvider() if provider_name == "langsmith": return _LangSmithProvider() if provider_name == "modal": @@ -878,6 +956,7 @@ def verify_sandbox_deps(provider: str) -> None: backend_modules: dict[str, tuple[str, str]] = { "agentcore": ("langchain_agentcore_codeinterpreter", "agentcore"), "daytona": ("langchain_daytona", "daytona"), + "e2b": ("langchain_e2b", "e2b"), "modal": ("langchain_modal", "modal"), "runloop": ("langchain_runloop", "runloop"), } diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 84b32ff509..4480013aa9 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -1023,13 +1023,21 @@ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]: parser.add_argument( "--sandbox", - choices=["none", "agentcore", "modal", "daytona", "runloop", "langsmith"], + choices=[ + "none", + "agentcore", + "modal", + "daytona", + "e2b", + "runloop", + "langsmith", + ], default="none", metavar="TYPE", help=( "Remote sandbox for code execution " "(default: none - local only; langsmith is included, " - "agentcore/modal/daytona/runloop require downloading extras)" + "agentcore/modal/daytona/e2b/runloop require downloading extras)" ), ) @@ -1205,7 +1213,8 @@ async def run_textual_cli_async( assistant_id: Agent identifier for memory storage auto_approve: Whether to auto-approve tool usage sandbox_type: Type of sandbox - ("none", "agentcore", "modal", "runloop", "daytona", "langsmith") + ("none", "agentcore", "modal", "runloop", "daytona", "e2b", + "langsmith") sandbox_id: Optional existing sandbox ID to reuse. sandbox_snapshot_name: Snapshot (langsmith) or blueprint (runloop) name. sandbox_setup: Optional path to setup script to run in the sandbox diff --git a/libs/code/pyproject.toml b/libs/code/pyproject.toml index d7ac0b6347..ace736a507 100644 --- a/libs/code/pyproject.toml +++ b/libs/code/pyproject.toml @@ -109,10 +109,11 @@ all-providers = [ # Sandbox providers agentcore = ["langchain-agentcore-codeinterpreter>=0.0.3,<1.0.0"] daytona = ["langchain-daytona>=0.0.6"] +e2b = ["langchain-e2b>=0.0.1"] modal = ["langchain-modal>=0.0.4"] runloop = ["langchain-runloop>=0.0.6"] all-sandboxes = [ - "deepagents-code[agentcore,daytona,modal,runloop]", + "deepagents-code[agentcore,daytona,e2b,modal,runloop]", ] # Standalone integrations (not part of any composite extra) diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 7326bb5e6b..7f8331903d 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -9167,6 +9167,9 @@ async def test_sandbox_sub_title_proper_casing(self) -> None: app2 = DeepAgentsApp(server_kwargs={"sandbox_type": "agentcore"}) assert app2.sub_title == "Sandbox: AgentCore" + app3 = DeepAgentsApp(server_kwargs={"sandbox_type": "e2b"}) + assert app3.sub_title == "Sandbox: E2B" + async def test_explicit_sub_title_overrides_sandbox(self) -> None: """An explicitly passed sub_title is not overwritten by sandbox info.""" app = DeepAgentsApp(sub_title="custom", server_kwargs={"sandbox_type": "modal"}) diff --git a/libs/code/tests/unit_tests/test_install_command.py b/libs/code/tests/unit_tests/test_install_command.py index dbeeda5865..ae46a74ede 100644 --- a/libs/code/tests/unit_tests/test_install_command.py +++ b/libs/code/tests/unit_tests/test_install_command.py @@ -31,6 +31,7 @@ async def test_install_slash_usage_when_no_extra() -> None: assert "Available extras:" in rendered assert "quickjs" in rendered assert "daytona" in rendered + assert "e2b" in rendered assert "openai" in rendered diff --git a/libs/code/tests/unit_tests/test_launch_init.py b/libs/code/tests/unit_tests/test_launch_init.py index 4d5199b1c3..59b131681a 100644 --- a/libs/code/tests/unit_tests/test_launch_init.py +++ b/libs/code/tests/unit_tests/test_launch_init.py @@ -170,6 +170,11 @@ class TestLaunchDependenciesScreen: installed=(("langchain-daytona", "0.0.5"),), missing=(), ), + ExtraDependencyStatus( + name="e2b", + installed=(), + missing=("langchain-e2b",), + ), ExtraDependencyStatus( name="runloop", installed=(), @@ -194,7 +199,7 @@ async def test_renders_installed_and_available_extras(self) -> None: assert "Sandboxes: daytona" in content assert "Available to add" in content assert "Model providers: bedrock" in content - assert "Sandboxes: runloop" in content + assert "Sandboxes: e2b, runloop" in content assert "Esc skip setup" in content async def test_enter_continues(self) -> None: diff --git a/libs/code/tests/unit_tests/test_sandbox_factory.py b/libs/code/tests/unit_tests/test_sandbox_factory.py index 6171838a1e..5093f23f20 100644 --- a/libs/code/tests/unit_tests/test_sandbox_factory.py +++ b/libs/code/tests/unit_tests/test_sandbox_factory.py @@ -14,12 +14,14 @@ get_default_working_dir, verify_sandbox_deps, ) +from deepagents_code.integrations.sandbox_provider import SandboxNotFoundError @pytest.mark.parametrize( ("provider", "package"), [ ("daytona", "langchain-daytona"), + ("e2b", "langchain-e2b"), ("modal", "langchain-modal"), ("runloop", "langchain-runloop"), ], @@ -432,11 +434,173 @@ def test_agentcore_delete_untracked_session() -> None: provider.delete(sandbox_id="nonexistent") # should not raise +def test_e2b_raises_on_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + """E2B should raise a helpful error without credentials.""" + monkeypatch.delenv("E2B_API_KEY", raising=False) + monkeypatch.delenv("DEEPAGENTS_CODE_E2B_API_KEY", raising=False) + with ( + patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + return_value=MagicMock(), + ), + pytest.raises(ValueError, match="No E2B API key found"), + ): + _get_provider("e2b") + + +def test_e2b_get_or_create_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + """Successful E2B creation should wrap the created sandbox directly.""" + monkeypatch.setenv("E2B_API_KEY", "fake-key") + monkeypatch.delenv("E2B_TEMPLATE", raising=False) + monkeypatch.setenv("E2B_SANDBOX_TIMEOUT", "7200") + + sandbox = MagicMock() + e2b_module = MagicMock() + e2b_module.Sandbox.create.return_value = sandbox + e2b_backend = MagicMock() + backend = MagicMock(id="sbx-e2b") + e2b_backend.E2BSandbox.return_value = backend + + def fake_import(module_name: str, **_: object) -> MagicMock: + if module_name == "e2b": + return e2b_module + return e2b_backend + + with patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + side_effect=fake_import, + ): + provider = _get_provider("e2b") + result = provider.get_or_create(timeout=2) + + e2b_module.Sandbox.create.assert_called_once_with( + timeout=7200, + api_key="fake-key", + ) + sandbox.commands.run.assert_not_called() + e2b_backend.E2BSandbox.assert_called_once_with(sandbox=sandbox) + assert result is backend + + +def test_e2b_get_or_create_passes_template_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """E2B should pass `E2B_TEMPLATE` only when explicitly configured.""" + monkeypatch.setenv("E2B_API_KEY", "fake-key") + monkeypatch.setenv("E2B_TEMPLATE", "custom-template") + + sandbox = MagicMock() + e2b_module = MagicMock() + e2b_module.Sandbox.create.return_value = sandbox + e2b_backend = MagicMock() + backend = MagicMock(id="sbx-e2b") + e2b_backend.E2BSandbox.return_value = backend + + def fake_import(module_name: str, **_: object) -> MagicMock: + if module_name == "e2b": + return e2b_module + return e2b_backend + + with patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + side_effect=fake_import, + ): + provider = _get_provider("e2b") + result = provider.get_or_create(timeout=2) + + e2b_module.Sandbox.create.assert_called_once_with( + template="custom-template", + timeout=60 * 30, + api_key="fake-key", + ) + assert result is backend + + +def test_e2b_get_or_create_connects_existing_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """E2B should reconnect by sandbox ID when provided.""" + monkeypatch.setenv("E2B_API_KEY", "fake-key") + + sandbox = MagicMock() + e2b_module = MagicMock() + e2b_module.Sandbox.connect.return_value = sandbox + e2b_backend = MagicMock() + backend = MagicMock(id="sbx-existing") + e2b_backend.E2BSandbox.return_value = backend + + def fake_import(module_name: str, **_: object) -> MagicMock: + if module_name == "e2b": + return e2b_module + return e2b_backend + + with patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + side_effect=fake_import, + ): + provider = _get_provider("e2b") + result = provider.get_or_create(sandbox_id="sbx-existing", timeout=2) + + e2b_module.Sandbox.connect.assert_called_once_with( + "sbx-existing", + api_key="fake-key", + ) + e2b_module.Sandbox.create.assert_not_called() + e2b_backend.E2BSandbox.assert_called_once_with(sandbox=sandbox) + assert result is backend + + +def test_e2b_connect_not_found_raises_sandbox_not_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """E2B sandbox-not-found errors should normalize to SandboxNotFoundError.""" + monkeypatch.setenv("E2B_API_KEY", "fake-key") + + e2b_module = MagicMock() + sandbox_not_found = type("SandboxNotFoundException", (Exception,), {}) + e2b_module.Sandbox.connect.side_effect = sandbox_not_found("missing") + e2b_backend = MagicMock() + + def fake_import(module_name: str, **_: object) -> MagicMock: + if module_name == "e2b": + return e2b_module + return e2b_backend + + with patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + side_effect=fake_import, + ): + provider = _get_provider("e2b") + + with pytest.raises(SandboxNotFoundError, match="sbx-missing"): + provider.get_or_create(sandbox_id="sbx-missing") + + +def test_e2b_delete_kills_sandbox_by_id(monkeypatch: pytest.MonkeyPatch) -> None: + """delete() should delegate to the E2B SDK classmethod.""" + monkeypatch.setenv("E2B_API_KEY", "fake-key") + e2b_module = MagicMock() + + with patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + return_value=e2b_module, + ): + provider = _get_provider("e2b") + + provider.delete(sandbox_id="sbx-e2b") + + e2b_module.Sandbox.kill.assert_called_once_with( + "sbx-e2b", + api_key="fake-key", + ) + + @pytest.mark.parametrize( ("provider", "expected"), [ ("agentcore", "/tmp"), ("daytona", "/home/daytona"), + ("e2b", "/home/user"), ("langsmith", "/root"), ("modal", "/workspace"), ("runloop", "/home/user"), @@ -455,6 +619,7 @@ class TestVerifySandboxDeps: [ ("agentcore", "langchain_agentcore_codeinterpreter"), ("daytona", "langchain_daytona"), + ("e2b", "langchain_e2b"), ("modal", "langchain_modal"), ("runloop", "langchain_runloop"), ], @@ -481,7 +646,7 @@ def test_raises_import_error_when_backend_missing( @pytest.mark.parametrize( "provider", - ["agentcore", "daytona", "modal", "runloop"], + ["agentcore", "daytona", "e2b", "modal", "runloop"], ) def test_passes_when_backend_installed(self, provider: str) -> None: """Should not raise when the backend module is found.""" diff --git a/libs/code/uv.lock b/libs/code/uv.lock index 3d3d4b6fe6..9267f859e8 100644 --- a/libs/code/uv.lock +++ b/libs/code/uv.lock @@ -1094,6 +1094,7 @@ all-providers = [ all-sandboxes = [ { name = "langchain-agentcore-codeinterpreter" }, { name = "langchain-daytona" }, + { name = "langchain-e2b" }, { name = "langchain-modal" }, { name = "langchain-runloop" }, ] @@ -1115,6 +1116,9 @@ daytona = [ deepseek = [ { name = "langchain-deepseek" }, ] +e2b = [ + { name = "langchain-e2b" }, +] fireworks = [ { name = "langchain-fireworks" }, ] @@ -1196,7 +1200,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.22.1,<1.0.0" }, { name = "deepagents", editable = "../deepagents" }, { name = "deepagents-acp", specifier = ">=0.0.8,<1.0.0" }, - { name = "deepagents-code", extras = ["agentcore", "daytona", "modal", "runloop"], marker = "extra == 'all-sandboxes'" }, + { name = "deepagents-code", extras = ["agentcore", "daytona", "e2b", "modal", "runloop"], marker = "extra == 'all-sandboxes'" }, { name = "deepagents-code", extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai"], marker = "extra == 'all-providers'" }, { name = "httpx", specifier = ">=0.28.1,<1.0.0" }, { name = "langchain", specifier = ">=1.3.4,<2.0.0" }, @@ -1208,6 +1212,7 @@ requires-dist = [ { name = "langchain-cohere", marker = "extra == 'cohere'", specifier = ">=0.6.0,<1.0.0" }, { name = "langchain-daytona", marker = "extra == 'daytona'", editable = "../partners/daytona" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'", specifier = ">=1.0.1,<2.0.0" }, + { name = "langchain-e2b", marker = "extra == 'e2b'", specifier = ">=0.0.1" }, { name = "langchain-fireworks", marker = "extra == 'fireworks'", specifier = ">=1.4.2,<2.0.0" }, { name = "langchain-google-genai", specifier = ">=4.2.4,<5.0.0" }, { name = "langchain-google-genai", marker = "extra == 'google-genai'", specifier = ">=4.2.4,<5.0.0" }, @@ -1250,7 +1255,7 @@ requires-dist = [ { name = "tomli-w", specifier = ">=1.2.0,<2.0.0" }, { name = "uuid-utils", specifier = ">=0.16.0,<1.0.0" }, ] -provides-extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai", "all-providers", "agentcore", "daytona", "modal", "runloop", "all-sandboxes", "quickjs"] +provides-extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai", "all-providers", "agentcore", "daytona", "e2b", "modal", "runloop", "all-sandboxes", "quickjs"] [package.metadata.requires-dev] test = [ @@ -1294,6 +1299,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -1312,6 +1326,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "e2b" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/07/97604da2a7b7db68b970bab60d1bbffa436e73492c000d1889daf6283cf6/e2b-2.25.1.tar.gz", hash = "sha256:b87f8da3bbcce613e1bef9a90c46ef042a053f3f311b5ab45fcff5bdf1b1b425", size = 163032, upload-time = "2026-05-29T23:45:55.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d5/d04b038ffb56ff6ba76cf6c2438908336aa58a5eb405b2fa191d398106c5/e2b-2.25.1-py3-none-any.whl", hash = "sha256:5ea5d1766082c1db504f86ebe17abe8b6a07f33d8addfb1a7778fae4a9549891", size = 309607, upload-time = "2026-05-29T23:45:54.327Z" }, +] + [[package]] name = "environs" version = "14.5.0" @@ -2685,6 +2721,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/dd/a803dfbf64273232f3fc82f859487331abb717671bbcdf266fd80de6ef78/langchain_deepseek-1.0.1-py3-none-any.whl", hash = "sha256:0a9862f335f1873370bb0fe1928ac19b8b9292b014ef5412da462ded8bb82c5a", size = 8325, upload-time = "2025-11-13T16:29:12.385Z" }, ] +[[package]] +name = "langchain-e2b" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deepagents" }, + { name = "e2b" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/71/7f0e9bd616cbe173539ba67d4d8e42f7e0d53e74afb3209082b9cb7bf428/langchain_e2b-0.0.1.tar.gz", hash = "sha256:ecf4d740979099dd1ab58eb836e8b061adfefc04a42b6784f69e33e34c02c166", size = 119992, upload-time = "2026-06-10T15:58:23.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/20/e44897521499ff8311b160d1d7c4466834616880e086b2bb3010e57c0fa5/langchain_e2b-0.0.1-py3-none-any.whl", hash = "sha256:dd5d4e17a56a09db1e3d639d8992171ce3426682255e33f71943632d8997094a", size = 5007, upload-time = "2026-06-10T15:58:22.672Z" }, +] + [[package]] name = "langchain-fireworks" version = "1.4.2" From 960b28e8eda78b19d014b09a9dc7c637f8a74ac7 Mon Sep 17 00:00:00 2001 From: Vladislav Sokolovskii Date: Wed, 10 Jun 2026 19:24:27 +0200 Subject: [PATCH 2/3] fix(code): delegate E2B lifecycle to package --- .../integrations/sandbox_factory.py | 75 +++++---- libs/code/pyproject.toml | 2 +- .../tests/unit_tests/test_sandbox_factory.py | 151 +++++++----------- libs/code/uv.lock | 8 +- 4 files changed, 97 insertions(+), 139 deletions(-) diff --git a/libs/code/deepagents_code/integrations/sandbox_factory.py b/libs/code/deepagents_code/integrations/sandbox_factory.py index 185c1c93e3..93a1ce5b22 100644 --- a/libs/code/deepagents_code/integrations/sandbox_factory.py +++ b/libs/code/deepagents_code/integrations/sandbox_factory.py @@ -700,11 +700,11 @@ def delete(self, *, sandbox_id: str, **kwargs: Any) -> None: # noqa: ARG002 class _E2BProvider(SandboxProvider): - """E2B sandbox provider — lifecycle management for E2B sandboxes.""" + """E2B sandbox provider — delegates to `langchain_e2b.E2BProvider`.""" def __init__(self) -> None: - self._e2b = _import_provider_module( - "e2b", + e2b_module = _import_provider_module( + "langchain_e2b", provider="e2b", package="langchain-e2b", ) @@ -717,61 +717,58 @@ def __init__(self) -> None: "No E2B API key found. Set E2B_API_KEY or DEEPAGENTS_CODE_E2B_API_KEY." ) raise ValueError(msg) - self._api_key = api_key - self._template = resolve_env_var("E2B_TEMPLATE") - timeout_value = resolve_env_var("E2B_SANDBOX_TIMEOUT") - self._sandbox_timeout = int(timeout_value) if timeout_value else 60 * 30 + try: + provider = e2b_module.E2BProvider + except AttributeError as exc: + msg = ( + "The 'e2b' sandbox provider requires langchain-e2b>=0.0.2. " + "Upgrade with `pip install -U langchain-e2b`." + ) + raise ImportError(msg) from exc + self._provider = provider( + api_key=api_key, + resolve_env_var=resolve_env_var, + ) def get_or_create( self, *, sandbox_id: str | None = None, - timeout: int = 180, # noqa: ARG002 - **kwargs: Any, # noqa: ARG002 + timeout: int = 180, + **kwargs: Any, ) -> SandboxBackendProtocol: """Get or create an E2B sandbox. Args: sandbox_id: Existing sandbox ID, or None to create. - timeout: Unused compatibility argument for the provider interface. - **kwargs: Unused. + timeout: Accepted for parity with other providers and forwarded to + `langchain_e2b.E2BProvider`. + **kwargs: E2B provider options. Returns: `E2BSandbox` instance. Raises: - RuntimeError: If the sandbox fails to connect or start. - SandboxNotFoundError: If `sandbox_id` does not exist. + SandboxNotFoundError: If `sandbox_id` does not exist. `E2BProvider` + translates the SDK's not-found error into a `KeyError`, which is + mapped here. + KeyError: If a `KeyError` is raised while no `sandbox_id` was supplied + (re-raised unchanged rather than mislabeled as not-found). """ - e2b_backend = _import_provider_module( - "langchain_e2b", - provider="e2b", - package="langchain-e2b", - ) - - if sandbox_id: - try: - sandbox = self._e2b.Sandbox.connect( - sandbox_id, - api_key=self._api_key, - ) - except Exception as exc: - if type(exc).__name__ == "SandboxNotFoundException": - raise SandboxNotFoundError(sandbox_id) from exc - msg = f"Failed to connect to existing E2B sandbox '{sandbox_id}': {exc}" - raise RuntimeError(msg) from exc - else: - sandbox = self._e2b.Sandbox.create( - timeout=self._sandbox_timeout, - api_key=self._api_key, - **({"template": self._template} if self._template else {}), + try: + return self._provider.get_or_create( + sandbox_id=sandbox_id, + timeout=timeout, + **kwargs, ) - - return e2b_backend.E2BSandbox(sandbox=sandbox) + except KeyError as e: + if sandbox_id is None: + raise + raise SandboxNotFoundError(sandbox_id) from e def delete(self, *, sandbox_id: str, **kwargs: Any) -> None: # noqa: ARG002 - """Kill an E2B sandbox by id.""" - self._e2b.Sandbox.kill(sandbox_id, api_key=self._api_key) + """Shut down an E2B sandbox by id.""" + self._provider.delete(sandbox_id=sandbox_id) class _AgentCoreProvider(SandboxProvider): diff --git a/libs/code/pyproject.toml b/libs/code/pyproject.toml index ace736a507..cb71d2436f 100644 --- a/libs/code/pyproject.toml +++ b/libs/code/pyproject.toml @@ -109,7 +109,7 @@ all-providers = [ # Sandbox providers agentcore = ["langchain-agentcore-codeinterpreter>=0.0.3,<1.0.0"] daytona = ["langchain-daytona>=0.0.6"] -e2b = ["langchain-e2b>=0.0.1"] +e2b = ["langchain-e2b>=0.0.2"] modal = ["langchain-modal>=0.0.4"] runloop = ["langchain-runloop>=0.0.6"] all-sandboxes = [ diff --git a/libs/code/tests/unit_tests/test_sandbox_factory.py b/libs/code/tests/unit_tests/test_sandbox_factory.py index 5093f23f20..33754e6035 100644 --- a/libs/code/tests/unit_tests/test_sandbox_factory.py +++ b/libs/code/tests/unit_tests/test_sandbox_factory.py @@ -4,6 +4,7 @@ import os import sys +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -448,127 +449,85 @@ def test_e2b_raises_on_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> None: _get_provider("e2b") -def test_e2b_get_or_create_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: - """Successful E2B creation should wrap the created sandbox directly.""" +def test_e2b_provider_delegates_to_langchain_e2b( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """E2B lifecycle should live in `langchain_e2b.E2BProvider`.""" monkeypatch.setenv("E2B_API_KEY", "fake-key") - monkeypatch.delenv("E2B_TEMPLATE", raising=False) - monkeypatch.setenv("E2B_SANDBOX_TIMEOUT", "7200") - - sandbox = MagicMock() - e2b_module = MagicMock() - e2b_module.Sandbox.create.return_value = sandbox - e2b_backend = MagicMock() backend = MagicMock(id="sbx-e2b") - e2b_backend.E2BSandbox.return_value = backend - - def fake_import(module_name: str, **_: object) -> MagicMock: - if module_name == "e2b": - return e2b_module - return e2b_backend + fake_provider = MagicMock() + fake_provider.get_or_create.return_value = backend + fake_module = MagicMock() + fake_module.E2BProvider.return_value = fake_provider with patch( "deepagents_code.integrations.sandbox_factory._import_provider_module", - side_effect=fake_import, + return_value=fake_module, ): provider = _get_provider("e2b") - result = provider.get_or_create(timeout=2) + result = provider.get_or_create(timeout=2, metadata={"purpose": "test"}) + provider.delete(sandbox_id="sbx-e2b") - e2b_module.Sandbox.create.assert_called_once_with( - timeout=7200, - api_key="fake-key", + fake_module.E2BProvider.assert_called_once() + provider_kwargs = fake_module.E2BProvider.call_args.kwargs + assert provider_kwargs["api_key"] == "fake-key" + assert callable(provider_kwargs["resolve_env_var"]) + fake_provider.get_or_create.assert_called_once_with( + sandbox_id=None, + timeout=2, + metadata={"purpose": "test"}, ) - sandbox.commands.run.assert_not_called() - e2b_backend.E2BSandbox.assert_called_once_with(sandbox=sandbox) + fake_provider.delete.assert_called_once_with(sandbox_id="sbx-e2b") assert result is backend -def test_e2b_get_or_create_passes_template_override( +def test_e2b_provider_uses_deepagents_prefixed_api_key( monkeypatch: pytest.MonkeyPatch, ) -> None: - """E2B should pass `E2B_TEMPLATE` only when explicitly configured.""" - monkeypatch.setenv("E2B_API_KEY", "fake-key") - monkeypatch.setenv("E2B_TEMPLATE", "custom-template") - - sandbox = MagicMock() - e2b_module = MagicMock() - e2b_module.Sandbox.create.return_value = sandbox - e2b_backend = MagicMock() - backend = MagicMock(id="sbx-e2b") - e2b_backend.E2BSandbox.return_value = backend - - def fake_import(module_name: str, **_: object) -> MagicMock: - if module_name == "e2b": - return e2b_module - return e2b_backend + """Deep Agents still owns its `DEEPAGENTS_CODE_` env fallback.""" + monkeypatch.delenv("E2B_API_KEY", raising=False) + monkeypatch.setenv("DEEPAGENTS_CODE_E2B_API_KEY", "prefixed-key") + fake_module = MagicMock() with patch( "deepagents_code.integrations.sandbox_factory._import_provider_module", - side_effect=fake_import, + return_value=fake_module, ): - provider = _get_provider("e2b") - result = provider.get_or_create(timeout=2) + _get_provider("e2b") - e2b_module.Sandbox.create.assert_called_once_with( - template="custom-template", - timeout=60 * 30, - api_key="fake-key", - ) - assert result is backend + assert fake_module.E2BProvider.call_args.kwargs["api_key"] == "prefixed-key" -def test_e2b_get_or_create_connects_existing_sandbox( +def test_e2b_provider_requires_provider_class( monkeypatch: pytest.MonkeyPatch, ) -> None: - """E2B should reconnect by sandbox ID when provided.""" + """Older `langchain-e2b` versions should raise an actionable error.""" monkeypatch.setenv("E2B_API_KEY", "fake-key") - sandbox = MagicMock() - e2b_module = MagicMock() - e2b_module.Sandbox.connect.return_value = sandbox - e2b_backend = MagicMock() - backend = MagicMock(id="sbx-existing") - e2b_backend.E2BSandbox.return_value = backend - - def fake_import(module_name: str, **_: object) -> MagicMock: - if module_name == "e2b": - return e2b_module - return e2b_backend - - with patch( - "deepagents_code.integrations.sandbox_factory._import_provider_module", - side_effect=fake_import, + with ( + patch( + "deepagents_code.integrations.sandbox_factory._import_provider_module", + return_value=SimpleNamespace(), + ), + pytest.raises(ImportError, match=r"langchain-e2b>=0\.0\.2"), ): - provider = _get_provider("e2b") - result = provider.get_or_create(sandbox_id="sbx-existing", timeout=2) - - e2b_module.Sandbox.connect.assert_called_once_with( - "sbx-existing", - api_key="fake-key", - ) - e2b_module.Sandbox.create.assert_not_called() - e2b_backend.E2BSandbox.assert_called_once_with(sandbox=sandbox) - assert result is backend + _get_provider("e2b") def test_e2b_connect_not_found_raises_sandbox_not_found( monkeypatch: pytest.MonkeyPatch, ) -> None: - """E2B sandbox-not-found errors should normalize to SandboxNotFoundError.""" + """E2B provider `KeyError` should normalize to `SandboxNotFoundError`.""" monkeypatch.setenv("E2B_API_KEY", "fake-key") - e2b_module = MagicMock() - sandbox_not_found = type("SandboxNotFoundException", (Exception,), {}) - e2b_module.Sandbox.connect.side_effect = sandbox_not_found("missing") - e2b_backend = MagicMock() - - def fake_import(module_name: str, **_: object) -> MagicMock: - if module_name == "e2b": - return e2b_module - return e2b_backend + fake_provider = MagicMock() + fake_provider.get_or_create.side_effect = KeyError("sbx-missing") + fake_module = MagicMock() + fake_module.E2BProvider.return_value = fake_provider with patch( "deepagents_code.integrations.sandbox_factory._import_provider_module", - side_effect=fake_import, + return_value=fake_module, ): provider = _get_provider("e2b") @@ -576,23 +535,25 @@ def fake_import(module_name: str, **_: object) -> MagicMock: provider.get_or_create(sandbox_id="sbx-missing") -def test_e2b_delete_kills_sandbox_by_id(monkeypatch: pytest.MonkeyPatch) -> None: - """delete() should delegate to the E2B SDK classmethod.""" +def test_e2b_keyerror_without_sandbox_id_is_not_reclassified( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unexpected provider `KeyError`s should propagate unchanged.""" monkeypatch.setenv("E2B_API_KEY", "fake-key") - e2b_module = MagicMock() + + fake_provider = MagicMock() + fake_provider.get_or_create.side_effect = KeyError("unexpected") + fake_module = MagicMock() + fake_module.E2BProvider.return_value = fake_provider with patch( "deepagents_code.integrations.sandbox_factory._import_provider_module", - return_value=e2b_module, + return_value=fake_module, ): provider = _get_provider("e2b") - provider.delete(sandbox_id="sbx-e2b") - - e2b_module.Sandbox.kill.assert_called_once_with( - "sbx-e2b", - api_key="fake-key", - ) + with pytest.raises(KeyError): + provider.get_or_create(sandbox_id=None) @pytest.mark.parametrize( diff --git a/libs/code/uv.lock b/libs/code/uv.lock index 9267f859e8..bdd93401b1 100644 --- a/libs/code/uv.lock +++ b/libs/code/uv.lock @@ -1212,7 +1212,7 @@ requires-dist = [ { name = "langchain-cohere", marker = "extra == 'cohere'", specifier = ">=0.6.0,<1.0.0" }, { name = "langchain-daytona", marker = "extra == 'daytona'", editable = "../partners/daytona" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'", specifier = ">=1.0.1,<2.0.0" }, - { name = "langchain-e2b", marker = "extra == 'e2b'", specifier = ">=0.0.1" }, + { name = "langchain-e2b", marker = "extra == 'e2b'", specifier = ">=0.0.2" }, { name = "langchain-fireworks", marker = "extra == 'fireworks'", specifier = ">=1.4.2,<2.0.0" }, { name = "langchain-google-genai", specifier = ">=4.2.4,<5.0.0" }, { name = "langchain-google-genai", marker = "extra == 'google-genai'", specifier = ">=4.2.4,<5.0.0" }, @@ -2723,15 +2723,15 @@ wheels = [ [[package]] name = "langchain-e2b" -version = "0.0.1" +version = "0.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deepagents" }, { name = "e2b" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/71/7f0e9bd616cbe173539ba67d4d8e42f7e0d53e74afb3209082b9cb7bf428/langchain_e2b-0.0.1.tar.gz", hash = "sha256:ecf4d740979099dd1ab58eb836e8b061adfefc04a42b6784f69e33e34c02c166", size = 119992, upload-time = "2026-06-10T15:58:23.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/78/dc4c8434745ef3421eb39a3197ddbf59aeaeda5281ce8d9c973d19387546/langchain_e2b-0.0.2.tar.gz", hash = "sha256:a65dd44c2750fff04a62414b95e1c7af843a3ca643b10d66646339dee2fca176", size = 122000, upload-time = "2026-06-10T17:21:50.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/20/e44897521499ff8311b160d1d7c4466834616880e086b2bb3010e57c0fa5/langchain_e2b-0.0.1-py3-none-any.whl", hash = "sha256:dd5d4e17a56a09db1e3d639d8992171ce3426682255e33f71943632d8997094a", size = 5007, upload-time = "2026-06-10T15:58:22.672Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/2030b7f2585af022a04e51fc69477732e51f2681db88b0ec99ee03469386/langchain_e2b-0.0.2-py3-none-any.whl", hash = "sha256:8972cd80e314941040c48a131ba1b6b161a16745108559b4114551f19f8c18fe", size = 6409, upload-time = "2026-06-10T17:21:49.365Z" }, ] [[package]] From 160c5ab3559a8d6451a61a96e9fdee191eba19bf Mon Sep 17 00:00:00 2001 From: Vladislav Sokolovskii Date: Wed, 10 Jun 2026 19:31:08 +0200 Subject: [PATCH 3/3] chore(evals): update lockfile for E2B sandbox extra --- libs/evals/uv.lock | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/evals/uv.lock b/libs/evals/uv.lock index 4fa1f214dc..08ae06326a 100644 --- a/libs/evals/uv.lock +++ b/libs/evals/uv.lock @@ -574,7 +574,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.22.1,<1.0.0" }, { name = "deepagents", editable = "../deepagents" }, { name = "deepagents-acp", specifier = ">=0.0.8,<1.0.0" }, - { name = "deepagents-code", extras = ["agentcore", "daytona", "modal", "runloop"], marker = "extra == 'all-sandboxes'" }, + { name = "deepagents-code", extras = ["agentcore", "daytona", "e2b", "modal", "runloop"], marker = "extra == 'all-sandboxes'" }, { name = "deepagents-code", extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai"], marker = "extra == 'all-providers'" }, { name = "httpx", specifier = ">=0.28.1,<1.0.0" }, { name = "langchain", specifier = ">=1.3.4,<2.0.0" }, @@ -586,6 +586,7 @@ requires-dist = [ { name = "langchain-cohere", marker = "extra == 'cohere'", specifier = ">=0.6.0,<1.0.0" }, { name = "langchain-daytona", marker = "extra == 'daytona'", editable = "../partners/daytona" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'", specifier = ">=1.0.1,<2.0.0" }, + { name = "langchain-e2b", marker = "extra == 'e2b'", specifier = ">=0.0.2" }, { name = "langchain-fireworks", marker = "extra == 'fireworks'", specifier = ">=1.4.2,<2.0.0" }, { name = "langchain-google-genai", specifier = ">=4.2.4,<5.0.0" }, { name = "langchain-google-genai", marker = "extra == 'google-genai'", specifier = ">=4.2.4,<5.0.0" }, @@ -628,7 +629,7 @@ requires-dist = [ { name = "tomli-w", specifier = ">=1.2.0,<2.0.0" }, { name = "uuid-utils", specifier = ">=0.16.0,<1.0.0" }, ] -provides-extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai", "all-providers", "agentcore", "daytona", "modal", "runloop", "all-sandboxes", "quickjs"] +provides-extras = ["anthropic", "baseten", "bedrock", "cohere", "deepseek", "fireworks", "google-genai", "groq", "huggingface", "ibm", "litellm", "mistralai", "nvidia", "ollama", "openai", "openrouter", "perplexity", "together", "vertex", "xai", "all-providers", "agentcore", "daytona", "e2b", "modal", "runloop", "all-sandboxes", "quickjs"] [package.metadata.requires-dev] test = [