diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 3fa12c5e..b6067eb4 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -63,6 +63,7 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h | `langsmith` (default) | `agent/integrations/langsmith.py` | `LANGSMITH_API_KEY_PROD`, `SANDBOX_TYPE="langsmith"` | | `daytona` | `agent/integrations/daytona.py` | `DAYTONA_API_KEY`, `SANDBOX_TYPE="daytona"`, optional `DAYTONA_SANDBOX_SNAPSHOT` | | `runloop` | `agent/integrations/runloop.py` | `RUNLOOP_API_KEY`, `SANDBOX_TYPE="runloop"` | +| `e2b` | `agent/integrations/e2b.py` | `E2B_API_KEY`, `SANDBOX_TYPE="e2b"`, optional `E2B_TEMPLATE` | | `modal` | `agent/integrations/modal.py` | Modal credentials, `SANDBOX_TYPE="modal"` | | `local` | `agent/integrations/local.py` | None (no isolation — development only), `SANDBOX_TYPE="local"` | @@ -86,14 +87,12 @@ def create_my_provider_sandbox(sandbox_id: str | None = None): ... ``` -2. **Register it** in `agent/utils/sandbox.py` by importing your factory and adding it to `SANDBOX_FACTORIES`: +2. **Register it** in `agent/utils/sandbox.py` by adding it to `SANDBOX_FACTORIES`: ```python -from agent.integrations.my_provider import create_my_provider_sandbox - SANDBOX_FACTORIES = { ... - "my_provider": create_my_provider_sandbox, + "my_provider": ("agent.integrations.my_provider", "create_my_provider_sandbox"), } ``` diff --git a/INSTALLATION.md b/INSTALLATION.md index 964ef42c..766a3d36 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -599,7 +599,7 @@ REVIEWER_OUTCOMES_DATASET="" PUBLIC_REPO_ORG_GATE="" # === Sandbox (optional) === -# Provider: langsmith (default), modal, daytona, runloop, or local. See CUSTOMIZATION.md. +# Provider: langsmith (default), modal, daytona, runloop, e2b, or local. See CUSTOMIZATION.md. SANDBOX_TYPE="langsmith" DEFAULT_SANDBOX_SNAPSHOT_ID="" # Required when SANDBOX_TYPE=langsmith (see step 4c) DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="" # Root FS size in bytes (default: 32 GiB) diff --git a/README.md b/README.md index e03c9550..4e34f72d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ create_deep_agent( Every task runs in its own **isolated cloud sandbox** — a remote Linux environment with full shell access. The repo is cloned in, the agent gets full permissions, and the blast radius of any mistake is fully contained. No production access, no confirmation prompts. -Open SWE supports multiple sandbox providers out of the box — [Modal](https://modal.com/), [Daytona](https://www.daytona.io/), [Runloop](https://www.runloop.ai/), and [LangSmith](https://smith.langchain.com/) — and you can plug in your own. See the [Customization Guide](CUSTOMIZATION.md#1-sandbox) for details. +Open SWE supports multiple sandbox providers out of the box — [Modal](https://modal.com/), [Daytona](https://www.daytona.io/), [Runloop](https://www.runloop.ai/), [E2B](https://e2b.dev/), and [LangSmith](https://smith.langchain.com/) — and you can plug in your own. See the [Customization Guide](CUSTOMIZATION.md#1-sandbox) for details. This follows the principle all three companies converge on: **isolate first, then give full permissions inside the boundary.** diff --git a/agent/integrations/e2b.py b/agent/integrations/e2b.py new file mode 100644 index 00000000..7730dd82 --- /dev/null +++ b/agent/integrations/e2b.py @@ -0,0 +1,46 @@ +import os + +from deepagents.backends.protocol import SandboxBackendProtocol +from e2b import Sandbox +from langchain_e2b import E2BSandbox + +DEFAULT_E2B_SANDBOX_TIMEOUT = 60 * 60 +E2B_TEMPLATE_ENV = "E2B_TEMPLATE" + + +def create_e2b_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol: + """Create or reconnect to an E2B sandbox. + + Args: + sandbox_id: Optional existing sandbox ID to reconnect to. + If None, creates a new sandbox. + + Returns: + E2BSandbox instance implementing SandboxBackendProtocol. + """ + api_key = os.getenv("E2B_API_KEY") + if not api_key: + raise ValueError("E2B_API_KEY environment variable is required") + + template = os.getenv(E2B_TEMPLATE_ENV) + if template is not None: + template = template.strip() + if not template: + raise ValueError(f"{E2B_TEMPLATE_ENV} must not be empty") + + if sandbox_id: + sandbox = Sandbox.connect( + sandbox_id, + timeout=DEFAULT_E2B_SANDBOX_TIMEOUT, + api_key=api_key, + ) + elif template: + sandbox = Sandbox.create( + template=template, + timeout=DEFAULT_E2B_SANDBOX_TIMEOUT, + api_key=api_key, + ) + else: + sandbox = Sandbox.create(timeout=DEFAULT_E2B_SANDBOX_TIMEOUT, api_key=api_key) + + return E2BSandbox(sandbox=sandbox) diff --git a/agent/utils/sandbox.py b/agent/utils/sandbox.py index 9614420a..8cf88943 100644 --- a/agent/utils/sandbox.py +++ b/agent/utils/sandbox.py @@ -10,6 +10,7 @@ SANDBOX_FACTORIES: dict[str, tuple[str, str]] = { "daytona": ("agent.integrations.daytona", "create_daytona_sandbox"), "modal": ("agent.integrations.modal", "create_modal_sandbox"), "runloop": ("agent.integrations.runloop", "create_runloop_sandbox"), + "e2b": ("agent.integrations.e2b", "create_e2b_sandbox"), "local": ("agent.integrations.local", "create_local_sandbox"), } @@ -34,7 +35,7 @@ def create_sandbox( """Create or reconnect to a sandbox using the configured provider. The provider is selected via the SANDBOX_TYPE environment variable. - Supported values: langsmith (default), daytona, modal, runloop, local. + Supported values: langsmith (default), daytona, modal, runloop, e2b, local. Args: sandbox_id: Optional existing sandbox ID to reconnect to. diff --git a/pyproject.toml b/pyproject.toml index 5b1966ab..1c2ebb7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "langchain-daytona>=0.0.7", "langchain-modal>=0.0.5", "langchain-runloop>=0.0.6", + "langchain-e2b==0.0.4", "exa-py>=2.16.0", "langchain-google-genai>=4.2.7", "langchain-mcp-adapters>=0.3.0", diff --git a/tests/test_e2b_integration.py b/tests/test_e2b_integration.py new file mode 100644 index 00000000..d23aaf23 --- /dev/null +++ b/tests/test_e2b_integration.py @@ -0,0 +1,111 @@ +import importlib.util +import sys +import types +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class _FakeSandbox: + create_calls: list[dict[str, object]] = [] + connect_calls: list[tuple[str, dict[str, object]]] = [] + + def __init__(self, sandbox_id: str): + self.sandbox_id = sandbox_id + + @classmethod + def create(cls, **kwargs): + cls.create_calls.append(kwargs) + return cls("created-sandbox") + + @classmethod + def connect(cls, sandbox_id: str, **kwargs): + cls.connect_calls.append((sandbox_id, kwargs)) + return cls(sandbox_id) + + +class _FakeE2BSandbox: + def __init__(self, *, sandbox): + self.sandbox = sandbox + + +def _load_e2b_module(monkeypatch): + _FakeSandbox.create_calls = [] + _FakeSandbox.connect_calls = [] + + fake_e2b = types.ModuleType("e2b") + fake_e2b.Sandbox = _FakeSandbox + + fake_langchain_e2b = types.ModuleType("langchain_e2b") + fake_langchain_e2b.E2BSandbox = _FakeE2BSandbox + + monkeypatch.setitem(sys.modules, "e2b", fake_e2b) + monkeypatch.setitem(sys.modules, "langchain_e2b", fake_langchain_e2b) + + module_path = ROOT / "agent" / "integrations" / "e2b.py" + spec = importlib.util.spec_from_file_location("e2b_under_test", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_create_e2b_sandbox_uses_default_timeout(monkeypatch): + monkeypatch.setenv("E2B_API_KEY", "api-key") + monkeypatch.delenv("E2B_TEMPLATE", raising=False) + module = _load_e2b_module(monkeypatch) + + backend = module.create_e2b_sandbox() + + assert backend.sandbox.sandbox_id == "created-sandbox" + assert _FakeSandbox.create_calls == [{"timeout": 3600, "api_key": "api-key"}] + assert _FakeSandbox.connect_calls == [] + + +def test_create_e2b_sandbox_uses_template(monkeypatch): + monkeypatch.setenv("E2B_API_KEY", "api-key") + monkeypatch.setenv("E2B_TEMPLATE", "open-swe-template") + module = _load_e2b_module(monkeypatch) + + module.create_e2b_sandbox() + + assert _FakeSandbox.create_calls == [ + {"template": "open-swe-template", "timeout": 3600, "api_key": "api-key"} + ] + + +def test_create_e2b_sandbox_reconnects_by_id(monkeypatch): + monkeypatch.setenv("E2B_API_KEY", "api-key") + module = _load_e2b_module(monkeypatch) + + backend = module.create_e2b_sandbox("sbx_existing") + + assert backend.sandbox.sandbox_id == "sbx_existing" + assert _FakeSandbox.connect_calls == [("sbx_existing", {"timeout": 3600, "api_key": "api-key"})] + assert _FakeSandbox.create_calls == [] + + +def test_e2b_missing_api_key(monkeypatch): + monkeypatch.delenv("E2B_API_KEY", raising=False) + monkeypatch.delenv("E2B_TEMPLATE", raising=False) + module = _load_e2b_module(monkeypatch) + + try: + module.create_e2b_sandbox() + except ValueError as exc: + assert "E2B_API_KEY" in str(exc) + else: + raise AssertionError("expected missing E2B_API_KEY to fail") + + +def test_e2b_rejects_empty_template(monkeypatch): + monkeypatch.setenv("E2B_API_KEY", "api-key") + monkeypatch.setenv("E2B_TEMPLATE", " ") + module = _load_e2b_module(monkeypatch) + + try: + module.create_e2b_sandbox() + except ValueError as exc: + assert "E2B_TEMPLATE must not be empty" in str(exc) + else: + raise AssertionError("expected empty E2B_TEMPLATE to fail") diff --git a/uv.lock b/uv.lock index 5e960cb2..9663297f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -727,6 +727,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.18.0" @@ -736,6 +745,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "e2b" +version = "2.33.0" +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/ce/76/93a08f6ed33e53c030ed931916c9e69eb81cd3b84c500649c7cf4cefd86d/e2b-2.33.0.tar.gz", hash = "sha256:0a14fa7f99fc09b540db0c191b2ce5b1fd4cc3ed837dbd64385add99305776a3", size = 185400, upload-time = "2026-07-16T09:24:45.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/21/2c7bdcd1c6b188b8ae6ee57e542b1e80436cc6692e955abcf8b9355ea9bf/e2b-2.33.0-py3-none-any.whl", hash = "sha256:c10cb697b713d418f5d70f5c9c707e923630a491bd30009c10c4daeb4e21fe51", size = 337079, upload-time = "2026-07-16T09:24:43.972Z" }, +] + [[package]] name = "exa-py" version = "2.16.0" @@ -1506,6 +1537,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/00/7a203f464e5ce08390129c51b60598a23b4aba5fde4b02ed7521da284368/langchain_daytona-0.0.7-py3-none-any.whl", hash = "sha256:3374c99afc51b290539dcc127f4d4a61deb986e06c2d3a8cdc342eb868246ddb", size = 5456, upload-time = "2026-06-03T20:25:58.859Z" }, ] +[[package]] +name = "langchain-e2b" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deepagents" }, + { name = "e2b" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/1a/ab8db1288a70b25bfb2489327fae0d3a82ea07231d446083a2dcb0bfc174/langchain_e2b-0.0.4.tar.gz", hash = "sha256:bf543ad7394b83c3f097c9914adfc59bb3af3af9837dea9ada5064abcf4666d2", size = 207862, upload-time = "2026-06-17T21:25:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/6f/4e2263f5372019bd05fc8c7fe50674b041cc6b22df3faf3037897587413f/langchain_e2b-0.0.4-py3-none-any.whl", hash = "sha256:6cf20f155ebf6b1a8f36d69dec4b5925c242dfcf779209c94ccb18fda3296419", size = 7482, upload-time = "2026-06-17T21:25:24.025Z" }, +] + [[package]] name = "langchain-fireworks" version = "1.4.4" @@ -2192,6 +2236,7 @@ dependencies = [ { name = "langchain-anthropic" }, { name = "langchain-aws" }, { name = "langchain-daytona" }, + { name = "langchain-e2b" }, { name = "langchain-fireworks" }, { name = "langchain-google-genai" }, { name = "langchain-mcp-adapters" }, @@ -2227,6 +2272,7 @@ requires-dist = [ { name = "langchain-anthropic", specifier = ">=1.4.6" }, { name = "langchain-aws", specifier = ">=1.6.2" }, { name = "langchain-daytona", specifier = ">=0.0.7" }, + { name = "langchain-e2b", specifier = "==0.0.4" }, { name = "langchain-fireworks", specifier = ">=1.4.4" }, { name = "langchain-google-genai", specifier = ">=4.2.7" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" },