from __future__ import annotations from openhands.core.config.condenser_config import ConversationWindowCondenserConfig from openhands.core.logger import openhands_logger as logger from openhands.events.action.agent import ( CondensationAction, RecallAction, ) from openhands.events.action.message import MessageAction, SystemMessageAction from openhands.events.event import EventSource from openhands.events.observation import Observation from openhands.llm.llm_registry import LLMRegistry from openhands.memory.condenser.condenser import Condensation, RollingCondenser, View class ConversationWindowCondenser(RollingCondenser): def __init__(self) -> None: super().__init__() def get_condensation(self, view: View) -> Condensation: """Apply conversation window truncation similar to _apply_conversation_window. This method: 1. Identifies essential initial events (System Message, First User Message, Recall Observation) 2. Keeps roughly half of the history 3. Ensures action-observation pairs are preserved 4. Returns a CondensationAction specifying which events to forget """ events = view.events # Handle empty history if not events: # No events to condense action = CondensationAction(forgotten_event_ids=[]) return Condensation(action=action) # 1. Identify essential initial events system_message: SystemMessageAction | None = None first_user_msg: MessageAction | None = None recall_action: RecallAction | None = None recall_observation: Observation | None = None # Find System Message (should be the first event, if it exists) system_message = next( (e for e in events if isinstance(e, SystemMessageAction)), None ) # Find First User Message first_user_msg = next( ( e for e in events if isinstance(e, MessageAction) and e.source == EventSource.USER ), None, ) if first_user_msg is None: logger.warning( 'No first user message found in history during condensation.' ) # Return empty condensation if no user message action = CondensationAction(forgotten_event_ids=[]) return Condensation(action=action) # Find the first user message index first_user_msg_index = -1 for i, event in enumerate(events): if isinstance(event, MessageAction) and event.source == EventSource.USER: first_user_msg_index = i break # Find Recall Action and Observation related to the First User Message for i in range(first_user_msg_index + 1, len(events)): event = events[i] if ( isinstance(event, RecallAction) and event.query == first_user_msg.content ): recall_action = event # Look for its observation for j in range(i + 1, len(events)): obs_event = events[j] if ( isinstance(obs_event, Observation) and obs_event.cause == recall_action.id ): recall_observation = obs_event break break # Collect essential events essential_events: list[int] = [] # Store event IDs if system_message: essential_events.append(system_message.id) essential_events.append(first_user_msg.id) if recall_action: essential_events.append(recall_action.id) if recall_observation: essential_events.append(recall_observation.id) # 2. Determine which events to keep num_essential_events = len(essential_events) total_events = len(events) num_non_essential_events = total_events - num_essential_events # Keep roughly half of the non-essential events num_recent_to_keep = max(1, num_non_essential_events // 2) # Calculate the starting index for recent events to keep slice_start_index = total_events - num_recent_to_keep slice_start_index = max(0, slice_start_index) # 3. Handle dangling observations at the start of the slice # Find the first non-observation event in the slice recent_events_slice = events[slice_start_index:] first_valid_event_index_in_slice = 0 for i, event in enumerate(recent_events_slice): if not isinstance(event, Observation): first_valid_event_index_in_slice = i break else: # All events in the slice are observations first_valid_event_index_in_slice = len(recent_events_slice) # Check if all events in the recent slice are dangling observations if first_valid_event_index_in_slice == len(recent_events_slice): logger.warning( 'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.' ) # Calculate the actual index in the full events list first_valid_event_index = slice_start_index + first_valid_event_index_in_slice if first_valid_event_index_in_slice > 0: logger.debug( f'Removed {first_valid_event_index_in_slice} dangling observation(s) ' f'from the start of recent event slice.' ) # 4. Determine which events to keep and which to forget events_to_keep: set[int] = set(essential_events) # Add recent events starting from first_valid_event_index for i in range(first_valid_event_index, total_events): events_to_keep.add(events[i].id) # Calculate which events to forget all_event_ids = {e.id for e in events} forgotten_event_ids = sorted(all_event_ids - events_to_keep) logger.info( f'ConversationWindowCondenser: Keeping {len(events_to_keep)} events, ' f'forgetting {len(forgotten_event_ids)} events.' ) # Create the condensation action if forgotten_event_ids: # Use range if the forgotten events are contiguous if ( len(forgotten_event_ids) > 1 and forgotten_event_ids[-1] - forgotten_event_ids[0] == len(forgotten_event_ids) - 1 ): action = CondensationAction( forgotten_events_start_id=forgotten_event_ids[0], forgotten_events_end_id=forgotten_event_ids[-1], ) else: action = CondensationAction(forgotten_event_ids=forgotten_event_ids) else: action = CondensationAction(forgotten_event_ids=[]) return Condensation(action=action) def should_condense(self, view: View) -> bool: return view.unhandled_condensation_request @classmethod def from_config( cls, _config: ConversationWindowCondenserConfig, llm_registry: LLMRegistry, ) -> ConversationWindowCondenser: return ConversationWindowCondenser() ConversationWindowCondenser.register_config(ConversationWindowCondenserConfig)