feat: migrate LangSmith sandbox creation to snapshot API (#1201)

* feat: migrate LangSmith sandbox creation to snapshot API

Replaces the template-based sandbox flow (DEFAULT_SANDBOX_TEMPLATE_NAME /
DEFAULT_SANDBOX_TEMPLATE_IMAGE) with the new snapshot-based flow.

- New required env var DEFAULT_SANDBOX_SNAPSHOT_ID (UUID of a pre-built
  LangSmith snapshot; build out-of-band via UI or SandboxClient.create_snapshot)
- Optional DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES overrides the root FS
  size at boot (default 32 GiB)
- Startup-time validation via a FastAPI lifespan hook: the server refuses
  to boot with a clear ValueError if SANDBOX_TYPE=langsmith and
  DEFAULT_SANDBOX_SNAPSHOT_ID is unset, so failures surface in boot logs
  rather than on the first thread
- Reconnect-to-existing-sandbox path unchanged
- Docs (INSTALLATION.md, CUSTOMIZATION.md) updated to describe the new
  snapshot workflow

* fix: format create_sandbox_snapshot.py to pass ruff

---------

Co-authored-by: aran-yogesh <yogesh.mahendran@langchain.dev>
This commit is contained in:
Ramon Nogueira 2026-04-21 15:17:19 -04:00 • committed by GitHub
parent f1907521f3
commit 5925a90a95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 168 additions and 93 deletions

View file

@ -24,13 +24,15 @@ return create_deep_agent(
By default, Open SWE runs each task in a [LangSmith cloud sandbox](https://docs.smith.langchain.com/) — an isolated Linux environment where the agent clones the repo and executes commands. Sandbox creation and connection is handled in `agent/integrations/langsmith.py`.
### Using a custom sandbox template
### Using a custom sandbox snapshot
Set environment variables to use a custom Docker image:
Build a snapshot in LangSmith (UI or `SandboxClient.create_snapshot`) from your Docker image and point Open SWE at its UUID:
```bash
DEFAULT_SANDBOX_TEMPLATE_NAME="my-template" # Template registered in LangSmith
DEFAULT_SANDBOX_TEMPLATE_IMAGE="my-org/my-image:latest" # Docker image
DEFAULT_SANDBOX_SNAPSHOT_ID="<snapshot-uuid>" # Required
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="34359738368" # Optional, default 32 GiB
DEFAULT_SANDBOX_VCPUS="4" # Optional, default 4
DEFAULT_SANDBOX_MEM_BYTES="16106127360" # Optional, default 15 GiB
```
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run.

View file

@ -129,17 +129,37 @@ To set up per-user OAuth:
3. Enter the **Client ID** and **Client Secret** from your GitHub App (found on the GitHub App settings page under **OAuth credentials**)
4. Save. You'll reference this Provider ID as `GITHUB_OAUTH_PROVIDER_ID` in your environment variables.
### 4c. Sandbox templates (optional)
### 4c. Sandbox snapshots
LangSmith sandboxes provide the isolated execution environment for each agent run. You can create a template using the same Docker image we use internally by visiting the sandbox page in LangSmith, and setting the following fields:
LangSmith sandboxes provide the isolated execution environment for each agent run. Open SWE boots each sandbox from a pre-built **snapshot** — you build the snapshot once (from a Docker image) and then reference it by UUID.
- `Name`: you can set this to whatever name you'd like, e.g. `open-swe`
- `Container Image`: `bracelangchain/deepagents-sandbox:v1` this contains the [Docker file in this repo](./Dockerfile)
- `CPU`: `500m`
- `Memory`: `4096Mi`
- `Ephemeral Storage`: `15Gi`
Build a snapshot in the LangSmith UI (Sandboxes → Snapshots → New), or via the SDK:
> If you don't set these, you can use a Python based docker image in the template.
```python
from langsmith.sandbox import SandboxClient
client = SandboxClient(api_key="<your key>")
snapshot = client.create_snapshot(
name="open-swe",
docker_image="bracelangchain/deepagents-sandbox:v1", # built from ./Dockerfile
fs_capacity_bytes=32 * 1024**3,
)
print(snapshot.id)
```
Then set the resulting UUID in your environment:
```bash
DEFAULT_SANDBOX_SNAPSHOT_ID="<snapshot-uuid>"
# Optional; overrides the snapshot's root FS size at sandbox boot. Default is 32 GiB.
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="34359738368"
# Optional; number of vCPUs per sandbox. Default is 4.
DEFAULT_SANDBOX_VCPUS="4"
# Optional; memory in bytes per sandbox. Default is 15 GiB.
DEFAULT_SANDBOX_MEM_BYTES="16106127360"
```
`DEFAULT_SANDBOX_SNAPSHOT_ID` is required when `SANDBOX_TYPE=langsmith`. The server validates this at startup and refuses to boot if it's missing.
## 5. Set up triggers
@ -347,8 +367,10 @@ SLACK_SIGNING_SECRET=""
EXA_API_KEY="" # From https://dashboard.exa.ai
# === Sandbox (optional) ===
DEFAULT_SANDBOX_TEMPLATE_NAME="" # Custom sandbox template name (default: deepagents-cli)
DEFAULT_SANDBOX_TEMPLATE_IMAGE="" # Custom Docker image (default: python:3)
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)
DEFAULT_SANDBOX_VCPUS="" # vCPUs per sandbox (default: 4)
DEFAULT_SANDBOX_MEM_BYTES="" # Memory in bytes per sandbox (default: 15 GiB)
# === Token Encryption ===
TOKEN_ENCRYPTION_KEY="" # Generate with: openssl rand -base64 32
@ -442,8 +464,9 @@ The `langgraph.json` at the project root already defines the graph entry point a
- Verify `LANGSMITH_API_KEY_PROD` is set and valid
- Check LangSmith sandbox quotas in your workspace settings
- If you see `Failed to check template ''`, ensure either `DEFAULT_SANDBOX_TEMPLATE_NAME` is set or that your LangSmith API key has permissions to create sandbox templates
- If you get a 403 Forbidden error on the sandbox templates endpoint, your LangSmith workspace may not have sandbox access enabled — contact LangSmith support
- If the server refuses to start with `DEFAULT_SANDBOX_SNAPSHOT_ID must be set`, build a snapshot (see step 4c) and export its UUID
- If you see `Failed to create sandbox from snapshot '<id>'`, confirm the snapshot exists in your workspace and has status `ready`
- If you get a 403 Forbidden error on the sandbox endpoints, your LangSmith workspace may not have sandbox access enabled — contact LangSmith support
### Agent not responding to comments

View file

@ -3,20 +3,22 @@
from __future__ import annotations
import base64
import contextlib
import logging
import os
import time
from abc import ABC, abstractmethod
from typing import Any
import httpx
from deepagents.backends import LangSmithSandbox
from deepagents.backends.protocol import SandboxBackendProtocol
from langsmith.sandbox import SandboxClient, SandboxTemplate
from langsmith.sandbox import SandboxClient
logger = logging.getLogger(__name__)
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES = 32 * 1024**3
DEFAULT_SANDBOX_VCPUS = 4
DEFAULT_SANDBOX_MEM_BYTES = 15 * 1024**3 # 15 GiB
def _get_langsmith_api_key() -> str | None:
"""Get LangSmith API key from environment.
@ -27,11 +29,16 @@ def _get_langsmith_api_key() -> str | None:
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
def _get_sandbox_template_config() -> tuple[str | None, str | None]:
"""Get sandbox template configuration from environment."""
template_name = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_NAME")
template_image = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_IMAGE")
return template_name, template_image
def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int]:
"""Get sandbox snapshot configuration from environment."""
snapshot_id = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_ID")
raw_capacity = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES")
fs_capacity_bytes = int(raw_capacity) if raw_capacity else DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
raw_vcpus = os.environ.get("DEFAULT_SANDBOX_VCPUS")
vcpus = int(raw_vcpus) if raw_vcpus else DEFAULT_SANDBOX_VCPUS
raw_mem = os.environ.get("DEFAULT_SANDBOX_MEM_BYTES")
mem_bytes = int(raw_mem) if raw_mem else DEFAULT_SANDBOX_MEM_BYTES
return snapshot_id, fs_capacity_bytes, vcpus, mem_bytes
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
@ -99,13 +106,15 @@ def create_langsmith_sandbox(
SandboxBackendProtocol instance
"""
api_key = _get_langsmith_api_key()
template_name, template_image = _get_sandbox_template_config()
snapshot_id, fs_capacity_bytes, vcpus, mem_bytes = _get_sandbox_snapshot_config()
provider = LangSmithProvider(api_key=api_key)
backend = provider.get_or_create(
sandbox_id=sandbox_id,
template=template_name,
template_image=template_image,
snapshot_id=snapshot_id,
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
)
_update_thread_sandbox_metadata(backend.id)
@ -169,10 +178,6 @@ class SandboxProvider(ABC):
raise NotImplementedError
DEFAULT_TEMPLATE_NAME = "open-swe-new"
DEFAULT_TEMPLATE_IMAGE = "python:3"
class LangSmithProvider(SandboxProvider):
"""LangSmith sandbox provider implementation."""
@ -185,13 +190,32 @@ class LangSmithProvider(SandboxProvider):
raise ValueError(msg)
self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key)
@classmethod
def validate_startup_config(cls) -> None:
"""Validate env-var configuration at server startup. Raises ValueError if invalid."""
if not os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_ID"):
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
raise ValueError(msg)
raw_capacity = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES")
if raw_capacity:
try:
int(raw_capacity)
except ValueError as e:
msg = (
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES must be an integer, "
f"got {raw_capacity!r}"
)
raise ValueError(msg) from e
def get_or_create(
self,
*,
sandbox_id: str | None = None,
timeout: int = 180,
template: str | None = None,
template_image: str | None = None,
snapshot_id: str | None = None,
fs_capacity_bytes: int | None = None,
vcpus: int | None = None,
mem_bytes: int | None = None,
**kwargs: Any,
) -> SandboxBackendProtocol:
"""Get existing or create new LangSmith sandbox."""
@ -206,74 +230,24 @@ class LangSmithProvider(SandboxProvider):
raise RuntimeError(msg) from e
return LangSmithSandbox(sandbox)
resolved_template_name, resolved_image_name = self._resolve_template(
template, template_image
)
self._ensure_template(resolved_template_name, resolved_image_name)
if not snapshot_id:
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
raise ValueError(msg)
try:
sandbox = self._client.create_sandbox(
template_name=resolved_template_name, timeout=timeout
snapshot_id=snapshot_id,
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
timeout=timeout,
)
except Exception as e:
msg = f"Failed to create sandbox from template '{resolved_template_name}': {e}"
msg = f"Failed to create sandbox from snapshot '{snapshot_id}': {e}"
raise RuntimeError(msg) from e
for _ in range(timeout // 2):
try:
result = sandbox.run("echo ready", timeout=5)
if result.exit_code == 0:
break
except Exception:
pass
time.sleep(2)
else:
with contextlib.suppress(Exception):
self._client.delete_sandbox(sandbox.name)
msg = f"LangSmith sandbox failed to start within {timeout} seconds"
raise RuntimeError(msg)
return LangSmithSandbox(sandbox)
def delete(self, *, sandbox_id: str, **kwargs: Any) -> None:
"""Delete a LangSmith sandbox."""
self._client.delete_sandbox(sandbox_id)
@staticmethod
def _resolve_template(
template: SandboxTemplate | str | None,
template_image: str | None = None,
) -> tuple[str, str]:
"""Resolve template name and image from kwargs."""
resolved_image = template_image or DEFAULT_TEMPLATE_IMAGE
if template is None:
return DEFAULT_TEMPLATE_NAME, resolved_image
if isinstance(template, str):
return template, resolved_image
if template_image is None and template.image:
resolved_image = template.image
return template.name, resolved_image
def _ensure_template(
self,
template_name: str,
template_image: str,
) -> None:
"""Ensure template exists, creating it if needed."""
from langsmith.sandbox import ResourceNotFoundError
try:
self._client.get_template(template_name)
except ResourceNotFoundError as e:
if e.resource_type != "template":
msg = f"Unexpected resource not found: {e}"
raise RuntimeError(msg) from e
try:
self._client.create_template(name=template_name, image=template_image)
except Exception as create_err:
msg = f"Failed to create template '{template_name}': {create_err}"
raise RuntimeError(msg) from create_err
except Exception as e:
msg = f"Failed to check template '{template_name}': {e}"
raise RuntimeError(msg) from e

View file

@ -33,3 +33,17 @@ def create_sandbox(sandbox_id: str | None = None):
supported = ", ".join(sorted(SANDBOX_FACTORIES))
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
return factory(sandbox_id)
def validate_sandbox_startup_config() -> None:
"""Validate the configured sandbox provider's env vars at server startup.
Raises ValueError if the active provider's configuration is invalid.
Called from the FastAPI lifespan hook so errors surface at boot rather
than on the first sandbox creation.
"""
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
if sandbox_type == "langsmith":
from agent.integrations.langsmith import LangSmithProvider
LangSmithProvider.validate_startup_config()

View file

@ -6,6 +6,8 @@ import json
import logging
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
@ -39,6 +41,7 @@ from .utils.linear import post_linear_trace_comment
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
from .utils.repo import extract_repo_from_text
from .utils.sandbox import validate_sandbox_startup_config
from .utils.slack import (
add_slack_reaction,
fetch_slack_thread_messages,
@ -54,7 +57,14 @@ from .utils.slack import (
logger = logging.getLogger(__name__)
app = FastAPI()
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
validate_sandbox_startup_config()
yield
app = FastAPI(lifespan=lifespan)
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")

View file

@ -0,0 +1,47 @@
"""Create a LangSmith sandbox snapshot for open-swe."""
import argparse
import os
from langsmith.sandbox import SandboxClient
DEFAULT_IMAGE = "bracelangchain/deepagents-sandbox:v1"
DEFAULT_FS_CAPACITY = 32 * 1024**3 # 32 GiB
def main() -> None:
parser = argparse.ArgumentParser(description="Create a LangSmith sandbox snapshot")
parser.add_argument(
"--name", default="open-swe-new", help="Snapshot name (default: open-swe-new)"
)
parser.add_argument(
"--image", default=DEFAULT_IMAGE, help=f"Docker image (default: {DEFAULT_IMAGE})"
)
parser.add_argument(
"--fs-capacity",
type=int,
default=DEFAULT_FS_CAPACITY,
help="FS capacity in bytes (default: 32 GiB)",
)
parser.add_argument(
"--api-key",
default=os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD"),
help="LangSmith API key",
)
args = parser.parse_args()
if not args.api_key:
raise SystemExit("Set LANGSMITH_API_KEY or pass --api-key")
client = SandboxClient(api_key=args.api_key)
snapshot = client.create_snapshot(
name=args.name,
docker_image=args.image,
fs_capacity_bytes=args.fs_capacity,
)
print(f"Snapshot created: {snapshot.id}")
print(f"\nAdd to your .env:\n DEFAULT_SANDBOX_SNAPSHOT_ID={snapshot.id}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,5 @@
from langsmith.sandbox import SandboxClient
c = SandboxClient()
for s in c.list_snapshots():
print(s.id, s.name, s.status)