from __future__ import annotations from abc import ABC, abstractmethod from contextlib import contextmanager from typing import Any from pydantic import BaseModel from openhands.controller.state.state import State from openhands.core.config.condenser_config import CondenserConfig from openhands.events.action.agent import CondensationAction from openhands.memory.view import View CONDENSER_METADATA_KEY = 'condenser_meta' """Key identifying where metadata is stored in a `State` object's `extra_data` field.""" def get_condensation_metadata(state: State) -> list[dict[str, Any]]: """Utility function to retrieve a list of metadata batches from a `State`. Args: state: The state to retrieve metadata from. Returns: list[dict[str, Any]]: A list of metadata batches, each representing a condensation. """ if CONDENSER_METADATA_KEY in state.extra_data: return state.extra_data[CONDENSER_METADATA_KEY] return [] CONDENSER_REGISTRY: dict[type[CondenserConfig], type[Condenser]] = {} """Registry of condenser configurations to their corresponding condenser classes.""" class Condensation(BaseModel): """Produced by a condenser to indicate the history has been condensed.""" action: CondensationAction class Condenser(ABC): """Abstract condenser interface. Condensers take a list of `Event` objects and reduce them into a potentially smaller list. Agents can use condensers to reduce the amount of events they need to consider when deciding which action to take. To use a condenser, agents can call the `condensed_history` method on the current `State` being considered and use the results instead of the full history. If the condenser returns a `Condensation` instead of a `View`, the agent should return `Condensation.action` instead of producing its own action. On the next agent step the condenser will use that condensation event to produce a new `View`. """ def __init__(self): self._metadata_batch: dict[str, Any] = {} self._llm_metadata: dict[str, Any] = {} def add_metadata(self, key: str, value: Any) -> None: """Add information to the current metadata batch. Any key/value pairs added to the metadata batch will be recorded in the `State` at the end of the current condensation. Args: key: The key to store the metadata under. value: The metadata to store. """ self._metadata_batch[key] = value def write_metadata(self, state: State) -> None: """Write the current batch of metadata to the `State`. Resets the current metadata batch: any metadata added after this call will be stored in a new batch and written to the `State` at the end of the next condensation. """ if CONDENSER_METADATA_KEY not in state.extra_data: state.extra_data[CONDENSER_METADATA_KEY] = [] if self._metadata_batch: state.extra_data[CONDENSER_METADATA_KEY].append(self._metadata_batch) # Since the batch has been written, clear it for the next condensation self._metadata_batch = {} @contextmanager def metadata_batch(self, state: State): """Context manager to ensure batched metadata is always written to the `State`.""" try: yield finally: self.write_metadata(state) @abstractmethod def condense(self, View) -> View | Condensation: """Condense a sequence of events into a potentially smaller list. New condenser strategies should override this method to implement their own condensation logic. Call `self.add_metadata` in the implementation to record any relevant per-condensation diagnostic information. Args: View: A view of the history containing all events that should be condensed. Returns: View | Condensation: A condensed view of the events or an event indicating the history has been condensed. """ def condensed_history(self, state: State) -> View | Condensation: """Condense the state's history.""" self._llm_metadata = state.to_llm_metadata('condenser') with self.metadata_batch(state): return self.condense(state.view) @classmethod def register_config(cls, configuration_type: type[CondenserConfig]) -> None: """Register a new condenser configuration type. Instances of registered configuration types can be passed to `from_config` to create instances of the corresponding condenser. Args: configuration_type: The type of configuration used to create instances of the condenser. Raises: ValueError: If the configuration type is already registered. """ if configuration_type in CONDENSER_REGISTRY: raise ValueError( f'Condenser configuration {configuration_type} is already registered' ) CONDENSER_REGISTRY[configuration_type] = cls @classmethod def from_config(cls, config: CondenserConfig) -> Condenser: """Create a condenser from a configuration object. Args: config: Configuration for the condenser. Returns: Condenser: A condenser instance. Raises: ValueError: If the condenser type is not recognized. """ try: condenser_class = CONDENSER_REGISTRY[type(config)] return condenser_class.from_config(config) except KeyError: raise ValueError(f'Unknown condenser config: {config}') class RollingCondenser(Condenser, ABC): """Base class for a specialized condenser strategy that applies condensation to a rolling history. The rolling history is generated by `View.from_events`, which analyzes all events in the history and produces a `View` object representing what will be sent to the LLM. If `should_condense` says so, the condenser is then responsible for generating a `Condensation` object from the `View` object. This will be added to the event history which should -- when given to `get_view` -- produce the condensed `View` to be passed to the LLM. """ @abstractmethod def should_condense(self, view: View) -> bool: """Determine if a view should be condensed.""" @abstractmethod def get_condensation(self, view: View) -> Condensation: """Get the condensation from a view.""" def condense(self, view: View) -> View | Condensation: # If we trigger the condenser-specific condensation threshold, compute and return # the condensation. if self.should_condense(view): return self.get_condensation(view) # Otherwise we're safe to just return the view. else: return view