open-swe/agent/integrations/langsmith.py

631 lines
23 KiB
Python
Raw Normal View History

"""LangSmith sandbox backend integration."""
from __future__ import annotations
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
import asyncio
import base64
import json
import logging
import os
import time
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
import uuid
from abc import ABC, abstractmethod
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
from typing import Any
import httpx
from deepagents.backends import LangSmithSandbox
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
from langsmith.sandbox import (
CommandTimeoutError,
SandboxClient,
SandboxConnectionError,
SandboxServerReloadError,
)
logger = logging.getLogger(__name__)
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES = 32 * 1024**3
DEFAULT_SANDBOX_VCPUS = 2
DEFAULT_SANDBOX_MEM_BYTES = 7936 * 1024**2 # 7936 MiB ("large" tier cap)
DEFAULT_SANDBOX_IDLE_TTL_SECONDS = 2 * 60 * 60 # 2 hours
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS = 24 * 60 * 60 # 24 hours
PROXY_CONFIG_MAX_ATTEMPTS = 3
PROXY_CONFIG_TIMEOUT_SECONDS = 10.0
PROXY_CONFIG_RETRY_DELAYS_SECONDS = (0.5, 1.0)
PROXY_CONFIG_RETRYABLE_STATUS_CODES = frozenset({408, 409, 425, 429, 500, 502, 503, 504, 529})
def _get_langsmith_api_key() -> str | None:
"""Get LangSmith API key from environment.
Checks LANGSMITH_API_KEY first, then falls back to LANGSMITH_API_KEY_PROD
for LangGraph Cloud deployments where LANGSMITH_API_KEY is reserved.
"""
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
def _get_sandbox_api_key() -> str | None:
"""LangSmith API key for sandbox operations.
``SANDBOX_LANGSMITH_API_KEY`` lets sandboxes run against a different
LangSmith workspace than the one used for tracing/other API calls; falls
back to the standard key.
"""
return os.environ.get("SANDBOX_LANGSMITH_API_KEY") or _get_langsmith_api_key()
def _get_sandbox_endpoint() -> str:
"""LangSmith API **root** for sandbox operations.
Overridable via ``SANDBOX_LANGSMITH_ENDPOINT`` to pair with
``SANDBOX_LANGSMITH_API_KEY``; falls back to ``LANGSMITH_ENDPOINT``. This is
the bare root (e.g. ``https://api.smith.langchain.com``) used to build the
proxy-config URL; the SDK clients take :func:`_get_sandbox_api_endpoint`.
"""
return (
os.environ.get("SANDBOX_LANGSMITH_ENDPOINT")
or os.environ.get("LANGSMITH_ENDPOINT")
or "https://api.smith.langchain.com"
)
def _get_sandbox_api_endpoint() -> str:
"""Sandbox API base URL for the langsmith SDK clients.
The SDK's ``api_endpoint`` is the sandbox base (root + ``/v2/sandboxes``),
not the API root, and its methods append ``/boxes``, ``/snapshots``, etc.
"""
root = _get_sandbox_endpoint().rstrip("/")
suffix = "/v2/sandboxes"
return root if root.endswith(suffix) else f"{root}{suffix}"
def _current_thread_id() -> str | None:
"""The LangGraph thread id for the active run, if any."""
try:
from langgraph.config import get_config
return get_config().get("configurable", {}).get("thread_id")
except Exception:
return None
def _sandbox_name_for_thread(thread_id: str | None) -> str | None:
"""Deterministic, thread-traceable sandbox name: ``openswe-<b32(thread uuid)>``.
The thread id (a UUID) is base32-encoded lowercase without padding so the
name is a compact, hyphen-free token that maps back to the thread. Returns
None when the thread id is missing or not a UUID, leaving the name unset.
"""
if not thread_id:
return None
try:
raw = uuid.UUID(thread_id).bytes
except ValueError:
return None
encoded = base64.b32encode(raw).decode("ascii").rstrip("=").lower()
return f"openswe-{encoded}"
def _parse_optional_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if not raw:
return default
try:
return int(raw)
except ValueError as e:
msg = f"{name} must be an integer, got {raw!r}"
raise ValueError(msg) from e
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
def _execute_client_grace_seconds() -> int:
"""Extra wall-clock seconds the client waits past a command's own timeout
before giving up and killing it. The server is meant to enforce the command
timeout; this is the client-side backstop for when it doesn't."""
return _parse_optional_int("SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS", 30)
def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int, int, int]:
"""Get sandbox snapshot configuration from environment."""
snapshot_id = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_ID")
fs_capacity_bytes = _parse_optional_int(
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES", DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
)
vcpus = _parse_optional_int("DEFAULT_SANDBOX_VCPUS", DEFAULT_SANDBOX_VCPUS)
mem_bytes = _parse_optional_int("DEFAULT_SANDBOX_MEM_BYTES", DEFAULT_SANDBOX_MEM_BYTES)
idle_ttl_seconds = _parse_optional_int(
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS", DEFAULT_SANDBOX_IDLE_TTL_SECONDS
)
delete_after_stop_seconds = _parse_optional_int(
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS,
)
return (
snapshot_id,
fs_capacity_bytes,
vcpus,
mem_bytes,
idle_ttl_seconds,
delete_after_stop_seconds,
)
def _get_sandbox_create_extra_fields() -> dict[str, Any]:
"""Parse SANDBOX_CREATE_EXTRA_JSON into extra fields merged into the
sandbox-create request body, e.g. ``{"_internal_runtime": "v2"}``."""
raw = os.environ.get("SANDBOX_CREATE_EXTRA_JSON")
if not raw or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
msg = f"SANDBOX_CREATE_EXTRA_JSON must be valid JSON, got {raw!r}"
raise ValueError(msg) from e
if not isinstance(parsed, dict):
msg = f"SANDBOX_CREATE_EXTRA_JSON must be a JSON object, got {type(parsed).__name__}"
raise ValueError(msg)
return parsed
def _install_create_extra_fields(client: SandboxClient, extra: dict[str, Any]) -> None:
"""Merge ``extra`` into the JSON body of the sandbox-create request.
The SDK's ``create_sandbox`` builds a fixed payload with no passthrough, so
wrap the HTTP client's ``post`` to inject the fields on the ``POST /boxes``
request only (other endpoints post to ``/boxes/{name}/...``).
"""
if not extra:
return
original_post = client._http.post
def post_with_extra(url: Any, *args: Any, **kwargs: Any) -> Any:
payload = kwargs.get("json")
if str(url).endswith("/boxes") and isinstance(payload, dict):
kwargs["json"] = {**payload, **extra}
return original_post(url, *args, **kwargs)
client._http.post = post_with_extra
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
return [
{
"name": "github-api",
"match_hosts": ["api.github.com"],
"headers": [
{
"name": "Authorization",
"type": "opaque",
"value": f"Bearer {github_token}",
}
],
},
{
"name": "github",
"match_hosts": ["github.com", "*.github.com"],
"headers": [
{
"name": "Authorization",
"type": "opaque",
"value": f"Basic {basic_auth}",
}
],
},
]
def _retry_after_seconds(response: httpx.Response | None) -> float | None:
if response is None:
return None
raw = response.headers.get("Retry-After")
if not raw:
return None
try:
delay = float(raw)
except ValueError:
return None
return max(delay, 0.0)
def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in PROXY_CONFIG_RETRYABLE_STATUS_CODES
return isinstance(exc, httpx.TransportError)
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
def _release_sandbox_name(client: SandboxClient, name: str | None) -> None:
"""Best-effort delete of any existing sandbox holding ``name``.
Sandbox names are unique in LangSmith and thread-deterministic, so the only
box that can hold this name is this thread's own — typically a dead one
(idle-stopped past its TTL) we're recreating. Provisioning is serialized per
thread, so this never races a live box. Without this, recreate would 409.
"""
if not name:
return
try:
client.delete_sandbox(name)
except Exception as exc: # noqa: BLE001 - name is free if nothing to delete
logger.debug("No pre-existing sandbox %s to release (%s)", name, type(exc).__name__)
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
Uses the LangSmith proxy-config API to set up header injection so that
git operations (clone, pull, push) authenticate via the proxy rather than
writing credentials to disk in the sandbox.
Args:
sandbox_name: The sandbox name/ID returned by the LangSmith API.
github_token: GitHub token to inject as Authorization header.
"""
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
api_key = _get_sandbox_api_key()
if not api_key:
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
return
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
langsmith_endpoint = _get_sandbox_endpoint()
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
for attempt in range(PROXY_CONFIG_MAX_ATTEMPTS):
try:
response = client.patch(
url,
json=payload,
headers={"X-API-Key": api_key},
)
response.raise_for_status()
break
except Exception as exc:
if attempt == PROXY_CONFIG_MAX_ATTEMPTS - 1 or not _is_retryable_proxy_config_error(
exc
):
raise
retry_after = (
_retry_after_seconds(exc.response)
if isinstance(exc, httpx.HTTPStatusError)
else None
)
delay = (
retry_after
or PROXY_CONFIG_RETRY_DELAYS_SECONDS[
min(attempt, len(PROXY_CONFIG_RETRY_DELAYS_SECONDS) - 1)
]
)
logger.warning(
"Failed to configure GitHub proxy for sandbox %s (%s); retrying in %.1fs",
sandbox_name,
type(exc).__name__,
delay,
)
time.sleep(delay)
logger.info("Configured GitHub proxy for sandbox %s", sandbox_name)
def create_langsmith_sandbox(
sandbox_id: str | None = None,
github_token: str | None = None,
feat: repo-scoped dynamic sandbox snapshots (#1595) * feat: repo-scoped dynamic sandbox snapshots Let admins build a per-repo sandbox image from a custom Dockerfile so runs targeting that repo boot from a snapshot with its deps pre-baked. Snapshot selection is purely additive: repos without a `ready` repo-scoped snapshot always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID. Backend adds a repo_snapshots store module (Dockerfile + build status keyed by owner/name), threads the resolved repo through the LangSmith sandbox creation path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor + build status/logs). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden repo snapshot builds Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins cannot accidentally build a repo snapshot from a bare Python image that lacks Open SWE's sandbox tools. Allow stale building records to be retried by tracking build_started_at and treating old or missing timestamps as stale. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: document repo snapshot base image config Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert missing base-image configuration into a handled dashboard API error so admins see a clear configuration message instead of an unhandled template-generation error. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00
*,
snapshot_id: str | None = None,
) -> SandboxBackendProtocol:
"""Create or connect to a LangSmith sandbox without automatic cleanup.
feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] (#1159) * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * removing logger.info * formatting and linting * fix: resolve lint errors in server.py (imports, unused vars, undefined names) * feat: use opaque proxy headers for GitHub auth in sandbox * linting formatting and test changes * linting * Delete .claude directory * Delete tests/evals directory * fix: address PR review — guard missing tokens, quote shell paths, add proxy auth tests * fix: restore authorship, branch_name support, and installation token for PR creation * linitng * fix: move installation token fetch before commit, clean up dead proxy validation code * feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] * feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] * fix: address review feedback — restore agents_md, add git user config, lint fixes * fix: drop github_token arg from sandbox creation, use generic create_sandbox factory with langsmith-only proxy config * fix: use _get_langsmith_api_key() for prod key fallback, warn when API key missing for proxy config * linting * linting * feat: add installation token auth to list_repos GitHub API call * agents.md update * linting * fix: address PR review feedback — shell precedence bug in prompt, remove dead code * linting * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul <braceasproul@gmail.com> * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul <braceasproul@gmail.com> * fix: address PR review feedback — restore {working_dir} in prompt, remove clone code block * fix:Extract check_or_recreate_sandbox utility from inline sandbox health check * fix: address PR review feedback — async list_repos, restore template name, fix prompt colon * fix: resolve merge conflicts with main, adopt deepagents v0.5.0a4 LangSmithSandbox * linting * yogesh/ope-21-stop-auto-cloning * Update agent/tools/list_repos.py Co-authored-by: Brace Sproul <braceasproul@gmail.com> * Update agent/prompt.py Co-authored-by: Brace Sproul <braceasproul@gmail.com> * feat: address PR review — list_repos uses GitHub API only, PR trigger includes org/repo * linting * feat: address PR review feedback — list_repos pagination, simpler return, sandbox health check * feat: support listing repos for personal user accounts via is_organization flag --------- Co-authored-by: Brace Sproul <braceasproul@gmail.com>
2026-04-10 17:04:55 -07:00
This function directly uses the LangSmithProvider to create/connect to sandboxes
without the context manager cleanup, allowing sandboxes to persist across
multiple agent invocations.
Args:
sandbox_id: Optional existing sandbox ID to connect to.
If None, creates a new sandbox.
github_token: Optional GitHub token. Used to configure proxy auth on
new sandboxes. Ignored when connecting to an existing sandbox.
feat: repo-scoped dynamic sandbox snapshots (#1595) * feat: repo-scoped dynamic sandbox snapshots Let admins build a per-repo sandbox image from a custom Dockerfile so runs targeting that repo boot from a snapshot with its deps pre-baked. Snapshot selection is purely additive: repos without a `ready` repo-scoped snapshot always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID. Backend adds a repo_snapshots store module (Dockerfile + build status keyed by owner/name), threads the resolved repo through the LangSmith sandbox creation path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor + build status/logs). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden repo snapshot builds Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins cannot accidentally build a repo snapshot from a bare Python image that lacks Open SWE's sandbox tools. Allow stale building records to be retried by tracking build_started_at and treating old or missing timestamps as stale. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: document repo snapshot base image config Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert missing base-image configuration into a handled dashboard API error so admins see a clear configuration message instead of an unhandled template-generation error. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00
snapshot_id: Optional repo-scoped snapshot to boot from. When omitted,
falls back to DEFAULT_SANDBOX_SNAPSHOT_ID.
Returns:
SandboxBackendProtocol instance
"""
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
api_key = _get_sandbox_api_key()
(
feat: repo-scoped dynamic sandbox snapshots (#1595) * feat: repo-scoped dynamic sandbox snapshots Let admins build a per-repo sandbox image from a custom Dockerfile so runs targeting that repo boot from a snapshot with its deps pre-baked. Snapshot selection is purely additive: repos without a `ready` repo-scoped snapshot always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID. Backend adds a repo_snapshots store module (Dockerfile + build status keyed by owner/name), threads the resolved repo through the LangSmith sandbox creation path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor + build status/logs). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden repo snapshot builds Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins cannot accidentally build a repo snapshot from a bare Python image that lacks Open SWE's sandbox tools. Allow stale building records to be retried by tracking build_started_at and treating old or missing timestamps as stale. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: document repo snapshot base image config Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert missing base-image configuration into a handled dashboard API error so admins see a clear configuration message instead of an unhandled template-generation error. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00
default_snapshot_id,
fs_capacity_bytes,
vcpus,
mem_bytes,
idle_ttl_seconds,
delete_after_stop_seconds,
) = _get_sandbox_snapshot_config()
feat: repo-scoped dynamic sandbox snapshots (#1595) * feat: repo-scoped dynamic sandbox snapshots Let admins build a per-repo sandbox image from a custom Dockerfile so runs targeting that repo boot from a snapshot with its deps pre-baked. Snapshot selection is purely additive: repos without a `ready` repo-scoped snapshot always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID. Backend adds a repo_snapshots store module (Dockerfile + build status keyed by owner/name), threads the resolved repo through the LangSmith sandbox creation path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor + build status/logs). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden repo snapshot builds Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins cannot accidentally build a repo snapshot from a bare Python image that lacks Open SWE's sandbox tools. Allow stale building records to be retried by tracking build_started_at and treating old or missing timestamps as stale. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: document repo snapshot base image config Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert missing base-image configuration into a handled dashboard API error so admins see a clear configuration message instead of an unhandled template-generation error. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00
effective_snapshot_id = snapshot_id or default_snapshot_id
provider = LangSmithProvider(api_key=api_key)
backend = provider.get_or_create(
sandbox_id=sandbox_id,
feat: repo-scoped dynamic sandbox snapshots (#1595) * feat: repo-scoped dynamic sandbox snapshots Let admins build a per-repo sandbox image from a custom Dockerfile so runs targeting that repo boot from a snapshot with its deps pre-baked. Snapshot selection is purely additive: repos without a `ready` repo-scoped snapshot always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID. Backend adds a repo_snapshots store module (Dockerfile + build status keyed by owner/name), threads the resolved repo through the LangSmith sandbox creation path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor + build status/logs). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden repo snapshot builds Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins cannot accidentally build a repo snapshot from a bare Python image that lacks Open SWE's sandbox tools. Allow stale building records to be retried by tracking build_started_at and treating old or missing timestamps as stale. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: document repo snapshot base image config Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert missing base-image configuration into a handled dashboard API error so admins see a clear configuration message instead of an unhandled template-generation error. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00
snapshot_id=effective_snapshot_id,
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
name=_sandbox_name_for_thread(_current_thread_id()),
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
idle_ttl_seconds=idle_ttl_seconds,
delete_after_stop_seconds=delete_after_stop_seconds,
)
_update_thread_sandbox_metadata(backend.id)
if sandbox_id is None and github_token:
_configure_github_proxy(backend.id, github_token)
return backend
def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
"""Update thread metadata with sandbox_id."""
try:
import asyncio
from langgraph_sdk import get_client
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
thread_id = _current_thread_id()
if not thread_id:
return
client = get_client()
async def _update() -> None:
await client.threads.update(
thread_id=thread_id,
metadata={"sandbox_id": sandbox_id},
)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(_update())
else:
loop.create_task(_update())
except Exception:
pass
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
class TimeoutLangSmithSandbox(LangSmithSandbox):
"""LangSmith backend that enforces a client-side execution deadline.
The langsmith SDK's default execute path is now a WebSocket stream with no
client-side read deadline: on a live socket where the dataplane never emits
an exit/error frame, ``CommandHandle.result`` blocks forever and wedges the
run (the blocking call sits in a thread that cancellation can't reclaim).
We drive a non-blocking ``CommandHandle`` ourselves and, if the command
overruns its own timeout by the grace window, kill it and surface a
timed-out tool result instead of hanging the graph. WebSocket connect
failures fall back to the base wait=True path, whose HTTP fallback carries
its own request deadline.
"""
_WS_FALLBACK_ERRORS = (
SandboxConnectionError,
SandboxServerReloadError,
ImportError,
OSError,
TypeError,
)
def _deadline(self, effective_timeout: int) -> int:
return effective_timeout + _execute_client_grace_seconds()
@staticmethod
def _result_to_response(result: Any) -> ExecuteResponse:
output = result.stdout or ""
if result.stderr:
output += "\n" + result.stderr if output else result.stderr
return ExecuteResponse(output=output, exit_code=result.exit_code, truncated=False)
@staticmethod
def _timeout_response(seconds: int, *, server_side: bool) -> ExecuteResponse:
where = "on the sandbox" if server_side else "by the client and killed"
return ExecuteResponse(
output=f"Command timed out after {seconds}s {where}.",
exit_code=124,
truncated=False,
)
@staticmethod
def _safe_kill(handle: Any) -> None:
try:
handle.kill()
except Exception: # noqa: BLE001 - best-effort cleanup of a wedged command
logger.warning("Failed to kill timed-out sandbox command", exc_info=True)
def _base_execute(self, command: str, timeout: int | None) -> ExecuteResponse:
# WS path unavailable; the base wait=True path falls back to HTTP,
# which carries its own request deadline.
return LangSmithSandbox.execute(self, command, timeout=timeout)
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
effective = timeout if timeout is not None else self._default_timeout
if not effective: # 0 / None: caller opted out of any deadline
return super().execute(command, timeout=timeout)
# run(wait=False) eagerly opens the WS and reads the "started" frame, so
# connect/setup failures raise here — fall back to the base path.
try:
handle = self._sandbox.run(command, timeout=effective, wait=False)
except (*self._WS_FALLBACK_ERRORS, TimeoutError):
return self._base_execute(command, timeout)
deadline = self._deadline(effective)
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="sbx-exec")
try:
future = pool.submit(lambda: handle.result)
try:
result = future.result(timeout=deadline)
except FuturesTimeout:
self._safe_kill(handle)
return self._timeout_response(deadline, server_side=False)
except CommandTimeoutError:
return self._timeout_response(effective, server_side=True)
except self._WS_FALLBACK_ERRORS:
return self._base_execute(command, timeout)
return self._result_to_response(result)
finally:
# Never join: a still-wedged worker must not block the caller.
pool.shutdown(wait=False)
async def aexecute(
self,
command: str,
*,
timeout: int | None = None, # noqa: ASYNC109 - forwarded semantic timeout, not an asyncio contract
) -> ExecuteResponse:
effective = timeout if timeout is not None else self._default_timeout
if not effective:
return await super().aexecute(command, timeout=timeout)
# run(wait=False) eagerly opens the WS and reads the "started" frame
# (blocking, bounded by the SDK connect timeout); connect/setup failures
# raise here — fall back to the base path.
try:
handle = await asyncio.to_thread(
self._sandbox.run, command, timeout=effective, wait=False
)
except (*self._WS_FALLBACK_ERRORS, TimeoutError):
return await asyncio.to_thread(self._base_execute, command, timeout)
deadline = self._deadline(effective)
try:
result = await asyncio.wait_for(
asyncio.to_thread(lambda: handle.result), timeout=deadline
)
except TimeoutError:
await asyncio.to_thread(self._safe_kill, handle)
return self._timeout_response(deadline, server_side=False)
except CommandTimeoutError:
return self._timeout_response(effective, server_side=True)
except self._WS_FALLBACK_ERRORS:
return await asyncio.to_thread(self._base_execute, command, timeout)
return self._result_to_response(result)
class SandboxProvider(ABC):
"""Interface for creating and deleting sandbox backends."""
@abstractmethod
def get_or_create(
self,
*,
sandbox_id: str | None = None,
**kwargs: Any,
) -> SandboxBackendProtocol:
"""Get an existing sandbox, or create one if needed."""
raise NotImplementedError
@abstractmethod
def delete(
self,
*,
sandbox_id: str,
**kwargs: Any,
) -> None:
"""Delete a sandbox by id."""
raise NotImplementedError
class LangSmithProvider(SandboxProvider):
"""LangSmith sandbox provider implementation."""
def __init__(self, api_key: str | None = None) -> None:
from langsmith import sandbox
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
self._api_key = api_key or _get_sandbox_api_key()
self._api_endpoint = _get_sandbox_api_endpoint()
if not self._api_key:
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
raise ValueError(msg)
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
self._client: SandboxClient = sandbox.SandboxClient(
api_key=self._api_key, api_endpoint=self._api_endpoint
)
@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)
for name in (
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES",
"DEFAULT_SANDBOX_VCPUS",
"DEFAULT_SANDBOX_MEM_BYTES",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
):
raw = os.environ.get(name)
if raw is None or raw == "":
continue
try:
value = int(raw)
except ValueError as e:
msg = f"{name} must be an integer, got {raw!r}"
raise ValueError(msg) from e
if (
name
in {
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
}
and value < 0
):
msg = f"{name} must be >= 0, got {value}"
raise ValueError(msg)
_get_sandbox_create_extra_fields()
def get_or_create(
self,
*,
sandbox_id: str | None = None,
timeout: int = 180,
snapshot_id: str | None = None,
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
name: str | None = None,
fs_capacity_bytes: int | None = None,
vcpus: int | None = None,
mem_bytes: int | None = None,
idle_ttl_seconds: int | None = None,
delete_after_stop_seconds: int | None = None,
**kwargs: Any,
) -> SandboxBackendProtocol:
"""Get existing or create new LangSmith sandbox."""
if kwargs:
msg = f"Received unsupported arguments: {list(kwargs.keys())}"
raise TypeError(msg)
if sandbox_id:
try:
sandbox = self._client.get_sandbox(name=sandbox_id)
except Exception as e:
msg = f"Failed to connect to existing sandbox '{sandbox_id}': {e}"
raise RuntimeError(msg) from e
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
return TimeoutLangSmithSandbox(sandbox)
if not snapshot_id:
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
raise ValueError(msg)
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
_release_sandbox_name(self._client, name)
try:
sandbox = self._client.create_sandbox(
snapshot_id=snapshot_id,
feat: optional separate LangSmith key/endpoint for sandboxes (#1760) * feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00
name=name,
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
idle_ttl_seconds=idle_ttl_seconds,
delete_after_stop_seconds=delete_after_stop_seconds,
timeout=timeout,
)
except Exception as e:
msg = f"Failed to create sandbox from snapshot '{snapshot_id}': {e}"
raise RuntimeError(msg) from e
fix: enforce client-side deadline on sandbox execute (#1451) * fix: enforce client-side deadline on sandbox execute The langsmith SDK's default execute path is now a WebSocket stream with no client-side read deadline. On a live socket where the dataplane never emits an exit/error frame, CommandHandle.result blocks forever in an uncancellable thread, wedging the run (introduced by the langsmith 0.8.3 -> 0.8.8 bump in #1385). The command `timeout` is only enforced server-side, so it doesn't fire. TimeoutLangSmithSandbox drives a non-blocking CommandHandle and kills the command if it overruns its timeout by a grace window (SANDBOX_EXECUTE_CLIENT_GRACE_SECONDS, default 30), returning a timed-out tool result instead of hanging. WS connect failures fall back to the base wait=True path, whose HTTP fallback carries its own request deadline. * fix: fall back to HTTP when WS execute connect fails run(wait=False) eagerly opens the WebSocket and reads the "started" frame, so connect/setup failures (and connect timeouts) raise from the run() call itself, not from handle.result. The previous structure left run() outside the try, so those failures bypassed the HTTP fallback and would fail every sandbox command in any environment where the WS path is unavailable. Move handle creation inside the fallback handler in both execute and aexecute, and run it via to_thread in the async path since it now blocks on connect. Add tests for connect-failure and connect-timeout fallback. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 11:16:08 -07:00
return TimeoutLangSmithSandbox(sandbox)
def delete(self, *, sandbox_id: str, **kwargs: Any) -> None:
"""Delete a LangSmith sandbox."""
self._client.delete_sandbox(sandbox_id)