Skip to content

Commit 2949bea

Browse files
committed
feat(cli): add config_composer for agent/dataset composition (RFC M3)
Add nemo_gym/config_composer.py: a pure, resolution-safe composer that rewrites an already-merged OmegaConf DictConfig along the agent and dataset axes (RFC M3 / friction #6). - find_agent_block_key: locate the single agent block carrying the type: benchmark dataset (mirrors benchmarks.py); raise NoComposableAgentBlockError on zero/ambiguous targets. - substitute_agent: swap the composable harness via resolve_agent_config_path(require_composable=True), carrying over the env's resources_server / model_server / datasets wiring and re-keying the inner block. Pattern B agents raise AgentNotComposableError. - substitute_dataset_params: edit num_repeats / prompt_config on the benchmark dataset entry. - substitute_model: documented no-op (model axis is the CLI's job). - _validate_no_mandatory_placeholders: reject remaining '???' fields in the agent and its referenced resources block without resolving interpolations. Never starts servers, reads secrets, resolves interpolations, or emits Hydra override tokens. 100% module coverage (24 tests). Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
1 parent 707d816 commit 2949bea

2 files changed

Lines changed: 547 additions & 0 deletions

File tree

nemo_gym/config_composer.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Compose an already-merged NeMo Gym config along the agent / dataset axes.
16+
17+
This module takes a *merged* OmegaConf :class:`~omegaconf.DictConfig` — the kind produced by Gym's
18+
Hydra config stack from a benchmark's ``config.yaml`` — and rewrites it in place along two axes:
19+
20+
- **Agent axis:** swap the composable agent harness (``--agent``) carrying the benchmark dataset for
21+
a different one, re-using the environment's own wiring (resources server, model server, datasets).
22+
- **Dataset axis:** edit the benchmark dataset entry's run parameters (``--num-repeats``,
23+
``--prompt-config``).
24+
25+
The *model axis* is intentionally out of scope: model selection is handled by the CLI's
26+
``--model*`` flags long before composition runs, so :func:`substitute_model` is a documented no-op.
27+
28+
Like :mod:`nemo_gym.agent_registry` and :mod:`nemo_gym.benchmarks`, composition is *pure and
29+
resolution-safe*: it never resolves interpolations (``resolve=False``), never starts servers, and
30+
never reads secrets, so it is safe to call when API keys referenced by a config are unset. It also
31+
never emits Hydra override tokens — it returns a new :class:`~omegaconf.DictConfig`.
32+
"""
33+
34+
from dataclasses import dataclass
35+
from typing import Callable, List, Optional
36+
37+
from omegaconf import DictConfig, ListConfig, OmegaConf
38+
39+
40+
# Keys carried over from the environment's existing agent block onto a freshly substituted agent, so
41+
# the agent stays wired to the same resources server, policy model, and benchmark dataset.
42+
_AGENT_TYPE_KEY = "responses_api_agents"
43+
_WIRING_KEYS = ("resources_server", "model_server", "datasets")
44+
_BENCHMARK_DATASET_TYPE = "benchmark"
45+
46+
47+
class ConfigComposerError(ValueError):
48+
"""Base error for failures while composing a merged config."""
49+
50+
51+
class NoComposableAgentBlockError(ConfigComposerError):
52+
"""The merged config has zero (or several ambiguous) agent blocks to compose against."""
53+
54+
55+
class MandatoryPlaceholderError(ConfigComposerError):
56+
"""A mandatory ``???`` (OmegaConf MISSING) field remained after composition."""
57+
58+
59+
@dataclass(frozen=True)
60+
class ComposeRequest:
61+
"""A composition request along the agent and dataset axes (model axis handled by the CLI)."""
62+
63+
agent: Optional[str] = None
64+
num_repeats: Optional[int] = None
65+
prompt_config: Optional[str] = None
66+
67+
@property
68+
def is_empty(self) -> bool:
69+
return self.agent is None and self.num_repeats is None and self.prompt_config is None
70+
71+
72+
def _deepcopy(merged: DictConfig) -> DictConfig:
73+
"""Return an independent, resolution-safe copy of ``merged`` (MISSING fields preserved)."""
74+
return OmegaConf.create(OmegaConf.to_container(merged, resolve=False, throw_on_missing=False))
75+
76+
77+
def _agent_block_type(block: DictConfig) -> Optional[str]:
78+
"""Return the single ``responses_api_agents.<type>`` inner key of a top-level block, if any."""
79+
agents = block.get(_AGENT_TYPE_KEY) if isinstance(block, DictConfig) else None
80+
if not isinstance(agents, DictConfig):
81+
return None
82+
keys = list(agents.keys())
83+
if len(keys) != 1:
84+
return None
85+
return keys[0]
86+
87+
88+
def _dataset_is_benchmark(dataset) -> bool:
89+
return isinstance(dataset, DictConfig) and dataset.get("type") == _BENCHMARK_DATASET_TYPE
90+
91+
92+
def find_agent_block_key(merged: DictConfig) -> str:
93+
"""Return the top-level key whose ``responses_api_agents`` block is the composition target.
94+
95+
Mirroring :mod:`nemo_gym.benchmarks`, the target is the agent block whose ``datasets`` contain a
96+
``type: benchmark`` entry. If exactly one agent block carries such a dataset, it wins even when
97+
other (auxiliary) agent blocks exist. Otherwise the choice must be unambiguous: a single agent
98+
block overall is accepted. Raises :class:`NoComposableAgentBlockError` for zero candidates or an
99+
ambiguous selection, listing the candidate keys.
100+
"""
101+
agent_block_keys: List[str] = []
102+
benchmark_block_keys: List[str] = []
103+
for top_level_key in merged:
104+
block = merged[top_level_key]
105+
agent_type = _agent_block_type(block)
106+
if agent_type is None:
107+
continue
108+
agent_block_keys.append(top_level_key)
109+
inner = block[_AGENT_TYPE_KEY][agent_type]
110+
datasets = inner.get("datasets") if isinstance(inner, DictConfig) else None
111+
if isinstance(datasets, (list, ListConfig)) and any(_dataset_is_benchmark(d) for d in datasets):
112+
benchmark_block_keys.append(top_level_key)
113+
114+
if len(benchmark_block_keys) == 1:
115+
return benchmark_block_keys[0]
116+
if len(benchmark_block_keys) > 1:
117+
raise NoComposableAgentBlockError(
118+
"Multiple agent blocks carry a `type: benchmark` dataset; cannot pick a composition "
119+
f"target unambiguously. Candidates: {sorted(benchmark_block_keys)}."
120+
)
121+
if len(agent_block_keys) == 1:
122+
return agent_block_keys[0]
123+
if len(agent_block_keys) == 0:
124+
raise NoComposableAgentBlockError(
125+
"No `responses_api_agents` block found in the merged config to compose against."
126+
)
127+
raise NoComposableAgentBlockError(
128+
"Ambiguous composition target: several agent blocks exist and none carries a "
129+
f"`type: benchmark` dataset. Candidates: {sorted(agent_block_keys)}."
130+
)
131+
132+
133+
def substitute_agent(
134+
merged: DictConfig,
135+
agent_block_key: str,
136+
new_agent: str,
137+
*,
138+
resolve_agent_config_path: Callable[..., str],
139+
) -> DictConfig:
140+
"""Replace the agent harness in ``agent_block_key`` with ``new_agent``, keeping env wiring.
141+
142+
``new_agent`` is resolved via ``resolve_agent_config_path(new_agent, require_composable=True)``;
143+
a Pattern B (self-contained) agent raises :class:`~nemo_gym.agent_registry.AgentNotComposableError`,
144+
which is left to propagate. The new agent config's inner block (shape
145+
``<k>.responses_api_agents.<new_agent>``) replaces the existing inner agent block, but the
146+
environment's ``resources_server`` / ``model_server`` / ``datasets`` win over the agent config's
147+
own values, and the inner key is re-keyed to ``<new_agent>``.
148+
"""
149+
merged = _deepcopy(merged)
150+
151+
new_agent_path = resolve_agent_config_path(new_agent, require_composable=True)
152+
new_agent_config = OmegaConf.load(new_agent_path)
153+
154+
new_inner_block: Optional[DictConfig] = None
155+
for top_level_value in new_agent_config.values():
156+
agent_type = _agent_block_type(top_level_value)
157+
if agent_type is not None:
158+
new_inner_block = top_level_value[_AGENT_TYPE_KEY][agent_type]
159+
break
160+
if new_inner_block is None:
161+
raise NoComposableAgentBlockError(
162+
f"Agent config for '{new_agent}' at {new_agent_path} has no `responses_api_agents` block."
163+
)
164+
165+
old_agents = merged[agent_block_key][_AGENT_TYPE_KEY]
166+
old_agent_type = next(iter(old_agents.keys()))
167+
old_inner_block = old_agents[old_agent_type]
168+
169+
new_inner_block = _deepcopy(new_inner_block)
170+
for wiring_key in _WIRING_KEYS:
171+
if wiring_key in old_inner_block:
172+
new_inner_block[wiring_key] = old_inner_block[wiring_key]
173+
174+
merged[agent_block_key][_AGENT_TYPE_KEY] = OmegaConf.create({new_agent: new_inner_block})
175+
return merged
176+
177+
178+
def substitute_dataset_params(
179+
merged: DictConfig,
180+
agent_block_key: str,
181+
*,
182+
num_repeats: Optional[int] = None,
183+
prompt_config: Optional[str] = None,
184+
) -> DictConfig:
185+
"""Edit the single ``type: benchmark`` dataset in the agent block; no-op for ``None`` fields."""
186+
if num_repeats is None and prompt_config is None:
187+
return _deepcopy(merged)
188+
189+
merged = _deepcopy(merged)
190+
agents = merged[agent_block_key][_AGENT_TYPE_KEY]
191+
inner = agents[next(iter(agents.keys()))]
192+
datasets = inner.get("datasets") or []
193+
benchmark_datasets = [d for d in datasets if _dataset_is_benchmark(d)]
194+
if len(benchmark_datasets) != 1:
195+
raise NoComposableAgentBlockError(
196+
f"Expected exactly one `type: benchmark` dataset in agent block '{agent_block_key}', "
197+
f"found {len(benchmark_datasets)}."
198+
)
199+
200+
dataset = benchmark_datasets[0]
201+
if num_repeats is not None:
202+
dataset["num_repeats"] = num_repeats
203+
if prompt_config is not None:
204+
dataset["prompt_config"] = prompt_config
205+
return merged
206+
207+
208+
def substitute_model(merged: DictConfig) -> DictConfig:
209+
"""No-op shim: the model axis is owned by the CLI's ``--model*`` flags, not the composer."""
210+
return merged
211+
212+
213+
def _validate_no_mandatory_placeholders(merged: DictConfig, agent_block_key: str) -> None:
214+
"""Raise :class:`MandatoryPlaceholderError` if any ``???`` remains in the agent or its resources.
215+
216+
Walks the composed agent block and, if it references one, the resources-server block it points
217+
at (``resources_server.name`` -> top-level key). Interpolations are *not* resolved.
218+
"""
219+
missing: List[str] = []
220+
221+
def _walk(node, path: str) -> None:
222+
if isinstance(node, DictConfig):
223+
for key in node:
224+
if OmegaConf.is_missing(node, key):
225+
missing.append(f"{path}.{key}" if path else str(key))
226+
else:
227+
_walk(node[key], f"{path}.{key}" if path else str(key))
228+
elif isinstance(node, (list, ListConfig)):
229+
for index, item in enumerate(node):
230+
_walk(item, f"{path}[{index}]")
231+
232+
agent_block = merged[agent_block_key]
233+
_walk(agent_block, agent_block_key)
234+
235+
agents = agent_block[_AGENT_TYPE_KEY]
236+
inner = agents[next(iter(agents.keys()))]
237+
resources_server = inner.get("resources_server") if isinstance(inner, DictConfig) else None
238+
if isinstance(resources_server, DictConfig) and not OmegaConf.is_missing(resources_server, "name"):
239+
resources_key = resources_server.get("name")
240+
if isinstance(resources_key, str) and resources_key in merged:
241+
_walk(merged[resources_key], resources_key)
242+
243+
if missing:
244+
raise MandatoryPlaceholderError(
245+
"Mandatory `???` field(s) remain after composition; provide them via CLI/config overrides: "
246+
+ ", ".join(sorted(missing))
247+
)
248+
249+
250+
def compose(
251+
merged: DictConfig,
252+
request: ComposeRequest,
253+
*,
254+
resolve_agent_config_path: Callable[..., str],
255+
) -> DictConfig:
256+
"""Compose ``merged`` per ``request`` and return a new validated config.
257+
258+
Pipeline: locate the agent block, optionally swap the agent (carrying env wiring), edit the
259+
benchmark dataset params, then assert no mandatory ``???`` field remains. An empty request is a
260+
pure passthrough (still deep-copied and validated).
261+
"""
262+
result = _deepcopy(merged)
263+
agent_block_key = find_agent_block_key(result)
264+
265+
if request.agent is not None:
266+
result = substitute_agent(
267+
result,
268+
agent_block_key,
269+
request.agent,
270+
resolve_agent_config_path=resolve_agent_config_path,
271+
)
272+
273+
result = substitute_dataset_params(
274+
result,
275+
agent_block_key,
276+
num_repeats=request.num_repeats,
277+
prompt_config=request.prompt_config,
278+
)
279+
280+
result = substitute_model(result)
281+
282+
_validate_no_mandatory_placeholders(result, agent_block_key)
283+
return result

0 commit comments

Comments
 (0)