diff --git a/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml b/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml index 708ceac31c..a437bea230 100644 --- a/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml +++ b/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml @@ -26,10 +26,22 @@ sandbox: protocol: http request_timeout_s: 300 use_server_proxy: true + # Must stay below the server's keep-alive idle timeout (uvicorn: ~5s), + # else pooled sockets are reused after the server has closed them. + keepalive_expiry_s: 3.0 + max_keepalive_connections: 20 + max_connections: 100 + connect_retries: 2 + # "aiohttp" routes through the optional httpx-aiohttp bridge, falling + # back to httpx with a warning when it is not installed. + transport_backend: httpx create: request_timeout_s: 1200 - timeout_s: 1200 - skip_health_check: true + # Must exceed the spec's ready_timeout_s: create includes the readiness wait. + timeout_s: 900 + # Skipping the check lets the first command race a pod whose exec daemon + # is not listening yet, which returns a 502 and kills the rollout. + skip_health_check: false retries: 10 retry_delay_s: 5.0 retry_max_delay_s: 90.0 @@ -42,5 +54,7 @@ sandbox: retries: 5 retry_delay_s: 1.0 retry_max_delay_s: 45.0 + # A retried command the server already started runs twice; agent commands + # are usually mutating. Raise only for idempotent workloads. command_retries: 0 close_timeout_s: 30 diff --git a/nemo_gym/sandbox/providers/opensandbox/provider.py b/nemo_gym/sandbox/providers/opensandbox/provider.py index 7d9925f7a2..445f317e38 100644 --- a/nemo_gym/sandbox/providers/opensandbox/provider.py +++ b/nemo_gym/sandbox/providers/opensandbox/provider.py @@ -343,13 +343,25 @@ def _to_sandbox_status(state: Any) -> SandboxStatus: @dataclass(frozen=True) class OpenSandboxConnectionConfig: - """OpenSandbox server connection settings.""" + """OpenSandbox server connection settings. + + ``keepalive_expiry_s`` must stay below the server's own keep-alive idle + timeout (uvicorn defaults to 5s), or pooled sockets are reused after the + server has closed them; null falls back to the SDK's default transport. + ``transport_backend`` is "httpx" or "aiohttp" (via the optional + ``httpx-aiohttp`` bridge, falling back to httpx when it is absent). + """ domain: str | None = None api_key: str | None = None protocol: str | None = None request_timeout_s: int | None = None use_server_proxy: bool = False + keepalive_expiry_s: float | None = 3.0 + max_keepalive_connections: int = 20 + max_connections: int = 100 + connect_retries: int = 2 + transport_backend: str = "httpx" @dataclass(frozen=True) @@ -442,6 +454,9 @@ class OpenSandboxProviderOptions: volumes: tuple[Mapping[str, Any], ...] = () skip_health_check: bool | None = None extensions: Mapping[str, str] = field(default_factory=dict) + # Scheduling requests (same keys as SandboxSpec.resources, which become the + # limits). Unset, the server applies the single resources map as both. + resource_requests: Mapping[str, Any] | None = None @classmethod def from_mapping(cls, options: Mapping[str, Any] | None) -> "OpenSandboxProviderOptions": @@ -476,6 +491,9 @@ def from_mapping(cls, options: Mapping[str, Any] | None) -> "OpenSandboxProvider extensions = options.get("extensions", {}) if not isinstance(extensions, Mapping): raise TypeError("OpenSandbox provider option 'extensions' must be a mapping") + resource_requests = options.get("resource_requests") + if resource_requests is not None and not isinstance(resource_requests, Mapping): + raise TypeError("OpenSandbox provider option 'resource_requests' must be a mapping") return cls( image_auth=dict(image_auth) if image_auth is not None else None, @@ -484,6 +502,7 @@ def from_mapping(cls, options: Mapping[str, Any] | None) -> "OpenSandboxProvider volumes=tuple(dict(volume) for volume in volumes), skip_health_check=skip_health_check, extensions=_string_map(dict(extensions)), + resource_requests=dict(resource_requests) if resource_requests is not None else None, ) @@ -539,8 +558,31 @@ def _connection_config( kwargs["request_timeout"] = timedelta(seconds=request_timeout_s) if self._connection.use_server_proxy: kwargs["use_server_proxy"] = True + if self._connection.keepalive_expiry_s is not None: + kwargs["transport"] = self._build_transport() return ConnectionConfig(**kwargs) + def _build_transport(self) -> Any: + """Build the SDK transport with the configured pool limits.""" + import httpx + + limits = httpx.Limits( + max_connections=self._connection.max_connections, + max_keepalive_connections=self._connection.max_keepalive_connections, + keepalive_expiry=self._connection.keepalive_expiry_s, + ) + if self._connection.transport_backend == "aiohttp": + try: + from httpx_aiohttp import AiohttpTransport + + return AiohttpTransport(limits=limits) + except ImportError: + LOGGER.warning( + "connection.transport_backend=aiohttp requested but httpx-aiohttp " + "is not installed; falling back to the httpx transport" + ) + return httpx.AsyncHTTPTransport(limits=limits, retries=self._connection.connect_retries) + async def aclose(self) -> None: """Close provider-owned resources.""" return None @@ -735,6 +777,8 @@ async def _create_once(self, spec: SandboxSpec) -> SandboxHandle: "extensions": self._resolve_extensions(options.extensions), "connection_config": self._connection_config(request_timeout_s=self._create.request_timeout_s), } + if options.resource_requests is not None: + kwargs["resource_requests"] = _resource_map(SandboxResources.from_mapping(options.resource_requests)) if spec.image is not None: kwargs["image"] = _to_image_spec(spec.image, options.image_auth) if options.snapshot_id is not None: diff --git a/pyproject.toml b/pyproject.toml index 5d116063a4..c61fcadcad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,9 +235,10 @@ sandbox = [ "tenacity>=9.1.4", # OpenSandbox SDK: used by the OpenSandbox sandbox provider for create/exec/delete and SDK pool creation. - # Updated: Sat May 16, 2026 with opensandbox>=0.1.9 + # Lower bound 0.1.15: first version exposing separate `resource_requests` on Sandbox.create. + # Updated: Thu Jul 30, 2026 with opensandbox>=0.1.15 # License: Apache 2.0 - "opensandbox>=0.1.9", + "opensandbox>=0.1.15", # OpenShell SDK: used by the OpenShell sandbox provider for gateway create/exec/delete over gRPC. # Lower bound 0.0.92: the version that made `workspace` a required argument on sandbox diff --git a/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml b/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml index 00d61d4489..f842c1a5f4 100644 --- a/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml +++ b/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml @@ -27,13 +27,19 @@ mini_swe_agent_2: sandbox_spec: ttl_s: 18000 ready_timeout_s: 1200 + # Limits (burst ceiling); memory-spiky test suites OOM-kill below this. resources: - cpu: 2 + cpu: 1 memory_mib: 8192 # 30 GiB works on every provider: within Fargate's 21-200 GiB ephemeral range # (an explicit 20 is rejected there) and fine as an ephemeral request elsewhere. disk_gib: 30 - provider_options: {} + provider_options: + # Scheduling requests, kept below the limits so sandboxes pack densely. + resource_requests: + cpu: 0.5 + memory_mib: 2048 + disk_gib: 30 metadata: benchmark: swebench-verified harness: mini-swe-agent diff --git a/tests/unit_tests/test_opensandbox_provider.py b/tests/unit_tests/test_opensandbox_provider.py index 1a472ac93f..fbeb07f842 100644 --- a/tests/unit_tests/test_opensandbox_provider.py +++ b/tests/unit_tests/test_opensandbox_provider.py @@ -15,12 +15,14 @@ import asyncio import builtins +import sys from dataclasses import dataclass from datetime import timedelta from pathlib import Path from types import SimpleNamespace from typing import Any +import httpx import pytest from nemo_gym.sandbox.providers.base import SandboxResources, SandboxSpec, SandboxStatus @@ -189,6 +191,38 @@ async def test_direct_create_passes_platform_to_sdk_create( ) +async def test_direct_create_passes_resource_requests_to_sdk_create( + fake_opensandbox_sdk: None, +) -> None: + provider = opensandbox_provider.OpenSandboxProvider(probe={"command": None}) + + await provider.create( + SandboxSpec( + image="mirror.gcr.io/astral/uv:python3.12-bookworm-slim", + resources={"cpu": 1, "memory_mib": 8192, "disk_gib": 30}, + provider_options={"resource_requests": {"cpu": 0.5, "memory_mib": 2048, "disk_gib": 30}}, + ), + ) + + assert FakeSandbox.created_kwargs["resource"] == {"cpu": "1", "memory": "8192Mi", "ephemeral-storage": "30Gi"} + assert FakeSandbox.created_kwargs["resource_requests"] == { + "cpu": "0.5", + "memory": "2048Mi", + "ephemeral-storage": "30Gi", + } + + with pytest.raises(TypeError, match="'resource_requests' must be a mapping"): + opensandbox_provider.OpenSandboxProviderOptions.from_mapping({"resource_requests": "big"}) + + with pytest.raises(ValueError, match="Unknown sandbox resource keys"): + await provider.create( + SandboxSpec( + image="mirror.gcr.io/astral/uv:python3.12-bookworm-slim", + provider_options={"resource_requests": {"memory_gib": 2}}, + ), + ) + + async def test_direct_create_passes_image_auth_to_sdk_create( fake_opensandbox_sdk: None, ) -> None: @@ -310,6 +344,8 @@ def test_connection_config_and_image_policy(fake_opensandbox_sdk: None) -> None: ) config = provider._connection_config() + transport = config.kwargs.pop("transport") + assert isinstance(transport, httpx.AsyncBaseTransport) assert config.kwargs == { "domain": "sandbox.example", "api_key": "key", # pragma: allowlist secret @@ -320,6 +356,48 @@ def test_connection_config_and_image_policy(fake_opensandbox_sdk: None) -> None: short_timeout_config = provider._connection_config(request_timeout_s=3) assert short_timeout_config.kwargs["request_timeout"] == timedelta(seconds=3) + +def test_connection_transport_backends(fake_opensandbox_sdk: None, monkeypatch: pytest.MonkeyPatch) -> None: + # Default backend is httpx, with the configured keepalive expiry on the pool. + provider = opensandbox_provider.OpenSandboxProvider() + transport = provider._build_transport() + assert isinstance(transport, httpx.AsyncHTTPTransport) + + # Custom pool settings still produce an httpx transport. + provider = opensandbox_provider.OpenSandboxProvider( + connection={ + "transport_backend": "httpx", + "keepalive_expiry_s": 2.5, + "max_connections": 7, + "max_keepalive_connections": 3, + "connect_retries": 1, + } + ) + transport = provider._build_transport() + assert isinstance(transport, httpx.AsyncHTTPTransport) + + # aiohttp requested but httpx-aiohttp unavailable: falls back to httpx. + with pytest.MonkeyPatch.context() as mp: + mp.setitem(sys.modules, "httpx_aiohttp", None) + provider = opensandbox_provider.OpenSandboxProvider(connection={"transport_backend": "aiohttp"}) + transport = provider._build_transport() + assert isinstance(transport, httpx.AsyncHTTPTransport) + + # keepalive_expiry_s=null disables transport injection entirely. + provider = opensandbox_provider.OpenSandboxProvider(connection={"keepalive_expiry_s": None}) + config = provider._connection_config() + assert "transport" not in config.kwargs + + +def test_connection_transport_backend_aiohttp_opt_in(fake_opensandbox_sdk: None) -> None: + # Opt-in aiohttp backend via the httpx-aiohttp bridge; the package is not a + # declared dependency, so this coverage only runs where it is installed. + httpx_aiohttp = pytest.importorskip("httpx_aiohttp", reason="optional httpx-aiohttp is not installed") + provider = opensandbox_provider.OpenSandboxProvider(connection={"transport_backend": "aiohttp"}) + transport = provider._build_transport() + assert isinstance(transport, httpx_aiohttp.AiohttpTransport) + assert transport.limits.keepalive_expiry == 3.0 + extensions = provider._resolve_extensions({"imagePullPolicy": "Never"}) assert extensions["imagePullPolicy"] == "Never" assert extensions["opensandbox.extensions.image-pull-policy"] == "Never" diff --git a/uv.lock b/uv.lock index f93e9c8bc2..8cce6c3779 100644 --- a/uv.lock +++ b/uv.lock @@ -1800,7 +1800,7 @@ requires-dist = [ { name = "nemo-gym", extras = ["dev", "sandbox"], marker = "extra == 'all'" }, { name = "omegaconf" }, { name = "openai", specifier = "<=2.7.2" }, - { name = "opensandbox", marker = "extra == 'sandbox'", specifier = ">=0.1.9" }, + { name = "opensandbox", marker = "extra == 'sandbox'", specifier = ">=0.1.15" }, { name = "openshell", marker = "extra == 'sandbox'", specifier = ">=0.0.92,<0.1" }, { name = "orjson" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6.0" }, @@ -2006,7 +2006,7 @@ wheels = [ [[package]] name = "opensandbox" -version = "0.1.9" +version = "0.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -2014,9 +2014,9 @@ dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/ab3cc141e041f71a373c97fcda8749dba9328f1b9bf80401378c0611556f/opensandbox-0.1.9.tar.gz", hash = "sha256:670fbf292c498f8467963d21e91ade9ea8b8f63f4ef18d18fff9581e0952ec03", size = 160034, upload-time = "2026-05-12T12:27:20.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/21/654a3d69815b09690e926d553f3f4a178640d1206000a1b49f5e22c8eb68/opensandbox-0.1.15.tar.gz", hash = "sha256:017abc9b399b88da51bf077d6fb94ee89b1783e601495e85ba85fed478fed1b0", size = 228729, upload-time = "2026-07-24T09:36:29.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/9b/553f8d7a30eddb12785711b2a1c682386878e2bb95450acd806f9fa62930/opensandbox-0.1.9-py3-none-any.whl", hash = "sha256:17faed35b60a982fee5a643fed8e4e12f041e5432d5ea0665d2828d1f2082759", size = 360945, upload-time = "2026-05-12T12:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5c/ab87ea696531210790feb8f575471036fccba29dedaff201736f15bbb3a7/opensandbox-0.1.15-py3-none-any.whl", hash = "sha256:992b01490551f4d8e3f99caa25e34cb9d1690f0c5027eeebab912738291957d1", size = 538522, upload-time = "2026-07-24T09:36:28.277Z" }, ] [[package]]