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>
This commit is contained in:
Adam Moussa 2026-07-17 16:22:38 -04:00
parent 6d34c42578
commit a2794118ff
No known key found for this signature in database
5 changed files with 180 additions and 12 deletions

View file

@ -342,9 +342,9 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
""" """
from langsmith.sandbox import SandboxClient from langsmith.sandbox import SandboxClient
from agent.integrations.langsmith import _get_langsmith_api_key from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key
api_key = _get_langsmith_api_key() api_key = _get_sandbox_api_key()
if not api_key: if not api_key:
raise RuntimeError("LANGSMITH_API_KEY is not configured") raise RuntimeError("LANGSMITH_API_KEY is not configured")
@ -356,7 +356,7 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
timeout = int( timeout = int(
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS) os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
) )
client = SandboxClient(api_key=api_key) client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint())
try: try:
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir: with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
dockerfile_path = Path(context_dir) / "Dockerfile" dockerfile_path = Path(context_dir) / "Dockerfile"

View file

@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import time import time
import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout from concurrent.futures import TimeoutError as FuturesTimeout
@ -45,6 +46,69 @@ def _get_langsmith_api_key() -> str | None:
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD") return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
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: def _parse_optional_int(name: str, default: int) -> int:
raw = os.environ.get(name) raw = os.environ.get(name)
if not raw: if not raw:
@ -172,6 +236,22 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
return isinstance(exc, httpx.TransportError) return isinstance(exc, httpx.TransportError)
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: def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic. """Configure sandbox proxy to inject GitHub auth for GitHub traffic.
@ -183,11 +263,11 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
sandbox_name: The sandbox name/ID returned by the LangSmith API. sandbox_name: The sandbox name/ID returned by the LangSmith API.
github_token: GitHub token to inject as Authorization header. github_token: GitHub token to inject as Authorization header.
""" """
api_key = _get_langsmith_api_key() api_key = _get_sandbox_api_key()
if not api_key: if not api_key:
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration") logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
return return
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com") langsmith_endpoint = _get_sandbox_endpoint()
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}" url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}} payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client: with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
@ -249,7 +329,7 @@ def create_langsmith_sandbox(
Returns: Returns:
SandboxBackendProtocol instance SandboxBackendProtocol instance
""" """
api_key = _get_langsmith_api_key() api_key = _get_sandbox_api_key()
( (
default_snapshot_id, default_snapshot_id,
fs_capacity_bytes, fs_capacity_bytes,
@ -265,6 +345,7 @@ def create_langsmith_sandbox(
backend = provider.get_or_create( backend = provider.get_or_create(
sandbox_id=sandbox_id, sandbox_id=sandbox_id,
snapshot_id=effective_snapshot_id, snapshot_id=effective_snapshot_id,
name=_sandbox_name_for_thread(_current_thread_id()),
fs_capacity_bytes=fs_capacity_bytes, fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus, vcpus=vcpus,
mem_bytes=mem_bytes, mem_bytes=mem_bytes,
@ -284,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
try: try:
import asyncio import asyncio
from langgraph.config import get_config
from langgraph_sdk import get_client from langgraph_sdk import get_client
config = get_config() thread_id = _current_thread_id()
thread_id = config.get("configurable", {}).get("thread_id")
if not thread_id: if not thread_id:
return return
client = get_client() client = get_client()
@ -454,11 +533,14 @@ class LangSmithProvider(SandboxProvider):
def __init__(self, api_key: str | None = None) -> None: def __init__(self, api_key: str | None = None) -> None:
from langsmith import sandbox from langsmith import sandbox
self._api_key = api_key or _get_langsmith_api_key() self._api_key = api_key or _get_sandbox_api_key()
self._api_endpoint = _get_sandbox_api_endpoint()
if not self._api_key: if not self._api_key:
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set" msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
raise ValueError(msg) raise ValueError(msg)
self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key) self._client: SandboxClient = sandbox.SandboxClient(
api_key=self._api_key, api_endpoint=self._api_endpoint
)
@classmethod @classmethod
def validate_startup_config(cls) -> None: def validate_startup_config(cls) -> None:
@ -499,6 +581,7 @@ class LangSmithProvider(SandboxProvider):
sandbox_id: str | None = None, sandbox_id: str | None = None,
timeout: int = 180, timeout: int = 180,
snapshot_id: str | None = None, snapshot_id: str | None = None,
name: str | None = None,
fs_capacity_bytes: int | None = None, fs_capacity_bytes: int | None = None,
vcpus: int | None = None, vcpus: int | None = None,
mem_bytes: int | None = None, mem_bytes: int | None = None,
@ -523,10 +606,12 @@ class LangSmithProvider(SandboxProvider):
raise ValueError(msg) raise ValueError(msg)
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields()) _install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
_release_sandbox_name(self._client, name)
try: try:
sandbox = self._client.create_sandbox( sandbox = self._client.create_sandbox(
snapshot_id=snapshot_id, snapshot_id=snapshot_id,
name=name,
fs_capacity_bytes=fs_capacity_bytes, fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus, vcpus=vcpus,
mem_bytes=mem_bytes, mem_bytes=mem_bytes,

View file

@ -69,6 +69,8 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled. > **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
For `langsmith`, sandboxes default to the same LangSmith credentials as tracing. To run sandboxes against a **different** LangSmith workspace, set `SANDBOX_LANGSMITH_API_KEY` (falls back to `LANGSMITH_API_KEY` / `LANGSMITH_API_KEY_PROD`) and optionally `SANDBOX_LANGSMITH_ENDPOINT` (falls back to `LANGSMITH_ENDPOINT`). These apply to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds — the `DEFAULT_SANDBOX_SNAPSHOT_ID` must exist in whichever workspace these credentials point at.
### Adding a new sandbox provider ### Adding a new sandbox provider
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature: 1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:

View file

@ -1,6 +1,8 @@
"""Tests for LangSmith sandbox env-var configuration parsing.""" """Tests for LangSmith sandbox env-var configuration parsing."""
from unittest.mock import patch import base64
import uuid
from unittest.mock import MagicMock, patch
import pytest import pytest
@ -11,12 +13,62 @@ from agent.integrations.langsmith import (
DEFAULT_SANDBOX_VCPUS, DEFAULT_SANDBOX_VCPUS,
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES, DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
LangSmithProvider, LangSmithProvider,
_get_sandbox_api_endpoint,
_get_sandbox_create_extra_fields, _get_sandbox_create_extra_fields,
_get_sandbox_snapshot_config, _get_sandbox_snapshot_config,
_install_create_extra_fields, _install_create_extra_fields,
_release_sandbox_name,
_sandbox_name_for_thread,
) )
def test_sandbox_api_endpoint_appends_v2_sandboxes() -> None:
with patch.dict("os.environ", {"LANGSMITH_ENDPOINT": "https://eu.smith.langchain.com"}):
assert _get_sandbox_api_endpoint() == "https://eu.smith.langchain.com/v2/sandboxes"
def test_sandbox_api_endpoint_no_double_suffix() -> None:
with patch.dict(
"os.environ",
{"SANDBOX_LANGSMITH_ENDPOINT": "https://x.smith.langchain.com/v2/sandboxes"},
):
assert _get_sandbox_api_endpoint() == "https://x.smith.langchain.com/v2/sandboxes"
def test_sandbox_name_for_thread_encodes_uuid() -> None:
thread_id = "12345678-1234-5678-1234-567812345678"
name = _sandbox_name_for_thread(thread_id)
assert name is not None
prefix, _, encoded = name.partition("-")
assert prefix == "openswe"
assert encoded == encoded.lower()
assert "=" not in encoded and "-" not in encoded
# Round-trips back to the original UUID.
padded = encoded.upper() + "=" * (-len(encoded) % 8)
assert uuid.UUID(bytes=base64.b32decode(padded)) == uuid.UUID(thread_id)
def test_sandbox_name_for_thread_none_or_invalid() -> None:
assert _sandbox_name_for_thread(None) is None
assert _sandbox_name_for_thread("not-a-uuid") is None
def test_release_sandbox_name_deletes_stale_box() -> None:
client = MagicMock()
_release_sandbox_name(client, "openswe-abc")
client.delete_sandbox.assert_called_once_with("openswe-abc")
def test_release_sandbox_name_swallows_missing_and_skips_none() -> None:
client = MagicMock()
client.delete_sandbox.side_effect = RuntimeError("not found")
_release_sandbox_name(client, "openswe-abc") # must not raise
client.delete_sandbox.reset_mock(side_effect=True)
_release_sandbox_name(client, None)
client.delete_sandbox.assert_not_called()
def test_defaults_when_env_unset() -> None: def test_defaults_when_env_unset() -> None:
with patch.dict( with patch.dict(
"os.environ", "os.environ",

View file

@ -125,6 +125,35 @@ class TestConfigureGithubProxy:
headers = mock_client.patch.call_args.kwargs["headers"] headers = mock_client.patch.call_args.kwargs["headers"]
assert headers == {"X-API-Key": "my-api-key"} assert headers == {"X-API-Key": "my-api-key"}
def test_sandbox_overrides_take_precedence(self) -> None:
"""SANDBOX_LANGSMITH_* override the shared key/endpoint for the proxy call."""
with (
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
patch.dict(
"os.environ",
{
"LANGSMITH_API_KEY": "shared-key",
"LANGSMITH_ENDPOINT": "https://shared.smith.langchain.com",
"SANDBOX_LANGSMITH_API_KEY": "sandbox-key",
"SANDBOX_LANGSMITH_ENDPOINT": "https://sandbox.smith.langchain.com",
},
),
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_client.patch.return_value = mock_response
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
_configure_github_proxy("sandbox-abc", "token")
assert (
mock_client.patch.call_args.args[0]
== "https://sandbox.smith.langchain.com/v2/sandboxes/boxes/sandbox-abc"
)
assert mock_client.patch.call_args.kwargs["headers"] == {"X-API-Key": "sandbox-key"}
def test_retries_transient_http_error(self) -> None: def test_retries_transient_http_error(self) -> None:
"""Transient proxy API errors should be retried on the same sandbox.""" """Transient proxy API errors should be retried on the same sandbox."""
request = httpx.Request( request = httpx.Request(