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 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:
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(
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:
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
dockerfile_path = Path(context_dir) / "Dockerfile"

View file

@ -8,6 +8,7 @@ import json
import logging
import os
import time
import uuid
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
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")
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:
@ -172,6 +236,22 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
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:
"""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.
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:
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
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}"
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
@ -249,7 +329,7 @@ def create_langsmith_sandbox(
Returns:
SandboxBackendProtocol instance
"""
api_key = _get_langsmith_api_key()
api_key = _get_sandbox_api_key()
(
default_snapshot_id,
fs_capacity_bytes,
@ -265,6 +345,7 @@ def create_langsmith_sandbox(
backend = provider.get_or_create(
sandbox_id=sandbox_id,
snapshot_id=effective_snapshot_id,
name=_sandbox_name_for_thread(_current_thread_id()),
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
@ -284,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
try:
import asyncio
from langgraph.config import get_config
from langgraph_sdk import get_client
config = get_config()
thread_id = config.get("configurable", {}).get("thread_id")
thread_id = _current_thread_id()
if not thread_id:
return
client = get_client()
@ -454,11 +533,14 @@ class LangSmithProvider(SandboxProvider):
def __init__(self, api_key: str | None = None) -> None:
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:
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
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
def validate_startup_config(cls) -> None:
@ -499,6 +581,7 @@ class LangSmithProvider(SandboxProvider):
sandbox_id: str | None = None,
timeout: int = 180,
snapshot_id: str | None = None,
name: str | None = None,
fs_capacity_bytes: int | None = None,
vcpus: int | None = None,
mem_bytes: int | None = None,
@ -523,10 +606,12 @@ class LangSmithProvider(SandboxProvider):
raise ValueError(msg)
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
_release_sandbox_name(self._client, name)
try:
sandbox = self._client.create_sandbox(
snapshot_id=snapshot_id,
name=name,
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
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.
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
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."""
from unittest.mock import patch
import base64
import uuid
from unittest.mock import MagicMock, patch
import pytest
@ -11,12 +13,62 @@ from agent.integrations.langsmith import (
DEFAULT_SANDBOX_VCPUS,
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
LangSmithProvider,
_get_sandbox_api_endpoint,
_get_sandbox_create_extra_fields,
_get_sandbox_snapshot_config,
_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:
with patch.dict(
"os.environ",

View file

@ -125,6 +125,35 @@ class TestConfigureGithubProxy:
headers = mock_client.patch.call_args.kwargs["headers"]
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:
"""Transient proxy API errors should be retried on the same sandbox."""
request = httpx.Request(