diff --git a/nemo_gym/sandbox/__init__.py b/nemo_gym/sandbox/__init__.py index 820fcbff71..25e613ed43 100644 --- a/nemo_gym/sandbox/__init__.py +++ b/nemo_gym/sandbox/__init__.py @@ -15,6 +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.providers import ( ExecResult, SandboxCreateError, @@ -49,5 +50,6 @@ "get_provider_class", "list_providers", "register_provider", + "resolve_provider_config", "rewrite_image", ] diff --git a/nemo_gym/sandbox/config.py b/nemo_gym/sandbox/config.py new file mode 100644 index 0000000000..6aa0c43580 --- /dev/null +++ b/nemo_gym/sandbox/config.py @@ -0,0 +1,117 @@ +# 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. + +"""Resolve a sandbox provider reference into a provider config. + +An agent selects a sandbox by name (``sandbox_provider: sandbox``). The named +block lives in its own provider config file, so swapping providers is swapping a +``config_paths`` entry, not editing the agent config:: + + # nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml + sandbox: + opensandbox: + connection: { ... } + + # agent config + sandbox_provider: sandbox + +An inline single-key mapping (``{provider_name: {...}}``) is also accepted for +keeping everything in one file. +""" + +from collections.abc import Mapping +from typing import Any + + +def _to_plain_dict(value: Any) -> Any: + """Return a plain ``dict`` for mappings, including OmegaConf ``DictConfig``.""" + try: + from omegaconf import DictConfig, OmegaConf + except ImportError: # pragma: no cover - omegaconf is a core dependency + DictConfig = () # type: ignore[assignment] + OmegaConf = None # type: ignore[assignment] + + if OmegaConf is not None and isinstance(value, DictConfig): + return OmegaConf.to_container(value, resolve=True) + if isinstance(value, Mapping): + return dict(value) + return value + + +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: + candidates.append(str(key)) + return sorted(candidates) + + +def resolve_provider_config( + sandbox_provider: str | Mapping[str, Any], + named_configs: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Resolve a ``sandbox_provider`` field into a single-key provider config dict. + + Args: + sandbox_provider: Either the name of a top-level sandbox config block + (resolved from ``named_configs``) or an inline single-key provider + mapping of the form ``{provider_name: {...}}``. + named_configs: Mapping of top-level config name to config block, typically + the merged global config dict. Required when ``sandbox_provider`` is a + name reference. + + Returns: + A plain ``{provider_name: provider_kwargs}`` dict suitable for + :func:`nemo_gym.sandbox.create_provider`. + + 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. + """ + 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//configs/.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__}" + ) + + if not isinstance(block, Mapping) or len(block) != 1: + raise ValueError( + f"Sandbox provider config from {source} must be a single-key mapping " + f"{{provider_name: config}}, got: {block!r}" + ) + + return dict(block) diff --git a/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml b/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml new file mode 100644 index 0000000000..8345100092 --- /dev/null +++ b/nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml @@ -0,0 +1,41 @@ +# OpenSandbox sandbox provider config. +# +# `sandbox` is the instance name an agent references via `sandbox_provider: +# sandbox`; the child key `opensandbox` selects the provider class and its value +# is passed to the provider constructor. +# +# Every shipped provider config binds the same name `sandbox`, so swapping +# providers is swapping this config path in `+config_paths` (no agent edit): +# +# AGENT=responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml +# MODEL=responses_api_models/vllm_model/configs/vllm_model.yaml +# ng_run "+config_paths=[$AGENT, nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml, $MODEL]" +# +# To run multiple sandboxes at once, give each block a distinct instance name +# (e.g. `opensandbox_foo`, `opensandbox_baz`) and reference each by name. +sandbox: + opensandbox: + connection: + domain: ${oc.env:OPENSANDBOX_DOMAIN,opensandbox-server.opensandbox-system.svc.cluster.local} + api_key: ${oc.env:OPENSANDBOX_API_KEY} + protocol: http + request_timeout_s: 300 + use_server_proxy: true + create: + request_timeout_s: 1200 + timeout_s: 1200 + skip_health_check: true + retries: 10 + retry_delay_s: 5.0 + retry_max_delay_s: 90.0 + probe: + timeout_s: 60 + deadline_s: 180 + stable_count: 2 + stable_delay_s: 1.0 + operations: + retries: 5 + retry_delay_s: 1.0 + retry_max_delay_s: 45.0 + command_retries: 0 + close_timeout_s: 30 diff --git a/responses_api_agents/mini_swe_agent_2/README.md b/responses_api_agents/mini_swe_agent_2/README.md index 1170f8a215..51a5e5346a 100644 --- a/responses_api_agents/mini_swe_agent_2/README.md +++ b/responses_api_agents/mini_swe_agent_2/README.md @@ -96,9 +96,48 @@ rewrites. ## Configuration +### How sandboxes are configured + +There is one concept to learn: **a sandbox is a named block**, and an agent points +at it by name. + +```yaml +# A named sandbox: maps to maps to that provider's config. +sandbox: # instance name (the handle the agent references) + opensandbox: # provider registry key -> provider class + connection: { ... } # provider-specific config +``` + +```yaml +# An agent selects a sandbox by name: +sandbox_provider: sandbox +``` + +The framework only ever resolves *a name -> one provider config*. Everything else +falls out of how you name and reference blocks: + +- **Single sandbox (default).** Ship a `sandbox` block; the agent defaults to + `sandbox_provider: sandbox`. Done. +- **Swap providers (no agent edit).** Every shipped provider config binds the same + name `sandbox`, so swapping providers is just swapping one config path in + `+config_paths`. +- **Multiple / mixed / same-type sandboxes.** Give blocks **distinct instance + names** (e.g. `opensandbox_foo`, `opensandbox_baz`) and reference each by name. + See [Advanced: multiple sandboxes](#advanced-multiple-sandboxes). + +> Names are arbitrary instance names, not provider types. Two config files that +> bind the **same** name merge last-wins (that is the swap mechanism); to run +> several at once, use distinct names. + ### Agent Configuration -Path - `responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_opensandbox.yaml` +The agent config is **provider-neutral**: it selects a sandbox by name via +`sandbox_provider`, and the named block lives in a separate provider config file. +This decouples the agent from any specific sandbox provider so you can swap +providers by swapping a single config path in `+config_paths` — no edits to the +agent config. + +Path - `responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml` ```yaml mini_swe_agent_2: @@ -106,39 +145,16 @@ mini_swe_agent_2: mini_swe_agent_2: entrypoint: app.py domain: coding - description: Software engineering tasks driven by mini-swe-agent harness on OpenSandbox. + description: Software engineering tasks driven by mini-swe-agent harness on a Gym sandbox. value: Improve agentic software engineering capabilities. model_server: type: responses_api_models name: policy_model concurrency: 64 env: sandbox - sandbox_provider: - opensandbox: - connection: - domain: opensandbox-server.opensandbox-system.svc.cluster.local - api_key: ${oc.env:OPENSANDBOX_API_KEY} - protocol: http - request_timeout_s: 300 - use_server_proxy: true - create: - request_timeout_s: 1200 - timeout_s: 1200 - skip_health_check: true - retries: 10 - retry_delay_s: 5.0 - retry_max_delay_s: 90.0 - probe: - timeout_s: 60 - deadline_s: 180 - stable_count: 2 - stable_delay_s: 1.0 - operations: - retries: 5 - retry_delay_s: 1.0 - retry_max_delay_s: 45.0 - command_retries: 0 - close_timeout_s: 30 + # Name of the sandbox to use; defined in a separate provider config (see + # "Sandbox Provider Configuration" below). + sandbox_provider: sandbox sandbox_spec: ttl_s: 18000 ready_timeout_s: 1200 @@ -153,7 +169,6 @@ mini_swe_agent_2: metadata: benchmark: swebench-verified harness: mini-swe-agent - sandbox-api: opensandbox-sdk sandbox_environment_kwargs: cwd: /testbed conda_env: testbed @@ -166,12 +181,94 @@ mini_swe_agent_2: step_limit: 250 ``` +`sandbox_provider` accepts either a name reference (resolved from a top-level +sandbox block in the merged config, the recommended decoupled form) or an inline +single-key provider mapping (`{provider_name: {...}}`) when you prefer to keep +everything in one file. + +### Sandbox Provider Configuration + +Each provider ships its own config file that defines a named sandbox block. The +default OpenSandbox config is: + +Path - `nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml` + +```yaml +sandbox: # name referenced by the agent's sandbox_provider + opensandbox: # provider registry key -> provider class + connection: + domain: ${oc.env:OPENSANDBOX_DOMAIN,opensandbox-server.opensandbox-system.svc.cluster.local} + api_key: ${oc.env:OPENSANDBOX_API_KEY} + protocol: http + request_timeout_s: 300 + use_server_proxy: true + create: + request_timeout_s: 1200 + timeout_s: 1200 + skip_health_check: true + retries: 10 + retry_delay_s: 5.0 + retry_max_delay_s: 90.0 + probe: + timeout_s: 60 + deadline_s: 180 + stable_count: 2 + stable_delay_s: 1.0 + operations: + retries: 5 + retry_delay_s: 1.0 + retry_max_delay_s: 45.0 + command_retries: 0 + close_timeout_s: 30 +``` + +To use a different provider, add a config file under +`nemo_gym/sandbox/providers//configs/.yaml` that defines a +`sandbox` block (the name the agent references) with that provider's registry key, +then point `+config_paths` at it instead — no agent edit required. + 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 SWE-bench tasks across a small set of resource sizes without changing the input data. +### Advanced: multiple sandboxes + +The default convention (every provider file binds the name `sandbox`) is optimized +for the single-sandbox case and path-only swapping. To run more than one sandbox +in the same `ng_run`, give each block a **distinct instance name** and reference it +explicitly. Because names are arbitrary, this covers every multi-sandbox case +without any framework change: + +- **Different providers at once** (e.g. one agent on OpenSandbox, a grader on + another provider): + + ```yaml + sandbox_rollout: + opensandbox: { ... } + sandbox_grading: + docker: { ... } + ``` + +- **Two configs of the same provider type** (e.g. two OpenSandbox endpoints — note + the same inner `opensandbox` key, distinct outer instance names): + + ```yaml + opensandbox_foo: + opensandbox: { connection: { domain: foo... } } + opensandbox_baz: + opensandbox: { connection: { domain: baz... } } + ``` + +Each agent then references the instance it needs (`sandbox_provider: +opensandbox_foo`). Whether a single agent consumes one or several sandboxes is +part of that agent's config contract; `mini_swe_agent_2` uses exactly one sandbox +per task. + +> Reminder: do not give two included config files the same instance name unless you +> intend swap-by-replace — same name merges last-wins. + ### Model Parameters `MiniSWEAgent.run()` maps supported Responses API fields into mini-swe-agent @@ -212,11 +309,16 @@ policy_api_key: dummy-key policy_model_name: ``` -Start the mini-swe-agent 2 server with the OpenSandbox provider and a policy -model server. The values below show a representative SWE-bench eval setup: +Start the mini-swe-agent 2 server by composing three config paths: the +provider-neutral agent config, a sandbox provider config, and a policy model +server config. To swap providers, change only the sandbox provider path. The +values below show a representative SWE-bench eval setup with OpenSandbox: ```bash -CONFIG_PATHS="responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_opensandbox.yaml,responses_api_models/vllm_model/configs/vllm_model.yaml" +AGENT="responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml" +SANDBOX="nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml" +MODEL="responses_api_models/vllm_model/configs/vllm_model.yaml" +CONFIG_PATHS="$AGENT,$SANDBOX,$MODEL" ng_run "+config_paths=[$CONFIG_PATHS]" \ +mini_swe_agent_2.responses_api_agents.mini_swe_agent_2.concurrency=64 \ @@ -276,8 +378,9 @@ outer per-sample guard there. `MiniSWESandboxEnvironment` adapts mini-swe-agent's synchronous environment contract to `nemo_gym.sandbox.Sandbox`. -When `env` is `sandbox`, Gym injects this environment config before calling -mini-swe-agent: +When `env` is `sandbox`, the agent resolves `sandbox_provider` (name reference or +inline mapping) to a single-key provider config and Gym injects this environment +config before calling mini-swe-agent: ```yaml environment: diff --git a/responses_api_agents/mini_swe_agent_2/app.py b/responses_api_agents/mini_swe_agent_2/app.py index febe8fe696..a175aeacc2 100644 --- a/responses_api_agents/mini_swe_agent_2/app.py +++ b/responses_api_agents/mini_swe_agent_2/app.py @@ -47,6 +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.server_utils import ( ServerClient, get_first_server_config_dict, @@ -61,7 +62,9 @@ class MiniSWEAgentConfig(BaseResponsesAPIAgentConfig): model_server: ModelServerRef env: Literal["sandbox"] concurrency: int - sandbox_provider: Optional[dict[str, Any]] = None + # A sandbox name resolved from a separate provider config (e.g. "sandbox"), + # or an inline single-key provider mapping ({provider_name: {...}}). + sandbox_provider: Optional[str | dict[str, Any]] = None sandbox_spec: Optional[dict[str, Any]] = None sandbox_environment_kwargs: Optional[dict[str, Any]] = None run_golden: bool = False @@ -747,8 +750,9 @@ async def run(self, body: MiniSWEAgentRunRequest) -> MiniSWEAgentVerifyResponse: should_write_config = bool(model_kwargs) 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) config.setdefault("environment", {}).update(self.config.sandbox_environment_kwargs or {}) - config["environment"]["provider"] = _sandbox_provider_for_config_dump(self.config.sandbox_provider) + config["environment"]["provider"] = _sandbox_provider_for_config_dump(resolved_sandbox_provider) config["environment"]["spec"] = _sandbox_spec_for_instance( self.config.sandbox_spec, resource_profiles=self.config.sandbox_resource_profiles, @@ -791,7 +795,7 @@ async def run(self, body: MiniSWEAgentRunRequest) -> MiniSWEAgentVerifyResponse: step_limit=step_limit, ) runner = runner_ray_remote - runtime_env = _sandbox_runtime_env(self.config.sandbox_provider) + runtime_env = _sandbox_runtime_env(resolved_sandbox_provider) if runtime_env.get("env_vars"): runner = runner.options(runtime_env=runtime_env) future = runner.remote(run_mini_swe_with_sandbox, params) diff --git a/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_opensandbox.yaml b/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml similarity index 52% rename from responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_opensandbox.yaml rename to responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml index c6fffd12ca..9f225012aa 100644 --- a/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_opensandbox.yaml +++ b/responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml @@ -1,41 +1,26 @@ +# Provider-neutral mini-swe-agent 2 config. +# +# `sandbox_provider` names the sandbox to use; its config lives in a separate +# provider file, so swapping providers is swapping that path in `+config_paths`: +# +# AGENT=responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml +# MODEL=responses_api_models/vllm_model/configs/vllm_model.yaml +# ng_run "+config_paths=[$AGENT, nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml, $MODEL]" mini_swe_agent_2: responses_api_agents: mini_swe_agent_2: entrypoint: app.py domain: coding - description: Software engineering tasks driven by mini-swe-agent harness on OpenSandbox. + description: Software engineering tasks driven by mini-swe-agent harness on a Gym sandbox. value: Improve agentic software engineering capabilities. model_server: type: responses_api_models name: policy_model concurrency: 64 env: sandbox - sandbox_provider: - opensandbox: - connection: - domain: opensandbox-server.opensandbox-system.svc.cluster.local - api_key: ${oc.env:OPENSANDBOX_API_KEY} - protocol: http - request_timeout_s: 300 - use_server_proxy: true - create: - request_timeout_s: 1200 - timeout_s: 1200 - skip_health_check: true - retries: 10 - retry_delay_s: 5.0 - retry_max_delay_s: 90.0 - probe: - timeout_s: 60 - deadline_s: 180 - stable_count: 2 - stable_delay_s: 1.0 - operations: - retries: 5 - retry_delay_s: 1.0 - retry_max_delay_s: 45.0 - command_retries: 0 - close_timeout_s: 30 + # Name of the sandbox to use; include a provider config that defines a + # `sandbox` block (e.g. nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml). + sandbox_provider: sandbox sandbox_spec: ttl_s: 18000 ready_timeout_s: 1200 @@ -50,7 +35,6 @@ mini_swe_agent_2: metadata: benchmark: swebench-verified harness: mini-swe-agent - sandbox-api: opensandbox-sdk sandbox_environment_kwargs: cwd: /testbed conda_env: testbed diff --git a/responses_api_agents/mini_swe_agent_2/tests/test_app.py b/responses_api_agents/mini_swe_agent_2/tests/test_app.py index 57ce79ad09..f2404b178e 100644 --- a/responses_api_agents/mini_swe_agent_2/tests/test_app.py +++ b/responses_api_agents/mini_swe_agent_2/tests/test_app.py @@ -766,6 +766,55 @@ async def test_run_writes_generation_params_to_config( "chat_template_kwargs": {"enable_thinking": True}, } + @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") + @patch("responses_api_agents.mini_swe_agent_2.app.get_config_path") + @patch("responses_api_agents.mini_swe_agent_2.app.runner_ray_remote") + @patch("asyncio.to_thread") + async def test_run_resolves_named_sandbox_provider_reference( + self, + mock_to_thread, + mock_runner_ray_remote, + mock_get_config_path, + mock_get_first_server_config_dict, + mock_load_from_global_config, + tmp_path, + monkeypatch, + ) -> None: + monkeypatch.chdir(tmp_path) + config = create_test_config() + config.sandbox_provider = "sandbox" + mock_server_client = MagicMock(spec=ServerClient) + server = MiniSWEAgent(config=config, server_client=mock_server_client) + + mock_server_client_instance = MagicMock() + mock_server_client_instance.global_config_dict = { + "policy_model_name": "test_model", + "sandbox": { + "opensandbox": { + "connection": { + "domain": "sandbox.example", + "api_key": "fixture-value", # pragma: allowlist secret + } + } + }, + } + mock_load_from_global_config.return_value = mock_server_client_instance + mock_get_first_server_config_dict.return_value = {"host": "0.0.0.0", "port": 8080} + setup_config_path_mock(mock_get_config_path) + setup_run_mini_swe_mock(mock_to_thread, mock_runner_ray_remote) + + await server.run(create_run_request()) + + runtime_env = mock_runner_ray_remote.options.call_args.kwargs["runtime_env"] + assert runtime_env["env_vars"] == {OPENSANDBOX_API_KEY_ENV: "fixture-value"} # pragma: allowlist secret + call_args = mock_runner_ray_remote.options.return_value.remote.call_args + params = call_args.args[1] + generated_config = yaml.safe_load(Path(params["config"]).read_text()) + provider = generated_config["environment"]["provider"]["opensandbox"] + assert provider["connection"]["domain"] == "sandbox.example" + assert "api_key" not in provider["connection"] + @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") @patch("responses_api_agents.mini_swe_agent_2.app.get_config_path") diff --git a/tests/unit_tests/test_sandbox.py b/tests/unit_tests/test_sandbox.py index c26f660b3e..fd155608a0 100644 --- a/tests/unit_tests/test_sandbox.py +++ b/tests/unit_tests/test_sandbox.py @@ -36,6 +36,7 @@ get_provider_class, list_providers, register_provider, + resolve_provider_config, ) from nemo_gym.sandbox.api import _AsyncLoopRunner from nemo_gym.sandbox.utils import rewrite_image @@ -407,6 +408,52 @@ def __init__(self) -> None: Sandbox({failing_provider_name: {}}) +def test_resolve_provider_config_named_reference() -> None: + global_config = { + "policy_model_name": "test_model", + "sandbox_main": {"opensandbox": {"connection": {"domain": "sandbox.example"}}}, + } + + resolved = resolve_provider_config("sandbox_main", global_config) + assert resolved == {"opensandbox": {"connection": {"domain": "sandbox.example"}}} + + # An OmegaConf DictConfig block resolves to a plain dict. + from omegaconf import OmegaConf + + omega_config = OmegaConf.create(global_config) + resolved_from_omega = resolve_provider_config("sandbox_main", omega_config) + assert resolved_from_omega == {"opensandbox": {"connection": {"domain": "sandbox.example"}}} + assert isinstance(resolved_from_omega["opensandbox"], dict) + + +def test_resolve_provider_config_inline_mapping() -> None: + inline = {"opensandbox": {"connection": {}}} + assert resolve_provider_config(inline) == inline + # The result is a fresh dict, not the same object. + assert resolve_provider_config(inline) is not inline + + +def test_resolve_provider_config_errors() -> None: + with pytest.raises(ValueError, match="non-empty string"): + resolve_provider_config("", {}) + + with pytest.raises(ValueError, match="is not defined in the merged config"): + resolve_provider_config("missing", {"sandbox_main": {"opensandbox": {}}}) + + # Error lists available single-key sandbox blocks as candidates. + with pytest.raises(ValueError, match="'sandbox_main'"): + resolve_provider_config("missing", {"sandbox_main": {"opensandbox": {}}}) + + 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"): + resolve_provider_config({"opensandbox": {}, "extra": {}}) + + with pytest.raises(ValueError, match="single-key mapping"): + resolve_provider_config("sandbox_main", {"sandbox_main": {}}) + + 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))