diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index e500b4bc..404ec00d 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -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="" # 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. diff --git a/INSTALLATION.md b/INSTALLATION.md index 129f9d6f..34ec7fea 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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="") +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="" +# 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 ''`, 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 diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 1d34bd98..ddc51b34 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -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 diff --git a/agent/utils/sandbox.py b/agent/utils/sandbox.py index 31ede796..05c04b88 100644 --- a/agent/utils/sandbox.py +++ b/agent/utils/sandbox.py @@ -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() diff --git a/agent/webapp.py b/agent/webapp.py index 2ba61379..a7166a37 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -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", "") diff --git a/scripts/create_sandbox_snapshot.py b/scripts/create_sandbox_snapshot.py new file mode 100644 index 00000000..8aedc4bd --- /dev/null +++ b/scripts/create_sandbox_snapshot.py @@ -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() diff --git a/scripts/list_snapshots.py b/scripts/list_snapshots.py new file mode 100644 index 00000000..3396b68c --- /dev/null +++ b/scripts/list_snapshots.py @@ -0,0 +1,5 @@ +from langsmith.sandbox import SandboxClient + +c = SandboxClient() +for s in c.list_snapshots(): + print(s.id, s.name, s.status)