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 @pytest.fixture 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') @pytest.fixture 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 @pytest.fixture 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