Skip to content
Draft
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
19 changes: 17 additions & 2 deletions nemo_gym/config_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ class AgentServerRef(BaseModel):
name: str


ServerRef = Union[ModelServerRef, ResourcesServerRef, AgentServerRef]
class SandboxServerRef(BaseModel):
type: Literal["sandbox_servers"]
name: str


ServerRef = Union[ModelServerRef, ResourcesServerRef, AgentServerRef, SandboxServerRef]
ServerRefTypeAdapter = TypeAdapter(ServerRef)


Expand Down Expand Up @@ -595,6 +600,7 @@ class BaseServerTypeConfig(BaseModel):
Literal["responses_api_models"],
Literal["resources_servers"],
Literal["responses_api_agents"],
Literal["sandbox_servers"],
]
]

Expand Down Expand Up @@ -623,10 +629,19 @@ class ResponsesAPIAgentServerTypeConfig(BaseServerTypeConfig):
responses_api_agents: Dict[str, BaseRunServerTypeConfig] = Field(min_length=1, max_length=1)


class SandboxServerTypeConfig(BaseServerTypeConfig):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should there be a SandboxServerInstanceConfig so that gym env start / validate passes?

I think GlobalConfigDictParser.filter_for_server_instance_configs() might fail

SERVER_TYPE: ClassVar[Literal["sandbox_servers"]] = "sandbox_servers"

model_config = ConfigDict(extra="allow")

sandbox_servers: Dict[str, BaseRunServerTypeConfig] = Field(min_length=1, max_length=1)


ServerTypeConfig = Union[
ResponsesAPIModelServerTypeConfig,
ResourcesServerTypeConfig,
ResponsesAPIAgentServerTypeConfig,
SandboxServerTypeConfig,
]


Expand Down Expand Up @@ -709,7 +724,7 @@ def is_almost_server(server_type_config_dict: Any) -> bool:
return False

# Check for server type.
server_type_keys = ["responses_api_models", "resources_servers", "responses_api_agents"]
server_type_keys = ["responses_api_models", "resources_servers", "responses_api_agents", "sandbox_servers"]
has_server_type = any(key in server_type_config_dict for key in server_type_keys)

if not has_server_type:
Expand Down
6 changes: 6 additions & 0 deletions nemo_gym/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from nemo_gym.sandbox.api import AsyncSandbox, Sandbox
from nemo_gym.sandbox.config import resolve_provider_config, resolve_provider_metadata
from nemo_gym.sandbox.providers import (
ConnectableProvider,
ExecResult,
SandboxCreateError,
SandboxCreateVerificationError,
Expand All @@ -31,12 +32,17 @@
list_providers,
register_provider,
)
from nemo_gym.sandbox.ref import SCOPE_OPERATE, SCOPE_OWNER, SandboxRef
from nemo_gym.sandbox.utils import rewrite_image


__all__ = [
"Sandbox",
"AsyncSandbox",
"ConnectableProvider",
"SandboxRef",
"SCOPE_OWNER",
"SCOPE_OPERATE",
"ExecResult",
"SandboxCreateError",
"SandboxCreateVerificationError",
Expand Down
36 changes: 36 additions & 0 deletions nemo_gym/sandbox/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from typing import Any, TypeVar

from nemo_gym.sandbox.providers import (
ConnectableProvider,
SandboxExecResult,
SandboxHandle,
SandboxProvider,
Expand Down Expand Up @@ -131,6 +132,41 @@ async def stop(self) -> None:
await self._provider.aclose()
self._closed = True

async def serialize(self, *, scope: str | None = None) -> dict[str, Any]:
"""Return a JSON descriptor another process can rebuild this box from.

Requires a provider that supports the connect capability (the remote
provider, or an external-control-plane provider such as OpenSandbox). For
the remote provider, ``scope`` mints a co-lease (``scope="operate"``).
"""
provider = self._provider
if not isinstance(provider, ConnectableProvider):
name = getattr(provider, "name", type(provider).__name__)
raise RuntimeError(f"provider {name!r} does not support serialize()/connect()")
descriptor = await provider.serialize_handle(self._require_handle(), scope=scope)
# Carry the working directory so a reattached sandbox lands in the same
# place, even for providers whose descriptor does not include it (the
# remote provider's SandboxRef already has it; e.g. OpenSandbox does not).
if isinstance(descriptor, dict) and descriptor.get("workdir") is None and self._spec is not None:
descriptor = {**descriptor, "workdir": self._spec.workdir}
return descriptor

@classmethod
async def connect(cls, descriptor: Mapping[str, Any] | Any, *, provider: SandboxProvider) -> "AsyncSandbox":
"""Rebuild a sandbox in this process from a descriptor produced by
:meth:`serialize`, using ``provider`` (which must support connect)."""
if not isinstance(provider, ConnectableProvider):
name = getattr(provider, "name", type(provider).__name__)
raise RuntimeError(f"provider {name!r} does not support serialize()/connect()")
if not isinstance(descriptor, Mapping) and hasattr(descriptor, "to_dict"):
descriptor = descriptor.to_dict()
handle = await provider.connect(descriptor)
workdir = descriptor.get("workdir") if isinstance(descriptor, Mapping) else None
sandbox = cls(provider, SandboxSpec(workdir=workdir))
sandbox._handle = handle
sandbox._stopped = False
return sandbox

async def __aenter__(self) -> "AsyncSandbox":
return self

Expand Down
2 changes: 2 additions & 0 deletions nemo_gym/sandbox/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Sandbox provider registry."""

from nemo_gym.sandbox.providers.base import (
ConnectableProvider,
ExecResult,
SandboxCreateError,
SandboxCreateVerificationError,
Expand All @@ -34,6 +35,7 @@


__all__ = [
"ConnectableProvider",
"ExecResult",
"SandboxCreateError",
"SandboxCreateVerificationError",
Expand Down
24 changes: 23 additions & 1 deletion nemo_gym/sandbox/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Protocol
from typing import Any, Protocol, runtime_checkable


class SandboxStatus(str, Enum):
Expand Down Expand Up @@ -168,3 +168,25 @@ async def close(self, handle: SandboxHandle) -> None:
async def aclose(self) -> None:
"""Close provider-scoped resources such as SDK clients."""
...


@runtime_checkable
class ConnectableProvider(Protocol):
"""Optional capability: rebuild a handle in another process from a descriptor.

Providers whose sandboxes are reachable by id (external control plane, e.g.
OpenSandbox and Fargate, and the sandbox server's remote provider) implement
this. A provider that does not implement it can only be shared by fronting it
with a sandbox server. Membership is checked with ``isinstance`` because the
protocol is ``runtime_checkable``.
"""

async def serialize_handle(self, handle: SandboxHandle, *, scope: str | None = None) -> dict[str, Any]:
"""Return a JSON-serializable descriptor that ``connect`` can rebuild a
handle from. ``scope`` is honored by providers that mint leases (the
remote provider) and ignored by the rest."""
...

async def connect(self, descriptor: Mapping[str, Any]) -> SandboxHandle:
"""Rebuild a live handle in this process from a descriptor."""
...
26 changes: 26 additions & 0 deletions nemo_gym/sandbox/providers/opensandbox/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,32 @@ async def aclose(self) -> None:
"""Close provider-owned resources."""
return None

async def serialize_handle(self, handle: SandboxHandle, *, scope: str | None = None) -> dict[str, Any]:
"""Return a descriptor for reattaching to this sandbox by id.

OpenSandbox sandboxes are reachable by id from any process that has the
connection config, so the id alone is enough to reconnect and no sandbox
server is needed to share one. ``scope`` is ignored: OpenSandbox has no
lease concept of its own.
"""
return {"sandbox_id": handle.sandbox_id}

async def connect(self, descriptor: Mapping[str, Any]) -> SandboxHandle:
"""Rebuild a live handle from an OpenSandbox sandbox id via the SDK."""
Sandbox, _, _, _, _ = _require_opensandbox_sdk()
sandbox_id = str(descriptor["sandbox_id"])
timeout_s = self._create.connect_attempt_timeout_s
sandbox = await asyncio.wait_for(
Sandbox.connect(
sandbox_id,
connection_config=self._connection_config(request_timeout_s=timeout_s),
connect_timeout=timedelta(seconds=timeout_s),
skip_health_check=True,
),
timeout=timeout_s,
)
return SandboxHandle(sandbox_id=str(sandbox.id), provider_name=self.name, raw=sandbox)

async def _await_sdk_call(
self,
awaitable: Any,
Expand Down
20 changes: 20 additions & 0 deletions nemo_gym/sandbox/providers/remote/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Remote sandbox provider: forwards operations to a sandbox server over HTTP."""

from nemo_gym.sandbox.providers.remote.provider import RemoteSandboxProvider


__all__ = ["RemoteSandboxProvider"]
Loading
Loading