mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
275 lines
9 KiB
Python
275 lines
9 KiB
Python
"""LangSmith sandbox backend integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from deepagents.backends import LangSmithSandbox
|
|
from deepagents.backends.protocol import SandboxBackendProtocol
|
|
from langsmith.sandbox import SandboxClient, SandboxTemplate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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")
|
|
|
|
|
|
def _get_sandbox_template_config() -> tuple[str | None, str | None]:
|
|
"""Get sandbox template configuration from environment."""
|
|
template_name = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_NAME")
|
|
template_image = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_IMAGE")
|
|
return template_name, template_image
|
|
|
|
|
|
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
|
"""Configure sandbox proxy to inject GitHub auth for all github.com requests.
|
|
|
|
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.
|
|
"""
|
|
api_key = _get_langsmith_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")
|
|
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
|
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
|
|
payload = {
|
|
"proxy_config": {
|
|
"rules": [
|
|
{
|
|
"name": "github",
|
|
"match_hosts": ["github.com", "*.github.com"],
|
|
"headers": [
|
|
{
|
|
"name": "Authorization",
|
|
"type": "opaque",
|
|
"value": f"Basic {basic_auth}",
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
with httpx.Client() as client:
|
|
response = client.patch(
|
|
url,
|
|
json=payload,
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
response.raise_for_status()
|
|
logger.info("Configured GitHub proxy for sandbox %s", sandbox_name)
|
|
|
|
|
|
def create_langsmith_sandbox(
|
|
sandbox_id: str | None = None,
|
|
github_token: str | None = None,
|
|
) -> SandboxBackendProtocol:
|
|
"""Create or connect to a LangSmith sandbox without automatic cleanup.
|
|
|
|
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.
|
|
|
|
Returns:
|
|
SandboxBackendProtocol instance
|
|
"""
|
|
api_key = _get_langsmith_api_key()
|
|
template_name, template_image = _get_sandbox_template_config()
|
|
|
|
provider = LangSmithProvider(api_key=api_key)
|
|
backend = provider.get_or_create(
|
|
sandbox_id=sandbox_id,
|
|
template=template_name,
|
|
template_image=template_image,
|
|
)
|
|
_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.config import get_config
|
|
from langgraph_sdk import get_client
|
|
|
|
config = get_config()
|
|
thread_id = config.get("configurable", {}).get("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
|
|
|
|
|
|
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
|
|
|
|
|
|
DEFAULT_TEMPLATE_NAME = "open-swe-new"
|
|
DEFAULT_TEMPLATE_IMAGE = "python:3"
|
|
|
|
|
|
class LangSmithProvider(SandboxProvider):
|
|
"""LangSmith sandbox provider implementation."""
|
|
|
|
def __init__(self, api_key: str | None = None) -> None:
|
|
from langsmith import sandbox
|
|
|
|
self._api_key = api_key or _get_langsmith_api_key()
|
|
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)
|
|
|
|
def get_or_create(
|
|
self,
|
|
*,
|
|
sandbox_id: str | None = None,
|
|
timeout: int = 180,
|
|
template: str | None = None,
|
|
template_image: str | 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
|
|
return LangSmithSandbox(sandbox)
|
|
|
|
resolved_template_name, resolved_image_name = self._resolve_template(
|
|
template, template_image
|
|
)
|
|
|
|
self._ensure_template(resolved_template_name, resolved_image_name)
|
|
|
|
try:
|
|
sandbox = self._client.create_sandbox(
|
|
template_name=resolved_template_name, timeout=timeout
|
|
)
|
|
except Exception as e:
|
|
msg = f"Failed to create sandbox from template '{resolved_template_name}': {e}"
|
|
raise RuntimeError(msg) from e
|
|
|
|
for _ in range(timeout // 2):
|
|
try:
|
|
result = sandbox.run("echo ready", timeout=5)
|
|
if result.exit_code == 0:
|
|
break
|
|
except Exception:
|
|
pass
|
|
time.sleep(2)
|
|
else:
|
|
with contextlib.suppress(Exception):
|
|
self._client.delete_sandbox(sandbox.name)
|
|
msg = f"LangSmith sandbox failed to start within {timeout} seconds"
|
|
raise RuntimeError(msg)
|
|
|
|
return LangSmithSandbox(sandbox)
|
|
|
|
def delete(self, *, sandbox_id: str, **kwargs: Any) -> None:
|
|
"""Delete a LangSmith sandbox."""
|
|
self._client.delete_sandbox(sandbox_id)
|
|
|
|
@staticmethod
|
|
def _resolve_template(
|
|
template: SandboxTemplate | str | None,
|
|
template_image: str | None = None,
|
|
) -> tuple[str, str]:
|
|
"""Resolve template name and image from kwargs."""
|
|
resolved_image = template_image or DEFAULT_TEMPLATE_IMAGE
|
|
if template is None:
|
|
return DEFAULT_TEMPLATE_NAME, resolved_image
|
|
if isinstance(template, str):
|
|
return template, resolved_image
|
|
if template_image is None and template.image:
|
|
resolved_image = template.image
|
|
return template.name, resolved_image
|
|
|
|
def _ensure_template(
|
|
self,
|
|
template_name: str,
|
|
template_image: str,
|
|
) -> None:
|
|
"""Ensure template exists, creating it if needed."""
|
|
from langsmith.sandbox import ResourceNotFoundError
|
|
|
|
try:
|
|
self._client.get_template(template_name)
|
|
except ResourceNotFoundError as e:
|
|
if e.resource_type != "template":
|
|
msg = f"Unexpected resource not found: {e}"
|
|
raise RuntimeError(msg) from e
|
|
try:
|
|
self._client.create_template(name=template_name, image=template_image)
|
|
except Exception as create_err:
|
|
msg = f"Failed to create template '{template_name}': {create_err}"
|
|
raise RuntimeError(msg) from create_err
|
|
except Exception as e:
|
|
msg = f"Failed to check template '{template_name}': {e}"
|
|
raise RuntimeError(msg) from e
|