Skip to content
Open
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: 12 additions & 7 deletions nemo_gym/environment/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import orjson
from omegaconf import DictConfig, OmegaConf
from omegaconf.errors import InterpolationResolutionError
from pydantic import BaseModel, ValidationError
from yaml import YAMLError

Expand All @@ -35,6 +36,7 @@
GlobalConfigDictParserConfig,
dataset_agent_pins,
resolve_dataset_agent,
translate_interpolation_error,
)
from nemo_gym.prompt import apply_prompt_to_row, load_prompt_config, validate_prompt_compatibility

Expand Down Expand Up @@ -171,14 +173,17 @@ def _resolve_manifest_composition(config_path: Path) -> ResolvedComposition:
)
parser = GlobalConfigDictParser()
with _with_component_root(config_path):
resolved = parser.parse(
GlobalConfigDictParserConfig(
initial_global_config_dict=initial,
skip_load_from_cli=True,
skip_load_from_dotenv=True,
offline=True,
try:
resolved = parser.parse(
GlobalConfigDictParserConfig(
initial_global_config_dict=initial,
skip_load_from_cli=True,
skip_load_from_dotenv=True,
offline=True,
)
)
)
except InterpolationResolutionError as e:

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.

Is this the only place where we had this raw interpolation error? If not, maybe it's worth updating all at once

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked the other two direct GlobalConfigDictParser().parse() call sites. benchmarks.py's strict branch is unguarded, but nothing calls it with strict=True in production, the one caller passes strict=False, which already tolerates unresolved interpolations via _parse_no_environment_tolerating_unset_values. _run_manifest_verifier in cli/env.py also calls .parse() unguarded, but its input is the fixed NO_MODEL_GLOBAL_CONFIG_DICT constant with no config_paths, so there's nothing there that could reference an undefined key.

Neither looked live to me, so I left them alone.

raise translate_interpolation_error(e) from e
servers = parser.filter_for_server_instance_configs(resolved)
by_instance = {server.name: server for server in servers}
agents = [server for server in servers if server.SERVER_TYPE == "responses_api_agents"]
Expand Down
34 changes: 19 additions & 15 deletions nemo_gym/global_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,24 @@ def _apply_verbosity(global_config_dict: DictConfig) -> None:
logging.getLogger().setLevel(logging.DEBUG)


def translate_interpolation_error(e: InterpolationResolutionError) -> ConfigInterpolationError:
"""Same class of user error as an unset '???' (see raise_on_missing_values), reported the same way
instead of letting omegaconf's traceback reach the top level. Covers both a missing `${key}`
(InterpolationKeyError) and a failing resolver such as `${oc.env:VAR}`, which carries its own
message and so is passed through as-is."""
match = re.search(r"Interpolation key '([^']+)' not found", str(e))
if not match:
return ConfigInterpolationError(str(e))
key = match.group(1)
return ConfigInterpolationError(
f"""Config value '{e.full_key}' references '{key}', which is not set after merging.

Provide it via a CLI override, in env.yaml, or in a config you pass via config_paths.
For example, on the command line:
++{key}=<value>"""
)


def set_global_config_dict(
global_config_dict_parser_config: Optional[GlobalConfigDictParserConfig] = None,
global_config_dict_parser_cls: Type[GlobalConfigDictParser] = GlobalConfigDictParser,
Expand All @@ -1390,21 +1408,7 @@ def set_global_config_dict(
try:
global_config_dict = global_config_dict_parser_cls().parse(global_config_dict_parser_config)
except InterpolationResolutionError as e:
# Same class of user error as an unset '???' (see raise_on_missing_values), so report it the same
# way instead of letting omegaconf's traceback reach the top level. Covers both a missing `${key}`
# (InterpolationKeyError) and a failing resolver such as `${oc.env:VAR}`, which carries its own
# message and so is passed through as-is.
match = re.search(r"Interpolation key '([^']+)' not found", str(e))
if not match:
raise ConfigInterpolationError(str(e)) from e
key = match.group(1)
raise ConfigInterpolationError(
f"""Config value '{e.full_key}' references '{key}', which is not set after merging.

Provide it via a CLI override, in env.yaml, or in a config you pass via config_paths.
For example, on the command line:
++{key}=<value>"""
) from e
raise translate_interpolation_error(e) from e

_GLOBAL_CONFIG_DICT = global_config_dict

Expand Down
16 changes: 16 additions & 0 deletions tests/unit_tests/test_environment_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest

from nemo_gym.config_types import ConfigInterpolationError
from nemo_gym.environment.manifest import EnvironmentManifest, dump_manifest, load_manifest
from nemo_gym.environment.validation import (
EnvironmentValidationError,
Expand Down Expand Up @@ -357,6 +358,21 @@ def test_benchmark_uses_root_prompt_without_executing_prepare(tmp_path: Path) ->
assert report.datasets[0].prompt_config.endswith("prompts/default.yaml")


def test_unresolved_interpolation_is_reported_cleanly_not_as_a_traceback(tmp_path: Path) -> None:
manifest_path = _asset(tmp_path)
config_path = manifest_path.with_name("config.yaml")
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(

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.

This test doesn't check the manifest path, but the legacy path. Please test your code with incomplete manifest instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked with a debug trace: this goes through the new except in _resolve_manifest_composition, not through set_global_config_dict (that one already had this handling before this PR, so it wasn't the broken path). The literal #2224 repro, gym env validate --config example_mcp_weather.yaml with no --manifest, doesn't reproduce on current main either way, same reason.

Not sure what distinguishes "manifest path" from "legacy path" here in your read, since the manifest branch is the one that was actually broken. Happy to change the test if you can point at the specific gap.

"jsonl_fpath: environments/demo/data/example.jsonl",
"jsonl_fpath: environments/demo/data/example.jsonl\n num_repeats: ${undefined_key}",
),
encoding="utf-8",
)

with pytest.raises(ConfigInterpolationError, match="undefined_key"):
validate_environment(manifest_path)


def test_malformed_benchmark_prompt_is_an_actionable_validation_error(tmp_path: Path) -> None:
manifest_path = _asset(tmp_path, kind="benchmark")
manifest_path.parent.joinpath("prompts/default.yaml").write_text("user: [broken\n", encoding="utf-8")
Expand Down
Loading