Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

# Changelog

## 0.0.4

- Added a Deep Agents Code sandbox provider entry point so `dcode --sandbox e2b`
works after installing `langchain-e2b` into the `dcode` environment.

## 0.0.3

- Removed the `E2BProvider` lifecycle helper. Use the E2B SDK to create,
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
pip install langchain-e2b
```

## Deep Agents SDK

```python
from e2b import Sandbox
from langchain_e2b import E2BSandbox
Expand All @@ -24,15 +26,30 @@ finally:
e2b_sandbox.kill()
```

## Deep Agents Code

Install `langchain-e2b` into the `dcode` environment, then run with the E2B
sandbox provider:

```bash
dcode --install langchain-e2b --package
export E2B_API_KEY=...
dcode --sandbox e2b
```

## What is this?

`langchain-e2b` adapts an existing E2B sandbox to the Deep Agents sandbox
protocol. It uses the low-level `e2b` SDK so Deep Agents can run shell commands
and move files through the standard Deep Agents sandbox interface.

This package intentionally does not hide E2B sandbox lifecycle management. Use
the E2B SDK to create, connect to, configure, and kill sandboxes, then pass the
connected sandbox object to `E2BSandbox`.
For SDK use, this package intentionally does not hide E2B sandbox lifecycle
management. Use the E2B SDK to create, connect to, configure, and kill
sandboxes, then pass the connected sandbox object to `E2BSandbox`.

For Deep Agents Code, the package also exposes a `dcode` sandbox provider entry
point. The provider creates, reconnects to, and deletes E2B sandboxes for
`dcode --sandbox e2b`.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion langchain_e2b/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

# Keep the `x-release-please-version` annotation — release-please uses it to
# bump `__version__` in sync with `pyproject.toml` on every release PR.
__version__ = "0.0.3" # x-release-please-version
__version__ = "0.0.4" # x-release-please-version
215 changes: 215 additions & 0 deletions langchain_e2b/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""E2B sandbox lifecycle provider for Deep Agents Code."""

from __future__ import annotations

import os
from collections.abc import Callable
from typing import NoReturn

import e2b
from deepagents_code.integrations.sandbox_provider import (
SandboxInstallHint,
SandboxNotFoundError,
SandboxProvider,
SandboxProviderMetadata,
)

from langchain_e2b.sandbox import DEFAULT_WORKDIR, E2BSandbox

DEFAULT_SANDBOX_TIMEOUT = 30 * 60
DEFAULT_COMMAND_TIMEOUT = 30 * 60

EnvResolver = Callable[[str], str | None]


def _default_resolve_env_var(name: str) -> str | None:
if not name.startswith("DEEPAGENTS_CODE_"):
prefixed = f"DEEPAGENTS_CODE_{name}"
if prefixed in os.environ:
return os.environ[prefixed] or None
return os.environ.get(name) or None


def _resolve_optional_env_var(
resolve_env_var: EnvResolver,
name: str,
) -> str | None:
value = resolve_env_var(name)
return value or None


def _resolve_int(
value: int | str | None,
*,
name: str,
default: int,
allow_zero: bool = False,
) -> int:
if value is None:
return default

try:
resolved = int(value)
except (TypeError, ValueError) as exc:
msg = f"{name} must be an integer number of seconds"
raise ValueError(msg) from exc

lower_bound = 0 if allow_zero else 1
if resolved < lower_bound:
requirement = "non-negative" if allow_zero else "positive"
msg = f"{name} must be {requirement}"
raise ValueError(msg)
return resolved


def _raise_unsupported_kwargs(kwargs: dict[str, object]) -> NoReturn:
names = ", ".join(sorted(kwargs))
msg = f"Received unsupported arguments: {names}"
raise TypeError(msg)


class E2BProvider(SandboxProvider):
"""Manage E2B sandboxes for Deep Agents Code."""

_metadata = SandboxProviderMetadata(
name="e2b",
working_dir=DEFAULT_WORKDIR,
install=SandboxInstallHint(kind="package", name="langchain-e2b"),
supports_sandbox_id=True,
supports_snapshot_name=False,
backend_module="langchain_e2b",
)

def __init__(
self,
*,
resolve_env_var: EnvResolver | None = None,
) -> None:
"""Initialize the provider without touching credentials.

Deep Agents Code may instantiate providers only to read metadata during
discovery, so credentials are resolved lazily by lifecycle methods.

Args:
resolve_env_var: Environment resolver used for provider settings.
"""
self._resolve_env_var = resolve_env_var or _default_resolve_env_var

@property
def metadata(self) -> SandboxProviderMetadata:
"""Return metadata used by Deep Agents Code provider discovery."""
return self._metadata

def get_or_create(
self,
*,
sandbox_id: str | None = None,
timeout: int | str | None = None,
template: str | None = None,
workdir: str = DEFAULT_WORKDIR,
command_timeout: int | str | None = None,
**kwargs: object,
) -> E2BSandbox:
"""Get or create an E2B sandbox backend.

Args:
sandbox_id: Existing E2B sandbox ID, or `None` to create one.
timeout: E2B sandbox lifetime in seconds.
template: E2B template for new sandboxes.
workdir: Working directory used by command execution.
command_timeout: Default command timeout for the backend.
**kwargs: Unsupported provider options.

Returns:
E2B sandbox backend.

Raises:
SandboxNotFoundError: If `sandbox_id` does not exist.
TypeError: If unsupported provider options are passed.
ValueError: If credentials or timeout settings are invalid.
"""
if kwargs:
_raise_unsupported_kwargs(kwargs)

api_key = self._resolve_api_key()
sandbox_timeout = self._resolve_sandbox_timeout(timeout)
default_command_timeout = _resolve_int(
command_timeout,
name="command_timeout",
default=DEFAULT_COMMAND_TIMEOUT,
allow_zero=True,
)

try:
if sandbox_id is not None:
sandbox = e2b.Sandbox.connect(
sandbox_id,
timeout=sandbox_timeout,
api_key=api_key,
)
else:
resolved_template = template or _resolve_optional_env_var(
self._resolve_env_var,
"E2B_TEMPLATE",
)
if resolved_template:
sandbox = e2b.Sandbox.create(
template=resolved_template,
timeout=sandbox_timeout,
api_key=api_key,
)
else:
sandbox = e2b.Sandbox.create(
timeout=sandbox_timeout,
api_key=api_key,
)
except e2b.SandboxNotFoundException as exc:
if sandbox_id is None:
raise
raise SandboxNotFoundError(sandbox_id) from exc

return E2BSandbox(
sandbox=sandbox,
workdir=workdir,
timeout=default_command_timeout,
)

def delete(self, *, sandbox_id: str, **kwargs: object) -> None:
"""Kill an E2B sandbox.

Args:
sandbox_id: E2B sandbox ID.
**kwargs: Unsupported provider options.

Raises:
SandboxNotFoundError: If `sandbox_id` does not exist.
TypeError: If unsupported provider options are passed.
ValueError: If credentials are missing.
"""
if kwargs:
_raise_unsupported_kwargs(kwargs)
try:
e2b.Sandbox.kill(sandbox_id, api_key=self._resolve_api_key())
except e2b.SandboxNotFoundException as exc:
raise SandboxNotFoundError(sandbox_id) from exc

def _resolve_api_key(self) -> str:
api_key = _resolve_optional_env_var(self._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)
return api_key

def _resolve_sandbox_timeout(self, timeout: int | str | None) -> int:
env_timeout = (
None
if timeout is not None
else _resolve_optional_env_var(self._resolve_env_var, "E2B_SANDBOX_TIMEOUT")
)
return _resolve_int(
timeout if timeout is not None else env_timeout,
name="timeout",
default=DEFAULT_SANDBOX_TIMEOUT,
)
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]

version = "0.0.3"
version = "0.0.4"
requires-python = ">=3.11,<4.0"
dependencies = [
"deepagents>=0.6.0,<0.7.0",
"e2b>=2.25.1,<3.0.0",
]

[project.entry-points."deepagents_code.sandbox_providers"]
e2b = "langchain_e2b.provider:E2BProvider"

[tool.hatch.build.targets.wheel]
packages = ["langchain_e2b"]

Expand All @@ -46,6 +49,7 @@ test = [
"ruff>=0.13.1,<0.16.0",
"ty>=0.0.1,<1.0.0",
"langchain-tests>=1.1.6",
"deepagents-code>=0.1.19,<0.2.0",
]

[tool.uv]
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from langchain_tests.integration_tests import SandboxIntegrationTests

from langchain_e2b import E2BSandbox
from langchain_e2b.provider import E2BProvider

if TYPE_CHECKING:
from collections.abc import Iterator
Expand Down Expand Up @@ -47,6 +48,31 @@ def sandbox(self) -> Iterator[SandboxBackendProtocol]:
_kill_sandbox(sandbox)


def test_e2b_provider_creates_executes_and_deletes_sandbox() -> None:
api_key = os.environ.get("E2B_API_KEY")
if not api_key:
pytest.skip(
"Missing secrets for E2B provider integration test: set E2B_API_KEY"
)

provider = E2BProvider()
sandbox_id: str | None = None
try:
backend = provider.get_or_create(
timeout=60 * 60,
template=os.environ.get("E2B_TEMPLATE"),
command_timeout=30,
)
sandbox_id = backend.id
result = backend.execute("echo provider-ready", timeout=10)

assert result.exit_code == 0
assert "provider-ready" in result.output
finally:
if sandbox_id is not None:
_delete_provider_sandbox(provider, sandbox_id)


def _kill_sandbox(sandbox: Sandbox) -> None:
last_error: BaseException | None = None
for attempt in range(KILL_ATTEMPTS):
Expand All @@ -61,3 +87,19 @@ def _kill_sandbox(sandbox: Sandbox) -> None:

msg = f"Failed to kill E2B sandbox {sandbox.sandbox_id!r}"
raise RuntimeError(msg) from last_error


def _delete_provider_sandbox(provider: E2BProvider, sandbox_id: str) -> None:
last_error: BaseException | None = None
for attempt in range(KILL_ATTEMPTS):
try:
provider.delete(sandbox_id=sandbox_id)
except (httpx.HTTPError, SandboxException) as exc:
last_error = exc
if attempt + 1 < KILL_ATTEMPTS:
time.sleep(KILL_RETRY_DELAY_SECONDS)
else:
return

msg = f"Failed to delete E2B sandbox {sandbox_id!r}"
raise RuntimeError(msg) from last_error
Loading
Loading