Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
46 changes: 45 additions & 1 deletion nemo_gym/sandbox/providers/opensandbox/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/unit_tests/test_opensandbox_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading