From c4d9ae4a6706ff1a90cac0a0655c5aeb28f5d165 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" <215916821+open-swe[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 00:30:14 -0400 Subject: [PATCH] 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] --- CUSTOMIZATION.md | 2 + INSTALLATION.md | 6 ++ agent/integrations/langsmith.py | 86 +++++++++++++++++----- pyproject.toml | 2 +- tests/test_langsmith_sandbox_config.py | 99 ++++++++++++++++++++++++++ uv.lock | 8 +-- 6 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 tests/test_langsmith_sandbox_config.py diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index d21184e5..87e60390 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -38,6 +38,8 @@ DEFAULT_SANDBOX_SNAPSHOT_ID="" # 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 ` and rely on the LangSmith proxy for the real credentials. diff --git a/INSTALLATION.md b/INSTALLATION.md index 16d1f942..f761a129 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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 diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 1676001a..e3f02901 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 10d392b7..0bea0737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_langsmith_sandbox_config.py b/tests/test_langsmith_sandbox_config.py new file mode 100644 index 00000000..37113cb9 --- /dev/null +++ b/tests/test_langsmith_sandbox_config.py @@ -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() diff --git a/uv.lock b/uv.lock index 39253625..00f40d8f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },