Skip to content

Commit 3d38a10

Browse files
enystopenhands-agentcsmith49
authored
Add loading from toml for condensers (OpenHands#6974)
Co-authored-by: openhands <[email protected]> Co-authored-by: Calvin Smith <[email protected]>
1 parent b1ab4d3 commit 3d38a10

7 files changed

Lines changed: 495 additions & 14 deletions

File tree

config.template.toml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ workspace_base = "./workspace"
9595
# List of allowed file extensions for uploads
9696
#file_uploads_allowed_extensions = [".*"]
9797

98+
# Whether to enable the default LLM summarizing condenser when no condenser is specified in config
99+
# When true, a LLMSummarizingCondenserConfig will be used as the default condenser
100+
# When false, a NoOpCondenserConfig (no summarization) will be used
101+
#enable_default_condenser = true
102+
98103
#################################### LLM #####################################
99104
# Configuration for LLM models (group name starts with 'llm')
100105
# use 'llm' for the default LLM config
@@ -294,6 +299,69 @@ llm_config = 'gpt3'
294299
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
295300
#security_analyzer = ""
296301

302+
#################################### Condenser #################################
303+
# Condensers control how conversation history is managed and compressed when
304+
# the context grows too large. Each agent uses one condenser configuration.
305+
##############################################################################
306+
[condenser]
307+
# The type of condenser to use. Available options:
308+
# - "noop": No condensing, keeps full history (default)
309+
# - "observation_masking": Keeps full event structure but masks older observations
310+
# - "recent": Keeps only recent events and discards older ones
311+
# - "llm": Uses an LLM to summarize conversation history
312+
# - "amortized": Intelligently forgets older events while preserving important context
313+
# - "llm_attention": Uses an LLM to prioritize most relevant context
314+
type = "noop"
315+
316+
# Examples for each condenser type (uncomment and modify as needed):
317+
318+
# 1. NoOp Condenser - No additional settings needed
319+
#type = "noop"
320+
321+
# 2. Observation Masking Condenser
322+
#type = "observation_masking"
323+
# Number of most-recent events where observations will not be masked
324+
#attention_window = 100
325+
326+
# 3. Recent Events Condenser
327+
#type = "recent"
328+
# Number of initial events to always keep (typically includes task description)
329+
#keep_first = 1
330+
# Maximum number of events to keep in history
331+
#max_events = 100
332+
333+
# 4. LLM Summarizing Condenser
334+
#type = "llm"
335+
# Reference to an LLM config to use for summarization
336+
#llm_config = "condenser"
337+
# Number of initial events to always keep (typically includes task description)
338+
#keep_first = 1
339+
# Maximum size of history before triggering summarization
340+
#max_size = 100
341+
342+
# 5. Amortized Forgetting Condenser
343+
#type = "amortized"
344+
# Number of initial events to always keep (typically includes task description)
345+
#keep_first = 1
346+
# Maximum size of history before triggering forgetting
347+
#max_size = 100
348+
349+
# 6. LLM Attention Condenser
350+
#type = "llm_attention"
351+
# Reference to an LLM config to use for attention scoring
352+
#llm_config = "condenser"
353+
# Number of initial events to always keep (typically includes task description)
354+
#keep_first = 1
355+
# Maximum size of history before triggering attention mechanism
356+
#max_size = 100
357+
358+
# Example of a custom LLM configuration for condensers that require an LLM
359+
# If not provided, it falls back to the default LLM
360+
#[llm.condenser]
361+
#model = "gpt-4o"
362+
#temperature = 0.1
363+
#max_tokens = 1024
364+
297365
#################################### Eval ####################################
298366
# Configuration for the evaluation, please refer to the specific evaluation
299367
# plugin for the available options

openhands/agenthub/codeact_agent/codeact_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def __init__(
9191
self.conversation_memory = ConversationMemory(self.prompt_manager)
9292

9393
self.condenser = Condenser.from_config(self.config.condenser)
94-
logger.debug(f'Using condenser: {self.condenser}')
94+
logger.debug(f'Using condenser: {type(self.condenser)}')
9595

9696
def reset(self) -> None:
9797
"""Resets the CodeAct Agent."""

openhands/core/config/app_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class AppConfig(BaseModel):
8282
daytona_target: str = Field(default='us')
8383
cli_multiline_input: bool = Field(default=False)
8484
conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds
85+
enable_default_condenser: bool = Field(default=True)
8586

8687
defaults_dict: ClassVar[dict] = {}
8788

openhands/core/config/condenser_config.py

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from typing import Literal
1+
from typing import Literal, cast
22

3-
from pydantic import BaseModel, Field
3+
from pydantic import BaseModel, Field, ValidationError
44

5+
from openhands.core import logger
56
from openhands.core.config.llm_config import LLMConfig
67

78

@@ -10,17 +11,21 @@ class NoOpCondenserConfig(BaseModel):
1011

1112
type: Literal['noop'] = Field('noop')
1213

14+
model_config = {'extra': 'forbid'}
15+
1316

1417
class ObservationMaskingCondenserConfig(BaseModel):
1518
"""Configuration for ObservationMaskingCondenser."""
1619

1720
type: Literal['observation_masking'] = Field('observation_masking')
1821
attention_window: int = Field(
19-
default=10,
22+
default=100,
2023
description='The number of most-recent events where observations will not be masked.',
2124
ge=1,
2225
)
2326

27+
model_config = {'extra': 'forbid'}
28+
2429

2530
class RecentEventsCondenserConfig(BaseModel):
2631
"""Configuration for RecentEventsCondenser."""
@@ -34,9 +39,11 @@ class RecentEventsCondenserConfig(BaseModel):
3439
ge=0,
3540
)
3641
max_events: int = Field(
37-
default=10, description='Maximum number of events to keep.', ge=1
42+
default=100, description='Maximum number of events to keep.', ge=1
3843
)
3944

45+
model_config = {'extra': 'forbid'}
46+
4047

4148
class LLMSummarizingCondenserConfig(BaseModel):
4249
"""Configuration for LLMCondenser."""
@@ -49,13 +56,17 @@ class LLMSummarizingCondenserConfig(BaseModel):
4956
# at least one event by default, because the best guess is that it's the user task
5057
keep_first: int = Field(
5158
default=1,
52-
description='The number of initial events to condense.',
59+
description='Number of initial events to always keep in history.',
5360
ge=0,
5461
)
5562
max_size: int = Field(
56-
default=10, description='Maximum number of events to keep.', ge=1
63+
default=100,
64+
description='Maximum size of the condensed history before triggering forgetting.',
65+
ge=2,
5766
)
5867

68+
model_config = {'extra': 'forbid'}
69+
5970

6071
class AmortizedForgettingCondenserConfig(BaseModel):
6172
"""Configuration for AmortizedForgettingCondenser."""
@@ -74,6 +85,8 @@ class AmortizedForgettingCondenserConfig(BaseModel):
7485
ge=0,
7586
)
7687

88+
model_config = {'extra': 'forbid'}
89+
7790

7891
class LLMAttentionCondenserConfig(BaseModel):
7992
"""Configuration for LLMAttentionCondenser."""
@@ -95,7 +108,10 @@ class LLMAttentionCondenserConfig(BaseModel):
95108
ge=0,
96109
)
97110

111+
model_config = {'extra': 'forbid'}
98112

113+
114+
# Type alias for convenience
99115
CondenserConfig = (
100116
NoOpCondenserConfig
101117
| ObservationMaskingCondenserConfig
@@ -104,3 +120,121 @@ class LLMAttentionCondenserConfig(BaseModel):
104120
| AmortizedForgettingCondenserConfig
105121
| LLMAttentionCondenserConfig
106122
)
123+
124+
125+
def condenser_config_from_toml_section(
126+
data: dict, llm_configs: dict | None = None
127+
) -> dict[str, CondenserConfig]:
128+
"""
129+
Create a CondenserConfig instance from a toml dictionary representing the [condenser] section.
130+
131+
For CondenserConfig, the handling is different since it's a union type. The type of condenser
132+
is determined by the 'type' field in the section.
133+
134+
Example:
135+
Parse condenser config like:
136+
[condenser]
137+
type = "noop"
138+
139+
For condensers that require an LLM config, you can specify the name of an LLM config:
140+
[condenser]
141+
type = "llm"
142+
llm_config = "my_llm" # References [llm.my_llm] section
143+
144+
Args:
145+
data: The TOML dictionary representing the [condenser] section.
146+
llm_configs: Optional dictionary of LLMConfig objects keyed by name.
147+
148+
Returns:
149+
dict[str, CondenserConfig]: A mapping where the key "condenser" corresponds to the configuration.
150+
"""
151+
152+
# Initialize the result mapping
153+
condenser_mapping: dict[str, CondenserConfig] = {}
154+
155+
# Process config
156+
try:
157+
# Determine which condenser type to use based on 'type' field
158+
condenser_type = data.get('type', 'noop')
159+
160+
# Handle LLM config reference if needed
161+
if (
162+
condenser_type in ('llm', 'llm_attention')
163+
and 'llm_config' in data
164+
and isinstance(data['llm_config'], str)
165+
):
166+
llm_config_name = data['llm_config']
167+
if llm_configs and llm_config_name in llm_configs:
168+
# Replace the string reference with the actual LLMConfig object
169+
data_copy = data.copy()
170+
data_copy['llm_config'] = llm_configs[llm_config_name]
171+
config = create_condenser_config(condenser_type, data_copy)
172+
else:
173+
logger.openhands_logger.warning(
174+
f"LLM config '{llm_config_name}' not found for condenser. Using default LLMConfig."
175+
)
176+
# Create a default LLMConfig if the referenced one doesn't exist
177+
data_copy = data.copy()
178+
# Try to use the fallback 'llm' config
179+
if llm_configs is not None:
180+
data_copy['llm_config'] = llm_configs.get('llm')
181+
config = create_condenser_config(condenser_type, data_copy)
182+
else:
183+
config = create_condenser_config(condenser_type, data)
184+
185+
condenser_mapping['condenser'] = config
186+
except (ValidationError, ValueError) as e:
187+
logger.openhands_logger.warning(
188+
f'Invalid condenser configuration: {e}. Using NoOpCondenserConfig.'
189+
)
190+
# Default to NoOpCondenserConfig if config fails
191+
config = NoOpCondenserConfig()
192+
condenser_mapping['condenser'] = config
193+
194+
return condenser_mapping
195+
196+
197+
# For backward compatibility
198+
from_toml_section = condenser_config_from_toml_section
199+
200+
201+
def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
202+
"""
203+
Create a CondenserConfig instance based on the specified type.
204+
205+
Args:
206+
condenser_type: The type of condenser to create.
207+
data: The configuration data.
208+
209+
Returns:
210+
A CondenserConfig instance.
211+
212+
Raises:
213+
ValueError: If the condenser type is unknown.
214+
ValidationError: If the provided data fails validation for the condenser type.
215+
"""
216+
# Mapping of condenser types to their config classes
217+
condenser_classes = {
218+
'noop': NoOpCondenserConfig,
219+
'observation_masking': ObservationMaskingCondenserConfig,
220+
'recent': RecentEventsCondenserConfig,
221+
'llm': LLMSummarizingCondenserConfig,
222+
'amortized': AmortizedForgettingCondenserConfig,
223+
'llm_attention': LLMAttentionCondenserConfig,
224+
}
225+
226+
if condenser_type not in condenser_classes:
227+
raise ValueError(f'Unknown condenser type: {condenser_type}')
228+
229+
# Create and validate the config using direct instantiation
230+
# Explicitly handle ValidationError to provide more context
231+
try:
232+
config_class = condenser_classes[condenser_type]
233+
# Use type casting to help mypy understand the return type
234+
return cast(CondenserConfig, config_class(**data))
235+
except ValidationError as e:
236+
# Just re-raise with a more descriptive message, but don't try to pass the errors
237+
# which can cause compatibility issues with different pydantic versions
238+
raise ValueError(
239+
f"Validation failed for condenser type '{condenser_type}': {e}"
240+
)

openhands/core/config/utils.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from dotenv import load_dotenv
1313
from pydantic import BaseModel, SecretStr, ValidationError
1414

15+
from openhands import __version__
1516
from openhands.core import logger
1617
from openhands.core.config.agent_config import AgentConfig
1718
from openhands.core.config.app_config import AppConfig
19+
from openhands.core.config.condenser_config import condenser_config_from_toml_section
1820
from openhands.core.config.config_utils import (
1921
OH_DEFAULT_AGENT,
2022
OH_MAX_ITERATIONS,
@@ -193,6 +195,44 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
193195
# Re-raise ValueError from SandboxConfig.from_toml_section
194196
raise ValueError('Error in [sandbox] section in config.toml')
195197

198+
# Process condenser section if present
199+
if 'condenser' in toml_config:
200+
try:
201+
# Pass the LLM configs to the condenser config parser
202+
condenser_mapping = condenser_config_from_toml_section(
203+
toml_config['condenser'], cfg.llms
204+
)
205+
# Assign the default condenser configuration to the default agent configuration
206+
if 'condenser' in condenser_mapping:
207+
# Get the default agent config and assign the condenser config to it
208+
default_agent_config = cfg.get_agent_config()
209+
default_agent_config.condenser = condenser_mapping['condenser']
210+
logger.openhands_logger.debug(
211+
'Default condenser configuration loaded from config toml and assigned to default agent'
212+
)
213+
except (TypeError, KeyError, ValidationError) as e:
214+
logger.openhands_logger.warning(
215+
f'Cannot parse [condenser] config from toml, values have not been applied.\nError: {e}'
216+
)
217+
# If no condenser section is in toml but enable_default_condenser is True,
218+
# set LLMSummarizingCondenserConfig as default
219+
elif cfg.enable_default_condenser:
220+
from openhands.core.config.condenser_config import LLMSummarizingCondenserConfig
221+
222+
# Get default agent config
223+
default_agent_config = cfg.get_agent_config()
224+
225+
# Create default LLM summarizing condenser config
226+
default_condenser = LLMSummarizingCondenserConfig(
227+
llm_config=cfg.get_llm_config(), # Use default LLM config
228+
)
229+
230+
# Set as default condenser
231+
default_agent_config.condenser = default_condenser
232+
logger.openhands_logger.debug(
233+
'Default LLM summarizing condenser assigned to default agent (no condenser in config)'
234+
)
235+
196236
# Process extended section if present
197237
if 'extended' in toml_config:
198238
try:
@@ -203,7 +243,15 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
203243
)
204244

205245
# Check for unknown sections
206-
known_sections = {'core', 'extended', 'agent', 'llm', 'security', 'sandbox'}
246+
known_sections = {
247+
'core',
248+
'extended',
249+
'agent',
250+
'llm',
251+
'security',
252+
'sandbox',
253+
'condenser',
254+
}
207255
for key in toml_config:
208256
if key.lower() not in known_sections:
209257
logger.openhands_logger.warning(f'Unknown section [{key}] in {toml_file}')
@@ -492,8 +540,6 @@ def parse_arguments() -> argparse.Namespace:
492540
args = parser.parse_args()
493541

494542
if args.version:
495-
from openhands import __version__
496-
497543
print(f'OpenHands version: {__version__}')
498544
sys.exit(0)
499545

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import openhands.memory.condenser.impl # noqa F401 (we import this to get the condensers registered)
22
from openhands.memory.condenser.condenser import Condenser, get_condensation_metadata
33

4-
__all__ = ['Condenser', 'get_condensation_metadata']
4+
__all__ = ['Condenser', 'get_condensation_metadata', 'CONDENSER_REGISTRY']

0 commit comments

Comments
 (0)