diff --git a/openhands/core/config/__init__.py b/openhands/core/config/__init__.py index d653f3e70a..e927c4f3b0 100644 --- a/openhands/core/config/__init__.py +++ b/openhands/core/config/__init__.py @@ -5,6 +5,7 @@ from openhands.core.config.config_utils import ( OH_MAX_ITERATIONS, get_field_info, ) +from openhands.core.config.extended_config import ExtendedConfig from openhands.core.config.llm_config import LLMConfig from openhands.core.config.sandbox_config import SandboxConfig from openhands.core.config.security_config import SecurityConfig @@ -28,6 +29,7 @@ __all__ = [ 'LLMConfig', 'SandboxConfig', 'SecurityConfig', + 'ExtendedConfig', 'load_app_config', 'load_from_env', 'load_from_toml', diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py index 7abf0936f8..98bc5d0c27 100644 --- a/openhands/core/config/app_config.py +++ b/openhands/core/config/app_config.py @@ -9,6 +9,7 @@ from openhands.core.config.config_utils import ( OH_MAX_ITERATIONS, model_defaults_to_dict, ) +from openhands.core.config.extended_config import ExtendedConfig from openhands.core.config.llm_config import LLMConfig from openhands.core.config.sandbox_config import SandboxConfig from openhands.core.config.security_config import SecurityConfig @@ -52,6 +53,7 @@ class AppConfig(BaseModel): default_agent: str = Field(default=OH_DEFAULT_AGENT) sandbox: SandboxConfig = Field(default_factory=SandboxConfig) security: SecurityConfig = Field(default_factory=SecurityConfig) + extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({})) runtime: str = Field(default='docker') file_store: str = Field(default='local') file_store_path: str = Field(default='/tmp/openhands_file_store') diff --git a/openhands/core/config/extended_config.py b/openhands/core/config/extended_config.py new file mode 100644 index 0000000000..071a6872d2 --- /dev/null +++ b/openhands/core/config/extended_config.py @@ -0,0 +1,40 @@ +from pydantic import RootModel + + +class ExtendedConfig(RootModel[dict]): + """Configuration for extended functionalities. + + This is implemented as a root model so that the entire input is stored + as the root value. This allows arbitrary keys to be stored and later + accessed via attribute or dictionary-style access. + """ + + @property + def root(self) -> dict: # type annotation to help mypy + return super().root + + def __str__(self) -> str: + # Use the root dict to build a string representation. + attr_str = [f'{k}={repr(v)}' for k, v in self.root.items()] + return f"ExtendedConfig({', '.join(attr_str)})" + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def from_dict(cls, data: dict) -> 'ExtendedConfig': + # Create an instance directly by wrapping the input dict. + return cls(data) + + def __getitem__(self, key: str) -> object: + # Provide dictionary-like access via the root dict. + return self.root[key] + + def __getattr__(self, key: str) -> object: + # Fallback for attribute access using the root dict. + try: + return self.root[key] + except KeyError as e: + raise AttributeError( + f"'ExtendedConfig' object has no attribute '{key}'" + ) from e diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index f057eb6ad2..b1e9a6ba09 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -19,6 +19,7 @@ from openhands.core.config.config_utils import ( OH_DEFAULT_AGENT, OH_MAX_ITERATIONS, ) +from openhands.core.config.extended_config import ExtendedConfig from openhands.core.config.llm_config import LLMConfig from openhands.core.config.sandbox_config import SandboxConfig from openhands.core.config.security_config import SecurityConfig @@ -134,6 +135,10 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None: for key, value in toml_config.items(): if isinstance(value, dict): try: + if key.lower() == 'extended': + # For ExtendedConfig (RootModel), pass the entire dict as the root value + cfg.extended = ExtendedConfig(value) + continue if key is not None and key.lower() == 'agent': # Every entry here is either a field for the default `agent` config group, or itself a group # The best way to tell the difference is to try to parse it as an AgentConfig object diff --git a/tests/unit/test_config_extended.py b/tests/unit/test_config_extended.py new file mode 100644 index 0000000000..8dbfa6b9bf --- /dev/null +++ b/tests/unit/test_config_extended.py @@ -0,0 +1,169 @@ +import os + +import pytest + +from openhands.core.config.app_config import AppConfig +from openhands.core.config.extended_config import ExtendedConfig +from openhands.core.config.utils import load_from_toml + + +def test_extended_config_from_dict(): + """ + Test that ExtendedConfig.from_dict successfully creates an instance + from a dictionary containing arbitrary extra keys. + """ + data = {'foo': 'bar', 'baz': 123, 'flag': True} + ext_cfg = ExtendedConfig.from_dict(data) + + # Check that the keys are accessible both as attributes and via __getitem__ + assert ext_cfg.foo == 'bar' + assert ext_cfg['baz'] == 123 + assert ext_cfg.flag is True + # Verify the root dictionary contains all keys + assert ext_cfg.root == data + + +def test_extended_config_empty(): + """ + Test that an empty ExtendedConfig can be created and accessed. + """ + ext_cfg = ExtendedConfig.from_dict({}) + assert ext_cfg.root == {} + + # Creating directly should also work + ext_cfg2 = ExtendedConfig({}) + assert ext_cfg2.root == {} + + +def test_extended_config_str_and_repr(): + """ + Test that __str__ and __repr__ return the correct string representations + of the ExtendedConfig instance. + """ + data = {'alpha': 'test', 'beta': 42} + ext_cfg = ExtendedConfig.from_dict(data) + string_repr = str(ext_cfg) + repr_str = repr(ext_cfg) + + # Ensure the representations include our key/value pairs + assert "alpha='test'" in string_repr + assert 'beta=42' in string_repr + + # __repr__ should match __str__ + assert string_repr == repr_str + + +def test_extended_config_getitem_and_getattr(): + """ + Test that __getitem__ and __getattr__ can be used to access values + in the ExtendedConfig instance. + """ + data = {'key1': 'value1', 'key2': 2} + ext_cfg = ExtendedConfig.from_dict(data) + + # Attribute access + assert ext_cfg.key1 == 'value1' + # Dictionary-style access + assert ext_cfg['key2'] == 2 + + +def test_extended_config_invalid_key(): + """ + Test that accessing a non-existent key via attribute access raises AttributeError. + """ + data = {'existing': 'yes'} + ext_cfg = ExtendedConfig.from_dict(data) + + with pytest.raises(AttributeError): + _ = ext_cfg.nonexistent + + with pytest.raises(KeyError): + _ = ext_cfg['nonexistent'] + + +def test_app_config_extended_from_toml(tmp_path: os.PathLike) -> None: + """ + Test that the [extended] section in a TOML file is correctly loaded into + AppConfig.extended and that it accepts arbitrary keys. + """ + # Create a temporary TOML file with multiple sections including [extended] + config_content = """ +[core] +workspace_base = "/tmp/workspace" + +[llm] +model = "test-model" +api_key = "toml-api-key" + +[extended] +custom1 = "custom_value" +custom2 = 42 +llm = "overridden" # even a key like 'llm' is accepted in extended + +[agent] +memory_enabled = true +""" + config_file = tmp_path / 'config.toml' + config_file.write_text(config_content) + + # Load the TOML into the AppConfig instance + config = AppConfig() + load_from_toml(config, str(config_file)) + + # Verify that extended section is applied + assert config.extended.custom1 == 'custom_value' + assert config.extended.custom2 == 42 + # Even though 'llm' is defined in extended, it should not affect the main llm config. + assert config.get_llm_config().model == 'test-model' + + +def test_app_config_extended_default(tmp_path: os.PathLike) -> None: + """ + Test that if there is no [extended] section in the TOML file, + AppConfig.extended remains its default (empty) ExtendedConfig. + """ + config_content = """ +[core] +workspace_base = "/tmp/workspace" + +[llm] +model = "test-model" +api_key = "toml-api-key" + +[agent] +memory_enabled = true +""" + config_file = tmp_path / 'config.toml' + config_file.write_text(config_content) + + config = AppConfig() + load_from_toml(config, str(config_file)) + + # Extended config should be empty + assert config.extended.root == {} + + +def test_app_config_extended_random_keys(tmp_path: os.PathLike) -> None: + """ + Test that the extended section accepts arbitrary keys, + including ones not defined in any schema. + """ + config_content = """ +[core] +workspace_base = "/tmp/workspace" + +[extended] +random_key = "random_value" +another_key = 3.14 +""" + config_file = tmp_path / 'config.toml' + config_file.write_text(config_content) + + config = AppConfig() + load_from_toml(config, str(config_file)) + + # Verify that extended config holds the arbitrary keys with correct values. + assert config.extended.random_key == 'random_value' + assert config.extended.another_key == 3.14 + # Verify the root dictionary contains all keys + assert config.extended.root == {'random_key': 'random_value', 'another_key': 3.14}