diff --git a/openhands/core/config/agent_config.py b/openhands/core/config/agent_config.py index 01a76575c5..61a2929353 100644 --- a/openhands/core/config/agent_config.py +++ b/openhands/core/config/agent_config.py @@ -1,6 +1,9 @@ -from pydantic import BaseModel, Field +from __future__ import annotations + +from pydantic import BaseModel, Field, ValidationError from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig +from openhands.core.logger import openhands_logger as logger class AgentConfig(BaseModel): @@ -11,25 +14,88 @@ class AgentConfig(BaseModel): codeact_enable_browsing: Whether browsing delegate is enabled in the action space. Default is False. Only works with function calling. codeact_enable_llm_editor: Whether LLM editor is enabled in the action space. Default is False. Only works with function calling. codeact_enable_jupyter: Whether Jupyter is enabled in the action space. Default is False. - micro_agent_name: The name of the micro agent to use for this agent. memory_enabled: Whether long-term memory (embeddings) is enabled. - memory_max_threads: The maximum number of threads indexing at the same time for embeddings. + memory_max_threads: The maximum number of threads indexing at the same time for embeddings. (deprecated) llm_config: The name of the llm config to use. If specified, this will override global llm config. enable_prompt_extensions: Whether to use prompt extensions (e.g., microagents, inject runtime info). Default is True. - disabled_microagents: A list of microagents to disable. Default is None. + disabled_microagents: A list of microagents to disable (by name, without .py extension, e.g. ["github", "lint"]). Default is None. condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig. - enable_history_truncation: If history should be truncated once LLM context limit is hit. + enable_history_truncation: Whether history should be truncated to continue the session when hitting LLM context length limit. + enable_som_visual_browsing: Whether to enable SoM (Set of Marks) visual browsing. Default is False. """ - codeact_enable_browsing: bool = Field(default=True) - enable_som_visual_browsing: bool = Field(default=False) - codeact_enable_llm_editor: bool = Field(default=False) - codeact_enable_jupyter: bool = Field(default=True) - micro_agent_name: str | None = Field(default=None) + llm_config: str | None = Field(default=None) memory_enabled: bool = Field(default=False) memory_max_threads: int = Field(default=3) - llm_config: str | None = Field(default=None) + codeact_enable_browsing: bool = Field(default=True) + codeact_enable_llm_editor: bool = Field(default=False) + codeact_enable_jupyter: bool = Field(default=True) enable_prompt_extensions: bool = Field(default=True) - disabled_microagents: list[str] | None = Field(default=None) - condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig) + disabled_microagents: list[str] = Field(default_factory=list) enable_history_truncation: bool = Field(default=True) + enable_som_visual_browsing: bool = Field(default=False) + condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig) + + model_config = {'extra': 'forbid'} + + @classmethod + def from_toml_section(cls, data: dict) -> dict[str, AgentConfig]: + """ + Create a mapping of AgentConfig instances from a toml dictionary representing the [agent] section. + + The default configuration is built from all non-dict keys in data. + Then, each key with a dict value is treated as a custom agent configuration, and its values override + the default configuration. + + Example: + Apply generic agent config with custom agent overrides, e.g. + [agent] + memory_enabled = false + enable_prompt_extensions = true + [agent.BrowsingAgent] + memory_enabled = true + results in memory_enabled being true for BrowsingAgent but false for others. + + Returns: + dict[str, AgentConfig]: A mapping where the key "agent" corresponds to the default configuration + and additional keys represent custom configurations. + """ + + # Initialize the result mapping + agent_mapping: dict[str, AgentConfig] = {} + + # Extract base config data (non-dict values) + base_data = {} + custom_sections: dict[str, dict] = {} + for key, value in data.items(): + if isinstance(value, dict): + custom_sections[key] = value + else: + base_data[key] = value + + # Try to create the base config + try: + base_config = cls.model_validate(base_data) + agent_mapping['agent'] = base_config + except ValidationError as e: + logger.warning(f'Invalid base agent configuration: {e}. Using defaults.') + # If base config fails, create a default one + base_config = cls() + # Still add it to the mapping + agent_mapping['agent'] = base_config + + # Process each custom section independently + for name, overrides in custom_sections.items(): + try: + # Merge base config with overrides + merged = {**base_config.model_dump(), **overrides} + custom_config = cls.model_validate(merged) + agent_mapping[name] = custom_config + except ValidationError as e: + logger.warning( + f'Invalid agent configuration for [{name}]: {e}. This section will be skipped.' + ) + # Skip this custom section but continue with others + continue + + return agent_mapping diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 254a54d97f..84c3f7ab02 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -144,27 +144,9 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None: # Process agent section if present if 'agent' in toml_config: try: - value = toml_config['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 - agent_group_ids: set[str] = set() - for nested_key, nested_value in value.items(): - if isinstance(nested_value, dict): - try: - agent_config = AgentConfig(**nested_value) - except ValidationError: - continue - agent_group_ids.add(nested_key) - cfg.set_agent_config(agent_config, nested_key) - - logger.openhands_logger.debug( - 'Attempt to load default agent config from config toml' - ) - value_without_groups = { - k: v for k, v in value.items() if k not in agent_group_ids - } - agent_config = AgentConfig(**value_without_groups) - cfg.set_agent_config(agent_config, 'agent') + agent_mapping = AgentConfig.from_toml_section(toml_config['agent']) + for agent_key, agent_conf in agent_mapping.items(): + cfg.set_agent_config(agent_conf, agent_key) except (TypeError, KeyError, ValidationError) as e: logger.openhands_logger.warning( f'Cannot parse [agent] config from toml, values have not been applied.\nError: {e}' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 375efb1dfa..a5c5604f68 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -781,3 +781,69 @@ memory_enabled = false assert agent_config1.memory_enabled agent_config2 = get_agent_config_arg('group2', temp_toml_file) assert not agent_config2.memory_enabled + + +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 = { + 'memory_enabled': True, + 'memory_max_threads': 5, + 'enable_prompt_extensions': True, + 'CustomAgent1': {'memory_enabled': False, 'codeact_enable_browsing': False}, + 'CustomAgent2': {'memory_max_threads': 10, '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'].memory_enabled is True + assert result['agent'].memory_max_threads == 5 + assert result['agent'].enable_prompt_extensions is True + + # Verify custom configs were correctly parsed and inherit from base + assert 'CustomAgent1' in result + assert result['CustomAgent1'].memory_enabled is False # Overridden + assert result['CustomAgent1'].memory_max_threads == 5 # Inherited + assert result['CustomAgent1'].codeact_enable_browsing is False # Overridden + assert result['CustomAgent1'].enable_prompt_extensions is True # Inherited + + assert 'CustomAgent2' in result + assert result['CustomAgent2'].memory_enabled is True # Inherited + assert result['CustomAgent2'].memory_max_threads == 10 # Overridden + 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 + 'memory_max_threads': 'not_an_int', # This should cause validation error + 'CustomAgent': {'memory_enabled': True, 'memory_max_threads': 8}, + } + + # 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'].memory_enabled is False # Default value + assert result['agent'].memory_max_threads == 3 # Default value + + # Verify custom config was still processed correctly + assert 'CustomAgent' in result + assert result['CustomAgent'].memory_enabled is True + assert result['CustomAgent'].memory_max_threads == 8