From c05237a1d94ae68f3905a94327afc5edda162778 Mon Sep 17 00:00:00 2001 From: Ananth Subramaniam Date: Wed, 24 Jun 2026 02:36:53 -0700 Subject: [PATCH 1/2] feat(sandbox): discover sandbox providers via entry points Add a `nemo_gym.sandbox_providers` entry point group so a separate package can publish a sandbox provider that becomes available on install/import, without editing the registry. Lookup precedence is explicit registration > built-in loaders > entry points; discovery is cached. Refs #1377 Signed-off-by: Ananth Subramaniam --- nemo_gym/sandbox/providers/registry.py | 45 ++++++++++++++----- .../mini_swe_agent_2/README.md | 8 ++++ tests/unit_tests/test_sandbox.py | 27 +++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/nemo_gym/sandbox/providers/registry.py b/nemo_gym/sandbox/providers/registry.py index 8c4e39e577..a0e6449663 100644 --- a/nemo_gym/sandbox/providers/registry.py +++ b/nemo_gym/sandbox/providers/registry.py @@ -12,9 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Provider registration utilities.""" +"""Provider registration utilities. + +Providers can be made available three ways, in lookup precedence order: + +1. ``register_provider(name, cls)`` — explicit in-process registration. +2. Built-in loaders shipped with NeMo Gym (e.g. ``opensandbox``). +3. Python entry points in the ``nemo_gym.sandbox_providers`` group, so a separate + package can publish a provider that becomes available on install. Declare one + in that package's ``pyproject.toml``:: + + [project.entry-points."nemo_gym.sandbox_providers"] + my_provider = "my_pkg.provider:MyProvider" +""" from collections.abc import Callable, Mapping +from importlib.metadata import entry_points from typing import Any, TypeAlias from nemo_gym.sandbox.providers.base import SandboxProvider @@ -23,8 +36,19 @@ ProviderClass: TypeAlias = type[SandboxProvider] ProviderLoader: TypeAlias = Callable[[], ProviderClass] +ENTRY_POINT_GROUP = "nemo_gym.sandbox_providers" + _PROVIDER_REGISTRY: dict[str, ProviderClass] = {} _BUILTIN_PROVIDER_LOADERS: dict[str, ProviderLoader] = {} +_ENTRY_POINT_LOADERS: dict[str, ProviderLoader] | None = None + + +def _entry_point_loaders() -> dict[str, ProviderLoader]: + """Discover provider loaders from installed entry points (cached).""" + global _ENTRY_POINT_LOADERS + if _ENTRY_POINT_LOADERS is None: + _ENTRY_POINT_LOADERS = {ep.name: ep.load for ep in entry_points(group=ENTRY_POINT_GROUP)} + return _ENTRY_POINT_LOADERS def register_provider(name: str, provider_class: ProviderClass, *, override: bool = False) -> None: @@ -37,15 +61,14 @@ def register_provider(name: str, provider_class: ProviderClass, *, override: boo def get_provider_class(name: str) -> ProviderClass: - """Return a registered provider class.""" - try: + """Return a provider class by name (explicit > built-in > entry point).""" + if name in _PROVIDER_REGISTRY: return _PROVIDER_REGISTRY[name] - except KeyError as e: - loader = _BUILTIN_PROVIDER_LOADERS.get(name) - if loader is not None: - return loader() - available = ", ".join(list_providers()) or "" - raise ValueError(f"Unknown sandbox provider {name!r}. Available providers: {available}") from e + loader = _BUILTIN_PROVIDER_LOADERS.get(name) or _entry_point_loaders().get(name) + if loader is not None: + return loader() + available = ", ".join(list_providers()) or "" + raise ValueError(f"Unknown sandbox provider {name!r}. Available providers: {available}") def create_provider(config: Mapping[str, Any]) -> SandboxProvider: @@ -65,8 +88,8 @@ def create_provider(config: Mapping[str, Any]) -> SandboxProvider: def list_providers() -> list[str]: - """List registered provider names.""" - return sorted({*_PROVIDER_REGISTRY, *_BUILTIN_PROVIDER_LOADERS}) + """List available provider names from all sources.""" + return sorted({*_PROVIDER_REGISTRY, *_BUILTIN_PROVIDER_LOADERS, *_entry_point_loaders()}) def _load_opensandbox_provider() -> ProviderClass: diff --git a/responses_api_agents/mini_swe_agent_2/README.md b/responses_api_agents/mini_swe_agent_2/README.md index e7f2d987a0..41c4543ed6 100644 --- a/responses_api_agents/mini_swe_agent_2/README.md +++ b/responses_api_agents/mini_swe_agent_2/README.md @@ -235,6 +235,14 @@ merged into each sandbox's spec metadata (`SandboxSpec.metadata`); the agent's o tags (e.g. `sandbox-api: opensandbox-sdk`) with the provider rather than in the agent config. +To ship a custom provider class from a separate package, register it under the +`nemo_gym.sandbox_providers` entry point group so it is available on install: + +```toml +[project.entry-points."nemo_gym.sandbox_providers"] +my_provider = "my_pkg.provider:MyProvider" +``` + Optional `sandbox_resource_profiles` can be configured as a list of resource maps. When present, the agent hashes `instance_id` and deterministically merges one profile into `sandbox_spec.resources`. This is useful for spreading diff --git a/tests/unit_tests/test_sandbox.py b/tests/unit_tests/test_sandbox.py index 1a45c24453..7b8d9f37cf 100644 --- a/tests/unit_tests/test_sandbox.py +++ b/tests/unit_tests/test_sandbox.py @@ -17,6 +17,7 @@ import threading from datetime import timedelta from pathlib import Path +from types import SimpleNamespace from typing import Any from uuid import uuid4 @@ -388,6 +389,32 @@ def test_provider_registry_validation_and_listing(monkeypatch: pytest.MonkeyPatc assert builtin_name in list_providers() +def test_provider_entry_point_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + ep_name = f"ep-{uuid4().hex}" + + class EntryPointProvider(FakeSandboxProvider): + pass + + def fake_entry_points(*, group: str) -> list[SimpleNamespace]: + assert group == provider_registry.ENTRY_POINT_GROUP + return [SimpleNamespace(name=ep_name, load=lambda: EntryPointProvider)] + + monkeypatch.setattr(provider_registry, "entry_points", fake_entry_points) + monkeypatch.setattr(provider_registry, "_ENTRY_POINT_LOADERS", None) + + assert ep_name in list_providers() + assert get_provider_class(ep_name) is EntryPointProvider + + # Built-in providers take precedence over an entry point with the same name. + monkeypatch.setattr(provider_registry, "_ENTRY_POINT_LOADERS", None) + monkeypatch.setattr( + provider_registry, + "entry_points", + lambda *, group: [SimpleNamespace(name="opensandbox", load=lambda: EntryPointProvider)], + ) + assert get_provider_class("opensandbox").__name__ == "OpenSandboxProvider" + + def test_create_provider_validation_and_constructor_cleanup() -> None: provider_name = f"fake-{uuid4().hex}" register_provider(provider_name, FakeSandboxProvider) From b0698c36bbc7b2a463bd5999a8bcc3701097dcd9 Mon Sep 17 00:00:00 2001 From: Ananth Subramaniam Date: Wed, 24 Jun 2026 02:51:50 -0700 Subject: [PATCH 2/2] feat(sandbox): detect sandbox provider entry-point name collisions Two installed distributions publishing the same provider entry-point name now raise a clear error naming both packages, instead of silently picking one nondeterministically. An entry point shadowed by a higher-precedence built-in or registered provider is logged as a warning and ignored. Refs #1377 Signed-off-by: Ananth Subramaniam --- nemo_gym/sandbox/providers/registry.py | 39 ++++++++++++++++++++++++-- tests/unit_tests/test_sandbox.py | 33 ++++++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/nemo_gym/sandbox/providers/registry.py b/nemo_gym/sandbox/providers/registry.py index a0e6449663..12347e2614 100644 --- a/nemo_gym/sandbox/providers/registry.py +++ b/nemo_gym/sandbox/providers/registry.py @@ -24,15 +24,22 @@ [project.entry-points."nemo_gym.sandbox_providers"] my_provider = "my_pkg.provider:MyProvider" + +On name collisions: two entry points sharing a name raise (selection would be +nondeterministic); an entry point shadowed by a higher-precedence built-in or +registered provider is warned and ignored. """ +import logging from collections.abc import Callable, Mapping -from importlib.metadata import entry_points +from importlib.metadata import EntryPoint, entry_points from typing import Any, TypeAlias from nemo_gym.sandbox.providers.base import SandboxProvider +LOGGER = logging.getLogger(__name__) + ProviderClass: TypeAlias = type[SandboxProvider] ProviderLoader: TypeAlias = Callable[[], ProviderClass] @@ -43,11 +50,37 @@ _ENTRY_POINT_LOADERS: dict[str, ProviderLoader] | None = None +def _entry_point_dist_name(ep: EntryPoint) -> str: + dist = getattr(ep, "dist", None) + return getattr(dist, "name", None) or "" + + def _entry_point_loaders() -> dict[str, ProviderLoader]: - """Discover provider loaders from installed entry points (cached).""" + """Discover provider loaders from installed entry points (cached). + + Raises if two distributions publish the same provider name, since lookup + would otherwise pick one nondeterministically. Warns when an entry point is + shadowed by a built-in or explicitly registered provider of the same name. + """ global _ENTRY_POINT_LOADERS if _ENTRY_POINT_LOADERS is None: - _ENTRY_POINT_LOADERS = {ep.name: ep.load for ep in entry_points(group=ENTRY_POINT_GROUP)} + loaders: dict[str, ProviderLoader] = {} + dist_by_name: dict[str, str] = {} + for ep in entry_points(group=ENTRY_POINT_GROUP): + dist_name = _entry_point_dist_name(ep) + if ep.name in loaders: + raise ValueError( + f"Duplicate sandbox provider entry point {ep.name!r} published by " + f"{dist_by_name[ep.name]!r} and {dist_name!r}. Rename one of them." + ) + if ep.name in _BUILTIN_PROVIDER_LOADERS or ep.name in _PROVIDER_REGISTRY: + LOGGER.warning( + f"Sandbox provider entry point {ep.name!r} from {dist_name!r} is shadowed by a " + f"built-in or registered provider of the same name and will not be used." + ) + loaders[ep.name] = ep.load + dist_by_name[ep.name] = dist_name + _ENTRY_POINT_LOADERS = loaders return _ENTRY_POINT_LOADERS diff --git a/tests/unit_tests/test_sandbox.py b/tests/unit_tests/test_sandbox.py index 7b8d9f37cf..e81a46abd9 100644 --- a/tests/unit_tests/test_sandbox.py +++ b/tests/unit_tests/test_sandbox.py @@ -389,6 +389,10 @@ def test_provider_registry_validation_and_listing(monkeypatch: pytest.MonkeyPatc assert builtin_name in list_providers() +def _fake_entry_point(name: str, provider: type, dist_name: str) -> SimpleNamespace: + return SimpleNamespace(name=name, load=lambda: provider, dist=SimpleNamespace(name=dist_name)) + + def test_provider_entry_point_discovery(monkeypatch: pytest.MonkeyPatch) -> None: ep_name = f"ep-{uuid4().hex}" @@ -397,7 +401,7 @@ class EntryPointProvider(FakeSandboxProvider): def fake_entry_points(*, group: str) -> list[SimpleNamespace]: assert group == provider_registry.ENTRY_POINT_GROUP - return [SimpleNamespace(name=ep_name, load=lambda: EntryPointProvider)] + return [_fake_entry_point(ep_name, EntryPointProvider, "pkg-a")] monkeypatch.setattr(provider_registry, "entry_points", fake_entry_points) monkeypatch.setattr(provider_registry, "_ENTRY_POINT_LOADERS", None) @@ -405,13 +409,36 @@ def fake_entry_points(*, group: str) -> list[SimpleNamespace]: assert ep_name in list_providers() assert get_provider_class(ep_name) is EntryPointProvider - # Built-in providers take precedence over an entry point with the same name. + +def test_provider_entry_point_collisions(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + class EntryPointProvider(FakeSandboxProvider): + pass + + # Two distributions publishing the same name raise, naming both packages. + dup_name = f"ep-{uuid4().hex}" + monkeypatch.setattr(provider_registry, "_ENTRY_POINT_LOADERS", None) + monkeypatch.setattr( + provider_registry, + "entry_points", + lambda *, group: [ + _fake_entry_point(dup_name, EntryPointProvider, "pkg-a"), + _fake_entry_point(dup_name, EntryPointProvider, "pkg-b"), + ], + ) + with pytest.raises(ValueError, match=r"Duplicate sandbox provider entry point.*pkg-a.*pkg-b"): + list_providers() + + # An entry point shadowed by a built-in is warned (at discovery) and ignored. monkeypatch.setattr(provider_registry, "_ENTRY_POINT_LOADERS", None) monkeypatch.setattr( provider_registry, "entry_points", - lambda *, group: [SimpleNamespace(name="opensandbox", load=lambda: EntryPointProvider)], + lambda *, group: [_fake_entry_point("opensandbox", EntryPointProvider, "pkg-a")], ) + with caplog.at_level("WARNING", logger=provider_registry.__name__): + list_providers() + assert any("shadowed" in message for message in caplog.messages) + # Built-in still wins on lookup. assert get_provider_class("opensandbox").__name__ == "OpenSandboxProvider"