mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 04:33:12 +00:00
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:
parent
6d34c42578
commit
a2794118ff
5 changed files with 180 additions and 12 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue