refactor: move LangSmithProvider into open-swe, drop deepagents-cli dependency

This commit is contained in:
aran-yogesh 2026-02-10 13:35:50 -08:00
parent 8c9bc67335
commit 16aafaf7ad
8 changed files with 235 additions and 110 deletions

View file

@ -0,0 +1,5 @@
"""Sandbox provider integrations."""
from agent.integrations.langsmith import LangSmithBackend, LangSmithProvider
__all__ = ["LangSmithBackend", "LangSmithProvider"]

View file

@ -0,0 +1,216 @@
"""LangSmith sandbox backend implementation.
Copied from deepagents-cli to avoid requiring deepagents-cli as a dependency.
"""
from __future__ import annotations
import contextlib
import os
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from deepagents.backends.protocol import (
ExecuteResponse,
FileDownloadResponse,
FileUploadResponse,
SandboxBackendProtocol,
)
from deepagents.backends.sandbox import BaseSandbox
if TYPE_CHECKING:
from langsmith.sandbox import Sandbox, SandboxClient, SandboxTemplate
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 configuration
DEFAULT_TEMPLATE_NAME = "deepagents-cli"
DEFAULT_TEMPLATE_IMAGE = "python:3"
class LangSmithBackend(BaseSandbox):
"""LangSmith backend implementation conforming to SandboxBackendProtocol.
This implementation inherits all file operation methods from BaseSandbox
and only implements the execute() method using LangSmith's API.
"""
def __init__(self, sandbox: Sandbox) -> None:
self._sandbox = sandbox
self._timeout: int = 30 * 60 # 30 mins default
@property
def id(self) -> str:
"""Unique identifier for the sandbox backend."""
return self._sandbox.name
def execute(self, command: str) -> ExecuteResponse:
"""Execute a command in the sandbox and return ExecuteResponse."""
result = self._sandbox.run(command, timeout=self._timeout)
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,
)
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
"""Download multiple files from the LangSmith sandbox."""
responses: list[FileDownloadResponse] = []
for path in paths:
content = self._sandbox.read(path)
responses.append(
FileDownloadResponse(path=path, content=content, error=None)
)
return responses
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
"""Upload multiple files to the LangSmith sandbox."""
responses: list[FileUploadResponse] = []
for path, content in files:
self._sandbox.write(path, content)
responses.append(FileUploadResponse(path=path, error=None))
return responses
class LangSmithProvider(SandboxProvider):
"""LangSmith sandbox provider implementation.
Manages LangSmith sandbox lifecycle using the LangSmith SDK.
"""
def __init__(self, api_key: str | None = None) -> None:
from langsmith import sandbox
self._api_key = api_key or os.environ.get("LANGSMITH_API_KEY")
if not self._api_key:
msg = "LANGSMITH_API_KEY environment variable 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 LangSmithBackend(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 "
f"'{resolved_template_name}': {e}"
)
raise RuntimeError(msg) from e
# Verify sandbox is ready by polling
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 LangSmithBackend(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
# SandboxTemplate object
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

View file

@ -1,96 +0,0 @@
"""Protocol definitions for sandbox backends.
This module defines the SandboxBackendProtocol that sandbox implementations
must follow. Copied from deepagents to reduce coupling.
"""
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass
class ExecuteResponse:
"""Result of code execution.
Simplified schema optimized for LLM consumption.
"""
output: str
"""Combined stdout and stderr output of the executed command."""
exit_code: int | None = None
"""The process exit code. 0 indicates success, non-zero indicates failure."""
truncated: bool = False
"""Whether the output was truncated due to backend limitations."""
@runtime_checkable
class SandboxBackendProtocol(Protocol):
"""Protocol for sandbox backends that support shell command execution.
Designed for backends running in isolated environments (containers, VMs,
remote hosts).
This is a minimal protocol for type checking - actual implementations
come from deepagents.
"""
@property
def id(self) -> str:
"""Unique identifier for the sandbox backend instance."""
...
def execute(self, command: str) -> ExecuteResponse:
"""Execute a command in the sandbox.
Args:
command: Full shell command string to execute.
Returns:
ExecuteResponse with combined output, exit code, and truncation flag.
"""
...
async def aexecute(self, command: str) -> ExecuteResponse:
"""Async version of execute."""
...
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
"""Read file content with line numbers."""
...
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
"""Async version of read."""
...
def write(self, file_path: str, content: str) -> "WriteResult":
"""Write content to a new file."""
...
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> "EditResult":
"""Perform exact string replacements in an existing file."""
...
@dataclass
class WriteResult:
"""Result from backend write operations."""
error: str | None = None
path: str | None = None
@dataclass
class EditResult:
"""Result from backend edit operations."""
error: str | None = None
path: str | None = None
occurrences: int | None = None

View file

@ -34,7 +34,7 @@ from .middleware import (
post_to_linear_after_model,
)
from .prompt import construct_system_prompt
from .protocol import SandboxBackendProtocol
from deepagents.backends.protocol import SandboxBackendProtocol
from .tools import commit_and_open_pr, fetch_url, http_request
@ -75,7 +75,7 @@ def _create_langsmith_sandbox(
Returns:
SandboxBackendProtocol instance
"""
from deepagents_cli.integrations.langsmith import LangSmithProvider
from .integrations.langsmith import LangSmithProvider
api_key = _get_langsmith_api_key()
template_name, template_image = _get_sandbox_template_config()

View file

@ -4,7 +4,7 @@
"graphs": {
"agent": "agent.server:get_agent"
},
"dependencies": ["."],
"dependencies": [".", "../../../deepagents/libs/deepagents"],
"http": {
"app": "agent.webapp:app"
},

View file

@ -7,7 +7,7 @@ requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
# Core deepagents library
"deepagents>=0.3.12",
"deepagents>=0.4.0",
# FastAPI for webhook handling
"fastapi>=0.104.0",
"uvicorn>=0.24.0",

20
apps/agent/uv.lock generated
View file

@ -346,7 +346,7 @@ wheels = [
[[package]]
name = "deepagents"
version = "0.3.12"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain" },
@ -355,9 +355,9 @@ dependencies = [
{ name = "langchain-google-genai" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/0b/9d3512327d48e619567797dffb34c356b2e0c7b0aa505fd3aaef342903d3/deepagents-0.3.12.tar.gz", hash = "sha256:ab2d7e7c47040d364a20cc19cc775294c1e942456652d6c12e0f21011068633c", size = 77962, upload-time = "2026-02-06T21:20:43.511Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/ac/895c5efe77ee64f38af64146509b97220867fc149a9a376b2c74f266fd45/deepagents-0.4.0.tar.gz", hash = "sha256:ccfbb2394d2c50a3cf6f61457c5c1d3868354beee2177a57b8497c72c38f8e9b", size = 77614, upload-time = "2026-02-10T16:31:16.99Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/0a/2b8542a19bb22cf49827a38d04e045c6dba97d03dddc721d3ed2e7be0e5e/deepagents-0.3.12-py3-none-any.whl", hash = "sha256:42e707a1be48db3bc445fbe3243b6dc19333565cac4eab8cdc0e37d780c6cfe7", size = 88553, upload-time = "2026-02-06T21:20:42.575Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c8/cbedac42e011889f151047cf22dbecef9a38330c2e90e517c2bd1b1e636d/deepagents-0.4.0-py3-none-any.whl", hash = "sha256:475af99429c7b6abe4c53d476d6b48f01960dc1bcaec7fa21093189acdfc2864", size = 87831, upload-time = "2026-02-10T16:31:15.596Z" },
]
[[package]]
@ -782,16 +782,16 @@ wheels = [
[[package]]
name = "langchain"
version = "1.2.9"
version = "1.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "langgraph" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/d5/e7c8d18bf1ee2d37839dde161d523049fd0a5b172cf4c62f17090e1b4dcb/langchain-1.2.9.tar.gz", hash = "sha256:ae266c640b63c38f16b6d996a50aea575940b29b63cbc652c5d12f0111357f01", size = 569621, upload-time = "2026-02-06T12:39:41.824Z" }
sdist = { url = "https://files.pythonhosted.org/packages/16/22/a4d4ac98fc2e393537130bbfba0d71a8113e6f884d96f935923e247397fe/langchain-1.2.10.tar.gz", hash = "sha256:bdcd7218d9c79a413cf15e106e4eb94408ac0963df9333ccd095b9ed43bf3be7", size = 570071, upload-time = "2026-02-10T14:56:49.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/d9/ee07b79f8f1cfd87a6b147879149bdb03c04656e83e5a8c97f38d8915d07/langchain-1.2.9-py3-none-any.whl", hash = "sha256:c1af39d22b7f0415a6f8fa63b37f692335601d3333592c481b899166c55f3fcb", size = 111240, upload-time = "2026-02-06T12:39:39.833Z" },
{ url = "https://files.pythonhosted.org/packages/7c/06/c3394327f815fade875724c0f6cff529777c96a1e17fea066deb997f8cf5/langchain-1.2.10-py3-none-any.whl", hash = "sha256:e07a377204451fffaed88276b8193e894893b1003e25c5bca6539288ccca3698", size = 111738, upload-time = "2026-02-10T14:56:47.985Z" },
]
[[package]]
@ -810,7 +810,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.9"
version = "1.2.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@ -822,9 +822,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/85/f501592b5d76b27a198f1102bafe365151a0a6f69444122fad6d10e6f4bf/langchain_core-1.2.9.tar.gz", hash = "sha256:a3768febc762307241d153b0f8bc58fd4b70c0ff077fda3274606741fca3f5a7", size = 815900, upload-time = "2026-02-05T14:21:43.942Z" }
sdist = { url = "https://files.pythonhosted.org/packages/12/17/1943cedfc118e04b8128e4c3e1dbf0fa0ea58eefddbb6198cfd699d19f01/langchain_core-1.2.11.tar.gz", hash = "sha256:f164bb36602dd74a3a50c1334fca75309ad5ed95767acdfdbb9fa95ce28a1e01", size = 831211, upload-time = "2026-02-10T20:35:28.35Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/46/77846a98913e444d0d564070a9056bd999daada52bd099dc1e8812272810/langchain_core-1.2.9-py3-none-any.whl", hash = "sha256:7e5ecba5ed7a65852e8d5288e9ceeba05340fa9baf32baf672818b497bbaea8f", size = 496296, upload-time = "2026-02-05T14:21:42.816Z" },
{ url = "https://files.pythonhosted.org/packages/10/30/1f80e3fc674353cad975ed5294353d42512535d2094ef032c06454c2c873/langchain_core-1.2.11-py3-none-any.whl", hash = "sha256:ae11ceb8dda60d0b9d09e763116e592f1683327c17be5b715f350fd29aee65d3", size = 500062, upload-time = "2026-02-10T20:35:26.698Z" },
]
[[package]]
@ -1043,7 +1043,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "deepagents", specifier = ">=0.3.12" },
{ name = "deepagents", specifier = ">=0.4.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "langchain", specifier = ">=1.2.9" },