From c97e29da3e04fe023db8b0c8c4d8fff4cdf47c1a Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sun, 15 Mar 2026 22:04:03 -0700 Subject: [PATCH 1/5] add hydra support --- .../isaaclab_tasks/utils/hydra.py | 14 +- source/isaaclab_tasks/test/test_hydra.py | 379 ++++++++++++++++++ 2 files changed, 390 insertions(+), 3 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index e616ee8e7782..2d50547f9f73 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -147,13 +147,21 @@ def collect_presets(cfg, path: str = "") -> dict: else: result.update(collect_presets(value, child_path)) elif isinstance(value, dict): - for dict_key, dict_val in value.items(): - if hasattr(dict_val, "__dataclass_fields__"): - result.update(collect_presets(dict_val, f"{child_path}.{dict_key}")) + _collect_from_dict(value, child_path, result) return result +def _collect_from_dict(d: dict, path: str, result: dict) -> None: + """Recursively collect presets from dict values, including nested dicts.""" + for dict_key, dict_val in d.items(): + child_path = f"{path}.{dict_key}" + if hasattr(dict_val, "__dataclass_fields__"): + result.update(collect_presets(dict_val, child_path)) + elif isinstance(dict_val, dict): + _collect_from_dict(dict_val, child_path, result) + + def resolve_task_config(task_name: str, agent_cfg_entry_point: str): """Resolve env and agent configs with Hydra overrides, presets, and scalars fully applied. diff --git a/source/isaaclab_tasks/test/test_hydra.py b/source/isaaclab_tasks/test/test_hydra.py index 64930b00bdad..b63ec9655b0c 100644 --- a/source/isaaclab_tasks/test/test_hydra.py +++ b/source/isaaclab_tasks/test/test_hydra.py @@ -700,3 +700,382 @@ class EnvCfgFactory: assert presets["robot.actuators.legs.armature"]["default"] == 0.0 assert presets["robot.actuators.legs.armature"]["newton"] == 0.01 assert presets["robot.actuators.legs.armature"]["physx"] == 0.0 + + +# ============================================================================= +# Tests: PresetCfg inside deeply nested dicts (e.g., event term params) +# ============================================================================= + + +@configclass +class OffsetCfg(PresetCfg): + """Mimics task-specific offset presets (e.g., AssembledOffsetCfg).""" + + task_a: tuple = (0.0, 0.0, 0.01) + task_b: tuple = (0.02, 0.0, 0.005) + default: tuple = task_a + + +@configclass +class FractionCfg(PresetCfg): + task_a: tuple = (0.05, 0.5) + task_b: tuple = (0.3, 1.0) + default: tuple = task_a + + +@configclass +class InnerTermCfg: + """Mimics an EventTermCfg with params containing presets.""" + + func: str = "reset_fn" + params: dict = None + + def __post_init__(self): + if self.params is None: + self.params = { + "offset": OffsetCfg(), + "fraction": FractionCfg(), + } + + +@configclass +class OuterTermCfg: + """Mimics a chained reset term with nested terms dict.""" + + func: str = "chain_fn" + params: dict = None + + def __post_init__(self): + if self.params is None: + self.params = { + "terms": { + "step_one": InnerTermCfg(), + } + } + + +@configclass +class DeepDictEnvCfg: + decimation: int = 4 + events: OuterTermCfg = OuterTermCfg() + + +def test_collect_presets_deep_nested_dicts(): + """collect_presets discovers PresetCfg inside dict→dict→configclass→dict chains.""" + cfg = DeepDictEnvCfg() + presets = collect_presets(cfg) + offset_path = "events.params.terms.step_one.params.offset" + fraction_path = "events.params.terms.step_one.params.fraction" + assert offset_path in presets, f"Expected '{offset_path}' in {list(presets.keys())}" + assert fraction_path in presets, f"Expected '{fraction_path}' in {list(presets.keys())}" + assert presets[offset_path]["task_a"] == (0.0, 0.0, 0.01) + assert presets[offset_path]["task_b"] == (0.02, 0.0, 0.005) + assert presets[fraction_path]["task_a"] == (0.05, 0.5) + assert presets[fraction_path]["task_b"] == (0.3, 1.0) + + +def test_resolve_preset_defaults_deep_nested_dicts(): + """resolve_preset_defaults resolves presets inside deeply nested dicts.""" + cfg = DeepDictEnvCfg() + resolved = resolve_preset_defaults(cfg) + inner = resolved.events.params["terms"]["step_one"] + assert inner.params["offset"] == (0.0, 0.0, 0.01) + assert inner.params["fraction"] == (0.05, 0.5) + assert not isinstance(inner.params["offset"], PresetCfg) + assert not isinstance(inner.params["fraction"], PresetCfg) + + +def test_deep_nested_dict_auto_default(): + """Deeply nested dict presets auto-apply default when no CLI override.""" + env_cfg = DeepDictEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + env_cfg = resolve_preset_defaults(env_cfg) + agent_cfg = resolve_preset_defaults(agent_cfg) + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [], [], presets) + inner = env_cfg.events.params["terms"]["step_one"] + assert inner.params["offset"] == (0.0, 0.0, 0.01) + assert inner.params["fraction"] == (0.05, 0.5) + + +def test_deep_nested_dict_global_preset(): + """Global preset=task_b replaces deeply nested dict presets.""" + env_cfg = DeepDictEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + env_cfg = resolve_preset_defaults(env_cfg) + agent_cfg = resolve_preset_defaults(agent_cfg) + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["task_b"], [], [], presets) + inner = env_cfg.events.params["terms"]["step_one"] + assert inner.params["offset"] == (0.02, 0.0, 0.005), ( + f"offset should be task_b value, got {inner.params['offset']}" + ) + assert inner.params["fraction"] == (0.3, 1.0), ( + f"fraction should be task_b value, got {inner.params['fraction']}" + ) + + +def test_deep_nested_dict_path_selection(): + """Path selection replaces a specific deeply nested dict preset.""" + env_cfg = DeepDictEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + env_cfg = resolve_preset_defaults(env_cfg) + agent_cfg = resolve_preset_defaults(agent_cfg) + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + sel = [("env", "events.params.terms.step_one.params.offset", "task_b")] + apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], sel, [], presets) + inner = env_cfg.events.params["terms"]["step_one"] + assert inner.params["offset"] == (0.02, 0.0, 0.005) + assert inner.params["fraction"] == (0.05, 0.5) # untouched + + +def test_deep_nested_dict_mixed_global_and_path(): + """Global preset applies to nested dicts, path selection overrides one.""" + env_cfg = DeepDictEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + env_cfg = resolve_preset_defaults(env_cfg) + agent_cfg = resolve_preset_defaults(agent_cfg) + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + sel = [("env", "events.params.terms.step_one.params.fraction", "task_a")] + apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["task_b"], sel, [], presets) + inner = env_cfg.events.params["terms"]["step_one"] + assert inner.params["offset"] == (0.02, 0.0, 0.005) # from global task_b + assert inner.params["fraction"] == (0.05, 0.5) # path override keeps task_a + + +# ============================================================================= +# Tests: preset() factory function +# ============================================================================= + + +def test_preset_factory_creates_presetcfg(): + """preset() returns a PresetCfg subclass instance with correct fields.""" + p = preset(default=0.0, high=1.0, low=-1.0) + assert isinstance(p, PresetCfg) + assert p.default == 0.0 + assert p.high == 1.0 + assert p.low == -1.0 + + +def test_preset_factory_collectable(): + """preset()-created instances are discovered by collect_presets.""" + + @configclass + class FactoryEnvCfg: + damping: object = None + + def __post_init__(self): + if self.damping is None: + self.damping = preset(default=5.0, high=20.0) + + cfg = FactoryEnvCfg() + presets = collect_presets(cfg) + assert "damping" in presets + assert presets["damping"]["default"] == 5.0 + assert presets["damping"]["high"] == 20.0 + + +def test_preset_factory_requires_default(): + """preset() raises ValueError when 'default' is not provided.""" + with pytest.raises(ValueError, match="default"): + preset(high=1.0, low=-1.0) + + +def test_preset_factory_string_values(): + """preset() works with string values.""" + p = preset(default="cpu", gpu="cuda:0") + assert isinstance(p, PresetCfg) + assert p.default == "cpu" + assert p.gpu == "cuda:0" + + +# ============================================================================= +# Tests: _collect_fields class-vs-instance priority +# ============================================================================= + + +def test_collect_fields_prefers_class_attr_over_instance(): + """Class-level attr mutations take priority over instance attrs in collection. + + This mirrors the pattern where robot-specific modules (e.g., joint_pos_env_cfg.py) + mutate PresetCfg class attributes after instances are already created. + """ + + @configclass + class MutablePresetCfg(PresetCfg): + default: str = "original_default" + alt: str = "alternative" + + instance = MutablePresetCfg() + assert instance.default == "original_default" + + MutablePresetCfg.default = "robot_specific_default" + + presets = collect_presets(instance) + assert "" in presets + assert presets[""]["default"] == "robot_specific_default" + + MutablePresetCfg.default = "original_default" + + +def test_collect_fields_includes_dynamic_class_attrs(): + """Fields added to PresetCfg class at runtime are discovered.""" + + @configclass + class ExtensiblePresetCfg(PresetCfg): + default: str = "base" + alt_a: str = "a" + + ExtensiblePresetCfg.alt_b = "b" + + instance = ExtensiblePresetCfg() + presets = collect_presets(instance) + assert "" in presets + assert "alt_b" in presets[""] + assert presets[""]["alt_b"] == "b" + + delattr(ExtensiblePresetCfg, "alt_b") + + +# ============================================================================= +# Tests: apply_overrides error handling +# ============================================================================= + + +def test_apply_overrides_unknown_preset_group_raises(): + """apply_overrides raises ValueError for unknown preset group paths.""" + env_cfg = PresetCfgEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + with pytest.raises(ValueError, match="Unknown preset group"): + apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "nonexistent", "val")], [], presets) + + +def test_apply_overrides_unknown_preset_name_raises(): + """apply_overrides raises ValueError for unknown preset name.""" + env_cfg = PresetCfgEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + with pytest.raises(ValueError, match="Unknown preset 'nonexistent'"): + apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "nonexistent")], [], presets) + + +def test_apply_overrides_conflicting_globals_raises(): + """Two global presets matching the same path cause ValueError.""" + + @configclass + class TwoAltsPresetCfg(PresetCfg): + default: str = "d" + opt_a: str = "a" + opt_b: str = "b" + + @configclass + class ConflictEnvCfg: + mode: TwoAltsPresetCfg = TwoAltsPresetCfg() + + env_cfg = ConflictEnvCfg() + agent_cfg = PresetCfgAgentCfg() + presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + with pytest.raises(ValueError, match="Conflicting global presets"): + apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["opt_a", "opt_b"], [], [], presets) + + +# ============================================================================= +# Tests: parse_overrides edge cases +# ============================================================================= + + +def test_parse_overrides_multiple_global_presets(): + """Multiple comma-separated global presets are split correctly.""" + presets = {"env": {"backend": {"default": None, "newton": None}}, "agent": {}} + global_p, _, _, _ = parse_overrides(["presets=fast,newton,debug"], presets) + assert global_p == ["fast", "newton", "debug"] + + +def test_parse_overrides_no_equals_treated_as_global_scalar(): + """Arguments without '=' are passed through as global scalars.""" + presets = {"env": {}, "agent": {}} + _, _, _, global_scalar = parse_overrides(["--flag", "positional"], presets) + assert "--flag" in global_scalar + assert "positional" in global_scalar + + +def test_parse_overrides_preset_scalar_detection(): + """Scalar within a preset path is detected as preset_scalar.""" + presets = {"env": {"backend": {"default": None}}, "agent": {}} + _, _, preset_scalar, _ = parse_overrides(["env.backend.dt=0.001", "env.backend.substeps=4"], presets) + assert ("env.backend.dt", "0.001") in preset_scalar + assert ("env.backend.substeps", "4") in preset_scalar + + +def test_parse_overrides_root_level_env_preset(): + """Root-level PresetCfg (path='') makes env= a valid preset selection.""" + presets = {"env": {"": {"default": None, "fast": None}}, "agent": {}} + _, sel, _, _ = parse_overrides(["env=fast"], presets) + assert sel == [("env", "", "fast")] + + +# ============================================================================= +# Tests: _parse_val +# ============================================================================= + + +def test_parse_val_types(): + """_parse_val converts strings to correct Python types.""" + from isaaclab_tasks.utils.hydra import _parse_val + + assert _parse_val("true") is True + assert _parse_val("True") is True + assert _parse_val("false") is False + assert _parse_val("none") is None + assert _parse_val("null") is None + assert _parse_val("42") == 42 + assert isinstance(_parse_val("42"), int) + assert _parse_val("3.14") == 3.14 + assert isinstance(_parse_val("3.14"), float) + assert _parse_val("hello") == "hello" + assert _parse_val('"quoted"') == "quoted" + assert _parse_val("'single'") == "single" + + +# ============================================================================= +# Tests: scalar override within preset path +# ============================================================================= + + +def test_scalar_override_within_preset_path(class_presets): + """Scalar overrides within preset paths are applied on top of the preset.""" + env_cfg, agent_cfg, presets = class_presets + env_cfg = resolve_preset_defaults(env_cfg) + agent_cfg = resolve_preset_defaults(agent_cfg) + hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} + apply_overrides( + env_cfg, agent_cfg, hydra_cfg, + [], [("env", "backend", "newton")], + [("env.backend.dt", "0.001")], presets, + ) + assert isinstance(env_cfg.backend, NewtonCfg) + assert env_cfg.backend.dt == 0.001 # overridden from 0.002 + assert env_cfg.backend.substeps == 4 # untouched + + +# ============================================================================= +# Tests: resolve_preset_defaults idempotency +# ============================================================================= + + +def test_resolve_preset_defaults_idempotent(): + """Calling resolve_preset_defaults twice yields the same result.""" + cfg = PresetCfgEnvCfg() + first = resolve_preset_defaults(cfg) + second = resolve_preset_defaults(first) + assert isinstance(second.backend, PhysxCfg) + assert isinstance(second.observations, NoiselessObservationsCfg) + assert second.backend.dt == first.backend.dt From 45e59e5916f1e7b0270c199488b9fcf83a99138b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sun, 15 Mar 2026 22:07:25 -0700 Subject: [PATCH 2/5] changlot update --- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 337e38d1ac92..8fc8813b3635 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.14" +version = "1.5.15" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index b09c7a5a8f05..1b232e4cb91b 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +1.5.15 (2026-03-24) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab_tasks.utils.hydra.collect_presets` not discovering + presets inside nested dicts (e.g. ``EventTerm.params.terms.*.params``). + +Added +^^^^^ + +* Added unit tests for the Hydra preset system. + + 1.5.14 (2026-03-24) ~~~~~~~~~~~~~~~~~~~ From d5076ae1e083a16573fc64952d9fb43cdacb1f6c Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sun, 15 Mar 2026 22:12:25 -0700 Subject: [PATCH 3/5] pass precommit --- source/isaaclab_tasks/test/test_hydra.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/source/isaaclab_tasks/test/test_hydra.py b/source/isaaclab_tasks/test/test_hydra.py index b63ec9655b0c..79c885af1cb3 100644 --- a/source/isaaclab_tasks/test/test_hydra.py +++ b/source/isaaclab_tasks/test/test_hydra.py @@ -809,12 +809,8 @@ def test_deep_nested_dict_global_preset(): hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["task_b"], [], [], presets) inner = env_cfg.events.params["terms"]["step_one"] - assert inner.params["offset"] == (0.02, 0.0, 0.005), ( - f"offset should be task_b value, got {inner.params['offset']}" - ) - assert inner.params["fraction"] == (0.3, 1.0), ( - f"fraction should be task_b value, got {inner.params['fraction']}" - ) + assert inner.params["offset"] == (0.02, 0.0, 0.005), f"offset should be task_b value, got {inner.params['offset']}" + assert inner.params["fraction"] == (0.3, 1.0), f"fraction should be task_b value, got {inner.params['fraction']}" def test_deep_nested_dict_path_selection(): @@ -1057,9 +1053,13 @@ def test_scalar_override_within_preset_path(class_presets): agent_cfg = resolve_preset_defaults(agent_cfg) hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} apply_overrides( - env_cfg, agent_cfg, hydra_cfg, - [], [("env", "backend", "newton")], - [("env.backend.dt", "0.001")], presets, + env_cfg, + agent_cfg, + hydra_cfg, + [], + [("env", "backend", "newton")], + [("env.backend.dt", "0.001")], + presets, ) assert isinstance(env_cfg.backend, NewtonCfg) assert env_cfg.backend.dt == 0.001 # overridden from 0.002 From f592bd2e0ca81bb66470cd7684a01440ba099800 Mon Sep 17 00:00:00 2001 From: AntoineRichard Date: Mon, 16 Mar 2026 16:53:20 +0100 Subject: [PATCH 4/5] implemented greptile's fix. --- .../isaaclab_tasks/utils/hydra.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index 2d50547f9f73..535055ebfdcd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -282,12 +282,25 @@ def resolve_preset_defaults(cfg): elif hasattr(value, "__dataclass_fields__"): resolve_preset_defaults(value) elif isinstance(value, dict): - for dict_val in value.values(): - if hasattr(dict_val, "__dataclass_fields__"): - resolve_preset_defaults(dict_val) + _resolve_from_dict(value) return cfg +def _resolve_from_dict(d: dict) -> None: + """Recursively resolve preset defaults in dict values, including nested dicts.""" + for dict_key, dict_val in d.items(): + if isinstance(dict_val, PresetCfg) and hasattr(dict_val, "__dataclass_fields__"): + default = getattr(dict_val, "default", None) + if default is not None: + d[dict_key] = default + if hasattr(default, "__dataclass_fields__"): + resolve_preset_defaults(default) + elif hasattr(dict_val, "__dataclass_fields__"): + resolve_preset_defaults(dict_val) + elif isinstance(dict_val, dict): + _resolve_from_dict(dict_val) + + def register_task(task_name: str, agent_entry: str) -> tuple: """Load configs, collect presets recursively, register base config to Hydra. From 7c0fafe8d50cc5e7bb71eef978987e010f834e69 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 24 Mar 2026 17:57:13 -0700 Subject: [PATCH 5/5] implement fixes --- .../isaaclab_tasks/utils/hydra.py | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index 535055ebfdcd..7370e5694758 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -113,11 +113,36 @@ def collect_presets(cfg, path: str = "") -> dict: """ result = {} + def _collect_fields(preset_obj): + """Collect fields from a PresetCfg, preferring class attrs over instance attrs. + + Robot-specific modules (e.g. ``joint_pos_env_cfg.py``) add new fields + and reassign ``default`` on the class after instances are created. + Class-level values take priority so that ``collect_presets`` sees them. + """ + cls = type(preset_obj) + d = {} + for fn in preset_obj.__dataclass_fields__: + cls_val = getattr(cls, fn, None) + d[fn] = cls_val if cls_val is not None else getattr(preset_obj, fn) + for attr in vars(cls): + if attr.startswith("_") or attr in d or callable(getattr(cls, attr)): + continue + d[attr] = getattr(cls, attr) + return d + + def _collect_from_dict(d: dict, dict_path: str) -> None: + """Recursively collect presets from dict values, including nested dicts.""" + for dict_key, dict_val in d.items(): + child_path = f"{dict_path}.{dict_key}" + if hasattr(dict_val, "__dataclass_fields__"): + result.update(collect_presets(dict_val, child_path)) + elif isinstance(dict_val, dict): + _collect_from_dict(dict_val, child_path) + # Root-level PresetCfg: the cfg itself is a PresetCfg subclass if isinstance(cfg, PresetCfg) and hasattr(cfg, "__dataclass_fields__"): - preset_dict = {} - for field_name in cfg.__dataclass_fields__: - preset_dict[field_name] = getattr(cfg, field_name) + preset_dict = _collect_fields(cfg) result[path] = preset_dict for alt in preset_dict.values(): if hasattr(alt, "__dataclass_fields__"): @@ -137,9 +162,7 @@ def collect_presets(cfg, path: str = "") -> dict: if hasattr(value, "__dataclass_fields__"): if isinstance(value, PresetCfg): - preset_dict = {} - for field_name in value.__dataclass_fields__: - preset_dict[field_name] = getattr(value, field_name) + preset_dict = _collect_fields(value) result[child_path] = preset_dict for alt in preset_dict.values(): if hasattr(alt, "__dataclass_fields__"): @@ -147,21 +170,11 @@ def collect_presets(cfg, path: str = "") -> dict: else: result.update(collect_presets(value, child_path)) elif isinstance(value, dict): - _collect_from_dict(value, child_path, result) + _collect_from_dict(value, child_path) return result -def _collect_from_dict(d: dict, path: str, result: dict) -> None: - """Recursively collect presets from dict values, including nested dicts.""" - for dict_key, dict_val in d.items(): - child_path = f"{path}.{dict_key}" - if hasattr(dict_val, "__dataclass_fields__"): - result.update(collect_presets(dict_val, child_path)) - elif isinstance(dict_val, dict): - _collect_from_dict(dict_val, child_path, result) - - def resolve_task_config(task_name: str, agent_cfg_entry_point: str): """Resolve env and agent configs with Hydra overrides, presets, and scalars fully applied.