mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
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:
parent
bdea1fb6da
commit
c4d9ae4a67
6 changed files with 182 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
99
tests/test_langsmith_sandbox_config.py
Normal file
99
tests/test_langsmith_sandbox_config.py
Normal 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
8
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue