Spaces:
Build error
Build error
| import logging | |
| import os | |
| from io import StringIO | |
| import pytest | |
| from openhands.core.config import ( | |
| AgentConfig, | |
| LLMConfig, | |
| OpenHandsConfig, | |
| finalize_config, | |
| get_agent_config_arg, | |
| get_llm_config_arg, | |
| load_from_env, | |
| load_from_toml, | |
| load_openhands_config, | |
| ) | |
| from openhands.core.config.condenser_config import ( | |
| LLMSummarizingCondenserConfig, | |
| NoOpCondenserConfig, | |
| RecentEventsCondenserConfig, | |
| ) | |
| from openhands.core.logger import openhands_logger | |
| def setup_env(): | |
| # Create old-style and new-style TOML files | |
| with open('old_style_config.toml', 'w') as f: | |
| f.write('[default]\nLLM_MODEL="GPT-4"\n') | |
| with open('new_style_config.toml', 'w') as f: | |
| f.write('[app]\nLLM_MODEL="GPT-3"\n') | |
| yield | |
| # Cleanup TOML files after the test | |
| os.remove('old_style_config.toml') | |
| os.remove('new_style_config.toml') | |
| def temp_toml_file(tmp_path): | |
| # Fixture to create a temporary directory and TOML file for testing | |
| tmp_toml_file = os.path.join(tmp_path, 'config.toml') | |
| yield tmp_toml_file | |
| def default_config(monkeypatch): | |
| # Fixture to provide a default OpenHandsConfig instance | |
| yield OpenHandsConfig() | |
| def test_compat_env_to_config(monkeypatch, setup_env): | |
| # Use `monkeypatch` to set environment variables for this specific test | |
| monkeypatch.setenv('SANDBOX_VOLUMES', '/repos/openhands/workspace:/workspace:rw') | |
| monkeypatch.setenv('LLM_API_KEY', 'sk-proj-rgMV0...') | |
| monkeypatch.setenv('LLM_MODEL', 'gpt-4o') | |
| monkeypatch.setenv('DEFAULT_AGENT', 'CodeActAgent') | |
| monkeypatch.setenv('SANDBOX_TIMEOUT', '10') | |
| config = OpenHandsConfig() | |
| load_from_env(config, os.environ) | |
| finalize_config(config) | |
| assert config.sandbox.volumes == '/repos/openhands/workspace:/workspace:rw' | |
| # Check that the old parameters are set for backward compatibility | |
| assert config.workspace_base == os.path.abspath('/repos/openhands/workspace') | |
| assert config.workspace_mount_path == os.path.abspath('/repos/openhands/workspace') | |
| assert config.workspace_mount_path_in_sandbox == '/workspace' | |
| assert isinstance(config.get_llm_config(), LLMConfig) | |
| assert config.get_llm_config().api_key.get_secret_value() == 'sk-proj-rgMV0...' | |
| assert config.get_llm_config().model == 'gpt-4o' | |
| assert isinstance(config.get_agent_config(), AgentConfig) | |
| assert config.default_agent == 'CodeActAgent' | |
| assert config.sandbox.timeout == 10 | |
| def test_load_from_old_style_env(monkeypatch, default_config): | |
| # Test loading configuration from old-style environment variables using monkeypatch | |
| monkeypatch.setenv('LLM_API_KEY', 'test-api-key') | |
| monkeypatch.setenv('DEFAULT_AGENT', 'BrowsingAgent') | |
| # Using deprecated WORKSPACE_BASE to test backward compatibility | |
| monkeypatch.setenv('WORKSPACE_BASE', '/opt/files/workspace') | |
| monkeypatch.setenv('SANDBOX_BASE_CONTAINER_IMAGE', 'custom_image') | |
| load_from_env(default_config, os.environ) | |
| assert default_config.get_llm_config().api_key.get_secret_value() == 'test-api-key' | |
| assert default_config.default_agent == 'BrowsingAgent' | |
| # Verify deprecated variables still work | |
| assert default_config.workspace_base == '/opt/files/workspace' | |
| assert default_config.workspace_mount_path is None # before finalize_config | |
| assert default_config.workspace_mount_path_in_sandbox is not None | |
| assert default_config.sandbox.base_container_image == 'custom_image' | |
| def test_load_from_new_style_toml(default_config, temp_toml_file): | |
| # Test loading configuration from a new-style TOML file | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write( | |
| """ | |
| [llm] | |
| model = "test-model" | |
| api_key = "toml-api-key" | |
| [llm.cheap] | |
| model = "some-cheap-model" | |
| api_key = "cheap-model-api-key" | |
| [agent] | |
| enable_prompt_extensions = true | |
| [agent.BrowsingAgent] | |
| llm_config = "cheap" | |
| enable_prompt_extensions = false | |
| [sandbox] | |
| timeout = 1 | |
| volumes = "/opt/files2/workspace:/workspace:rw" | |
| [core] | |
| default_agent = "TestAgent" | |
| """ | |
| ) | |
| load_from_toml(default_config, temp_toml_file) | |
| # default llm & agent configs | |
| assert default_config.default_agent == 'TestAgent' | |
| assert default_config.get_llm_config().model == 'test-model' | |
| assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key' | |
| assert default_config.get_agent_config().enable_prompt_extensions is True | |
| # undefined agent config inherits default ones | |
| assert ( | |
| default_config.get_llm_config_from_agent('CodeActAgent') | |
| == default_config.get_llm_config() | |
| ) | |
| assert ( | |
| default_config.get_agent_config('CodeActAgent').enable_prompt_extensions is True | |
| ) | |
| # defined agent config overrides default ones | |
| assert default_config.get_llm_config_from_agent( | |
| 'BrowsingAgent' | |
| ) == default_config.get_llm_config('cheap') | |
| assert ( | |
| default_config.get_llm_config_from_agent('BrowsingAgent').model | |
| == 'some-cheap-model' | |
| ) | |
| assert ( | |
| default_config.get_agent_config('BrowsingAgent').enable_prompt_extensions | |
| is False | |
| ) | |
| assert default_config.sandbox.volumes == '/opt/files2/workspace:/workspace:rw' | |
| assert default_config.sandbox.timeout == 1 | |
| assert default_config.workspace_mount_path is None | |
| assert default_config.workspace_mount_path_in_sandbox is not None | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| finalize_config(default_config) | |
| # after finalize_config, workspace_mount_path is set based on sandbox.volumes | |
| assert default_config.workspace_mount_path == os.path.abspath( | |
| '/opt/files2/workspace' | |
| ) | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| def test_llm_config_native_tool_calling(default_config, temp_toml_file, monkeypatch): | |
| # default is None | |
| assert default_config.get_llm_config().native_tool_calling is None | |
| # set to false | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write( | |
| """ | |
| [core] | |
| [llm.gpt4o-mini] | |
| native_tool_calling = false | |
| """ | |
| ) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.get_llm_config().native_tool_calling is None | |
| assert default_config.get_llm_config('gpt4o-mini').native_tool_calling is False | |
| # set to true using string | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write( | |
| """ | |
| [core] | |
| [llm.gpt4o-mini] | |
| native_tool_calling = true | |
| """ | |
| ) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.get_llm_config('gpt4o-mini').native_tool_calling is True | |
| # override to false by env | |
| # see utils.set_attr_from_env | |
| monkeypatch.setenv('LLM_NATIVE_TOOL_CALLING', 'false') | |
| load_from_env(default_config, os.environ) | |
| assert default_config.get_llm_config().native_tool_calling is False | |
| assert ( | |
| default_config.get_llm_config('gpt4o-mini').native_tool_calling is True | |
| ) # load_from_env didn't override the named config set in the toml file under [llm.gpt4o-mini] | |
| def test_env_overrides_compat_toml(monkeypatch, default_config, temp_toml_file): | |
| # test that environment variables override TOML values using monkeypatch | |
| # uses a toml file with sandbox_vars instead of a sandbox section | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [llm] | |
| model = "test-model" | |
| api_key = "toml-api-key" | |
| [core] | |
| disable_color = true | |
| [sandbox] | |
| volumes = "/opt/files3/workspace:/workspace:rw" | |
| timeout = 500 | |
| user_id = 1001 | |
| """) | |
| monkeypatch.setenv('LLM_API_KEY', 'env-api-key') | |
| monkeypatch.setenv('SANDBOX_VOLUMES', '/tmp/test:/workspace:ro') | |
| monkeypatch.setenv('SANDBOX_TIMEOUT', '1000') | |
| monkeypatch.setenv('SANDBOX_USER_ID', '1002') | |
| monkeypatch.delenv('LLM_MODEL', raising=False) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.workspace_mount_path is None | |
| load_from_env(default_config, os.environ) | |
| assert os.environ.get('LLM_MODEL') is None | |
| assert default_config.get_llm_config().model == 'test-model' | |
| assert default_config.get_llm_config('llm').model == 'test-model' | |
| assert default_config.get_llm_config_from_agent().model == 'test-model' | |
| assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key' | |
| # Environment variable should override TOML value | |
| assert default_config.sandbox.volumes == '/tmp/test:/workspace:ro' | |
| assert default_config.workspace_mount_path is None | |
| assert default_config.disable_color is True | |
| assert default_config.sandbox.timeout == 1000 | |
| assert default_config.sandbox.user_id == 1002 | |
| finalize_config(default_config) | |
| # after finalize_config, workspace_mount_path is set based on the sandbox.volumes | |
| assert default_config.workspace_mount_path == os.path.abspath('/tmp/test') | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file): | |
| # test that environment variables override TOML values using monkeypatch | |
| # uses a toml file with a sandbox section | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [llm] | |
| model = "test-model" | |
| api_key = "toml-api-key" | |
| [core] | |
| [sandbox] | |
| volumes = "/opt/files3/workspace:/workspace:rw" | |
| timeout = 500 | |
| user_id = 1001 | |
| """) | |
| monkeypatch.setenv('LLM_API_KEY', 'env-api-key') | |
| monkeypatch.setenv('SANDBOX_VOLUMES', '/tmp/test:/workspace:ro') | |
| monkeypatch.setenv('SANDBOX_TIMEOUT', '1000') | |
| monkeypatch.setenv('SANDBOX_USER_ID', '1002') | |
| monkeypatch.delenv('LLM_MODEL', raising=False) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.workspace_mount_path is None | |
| # before load_from_env, values are set to the values from the toml file | |
| assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key' | |
| assert default_config.sandbox.volumes == '/opt/files3/workspace:/workspace:rw' | |
| assert default_config.sandbox.timeout == 500 | |
| assert default_config.sandbox.user_id == 1001 | |
| load_from_env(default_config, os.environ) | |
| # values from env override values from toml | |
| assert os.environ.get('LLM_MODEL') is None | |
| assert default_config.get_llm_config().model == 'test-model' | |
| assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key' | |
| assert default_config.sandbox.volumes == '/tmp/test:/workspace:ro' | |
| assert default_config.sandbox.timeout == 1000 | |
| assert default_config.sandbox.user_id == 1002 | |
| finalize_config(default_config) | |
| # after finalize_config, workspace_mount_path is set based on sandbox.volumes | |
| assert default_config.workspace_mount_path == os.path.abspath('/tmp/test') | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| def test_sandbox_config_from_toml(monkeypatch, default_config, temp_toml_file): | |
| # Test loading configuration from a new-style TOML file | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write( | |
| """ | |
| [core] | |
| [llm] | |
| model = "test-model" | |
| [sandbox] | |
| volumes = "/opt/files/workspace:/workspace:rw" | |
| timeout = 1 | |
| base_container_image = "custom_image" | |
| user_id = 1001 | |
| """ | |
| ) | |
| monkeypatch.setattr(os, 'environ', {}) | |
| load_from_toml(default_config, temp_toml_file) | |
| load_from_env(default_config, os.environ) | |
| finalize_config(default_config) | |
| assert default_config.get_llm_config().model == 'test-model' | |
| assert default_config.sandbox.volumes == '/opt/files/workspace:/workspace:rw' | |
| assert default_config.workspace_mount_path == os.path.abspath( | |
| '/opt/files/workspace' | |
| ) | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| assert default_config.sandbox.timeout == 1 | |
| assert default_config.sandbox.base_container_image == 'custom_image' | |
| assert default_config.sandbox.user_id == 1001 | |
| def test_load_from_env_with_list(monkeypatch, default_config): | |
| """Test loading list values from environment variables, particularly SANDBOX_RUNTIME_EXTRA_BUILD_ARGS.""" | |
| # Set the environment variable with a list-formatted string | |
| monkeypatch.setenv( | |
| 'SANDBOX_RUNTIME_EXTRA_BUILD_ARGS', | |
| '[' | |
| + ' "--add-host=host.docker.internal:host-gateway",' | |
| + ' "--build-arg=https_proxy=https://my-proxy:912",' | |
| + ']', | |
| ) | |
| # Load configuration from environment | |
| load_from_env(default_config, os.environ) | |
| # Verify that the list was correctly parsed | |
| assert isinstance(default_config.sandbox.runtime_extra_build_args, list) | |
| assert len(default_config.sandbox.runtime_extra_build_args) == 2 | |
| assert ( | |
| '--add-host=host.docker.internal:host-gateway' | |
| in default_config.sandbox.runtime_extra_build_args | |
| ) | |
| assert ( | |
| '--build-arg=https_proxy=https://my-proxy:912' | |
| in default_config.sandbox.runtime_extra_build_args | |
| ) | |
| def test_security_config_from_toml(default_config, temp_toml_file): | |
| """Test loading security specific configurations.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write( | |
| """ | |
| [core] # make sure core is loaded first | |
| workspace_base = "/opt/files/workspace" | |
| [llm] | |
| model = "test-model" | |
| [security] | |
| confirmation_mode = false | |
| security_analyzer = "semgrep" | |
| """ | |
| ) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.security.confirmation_mode is False | |
| assert default_config.security.security_analyzer == 'semgrep' | |
| def test_security_config_from_dict(): | |
| """Test creating SecurityConfig instance from dictionary.""" | |
| from openhands.core.config.security_config import SecurityConfig | |
| # Test with all fields | |
| config_dict = {'confirmation_mode': True, 'security_analyzer': 'some_analyzer'} | |
| security_config = SecurityConfig(**config_dict) | |
| # Verify all fields are correctly set | |
| assert security_config.confirmation_mode is True | |
| assert security_config.security_analyzer == 'some_analyzer' | |
| def test_defaults_dict_after_updates(default_config): | |
| # Test that `defaults_dict` retains initial values after updates. | |
| initial_defaults = default_config.defaults_dict | |
| assert initial_defaults['workspace_mount_path']['default'] is None | |
| assert initial_defaults['default_agent']['default'] == 'CodeActAgent' | |
| updated_config = OpenHandsConfig() | |
| updated_config.get_llm_config().api_key = 'updated-api-key' | |
| updated_config.get_llm_config('llm').api_key = 'updated-api-key' | |
| updated_config.get_llm_config_from_agent('agent').api_key = 'updated-api-key' | |
| updated_config.get_llm_config_from_agent( | |
| 'BrowsingAgent' | |
| ).api_key = 'updated-api-key' | |
| updated_config.default_agent = 'BrowsingAgent' | |
| defaults_after_updates = updated_config.defaults_dict | |
| assert defaults_after_updates['default_agent']['default'] == 'CodeActAgent' | |
| assert defaults_after_updates['workspace_mount_path']['default'] is None | |
| assert defaults_after_updates['sandbox']['timeout']['default'] == 120 | |
| assert ( | |
| defaults_after_updates['sandbox']['base_container_image']['default'] | |
| == 'nikolaik/python-nodejs:python3.12-nodejs22' | |
| ) | |
| assert defaults_after_updates == initial_defaults | |
| def test_sandbox_volumes(monkeypatch, default_config): | |
| # Test SANDBOX_VOLUMES with multiple mounts (no explicit /workspace mount) | |
| monkeypatch.setenv( | |
| 'SANDBOX_VOLUMES', | |
| '/host/path1:/container/path1,/host/path2:/container/path2:ro', | |
| ) | |
| load_from_env(default_config, os.environ) | |
| finalize_config(default_config) | |
| # Check that sandbox.volumes is set correctly | |
| assert ( | |
| default_config.sandbox.volumes | |
| == '/host/path1:/container/path1,/host/path2:/container/path2:ro' | |
| ) | |
| # With the new behavior, workspace_base and workspace_mount_path should be None | |
| # when no explicit /workspace mount is found | |
| assert default_config.workspace_base is None | |
| assert default_config.workspace_mount_path is None | |
| assert ( | |
| default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| ) # Default value | |
| def test_sandbox_volumes_with_mode(monkeypatch, default_config): | |
| # Test SANDBOX_VOLUMES with read-only mode (no explicit /workspace mount) | |
| monkeypatch.setenv('SANDBOX_VOLUMES', '/host/path1:/container/path1:ro') | |
| load_from_env(default_config, os.environ) | |
| finalize_config(default_config) | |
| # Check that sandbox.volumes is set correctly | |
| assert default_config.sandbox.volumes == '/host/path1:/container/path1:ro' | |
| # With the new behavior, workspace_base and workspace_mount_path should be None | |
| # when no explicit /workspace mount is found | |
| assert default_config.workspace_base is None | |
| assert default_config.workspace_mount_path is None | |
| assert ( | |
| default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| ) # Default value | |
| def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config): | |
| # Invalid TOML format doesn't break the configuration | |
| monkeypatch.setenv('LLM_MODEL', 'gpt-5-turbo-1106') | |
| monkeypatch.setenv('WORKSPACE_MOUNT_PATH', '/home/user/project') | |
| monkeypatch.delenv('LLM_API_KEY', raising=False) | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write('INVALID TOML CONTENT') | |
| load_from_toml(default_config, temp_toml_file) | |
| load_from_env(default_config, os.environ) | |
| default_config.jwt_secret = None # prevent leak | |
| for llm in default_config.llms.values(): | |
| llm.api_key = None # prevent leak | |
| assert default_config.get_llm_config().model == 'gpt-5-turbo-1106' | |
| assert default_config.get_llm_config().custom_llm_provider is None | |
| assert default_config.workspace_mount_path == '/home/user/project' | |
| def test_load_from_toml_file_not_found(default_config): | |
| """Test loading configuration when the TOML file doesn't exist. | |
| This ensures that: | |
| 1. The program doesn't crash when the config file is missing | |
| 2. The config object retains its default values | |
| 3. The application remains usable | |
| """ | |
| # Try to load from a non-existent file | |
| load_from_toml(default_config, 'nonexistent.toml') | |
| # Verify that config object maintains default values | |
| assert default_config.get_llm_config() is not None | |
| assert default_config.get_agent_config() is not None | |
| assert default_config.sandbox is not None | |
| def test_core_not_in_toml(default_config, temp_toml_file): | |
| """Test loading configuration when the core section is not in the TOML file. | |
| default values should be used for the missing sections. | |
| """ | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [llm] | |
| model = "test-model" | |
| [agent] | |
| enable_prompt_extensions = true | |
| [sandbox] | |
| timeout = 1 | |
| base_container_image = "custom_image" | |
| user_id = 1001 | |
| [security] | |
| security_analyzer = "semgrep" | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| assert default_config.get_llm_config().model == 'test-model' | |
| assert default_config.get_agent_config().enable_prompt_extensions is True | |
| assert default_config.sandbox.base_container_image == 'custom_image' | |
| assert default_config.sandbox.user_id == 1001 | |
| assert default_config.security.security_analyzer == 'semgrep' | |
| def test_load_from_toml_partial_invalid(default_config, temp_toml_file, caplog): | |
| """Test loading configuration with partially invalid TOML content. | |
| This ensures that: | |
| 1. Valid configuration sections are properly loaded | |
| 2. Invalid fields in security and sandbox sections raise ValueError | |
| 4. The config object maintains correct values for valid fields | |
| """ | |
| with open(temp_toml_file, 'w', encoding='utf-8') as f: | |
| f.write(""" | |
| [core] | |
| debug = true | |
| [llm] | |
| # Not set in `openhands/core/schema/config.py` | |
| invalid_field = "test" | |
| model = "gpt-4" | |
| [agent] | |
| enable_prompt_extensions = true | |
| [sandbox] | |
| invalid_field_in_sandbox = "test" | |
| """) | |
| # Create a string buffer to capture log output | |
| log_output = StringIO() | |
| handler = logging.StreamHandler(log_output) | |
| handler.setLevel(logging.WARNING) | |
| formatter = logging.Formatter('%(message)s') | |
| handler.setFormatter(formatter) | |
| openhands_logger.addHandler(handler) | |
| try: | |
| # Since sandbox_config.from_toml_section now raises ValueError for invalid fields, | |
| # we need to catch that exception | |
| with pytest.raises(ValueError) as excinfo: | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify the error message mentions the invalid sandbox field | |
| assert 'Error in [sandbox] section in config.toml' in str(excinfo.value) | |
| log_content = log_output.getvalue() | |
| # The LLM config should still log a warning but not raise an exception | |
| assert 'Cannot parse [llm] config from toml' in log_content | |
| # Verify valid configurations are loaded before the error was raised | |
| assert default_config.debug is True | |
| finally: | |
| openhands_logger.removeHandler(handler) | |
| def test_load_from_toml_security_invalid(default_config, temp_toml_file): | |
| """Test that invalid security configuration raises ValueError.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as f: | |
| f.write(""" | |
| [core] | |
| debug = true | |
| [security] | |
| invalid_security_field = "test" | |
| """) | |
| with pytest.raises(ValueError) as excinfo: | |
| load_from_toml(default_config, temp_toml_file) | |
| assert 'Error in [security] section in config.toml' in str(excinfo.value) | |
| def test_finalize_config(default_config): | |
| # Test finalize config | |
| assert default_config.workspace_mount_path is None | |
| default_config.workspace_base = None | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path is None | |
| def test_workspace_mount_path_default(default_config): | |
| assert default_config.workspace_mount_path is None | |
| default_config.workspace_base = '/home/user/project' | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path == os.path.abspath( | |
| default_config.workspace_base | |
| ) | |
| def test_workspace_mount_rewrite(default_config, monkeypatch): | |
| default_config.workspace_base = '/home/user/project' | |
| default_config.workspace_mount_rewrite = '/home/user:/sandbox' | |
| monkeypatch.setattr('os.getcwd', lambda: '/current/working/directory') | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path == '/sandbox/project' | |
| def test_cache_dir_creation(default_config, tmpdir): | |
| default_config.cache_dir = str(tmpdir.join('test_cache')) | |
| finalize_config(default_config) | |
| assert os.path.exists(default_config.cache_dir) | |
| def test_sandbox_volumes_with_workspace(default_config): | |
| """Test that sandbox.volumes with explicit /workspace mount works correctly.""" | |
| default_config.sandbox.volumes = '/home/user/mydir:/workspace:rw,/data:/data:ro' | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path == '/home/user/mydir' | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| assert default_config.workspace_base == '/home/user/mydir' | |
| def test_sandbox_volumes_without_workspace(default_config): | |
| """Test that sandbox.volumes without explicit /workspace mount doesn't set workspace paths.""" | |
| default_config.sandbox.volumes = '/data:/data:ro,/models:/models:ro' | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path is None | |
| assert default_config.workspace_base is None | |
| assert ( | |
| default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| ) # Default value remains unchanged | |
| def test_sandbox_volumes_with_workspace_not_first(default_config): | |
| """Test that sandbox.volumes with /workspace mount not as first entry works correctly.""" | |
| default_config.sandbox.volumes = ( | |
| '/data:/data:ro,/home/user/mydir:/workspace:rw,/models:/models:ro' | |
| ) | |
| finalize_config(default_config) | |
| assert default_config.workspace_mount_path == '/home/user/mydir' | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| assert default_config.workspace_base == '/home/user/mydir' | |
| def test_agent_config_condenser_with_no_enabled(): | |
| """Test default agent condenser with enable_default_condenser=False.""" | |
| config = OpenHandsConfig(enable_default_condenser=False) | |
| agent_config = config.get_agent_config() | |
| assert isinstance(agent_config.condenser, NoOpCondenserConfig) | |
| def test_sandbox_volumes_toml(default_config, temp_toml_file): | |
| """Test that volumes configuration under [sandbox] works correctly.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [sandbox] | |
| volumes = "/home/user/mydir:/workspace:rw,/data:/data:ro" | |
| timeout = 1 | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| finalize_config(default_config) | |
| # Check that sandbox.volumes is set correctly | |
| assert ( | |
| default_config.sandbox.volumes | |
| == '/home/user/mydir:/workspace:rw,/data:/data:ro' | |
| ) | |
| assert default_config.workspace_mount_path == '/home/user/mydir' | |
| assert default_config.workspace_mount_path_in_sandbox == '/workspace' | |
| assert default_config.workspace_base == '/home/user/mydir' | |
| assert default_config.sandbox.timeout == 1 | |
| def test_condenser_config_from_toml_basic(default_config, temp_toml_file): | |
| """Test loading basic condenser configuration from TOML.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [condenser] | |
| type = "recent" | |
| keep_first = 3 | |
| max_events = 15 | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify that the condenser config is correctly assigned to the default agent config | |
| agent_config = default_config.get_agent_config() | |
| assert isinstance(agent_config.condenser, RecentEventsCondenserConfig) | |
| assert agent_config.condenser.keep_first == 3 | |
| assert agent_config.condenser.max_events == 15 | |
| # We can also verify the function works directly | |
| from openhands.core.config.condenser_config import ( | |
| condenser_config_from_toml_section, | |
| ) | |
| condenser_data = {'type': 'recent', 'keep_first': 3, 'max_events': 15} | |
| condenser_mapping = condenser_config_from_toml_section(condenser_data) | |
| assert 'condenser' in condenser_mapping | |
| assert isinstance(condenser_mapping['condenser'], RecentEventsCondenserConfig) | |
| assert condenser_mapping['condenser'].keep_first == 3 | |
| assert condenser_mapping['condenser'].max_events == 15 | |
| def test_condenser_config_from_toml_with_llm_reference(default_config, temp_toml_file): | |
| """Test loading condenser configuration with LLM reference from TOML.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [llm.condenser_llm] | |
| model = "gpt-4" | |
| api_key = "test-key" | |
| [condenser] | |
| type = "llm" | |
| llm_config = "condenser_llm" | |
| keep_first = 2 | |
| max_size = 50 | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify that the LLM config was loaded | |
| assert 'condenser_llm' in default_config.llms | |
| assert default_config.llms['condenser_llm'].model == 'gpt-4' | |
| # Verify that the condenser config is correctly assigned to the default agent config | |
| agent_config = default_config.get_agent_config() | |
| assert isinstance(agent_config.condenser, LLMSummarizingCondenserConfig) | |
| assert agent_config.condenser.keep_first == 2 | |
| assert agent_config.condenser.max_size == 50 | |
| assert agent_config.condenser.llm_config.model == 'gpt-4' | |
| # Test the condenser config with the LLM reference | |
| from openhands.core.config.condenser_config import ( | |
| condenser_config_from_toml_section, | |
| ) | |
| condenser_data = { | |
| 'type': 'llm', | |
| 'llm_config': 'condenser_llm', | |
| 'keep_first': 2, | |
| 'max_size': 50, | |
| } | |
| condenser_mapping = condenser_config_from_toml_section( | |
| condenser_data, default_config.llms | |
| ) | |
| assert 'condenser' in condenser_mapping | |
| assert isinstance(condenser_mapping['condenser'], LLMSummarizingCondenserConfig) | |
| assert condenser_mapping['condenser'].keep_first == 2 | |
| assert condenser_mapping['condenser'].max_size == 50 | |
| assert condenser_mapping['condenser'].llm_config.model == 'gpt-4' | |
| def test_condenser_config_from_toml_with_missing_llm_reference( | |
| default_config, temp_toml_file | |
| ): | |
| """Test loading condenser configuration with missing LLM reference from TOML.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [condenser] | |
| type = "llm" | |
| llm_config = "missing_llm" | |
| keep_first = 2 | |
| max_size = 50 | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| # Test the condenser config with a missing LLM reference | |
| from openhands.core.config.condenser_config import ( | |
| condenser_config_from_toml_section, | |
| ) | |
| condenser_data = { | |
| 'type': 'llm', | |
| 'llm_config': 'missing_llm', | |
| 'keep_first': 2, | |
| 'max_size': 50, | |
| } | |
| condenser_mapping = condenser_config_from_toml_section( | |
| condenser_data, default_config.llms | |
| ) | |
| assert 'condenser' in condenser_mapping | |
| assert isinstance(condenser_mapping['condenser'], NoOpCondenserConfig) | |
| # Should not have a default LLMConfig when the reference is missing | |
| assert not hasattr(condenser_mapping['condenser'], 'llm_config') | |
| def test_condenser_config_from_toml_with_invalid_config(default_config, temp_toml_file): | |
| """Test loading invalid condenser configuration from TOML.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [condenser] | |
| type = "invalid_type" | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| # Test the condenser config with an invalid type | |
| from openhands.core.config.condenser_config import ( | |
| condenser_config_from_toml_section, | |
| ) | |
| condenser_data = {'type': 'invalid_type'} | |
| condenser_mapping = condenser_config_from_toml_section(condenser_data) | |
| # Should default to NoOpCondenserConfig when the type is invalid | |
| assert 'condenser' in condenser_mapping | |
| assert isinstance(condenser_mapping['condenser'], NoOpCondenserConfig) | |
| def test_condenser_config_from_toml_with_validation_error( | |
| default_config, temp_toml_file | |
| ): | |
| """Test loading condenser configuration with validation error from TOML.""" | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [condenser] | |
| type = "recent" | |
| keep_first = -1 # Invalid: must be >= 0 | |
| max_events = 0 # Invalid: must be >= 1 | |
| """) | |
| load_from_toml(default_config, temp_toml_file) | |
| # Test the condenser config with validation errors | |
| from openhands.core.config.condenser_config import ( | |
| condenser_config_from_toml_section, | |
| ) | |
| condenser_data = {'type': 'recent', 'keep_first': -1, 'max_events': 0} | |
| condenser_mapping = condenser_config_from_toml_section(condenser_data) | |
| # Should default to NoOpCondenserConfig when validation fails | |
| assert 'condenser' in condenser_mapping | |
| assert isinstance(condenser_mapping['condenser'], NoOpCondenserConfig) | |
| def test_default_condenser_behavior_enabled(default_config, temp_toml_file): | |
| """Test the default condenser behavior when enable_default_condenser is True.""" | |
| # Create a minimal TOML file with no condenser section | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [core] | |
| # Empty core section, no condenser section | |
| """) | |
| # Set enable_default_condenser to True | |
| default_config.enable_default_condenser = True | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify the default agent config has LLMSummarizingCondenserConfig | |
| agent_config = default_config.get_agent_config() | |
| assert isinstance(agent_config.condenser, LLMSummarizingCondenserConfig) | |
| assert agent_config.condenser.keep_first == 1 | |
| assert agent_config.condenser.max_size == 100 | |
| def test_default_condenser_behavior_disabled(default_config, temp_toml_file): | |
| """Test the default condenser behavior when enable_default_condenser is False.""" | |
| # Create a minimal TOML file with no condenser section | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [core] | |
| # Empty core section, no condenser section | |
| """) | |
| # Set enable_default_condenser to False | |
| default_config.enable_default_condenser = False | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify the agent config uses NoOpCondenserConfig | |
| agent_config = default_config.get_agent_config() | |
| assert isinstance(agent_config.condenser, NoOpCondenserConfig) | |
| def test_default_condenser_explicit_toml_override(default_config, temp_toml_file): | |
| """Test that explicit condenser in TOML takes precedence over the default.""" | |
| # Set enable_default_condenser to True | |
| default_config.enable_default_condenser = True | |
| # Create a TOML file with an explicit condenser section | |
| with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: | |
| toml_file.write(""" | |
| [condenser] | |
| type = "recent" | |
| keep_first = 3 | |
| max_events = 15 | |
| """) | |
| # Load the config | |
| load_from_toml(default_config, temp_toml_file) | |
| # Verify the explicit condenser from TOML takes precedence | |
| agent_config = default_config.get_agent_config() | |
| assert isinstance(agent_config.condenser, RecentEventsCondenserConfig) | |
| assert agent_config.condenser.keep_first == 3 | |
| assert agent_config.condenser.max_events == 15 | |
| def test_api_keys_repr_str(): | |
| # Test LLMConfig | |
| llm_config = LLMConfig( | |
| api_key='my_api_key', | |
| aws_access_key_id='my_access_key', | |
| aws_secret_access_key='my_secret_key', | |
| ) | |
| # Check that no secret keys are emitted in representations of the config object | |
| assert 'my_api_key' not in repr(llm_config) | |
| assert 'my_api_key' not in str(llm_config) | |
| assert 'my_access_key' not in repr(llm_config) | |
| assert 'my_access_key' not in str(llm_config) | |
| assert 'my_secret_key' not in repr(llm_config) | |
| assert 'my_secret_key' not in str(llm_config) | |
| # Check that no other attrs in LLMConfig have 'key' or 'token' in their name | |
| # This will fail when new attrs are added, and attract attention | |
| known_key_token_attrs_llm = [ | |
| 'api_key', | |
| 'aws_access_key_id', | |
| 'aws_secret_access_key', | |
| 'input_cost_per_token', | |
| 'output_cost_per_token', | |
| 'custom_tokenizer', | |
| ] | |
| for attr_name in LLMConfig.model_fields.keys(): | |
| if ( | |
| not attr_name.startswith('__') | |
| and attr_name not in known_key_token_attrs_llm | |
| ): | |
| assert 'key' not in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'key' in LLMConfig" | |
| ) | |
| assert 'token' not in attr_name.lower() or 'tokens' in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'token' in LLMConfig" | |
| ) | |
| # Test AgentConfig | |
| # No attrs in AgentConfig have 'key' or 'token' in their name | |
| agent_config = AgentConfig(enable_prompt_extensions=True, enable_browsing=False) | |
| for attr_name in AgentConfig.model_fields.keys(): | |
| if not attr_name.startswith('__'): | |
| assert 'key' not in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'key' in AgentConfig" | |
| ) | |
| assert 'token' not in attr_name.lower() or 'tokens' in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'token' in AgentConfig" | |
| ) | |
| # Test OpenHandsConfig | |
| app_config = OpenHandsConfig( | |
| llms={'llm': llm_config}, | |
| agents={'agent': agent_config}, | |
| e2b_api_key='my_e2b_api_key', | |
| jwt_secret='my_jwt_secret', | |
| modal_api_token_id='my_modal_api_token_id', | |
| modal_api_token_secret='my_modal_api_token_secret', | |
| runloop_api_key='my_runloop_api_key', | |
| daytona_api_key='my_daytona_api_key', | |
| ) | |
| assert 'my_e2b_api_key' not in repr(app_config) | |
| assert 'my_e2b_api_key' not in str(app_config) | |
| assert 'my_jwt_secret' not in repr(app_config) | |
| assert 'my_jwt_secret' not in str(app_config) | |
| assert 'my_modal_api_token_id' not in repr(app_config) | |
| assert 'my_modal_api_token_id' not in str(app_config) | |
| assert 'my_modal_api_token_secret' not in repr(app_config) | |
| assert 'my_modal_api_token_secret' not in str(app_config) | |
| assert 'my_runloop_api_key' not in repr(app_config) | |
| assert 'my_runloop_api_key' not in str(app_config) | |
| assert 'my_daytona_api_key' not in repr(app_config) | |
| assert 'my_daytona_api_key' not in str(app_config) | |
| # Check that no other attrs in OpenHandsConfig have 'key' or 'token' in their name | |
| # This will fail when new attrs are added, and attract attention | |
| known_key_token_attrs_app = [ | |
| 'e2b_api_key', | |
| 'modal_api_token_id', | |
| 'modal_api_token_secret', | |
| 'runloop_api_key', | |
| 'daytona_api_key', | |
| 'search_api_key', | |
| ] | |
| for attr_name in OpenHandsConfig.model_fields.keys(): | |
| if ( | |
| not attr_name.startswith('__') | |
| and attr_name not in known_key_token_attrs_app | |
| ): | |
| assert 'key' not in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'key' in OpenHandsConfig" | |
| ) | |
| assert 'token' not in attr_name.lower() or 'tokens' in attr_name.lower(), ( | |
| f"Unexpected attribute '{attr_name}' contains 'token' in OpenHandsConfig" | |
| ) | |
| def test_max_iterations_and_max_budget_per_task_from_toml(temp_toml_file): | |
| temp_toml = """ | |
| [core] | |
| max_iterations = 42 | |
| max_budget_per_task = 4.7 | |
| """ | |
| config = OpenHandsConfig() | |
| with open(temp_toml_file, 'w') as f: | |
| f.write(temp_toml) | |
| load_from_toml(config, temp_toml_file) | |
| assert config.max_iterations == 42 | |
| assert config.max_budget_per_task == 4.7 | |
| def test_get_llm_config_arg(temp_toml_file): | |
| temp_toml = """ | |
| [core] | |
| max_iterations = 100 | |
| max_budget_per_task = 4.0 | |
| [llm.gpt3] | |
| model="gpt-3.5-turbo" | |
| api_key="redacted" | |
| [llm.gpt4o] | |
| model="gpt-4o" | |
| api_key="redacted" | |
| """ | |
| with open(temp_toml_file, 'w') as f: | |
| f.write(temp_toml) | |
| llm_config = get_llm_config_arg('gpt3', temp_toml_file) | |
| assert llm_config.model == 'gpt-3.5-turbo' | |
| def test_get_agent_configs(default_config, temp_toml_file): | |
| temp_toml = """ | |
| [core] | |
| max_iterations = 100 | |
| max_budget_per_task = 4.0 | |
| [agent.CodeActAgent] | |
| enable_prompt_extensions = true | |
| [agent.BrowsingAgent] | |
| enable_jupyter = false | |
| """ | |
| with open(temp_toml_file, 'w') as f: | |
| f.write(temp_toml) | |
| load_from_toml(default_config, temp_toml_file) | |
| codeact_config = default_config.get_agent_configs().get('CodeActAgent') | |
| assert codeact_config.enable_prompt_extensions is True | |
| browsing_config = default_config.get_agent_configs().get('BrowsingAgent') | |
| assert browsing_config.enable_jupyter is False | |
| def test_get_agent_config_arg(temp_toml_file): | |
| temp_toml = """ | |
| [core] | |
| max_iterations = 100 | |
| max_budget_per_task = 4.0 | |
| [agent.CodeActAgent] | |
| enable_prompt_extensions = false | |
| enable_browsing = false | |
| [agent.BrowsingAgent] | |
| enable_prompt_extensions = true | |
| enable_jupyter = false | |
| """ | |
| with open(temp_toml_file, 'w') as f: | |
| f.write(temp_toml) | |
| agent_config = get_agent_config_arg('CodeActAgent', temp_toml_file) | |
| assert not agent_config.enable_prompt_extensions | |
| assert not agent_config.enable_browsing | |
| agent_config2 = get_agent_config_arg('BrowsingAgent', temp_toml_file) | |
| assert agent_config2.enable_prompt_extensions | |
| assert not agent_config2.enable_jupyter | |
| def test_agent_config_custom_group_name(temp_toml_file): | |
| temp_toml = """ | |
| [core] | |
| max_iterations = 99 | |
| [agent.group1] | |
| enable_prompt_extensions = true | |
| [agent.group2] | |
| enable_prompt_extensions = false | |
| """ | |
| with open(temp_toml_file, 'w') as f: | |
| f.write(temp_toml) | |
| # just a sanity check that load app config wouldn't fail | |
| app_config = load_openhands_config(config_file=temp_toml_file) | |
| assert app_config.max_iterations == 99 | |
| # run_infer in evaluation can use `get_agent_config_arg` to load custom | |
| # agent configs with any group name (not just agent name) | |
| agent_config1 = get_agent_config_arg('group1', temp_toml_file) | |
| assert agent_config1.enable_prompt_extensions | |
| agent_config2 = get_agent_config_arg('group2', temp_toml_file) | |
| assert not agent_config2.enable_prompt_extensions | |
| def test_agent_config_from_toml_section(): | |
| """Test that AgentConfig.from_toml_section correctly parses agent configurations from TOML.""" | |
| from openhands.core.config.agent_config import AgentConfig | |
| # Test with base config and custom configs | |
| agent_section = { | |
| 'enable_prompt_extensions': True, | |
| 'enable_browsing': True, | |
| 'CustomAgent1': {'enable_browsing': False}, | |
| 'CustomAgent2': {'enable_prompt_extensions': False}, | |
| 'InvalidAgent': { | |
| 'invalid_field': 'some_value' # This should be skipped but not affect others | |
| }, | |
| } | |
| # Parse the section | |
| result = AgentConfig.from_toml_section(agent_section) | |
| # Verify the base config was correctly parsed | |
| assert 'agent' in result | |
| assert result['agent'].enable_prompt_extensions is True | |
| assert result['agent'].enable_browsing is True | |
| # Verify custom configs were correctly parsed and inherit from base | |
| assert 'CustomAgent1' in result | |
| assert result['CustomAgent1'].enable_browsing is False # Overridden | |
| assert result['CustomAgent1'].enable_prompt_extensions is True # Inherited | |
| assert 'CustomAgent2' in result | |
| assert result['CustomAgent2'].enable_browsing is True # Inherited | |
| assert result['CustomAgent2'].enable_prompt_extensions is False # Overridden | |
| # Verify the invalid config was skipped | |
| assert 'InvalidAgent' not in result | |
| def test_agent_config_from_toml_section_with_invalid_base(): | |
| """Test that AgentConfig.from_toml_section handles invalid base configurations gracefully.""" | |
| from openhands.core.config.agent_config import AgentConfig | |
| # Test with invalid base config but valid custom configs | |
| agent_section = { | |
| 'invalid_field': 'some_value', # This should be ignored in base config | |
| 'enable_jupyter': 'not_a_bool', # This should cause validation error | |
| 'CustomAgent': { | |
| 'enable_browsing': False, | |
| 'enable_jupyter': True, | |
| }, | |
| } | |
| # Parse the section | |
| result = AgentConfig.from_toml_section(agent_section) | |
| # Verify a default base config was created despite the invalid fields | |
| assert 'agent' in result | |
| assert result['agent'].enable_browsing is True # Default value | |
| assert result['agent'].enable_jupyter is True # Default value | |
| # Verify custom config was still processed correctly | |
| assert 'CustomAgent' in result | |
| assert result['CustomAgent'].enable_browsing is False | |
| assert result['CustomAgent'].enable_jupyter is True | |