Skip to content
Merged
3 changes: 2 additions & 1 deletion nemo_gym/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"""Public sandbox API for NeMo Gym."""

from nemo_gym.sandbox.api import AsyncSandbox, Sandbox
from nemo_gym.sandbox.config import resolve_provider_config
from nemo_gym.sandbox.config import resolve_provider_config, resolve_provider_metadata
from nemo_gym.sandbox.providers import (
ExecResult,
SandboxCreateError,
Expand Down Expand Up @@ -51,5 +51,6 @@
"list_providers",
"register_provider",
"resolve_provider_config",
"resolve_provider_metadata",
"rewrite_image",
]
113 changes: 82 additions & 31 deletions nemo_gym/sandbox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,26 @@

An inline single-key mapping (``{provider_name: {...}}``) is also accepted for
keeping everything in one file.

A block may also carry a reserved ``default_metadata`` key. Its entries are merged
into the sandbox's ``SandboxSpec.metadata`` as defaults (the agent's own
``sandbox_spec.metadata`` overrides them), so provider-identifying tags live with
the provider rather than the agent config::

sandbox:
opensandbox: { connection: { ... } }
default_metadata: { sandbox-api: opensandbox-sdk }
"""

from collections.abc import Mapping
from typing import Any


# Reserved keys inside a sandbox block that are not the provider config.
SANDBOX_BLOCK_DEFAULT_METADATA_KEY = "default_metadata"
SANDBOX_BLOCK_RESERVED_KEYS = frozenset({SANDBOX_BLOCK_DEFAULT_METADATA_KEY})


def _to_plain_dict(value: Any) -> Any:
"""Return a plain ``dict`` for mappings, including OmegaConf ``DictConfig``."""
try:
Expand All @@ -49,18 +63,50 @@ def _to_plain_dict(value: Any) -> Any:
return value


def _provider_keys(block: Mapping[str, Any]) -> list[str]:
"""Return the provider keys in a block (everything but reserved keys)."""
return [key for key in block if key not in SANDBOX_BLOCK_RESERVED_KEYS]


def _candidate_sandbox_names(named_configs: Mapping[str, Any] | None) -> list[str]:
"""List top-level config keys that look like named sandbox provider blocks."""
if not named_configs:
return []
candidates: list[str] = []
for key, value in named_configs.items():
plain = _to_plain_dict(value)
if isinstance(plain, Mapping) and len(plain) == 1:
if isinstance(plain, Mapping) and len(_provider_keys(plain)) == 1:
candidates.append(str(key))
return sorted(candidates)


def _resolve_block(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None,
) -> tuple[dict[str, Any], str]:
"""Resolve a ``sandbox_provider`` reference or inline mapping to a plain block."""
if isinstance(sandbox_provider, str):
name = sandbox_provider
if not name:
raise ValueError("Sandbox provider reference must be a non-empty string")
block = named_configs.get(name) if named_configs is not None else None
if block is None:
available = ", ".join(repr(n) for n in _candidate_sandbox_names(named_configs)) or "(none)"
raise ValueError(
f"Sandbox provider reference {name!r} is not defined in the merged config. "
f"Define a top-level '{name}:' block (e.g. via "
f"nemo_gym/sandbox/providers/<provider>/configs/<provider>.yaml) and include it in "
f"your config_paths. Available sandbox configs: {available}"
)
return _to_plain_dict(block), f"reference {name!r}"
if isinstance(sandbox_provider, Mapping):
return _to_plain_dict(sandbox_provider), "inline sandbox_provider config"
raise TypeError(
"sandbox_provider must be a name reference (str) or a single-key provider mapping, "
f"got {type(sandbox_provider).__name__}"
)


def resolve_provider_config(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None = None,
Expand All @@ -77,41 +123,46 @@ def resolve_provider_config(

Returns:
A plain ``{provider_name: provider_kwargs}`` dict suitable for
:func:`nemo_gym.sandbox.create_provider`.
:func:`nemo_gym.sandbox.create_provider`. Reserved keys such as
``default_metadata`` are excluded; read them with
:func:`resolve_provider_metadata`.

Raises:
TypeError: If ``sandbox_provider`` is neither a string nor a mapping.
ValueError: If a named reference cannot be found, or if the resolved block
is not a single-key provider mapping.
ValueError: If a named reference cannot be found, or if the block does not
hold exactly one provider key.
"""
if isinstance(sandbox_provider, str):
name = sandbox_provider
if not name:
raise ValueError("Sandbox provider reference must be a non-empty string")
block = named_configs.get(name) if named_configs is not None else None
if block is None:
available = ", ".join(repr(n) for n in _candidate_sandbox_names(named_configs)) or "(none)"
raise ValueError(
f"Sandbox provider reference {name!r} is not defined in the merged config. "
f"Define a top-level '{name}:' block (e.g. via "
f"nemo_gym/sandbox/providers/<provider>/configs/<provider>.yaml) and include it in "
f"your config_paths. Available sandbox configs: {available}"
)
block = _to_plain_dict(block)
source = f"reference {name!r}"
elif isinstance(sandbox_provider, Mapping):
block = _to_plain_dict(sandbox_provider)
source = "inline sandbox_provider config"
else:
raise TypeError(
"sandbox_provider must be a name reference (str) or a single-key provider mapping, "
f"got {type(sandbox_provider).__name__}"
)
block, source = _resolve_block(sandbox_provider, named_configs)
if not isinstance(block, Mapping):
raise ValueError(f"Sandbox provider config from {source} must be a mapping, got: {block!r}")

if not isinstance(block, Mapping) or len(block) != 1:
provider_keys = _provider_keys(block)
if len(provider_keys) != 1:
raise ValueError(
f"Sandbox provider config from {source} must be a single-key mapping "
f"{{provider_name: config}}, got: {block!r}"
f"Sandbox provider config from {source} must have exactly one provider key "
f"{{provider_name: config}}, got keys: {provider_keys!r}"
)

return dict(block)
return {provider_keys[0]: block[provider_keys[0]]}


def resolve_provider_metadata(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a sandbox block's ``default_metadata``.

These are provider-contributed defaults to merge into ``SandboxSpec.metadata``.
Returns an empty dict when the block has no ``default_metadata`` key. See
:func:`resolve_provider_config` for argument semantics.
"""
block, source = _resolve_block(sandbox_provider, named_configs)
if not isinstance(block, Mapping):
raise ValueError(f"Sandbox provider config from {source} must be a mapping, got: {block!r}")

metadata = block.get(SANDBOX_BLOCK_DEFAULT_METADATA_KEY) or {}
if not isinstance(metadata, Mapping):
raise ValueError(
f"Sandbox '{SANDBOX_BLOCK_DEFAULT_METADATA_KEY}' from {source} must be a mapping, got: {metadata!r}"
)
return dict(metadata)
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
#
# To run multiple sandboxes at once, give each block a distinct instance name
# (e.g. `opensandbox_foo`, `opensandbox_baz`) and reference each by name.
#
# `default_metadata` (optional) is merged into every sandbox's spec metadata
# (SandboxSpec.metadata); an agent's own sandbox_spec.metadata overrides it.
sandbox:
default_metadata:
sandbox-api: opensandbox-sdk
opensandbox:
connection:
domain: ${oc.env:OPENSANDBOX_DOMAIN,opensandbox-server.opensandbox-system.svc.cluster.local}
Expand Down
8 changes: 8 additions & 0 deletions responses_api_agents/mini_swe_agent_2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Path - `nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml`

```yaml
sandbox: # name referenced by the agent's sandbox_provider
default_metadata: # optional: merged into sandbox spec metadata (see below)
sandbox-api: opensandbox-sdk
opensandbox: # provider registry key -> provider class
connection:
domain: ${oc.env:OPENSANDBOX_DOMAIN,opensandbox-server.opensandbox-system.svc.cluster.local}
Expand Down Expand Up @@ -227,6 +229,12 @@ To use a different provider, add a config file under
`sandbox` block (the name the agent references) with that provider's registry key,
then point `+config_paths` at it instead — no agent edit required.

An optional `default_metadata` key holds provider-contributed defaults that are
merged into each sandbox's spec metadata (`SandboxSpec.metadata`); the agent's own
`sandbox_spec.metadata` overrides them on conflict. This keeps provider-identifying
tags (e.g. `sandbox-api: opensandbox-sdk`) with the provider rather than in the
agent config.

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
Expand Down
9 changes: 7 additions & 2 deletions responses_api_agents/mini_swe_agent_2/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
NeMoGymResponseCreateParamsNonStreaming,
)
from nemo_gym.reward_profile import compute_pass_majority_metrics, highest_k_metrics
from nemo_gym.sandbox import resolve_provider_config
from nemo_gym.sandbox import resolve_provider_config, resolve_provider_metadata
from nemo_gym.server_utils import (
ServerClient,
get_first_server_config_dict,
Expand Down Expand Up @@ -751,13 +751,18 @@ async def run(self, body: MiniSWEAgentRunRequest) -> MiniSWEAgentVerifyResponse:
if self.config.sandbox_provider is None:
raise ValueError("mini_swe_agent_2 requires sandbox_provider")
resolved_sandbox_provider = resolve_provider_config(self.config.sandbox_provider, global_config_dict)
provider_default_metadata = resolve_provider_metadata(self.config.sandbox_provider, global_config_dict)
config.setdefault("environment", {}).update(self.config.sandbox_environment_kwargs or {})
config["environment"]["provider"] = _sandbox_provider_for_config_dump(resolved_sandbox_provider)
config["environment"]["spec"] = _sandbox_spec_for_instance(
instance_spec = _sandbox_spec_for_instance(
self.config.sandbox_spec,
resource_profiles=self.config.sandbox_resource_profiles,
instance_id=instance_id,
)
if provider_default_metadata:
# Provider defaults first; the agent's own spec metadata wins on conflict.
instance_spec["metadata"] = {**provider_default_metadata, **(instance_spec.get("metadata") or {})}
config["environment"]["spec"] = instance_spec
should_write_config = True

if should_write_config:
Expand Down
5 changes: 4 additions & 1 deletion responses_api_agents/mini_swe_agent_2/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,12 +791,13 @@ async def test_run_resolves_named_sandbox_provider_reference(
mock_server_client_instance.global_config_dict = {
"policy_model_name": "test_model",
"sandbox": {
"default_metadata": {"sandbox-api": "opensandbox-sdk"},
"opensandbox": {
"connection": {
"domain": "sandbox.example",
"api_key": "fixture-value", # pragma: allowlist secret
}
}
},
},
}
mock_load_from_global_config.return_value = mock_server_client_instance
Expand All @@ -814,6 +815,8 @@ async def test_run_resolves_named_sandbox_provider_reference(
provider = generated_config["environment"]["provider"]["opensandbox"]
assert provider["connection"]["domain"] == "sandbox.example"
assert "api_key" not in provider["connection"]
# Provider default_metadata flows into the sandbox spec metadata.
assert generated_config["environment"]["spec"]["metadata"]["sandbox-api"] == "opensandbox-sdk"

@patch("responses_api_agents.mini_swe_agent_2.app.ServerClient.load_from_global_config")
@patch("responses_api_agents.mini_swe_agent_2.app.get_first_server_config_dict")
Expand Down
26 changes: 24 additions & 2 deletions tests/unit_tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
list_providers,
register_provider,
resolve_provider_config,
resolve_provider_metadata,
)
from nemo_gym.sandbox.api import _AsyncLoopRunner
from nemo_gym.sandbox.utils import rewrite_image
Expand Down Expand Up @@ -450,13 +451,34 @@ def test_resolve_provider_config_errors() -> None:
with pytest.raises(TypeError, match="must be a name reference"):
resolve_provider_config(123) # type: ignore[arg-type]

with pytest.raises(ValueError, match="single-key mapping"):
with pytest.raises(ValueError, match="exactly one provider key"):
resolve_provider_config({"opensandbox": {}, "extra": {}})

with pytest.raises(ValueError, match="single-key mapping"):
with pytest.raises(ValueError, match="exactly one provider key"):
resolve_provider_config("sandbox_main", {"sandbox_main": {}})


def test_resolve_provider_metadata() -> None:
block = {
"opensandbox": {"connection": {"domain": "sandbox.example"}},
"default_metadata": {"sandbox-api": "opensandbox-sdk"},
}

# default_metadata is excluded from the provider config and read separately.
assert resolve_provider_config(block) == {"opensandbox": {"connection": {"domain": "sandbox.example"}}}
assert resolve_provider_metadata(block) == {"sandbox-api": "opensandbox-sdk"}

# A named reference works the same way.
global_config = {"sandbox": block}
assert resolve_provider_metadata("sandbox", global_config) == {"sandbox-api": "opensandbox-sdk"}

# No default_metadata key -> empty dict.
assert resolve_provider_metadata({"opensandbox": {}}) == {}

with pytest.raises(ValueError, match="must be a mapping"):
resolve_provider_metadata({"opensandbox": {}, "default_metadata": "not-a-mapping"})


def test_async_sandbox_transfer_fallback_and_unknown_status(tmp_path: Path) -> None:
asyncio.run(_assert_async_sandbox_transfer_fallback_and_unknown_status(tmp_path))

Expand Down
Loading