feat: add idle TTL and delete-after-stop sandbox lifecycle controls (#1265)

* feat: add idle TTL and delete-after-stop sandbox lifecycle controls

* bump langsmith>=0.8.3, lower default idle TTL to 10 min

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
open-swe[bot] 2026-05-08 00:30:14 -04:00 • committed by GitHub
parent bdea1fb6da
commit c4d9ae4a67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 182 additions and 21 deletions

View file

@ -38,6 +38,8 @@ DEFAULT_SANDBOX_SNAPSHOT_ID="<snapshot-uuid>" # Required
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="34359738368" # Optional, default 32 GiB
DEFAULT_SANDBOX_VCPUS="4" # Optional, default 4
DEFAULT_SANDBOX_MEM_BYTES="16106127360" # Optional, default 15 GiB
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="600" # Optional, default 600 (10 min); 0 disables
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400" # Optional, default 86400 (24 h); 0 disables
```
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run. The default snapshot includes the GitHub CLI; agents invoke it as `GH_TOKEN=dummy gh <command>` and rely on the LangSmith proxy for the real credentials.

View file

@ -186,6 +186,10 @@ DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="34359738368"
DEFAULT_SANDBOX_VCPUS="4"
# Optional; memory in bytes per sandbox. Default is 15 GiB.
DEFAULT_SANDBOX_MEM_BYTES="16106127360"
# Optional; auto-stop a sandbox after this many seconds of inactivity. Default is 600 (10 min). 0 disables.
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="600"
# Optional; delete a stopped sandbox after this many seconds. Default is 86400 (24 hours). 0 disables.
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400"
```
`DEFAULT_SANDBOX_SNAPSHOT_ID` is required when `SANDBOX_TYPE=langsmith`. The server validates this at startup and refuses to boot if it's missing. The snapshot should include the GitHub CLI from the project Dockerfile; Open SWE authenticates `git` and `gh` through the LangSmith sandbox proxy using runtime-minted GitHub App installation tokens, not deployment-stored GitHub access tokens.
@ -400,6 +404,8 @@ DEFAULT_SANDBOX_SNAPSHOT_ID="" # Required when SANDBOX_TYPE=langsmith (s
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="" # Root FS size in bytes (default: 32 GiB)
DEFAULT_SANDBOX_VCPUS="" # vCPUs per sandbox (default: 4)
DEFAULT_SANDBOX_MEM_BYTES="" # Memory in bytes per sandbox (default: 15 GiB)
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="" # Auto-stop after N seconds idle (default: 600; 0 disables)
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="" # Delete N seconds after stop (default: 86400; 0 disables)
# === Token Encryption ===
TOKEN_ENCRYPTION_KEY="" # Generate with: openssl rand -base64 32

View file

@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES = 32 * 1024**3
DEFAULT_SANDBOX_VCPUS = 2
DEFAULT_SANDBOX_MEM_BYTES = 7936 * 1024**2 # 7936 MiB ("large" tier cap)
DEFAULT_SANDBOX_IDLE_TTL_SECONDS = 10 * 60 # 10 minutes
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS = 24 * 60 * 60 # 24 hours
def _get_langsmith_api_key() -> str | None:
@ -29,16 +31,40 @@ 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_snapshot_config() -> tuple[str | None, int, int, int]:
def _parse_optional_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if not raw:
return default
try:
return int(raw)
except ValueError as e:
msg = f"{name} must be an integer, got {raw!r}"
raise ValueError(msg) from e
def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int, int, int]:
"""Get sandbox snapshot configuration from environment."""
snapshot_id = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_ID")
raw_capacity = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES")
fs_capacity_bytes = int(raw_capacity) if raw_capacity else DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
raw_vcpus = os.environ.get("DEFAULT_SANDBOX_VCPUS")
vcpus = int(raw_vcpus) if raw_vcpus else DEFAULT_SANDBOX_VCPUS
raw_mem = os.environ.get("DEFAULT_SANDBOX_MEM_BYTES")
mem_bytes = int(raw_mem) if raw_mem else DEFAULT_SANDBOX_MEM_BYTES
return snapshot_id, fs_capacity_bytes, vcpus, mem_bytes
fs_capacity_bytes = _parse_optional_int(
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES", DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
)
vcpus = _parse_optional_int("DEFAULT_SANDBOX_VCPUS", DEFAULT_SANDBOX_VCPUS)
mem_bytes = _parse_optional_int("DEFAULT_SANDBOX_MEM_BYTES", DEFAULT_SANDBOX_MEM_BYTES)
idle_ttl_seconds = _parse_optional_int(
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS", DEFAULT_SANDBOX_IDLE_TTL_SECONDS
)
delete_after_stop_seconds = _parse_optional_int(
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS,
)
return (
snapshot_id,
fs_capacity_bytes,
vcpus,
mem_bytes,
idle_ttl_seconds,
delete_after_stop_seconds,
)
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
@ -117,7 +143,14 @@ def create_langsmith_sandbox(
SandboxBackendProtocol instance
"""
api_key = _get_langsmith_api_key()
snapshot_id, fs_capacity_bytes, vcpus, mem_bytes = _get_sandbox_snapshot_config()
(
snapshot_id,
fs_capacity_bytes,
vcpus,
mem_bytes,
idle_ttl_seconds,
delete_after_stop_seconds,
) = _get_sandbox_snapshot_config()
provider = LangSmithProvider(api_key=api_key)
backend = provider.get_or_create(
@ -126,6 +159,8 @@ def create_langsmith_sandbox(
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
idle_ttl_seconds=idle_ttl_seconds,
delete_after_stop_seconds=delete_after_stop_seconds,
)
_update_thread_sandbox_metadata(backend.id)
@ -207,16 +242,31 @@ class LangSmithProvider(SandboxProvider):
if not os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_ID"):
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
raise ValueError(msg)
raw_capacity = os.environ.get("DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES")
if raw_capacity:
for name in (
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES",
"DEFAULT_SANDBOX_VCPUS",
"DEFAULT_SANDBOX_MEM_BYTES",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
):
raw = os.environ.get(name)
if raw is None or raw == "":
continue
try:
int(raw_capacity)
value = int(raw)
except ValueError as e:
msg = (
"DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES must be an integer, "
f"got {raw_capacity!r}"
)
msg = f"{name} must be an integer, got {raw!r}"
raise ValueError(msg) from e
if (
name
in {
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS",
}
and value < 0
):
msg = f"{name} must be >= 0, got {value}"
raise ValueError(msg)
def get_or_create(
self,
@ -227,6 +277,8 @@ class LangSmithProvider(SandboxProvider):
fs_capacity_bytes: int | None = None,
vcpus: int | None = None,
mem_bytes: int | None = None,
idle_ttl_seconds: int | None = None,
delete_after_stop_seconds: int | None = None,
**kwargs: Any,
) -> SandboxBackendProtocol:
"""Get existing or create new LangSmith sandbox."""
@ -251,6 +303,8 @@ class LangSmithProvider(SandboxProvider):
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,
idle_ttl_seconds=idle_ttl_seconds,
delete_after_stop_seconds=delete_after_stop_seconds,
timeout=timeout,
)
except Exception as e:

View file

@ -18,7 +18,7 @@ dependencies = [
"markdownify>=1.2.2",
"langchain-anthropic>=1.4.2",
"langgraph-cli[inmem]>=0.4.24",
"langsmith>=0.8.0",
"langsmith>=0.8.3",
"langchain-openai>=1.2.1",
"langchain-daytona>=0.0.5",
"langchain-modal>=0.0.3",

View file

@ -0,0 +1,99 @@
"""Tests for LangSmith sandbox env-var configuration parsing."""
from unittest.mock import patch
import pytest
from agent.integrations.langsmith import (
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS,
DEFAULT_SANDBOX_IDLE_TTL_SECONDS,
DEFAULT_SANDBOX_MEM_BYTES,
DEFAULT_SANDBOX_VCPUS,
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
LangSmithProvider,
_get_sandbox_snapshot_config,
)
def test_defaults_when_env_unset() -> None:
with patch.dict(
"os.environ",
{"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-1"},
clear=True,
):
snapshot_id, fs, vcpus, mem, idle, delete_after = _get_sandbox_snapshot_config()
assert snapshot_id == "snap-1"
assert fs == DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
assert vcpus == DEFAULT_SANDBOX_VCPUS
assert mem == DEFAULT_SANDBOX_MEM_BYTES
assert idle == DEFAULT_SANDBOX_IDLE_TTL_SECONDS
assert delete_after == DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS
def test_overrides_from_env() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-2",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "120",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "3600",
},
clear=True,
):
_, _, _, _, idle, delete_after = _get_sandbox_snapshot_config()
assert idle == 120
assert delete_after == 3600
def test_zero_disables_ttls() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-3",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "0",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "0",
},
clear=True,
):
_, _, _, _, idle, delete_after = _get_sandbox_snapshot_config()
assert idle == 0
assert delete_after == 0
def test_validate_startup_rejects_non_integer_ttl() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-4",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "not-a-number",
},
clear=True,
):
with pytest.raises(ValueError, match="DEFAULT_SANDBOX_IDLE_TTL_SECONDS"):
LangSmithProvider.validate_startup_config()
def test_validate_startup_rejects_negative_ttl() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-5",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "-1",
},
clear=True,
):
with pytest.raises(ValueError, match=">= 0"):
LangSmithProvider.validate_startup_config()
def test_validate_startup_accepts_valid_config() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-6",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "1800",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "86400",
},
clear=True,
):
LangSmithProvider.validate_startup_config()

8
uv.lock generated
View file

@ -1577,7 +1577,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.8.0"
version = "0.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@ -1590,9 +1590,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/1e8ea5e8bab2a65fa95bd36229ef38e8723ec46e430e20ca2d953487a7f1/langsmith-0.8.3.tar.gz", hash = "sha256:767ff7a8d136ed42926bf99059ac631dc6883542d6e3104b32e71c7625e1fa05", size = 4460330, upload-time = "2026-05-07T19:56:56.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
{ url = "https://files.pythonhosted.org/packages/98/a9/51e644c1f1dbc3dd7d22dfd6412eab206d538c81e024e4f287373544bdcb/langsmith-0.8.3-py3-none-any.whl", hash = "sha256:b2e40e308222fa0beb2dccee3b4b30bfee9062d7a4f20a3e3e93df3c51a08ab4", size = 399048, upload-time = "2026-05-07T19:56:53.994Z" },
]
[package.optional-dependencies]
@ -1889,7 +1889,7 @@ requires-dist = [
{ name = "langgraph", specifier = ">=1.1.10" },
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.24" },
{ name = "langgraph-sdk", specifier = ">=0.3.13" },
{ name = "langsmith", specifier = ">=0.8.0" },
{ name = "langsmith", specifier = ">=0.8.3" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
{ name = "pyjwt", specifier = ">=2.12.1" },