From 652a522e501aa7587b823b42510aa5d288b36e89 Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:50:42 -0700 Subject: [PATCH] fix(config): deserialize skill source tables (#703) --- src/openjarvis/core/config.py | 54 ++++++++++++++++++++---- tests/core/test_config_skills_sources.py | 37 +++++++++++++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 593bfa1d..0ed53829 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -12,9 +12,18 @@ import os import platform import shutil import subprocess -from dataclasses import dataclass, field +from dataclasses import dataclass, field, is_dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Optional, + get_args, + get_origin, + get_type_hints, +) from openjarvis.core.paths import ( ConfigurationError, @@ -1710,10 +1719,16 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None: """Overlay TOML key/value pairs onto a dataclass instance. Recursively handles nested dicts when the target attribute is itself - a dataclass. Normalises TOML arrays to comma-separated strings — both - for dataclass fields annotated as ``str`` and for backward-compat - property setters that expect string input. + a dataclass, including dict entries in lists of dataclasses. Normalises + TOML arrays to comma-separated strings — both for dataclass fields annotated + as ``str`` and for backward-compat property setters that expect string input. """ + try: + type_hints = get_type_hints(type(target)) + except (NameError, TypeError): + # Some config types contain optional runtime-only forward references. + type_hints = {} + for key, value in section.items(): if hasattr(target, key): if isinstance(value, dict): @@ -1728,14 +1743,35 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None: # property setters (e.g. reward_weights, default_tools). if isinstance(value, list): is_str_field = False + item_dataclass = None if hasattr(target, "__dataclass_fields__"): field_obj = target.__dataclass_fields__.get(key) - if field_obj is not None and field_obj.type in ("str", str): - is_str_field = True - elif field_obj is None: + if field_obj is not None: + field_type = type_hints.get(key, field_obj.type) + type_args = get_args(field_type) + if ( + get_origin(field_type) is list + and len(type_args) == 1 + and is_dataclass(type_args[0]) + ): + item_dataclass = type_args[0] + elif field_obj.type in ("str", str): + is_str_field = True + else: # Property, not a real field — normalise to string is_str_field = True - if is_str_field: + + if item_dataclass is not None: + converted = [] + for item in value: + if isinstance(item, dict): + nested = item_dataclass() + _apply_toml_section(nested, item) + converted.append(nested) + else: + converted.append(item) + value = converted + elif is_str_field: value = ",".join(str(v) for v in value) setattr(target, key, value) diff --git a/tests/core/test_config_skills_sources.py b/tests/core/test_config_skills_sources.py index 2df0c965..a0f8b9cb 100644 --- a/tests/core/test_config_skills_sources.py +++ b/tests/core/test_config_skills_sources.py @@ -2,7 +2,9 @@ from __future__ import annotations -from openjarvis.core.config import SkillsConfig, SkillSourceConfig +from pathlib import Path + +from openjarvis.core.config import SkillsConfig, SkillSourceConfig, load_config class TestSkillSourceConfig: @@ -41,3 +43,36 @@ class TestSkillsConfigWithSources: ) assert len(cfg.sources) == 2 assert cfg.sources[0].source == "hermes" + + def test_loads_source_tables_as_config_objects( + self, tmp_path: Path, monkeypatch + ) -> None: + monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home")) + toml_file = tmp_path / "config.toml" + toml_file.write_text( + "[[skills.sources]]\n" + 'source = "hermes"\n' + 'filter = { category = ["productivity"] }\n\n' + "[[skills.sources]]\n" + 'source = "github"\n' + 'url = "https://github.com/example/skill-library"\n' + "auto_update = true\n" + ) + + load_config.cache_clear() + try: + cfg = load_config(toml_file) + finally: + load_config.cache_clear() + + assert cfg.skills.sources == [ + SkillSourceConfig( + source="hermes", + filter={"category": ["productivity"]}, + ), + SkillSourceConfig( + source="github", + url="https://github.com/example/skill-library", + auto_update=True, + ), + ]