Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
TokenEvent,
UserRejectObservation,
)
from openhands.sdk.event.condenser import Condensation, CondensationRequest
from openhands.sdk.event.condenser import (
Condensation,
CondensationRequest,
)
from openhands.sdk.llm import (
LLMResponse,
Message,
Expand Down
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/context/condenser/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from openhands.sdk.context.condenser.base import (
CondenserBase,
NoCondensationAvailableException,
RollingCondenser,
)
from openhands.sdk.context.condenser.llm_summarizing_condenser import (
Expand All @@ -15,4 +16,5 @@
"NoOpCondenser",
"PipelineCondenser",
"LLMSummarizingCondenser",
"NoCondensationAvailableException",
]
67 changes: 59 additions & 8 deletions openhands-sdk/openhands/sdk/context/condenser/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from enum import Enum
from logging import getLogger

from openhands.sdk.context.view import View
Expand Down Expand Up @@ -66,22 +67,57 @@ class PipelinableCondenserBase(CondenserBase):
condenser should not nest another pipeline condenser)"""


class NoCondensationAvailableException(Exception):
"""Raised when a condenser is asked to provide a condensation but none is available.

This can happen if the condenser's `should_condense` method returns True, but due to
API constraints no condensation can be generated.

When this exception is raised from a rolling condenser's `get_condensation` method,
the agent will fall back to using the uncondensed view for the next agent step.
"""


class CondensationRequirement(Enum):
"""The type of condensation required by a rolling condenser."""

HARD = "hard"
"""Indicates that a condensation is required right now, and the agent cannot proceed
without it.
"""

SOFT = "soft"
"""Indicates that a condensation is desired but not strictly required."""


class RollingCondenser(PipelinableCondenserBase, 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.
If `condensation_requirement` 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, agent_llm: LLM | None = None) -> bool:
"""Determine if a view should be condensed."""
def condensation_requirement(
self, view: View, agent_llm: LLM | None = None
) -> CondensationRequirement | None:
"""Determine how a view should be condensed.

Args:
view: The current view of the conversation history.
agent_llm: LLM instance used by the agent. Condensers use this for token
counting purposes. Defaults to None.

Returns:
CondensationRequirement | None: The type of condensation required, or None
if no condensation is needed.
"""

@abstractmethod
def get_condensation(
Expand All @@ -92,8 +128,23 @@ def get_condensation(
def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensation:
# If we trigger the condenser-specific condensation threshold, compute and
# return the condensation.
if self.should_condense(view, agent_llm=agent_llm):
return self.get_condensation(view, agent_llm=agent_llm)
request = self.condensation_requirement(view, agent_llm=agent_llm)
if request is not None:
try:
return self.get_condensation(view, agent_llm=agent_llm)

except NoCondensationAvailableException as e:
logger.debug(f"No condensation available: {e}")

if request == CondensationRequirement.SOFT:
# For soft requests, we can just return the uncondensed view. This
# request will _eventually_ be handled, but it's not critical that
# we do so immediately.
return view

# Otherwise re-raise the exception.
else:
raise e

# Otherwise we're safe to just return the view.
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from pydantic import Field, model_validator

from openhands.sdk.context.condenser.base import RollingCondenser
from openhands.sdk.context.condenser.base import (
CondensationRequirement,
NoCondensationAvailableException,
RollingCondenser,
)
from openhands.sdk.context.condenser.utils import (
get_suffix_length_for_token_reduction,
get_total_token_count,
Expand Down Expand Up @@ -84,9 +88,30 @@ def get_condensation_reasons(

return reasons

def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool:
def condensation_requirement(
self, view: View, agent_llm: LLM | None = None
) -> CondensationRequirement | None:
reasons = self.get_condensation_reasons(view, agent_llm)
return reasons != set()

# No reasons => no condensation needed.
if reasons == set():
return None

# If the reasons are for resource constraints, we can treat it as a soft
# requirement. We want to condense when we can, but there's still space in the
# context window or we'd also see Reason.REQUEST. That means we can delay the
# condensation if there isn't one available (based on the view's manipulation
# indices).
resource_reasons = {Reason.TOKENS, Reason.EVENTS}
if reasons.issubset(resource_reasons):
return CondensationRequirement.SOFT

# Requests -- whether they come from the user or the agent -- are always hard
# requirements. We need to condense now because:
# 1. the user expects it
# 2. the agent has no more room in the context window and can't continue
if Reason.REQUEST in reasons:
return CondensationRequirement.HARD

def _get_summary_event_content(self, view: View) -> str:
"""Extract the text content from the summary event in the view, if any.
Expand Down Expand Up @@ -124,12 +149,7 @@ def _generate_condensation(
Raises:
ValueError: If forgotten_events is empty (0 events to condense).
"""
if len(forgotten_events) == 0:
raise ValueError(
"Cannot condense 0 events. This typically occurs when a tool loop "
"spans almost the entire view, leaving no valid range for forgetting "
"events. Consider adjusting keep_first or max_size parameters."
)
assert len(forgotten_events) > 0, "No events to condense."

# Convert events to strings for the template
event_strings = [str(forgotten_event) for forgotten_event in forgotten_events]
Expand Down Expand Up @@ -236,11 +256,19 @@ def get_condensation(
) -> Condensation:
# The condensation is dependent on the events we want to drop and the previous
# summary.
summary_event_content = self._get_summary_event_content(view)
forgotten_events, summary_offset = self._get_forgotten_events(
view, agent_llm=agent_llm
)

if not forgotten_events:
raise NoCondensationAvailableException(
"Cannot condense 0 events. This typically occurs when a tool loop "
"spans almost the entire view, leaving no valid range for forgetting "
"events. Consider adjusting keep_first or max_size parameters."
)

summary_event_content = self._get_summary_event_content(view)

return self._generate_condensation(
summary_event_content=summary_event_content,
forgotten_events=forgotten_events,
Expand Down
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/context/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,11 @@ def from_events(events: Sequence[Event]) -> View:
# Check for an unhandled condensation request -- these are events closer to the
# end of the list than any condensation action.
unhandled_condensation_request = False

for event in reversed(events):
if isinstance(event, Condensation):
break

if isinstance(event, CondensationRequest):
unhandled_condensation_request = True
break
Expand Down
11 changes: 6 additions & 5 deletions tests/integration/tests/t09_token_condenser.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class TokenCondenserTest(BaseIntegrationTest):

def __init__(self, *args, **kwargs):
"""Initialize test with tracking variables."""
self.condensation_count = 0
self.condensations: list[Condensation] = []
super().__init__(*args, **kwargs)

# Some models explicitly disallow long, repetitive tool loops for cost/safety.
Expand Down Expand Up @@ -87,25 +87,26 @@ def conversation_callback(self, event):
super().conversation_callback(event)

if isinstance(event, Condensation):
if self.condensation_count >= 1:
if len(self.condensations) >= 1:
logger.info("2nd condensation detected! Stopping test early.")
self.conversation.pause()
# We allow the first condensation request to test if
# thinking block + condensation will work together
self.condensation_count += 1
self.condensations.append(event)

def setup(self) -> None:
logger.info(f"Token condenser test: max_tokens={self.condenser.max_tokens}")

def verify_result(self) -> TestResult:
"""Verify that condensation was triggered based on token count."""
if self.condensation_count == 0:
if len(self.condensations) == 0:
return TestResult(
success=False,
reason="Condensation not triggered. Token counting may not work.",
)

events_summarized = len(self.condensations[0].forgotten_event_ids)
return TestResult(
success=True,
reason="Condensation triggered. Token counting works correctly.",
reason=f"Condensation triggered, summarizing {events_summarized} events.",
)
Loading
Loading