mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 04:33:12 +00:00
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:
parent
f1907521f3
commit
5925a90a95
7 changed files with 168 additions and 93 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
47
scripts/create_sandbox_snapshot.py
Normal file
47
scripts/create_sandbox_snapshot.py
Normal 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()
|
||||
5
scripts/list_snapshots.py
Normal file
5
scripts/list_snapshots.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue