From 494a11b09a5b90f3e8bffd7a03176d9c9705f045 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 10:49:21 -0700 Subject: [PATCH 01/19] exception for no condensation available --- .../openhands/sdk/context/condenser/base.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index 938b3495d9..11c72df812 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -65,6 +65,15 @@ class PipelinableCondenserBase(CondenserBase): """Abstract condenser interface which may be pipelined. (Since a pipeline 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 RollingCondenser(PipelinableCondenserBase, ABC): """Base class for a specialized condenser strategy that applies condensation to a @@ -93,7 +102,12 @@ def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensat # 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) + try: + return self.get_condensation(view, agent_llm=agent_llm) + + except NoCondensationAvailableException as e: + logger.debug(f"No condensation available: {e}") + return view # Otherwise we're safe to just return the view. else: From fbb91c488742c73d1236f8ca3ff5c7ab28c480ff Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 10:50:07 -0700 Subject: [PATCH 02/19] fix imports --- openhands-sdk/openhands/sdk/context/condenser/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openhands-sdk/openhands/sdk/context/condenser/__init__.py b/openhands-sdk/openhands/sdk/context/condenser/__init__.py index 155f745f64..09c7ad09b6 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/__init__.py +++ b/openhands-sdk/openhands/sdk/context/condenser/__init__.py @@ -1,5 +1,6 @@ from openhands.sdk.context.condenser.base import ( CondenserBase, + NoCondensationAvailableException, RollingCondenser, ) from openhands.sdk.context.condenser.llm_summarizing_condenser import ( @@ -15,4 +16,5 @@ "NoOpCondenser", "PipelineCondenser", "LLMSummarizingCondenser", + "NoCondensationAvailableException", ] From 8a073f81758937fd0066e9efc881e9b42e94d3e0 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 10:53:20 -0700 Subject: [PATCH 03/19] adding tests for rolling condenser base class --- .../condenser/test_rolling_condenser.py | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/sdk/context/condenser/test_rolling_condenser.py diff --git a/tests/sdk/context/condenser/test_rolling_condenser.py b/tests/sdk/context/condenser/test_rolling_condenser.py new file mode 100644 index 0000000000..6db94b2bc3 --- /dev/null +++ b/tests/sdk/context/condenser/test_rolling_condenser.py @@ -0,0 +1,138 @@ +from unittest.mock import MagicMock + +import pytest + +from openhands.sdk.context.condenser.base import ( + NoCondensationAvailableException, + RollingCondenser, +) +from openhands.sdk.context.view import View +from openhands.sdk.event.base import Event +from openhands.sdk.event.condenser import Condensation +from openhands.sdk.event.llm_convertible import MessageEvent +from openhands.sdk.llm import LLM, Message, TextContent + + +def message_event(content: str) -> MessageEvent: + return MessageEvent( + llm_message=Message(role="user", content=[TextContent(text=content)]), + source="user", + ) + + +class MockRollingCondenser(RollingCondenser): + """Mock implementation of RollingCondenser for testing.""" + + def __init__( + self, + should_condense_value: bool = True, + raise_exception: bool = False, + ): + self._should_condense_value = should_condense_value + self._raise_exception = raise_exception + + def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool: + return self._should_condense_value + + def get_condensation( + self, view: View, agent_llm: LLM | None = None + ) -> Condensation: + if self._raise_exception: + raise NoCondensationAvailableException( + "No condensation available due to API constraints" + ) + # Return a simple condensation for successful case + return Condensation( + forgotten_event_ids=[view.events[0].id], + summary="Mock summary", + summary_offset=0, + llm_response_id="mock-response-id", + ) + + +def test_rolling_condenser_returns_view_when_no_condensation_needed() -> None: + """Test that RollingCondenser returns the original view when should_condense returns False.""" + condenser = MockRollingCondenser(should_condense_value=False) + + events: list[Event] = [ + message_event("Event 1"), + message_event("Event 2"), + message_event("Event 3"), + ] + view = View.from_events(events) + + result = condenser.condense(view) + + assert isinstance(result, View) + assert result == view + + +def test_rolling_condenser_returns_condensation_when_needed() -> None: + """Test that RollingCondenser returns a Condensation when should_condense returns True.""" + condenser = MockRollingCondenser(should_condense_value=True, raise_exception=False) + + events: list[Event] = [ + message_event("Event 1"), + message_event("Event 2"), + message_event("Event 3"), + ] + view = View.from_events(events) + + result = condenser.condense(view) + + assert isinstance(result, Condensation) + assert result.summary == "Mock summary" + + +def test_rolling_condenser_returns_view_on_no_condensation_available_exception() -> None: + """Test that RollingCondenser returns the original view when NoCondensationAvailableException is raised. + + This tests the exception handling added in base.py:105-110 which catches + NoCondensationAvailableException from get_condensation() and returns the + original view as a fallback. + """ + condenser = MockRollingCondenser(should_condense_value=True, raise_exception=True) + + events: list[Event] = [ + message_event("Event 1"), + message_event("Event 2"), + message_event("Event 3"), + ] + view = View.from_events(events) + + # Even though should_condense returns True, the exception should be caught + # and the original view should be returned + result = condenser.condense(view) + + assert isinstance(result, View) + assert result == view + assert result.events == events + + +def test_rolling_condenser_with_agent_llm() -> None: + """Test that RollingCondenser works with optional agent_llm parameter.""" + condenser = MockRollingCondenser(should_condense_value=True, raise_exception=False) + + events: list[Event] = [ + message_event("Event 1"), + message_event("Event 2"), + message_event("Event 3"), + ] + view = View.from_events(events) + + # Create a mock LLM + mock_llm = MagicMock(spec=LLM) + + # Condense with agent_llm parameter + result = condenser.condense(view, agent_llm=mock_llm) + + assert isinstance(result, Condensation) + assert result.summary == "Mock summary" + + +def test_no_condensation_available_exception_message() -> None: + """Test that NoCondensationAvailableException can be raised with a custom message.""" + exception_message = "Custom error message about API constraints" + + with pytest.raises(NoCondensationAvailableException, match=exception_message): + raise NoCondensationAvailableException(exception_message) From dafde2c90845c4ea97497aaa375668d9ca309422 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:00:31 -0700 Subject: [PATCH 04/19] moving excedption behavior, changing base exception class --- .../condenser/llm_summarizing_condenser.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py index ad0d3c6fe6..c50f7fa989 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py +++ b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py @@ -4,7 +4,7 @@ from pydantic import Field, model_validator -from openhands.sdk.context.condenser.base import RollingCondenser +from openhands.sdk.context.condenser.base import NoCondensationAvailableException, RollingCondenser from openhands.sdk.context.condenser.utils import ( get_suffix_length_for_token_reduction, get_total_token_count, @@ -124,12 +124,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] @@ -236,11 +231,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, From 6846132bf3a58e41835b7b59a63e3301898b7b3d Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:00:57 -0700 Subject: [PATCH 05/19] linting --- openhands-sdk/openhands/sdk/context/condenser/base.py | 4 +++- .../sdk/context/condenser/llm_summarizing_condenser.py | 7 +++++-- tests/sdk/context/condenser/test_rolling_condenser.py | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index 11c72df812..a0b7c248f1 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -65,9 +65,10 @@ class PipelinableCondenserBase(CondenserBase): """Abstract condenser interface which may be pipelined. (Since a pipeline 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. @@ -75,6 +76,7 @@ class NoCondensationAvailableException(Exception): the agent will fall back to using the uncondensed view for the next agent step. """ + class RollingCondenser(PipelinableCondenserBase, ABC): """Base class for a specialized condenser strategy that applies condensation to a rolling history. diff --git a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py index c50f7fa989..b66f9d8d47 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py +++ b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py @@ -4,7 +4,10 @@ from pydantic import Field, model_validator -from openhands.sdk.context.condenser.base import NoCondensationAvailableException, RollingCondenser +from openhands.sdk.context.condenser.base import ( + NoCondensationAvailableException, + RollingCondenser, +) from openhands.sdk.context.condenser.utils import ( get_suffix_length_for_token_reduction, get_total_token_count, @@ -241,7 +244,7 @@ def get_condensation( "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( diff --git a/tests/sdk/context/condenser/test_rolling_condenser.py b/tests/sdk/context/condenser/test_rolling_condenser.py index 6db94b2bc3..e461e850d9 100644 --- a/tests/sdk/context/condenser/test_rolling_condenser.py +++ b/tests/sdk/context/condenser/test_rolling_condenser.py @@ -84,7 +84,9 @@ def test_rolling_condenser_returns_condensation_when_needed() -> None: assert result.summary == "Mock summary" -def test_rolling_condenser_returns_view_on_no_condensation_available_exception() -> None: +def test_rolling_condenser_returns_view_on_no_condensation_available_exception() -> ( + None +): """Test that RollingCondenser returns the original view when NoCondensationAvailableException is raised. This tests the exception handling added in base.py:105-110 which catches From 6b2e9d96c9303b24f59907f4b003ba31d8b9e09a Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:06:39 -0700 Subject: [PATCH 06/19] linting --- tests/sdk/context/condenser/test_rolling_condenser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/sdk/context/condenser/test_rolling_condenser.py b/tests/sdk/context/condenser/test_rolling_condenser.py index e461e850d9..64de0c0050 100644 --- a/tests/sdk/context/condenser/test_rolling_condenser.py +++ b/tests/sdk/context/condenser/test_rolling_condenser.py @@ -87,7 +87,8 @@ def test_rolling_condenser_returns_condensation_when_needed() -> None: def test_rolling_condenser_returns_view_on_no_condensation_available_exception() -> ( None ): - """Test that RollingCondenser returns the original view when NoCondensationAvailableException is raised. + """Test that RollingCondenser returns the original view when + NoCondensationAvailableException is raised. This tests the exception handling added in base.py:105-110 which catches NoCondensationAvailableException from get_condensation() and returns the @@ -133,7 +134,7 @@ def test_rolling_condenser_with_agent_llm() -> None: def test_no_condensation_available_exception_message() -> None: - """Test that NoCondensationAvailableException can be raised with a custom message.""" + """Test that NoCondensationAvailableException raisable with custom message.""" exception_message = "Custom error message about API constraints" with pytest.raises(NoCondensationAvailableException, match=exception_message): From f57bc818c493edcc59455c2bd40e554127da17e9 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:08:24 -0700 Subject: [PATCH 07/19] yet more linting --- tests/sdk/context/condenser/test_rolling_condenser.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/sdk/context/condenser/test_rolling_condenser.py b/tests/sdk/context/condenser/test_rolling_condenser.py index 64de0c0050..df8b968af8 100644 --- a/tests/sdk/context/condenser/test_rolling_condenser.py +++ b/tests/sdk/context/condenser/test_rolling_condenser.py @@ -51,7 +51,9 @@ def get_condensation( def test_rolling_condenser_returns_view_when_no_condensation_needed() -> None: - """Test that RollingCondenser returns the original view when should_condense returns False.""" + """Test that RollingCondenser returns the original view when should_condense returns + False. + """ condenser = MockRollingCondenser(should_condense_value=False) events: list[Event] = [ @@ -68,7 +70,9 @@ def test_rolling_condenser_returns_view_when_no_condensation_needed() -> None: def test_rolling_condenser_returns_condensation_when_needed() -> None: - """Test that RollingCondenser returns a Condensation when should_condense returns True.""" + """Test that RollingCondenser returns a Condensation when should_condense returns + True. + """ condenser = MockRollingCondenser(should_condense_value=True, raise_exception=False) events: list[Event] = [ From c160c5c2d6179338f8176d62baf411a488f092c2 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:13:16 -0700 Subject: [PATCH 08/19] updating tests that reference old exception logic --- tests/sdk/context/condenser/test_llm_summarizing_condenser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py index ebb463e9b8..995f074f40 100644 --- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py +++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py @@ -554,7 +554,7 @@ def test_most_aggressive_condensation_chosen(mock_llm: LLM) -> None: def test_generate_condensation_raises_on_zero_events(mock_llm: LLM) -> None: - """Test that _generate_condensation raises ValueError when given 0 events. + """Test that _generate_condensation raises AssertionError when given 0 events. This prevents the LLM from being called with an empty event list, which would produce a confusing summary like "I don't see any events provided to summarize." @@ -562,7 +562,7 @@ def test_generate_condensation_raises_on_zero_events(mock_llm: LLM) -> None: """ condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) - with pytest.raises(ValueError, match="Cannot condense 0 events"): + with pytest.raises(AssertionError, match="No events to condense"): condenser._generate_condensation( summary_event_content="", forgotten_events=[], From d74c03947be7582502628ff68353977afb784209 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 11:46:19 -0700 Subject: [PATCH 09/19] minor integration test logging changed --- tests/integration/tests/t09_token_condenser.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/integration/tests/t09_token_condenser.py b/tests/integration/tests/t09_token_condenser.py index dec8cf5715..193a94df73 100644 --- a/tests/integration/tests/t09_token_condenser.py +++ b/tests/integration/tests/t09_token_condenser.py @@ -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. @@ -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.", ) From 053e4c0a3f704923f9f8fb337583850a3c04967e Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 16:55:30 -0700 Subject: [PATCH 10/19] reasons for a condensation request --- openhands-sdk/openhands/sdk/agent/agent.py | 8 ++++++-- .../sdk/conversation/impl/local_conversation.py | 5 ++++- openhands-sdk/openhands/sdk/event/condenser.py | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 2806677ade..8985f9d103 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -27,7 +27,11 @@ TokenEvent, UserRejectObservation, ) -from openhands.sdk.event.condenser import Condensation, CondensationRequest +from openhands.sdk.event.condenser import ( + Condensation, + CondensationRequest, + CondensationRequestReason, +) from openhands.sdk.llm import ( LLMResponse, Message, @@ -200,7 +204,7 @@ def step( logger.warning( "LLM raised context window exceeded error, triggering condensation" ) - on_event(CondensationRequest()) + on_event(CondensationRequest(reason=CondensationRequestReason.CONTEXT_LIMIT)) return # No condenser available or doesn't handle requests; log helpful warning self._log_context_window_exceeded_warning() diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 984f01f846..336553f546 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -30,6 +30,7 @@ PauseEvent, UserRejectObservation, ) +from openhands.sdk.event.condenser import CondensationRequestReason from openhands.sdk.event.conversation_error import ConversationErrorEvent from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback from openhands.sdk.llm import LLM, Message, TextContent @@ -646,7 +647,9 @@ def condense(self) -> None: ) # Add a condensation request event - condensation_request = CondensationRequest() + condensation_request = CondensationRequest( + reason=CondensationRequestReason.MANUAL + ) self._on_event(condensation_request) # Force the agent to take a single step to process the condensation request diff --git a/openhands-sdk/openhands/sdk/event/condenser.py b/openhands-sdk/openhands/sdk/event/condenser.py index 6f58a45d17..50416c5fe4 100644 --- a/openhands-sdk/openhands/sdk/event/condenser.py +++ b/openhands-sdk/openhands/sdk/event/condenser.py @@ -1,3 +1,4 @@ +from enum import Enum from pydantic import Field from rich.text import Text @@ -45,6 +46,15 @@ def visualize(self) -> Text: text.append(f"{self.summary}\n") return text +class CondensationRequestReason(Enum): + MANUAL = "manual" + """The condensation was requested manually by the user or system.""" + + CONTEXT_LIMIT = "context_limit" + """The condensation was requested due to reaching the context window limit.""" + + UNRECOVERABLE = "unrecoverable" + """The condensation was requested due to an unrecoverable error.""" class CondensationRequest(Event): """This action is used to request a condensation of the conversation history. @@ -52,9 +62,10 @@ class CondensationRequest(Event): Attributes: action (str): The action type, namely ActionType.CONDENSATION_REQUEST. """ - + source: SourceType = "environment" - + reason: CondensationRequestReason | None = None + @property def visualize(self) -> Text: text = Text() From 042cbbd8fc611588da1d3797a6be82a472c66171 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 16:59:19 -0700 Subject: [PATCH 11/19] view extracts unhandled condensation request reasons --- openhands-sdk/openhands/sdk/context/view.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/context/view.py b/openhands-sdk/openhands/sdk/context/view.py index 0b6b594a98..67d807b3fa 100644 --- a/openhands-sdk/openhands/sdk/context/view.py +++ b/openhands-sdk/openhands/sdk/context/view.py @@ -15,6 +15,7 @@ LLMConvertibleEvent, ) from openhands.sdk.event.base import Event, EventID +from openhands.sdk.event.condenser import CondensationRequestReason from openhands.sdk.event.llm_convertible import ( ActionEvent, ObservationBaseEvent, @@ -80,6 +81,9 @@ class View(BaseModel): unhandled_condensation_request: bool = False """Whether there is an unhandled condensation request in the view.""" + unhandled_condensation_request_reasons: list[CondensationRequestReason] = [] + """List of reasons for unhandled condensation requests in the view.""" + condensations: list[Condensation] = [] """A list of condensations that were processed to produce the view.""" @@ -489,15 +493,21 @@ 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 + unhandled_condensation_request_reasons: list[CondensationRequestReason] = [] + for event in reversed(events): if isinstance(event, Condensation): break + if isinstance(event, CondensationRequest): unhandled_condensation_request = True - break + + if event.reason is not None: + unhandled_condensation_request_reasons.append(event.reason) return View( events=View.filter_unmatched_tool_calls(kept_events), unhandled_condensation_request=unhandled_condensation_request, + unhandled_condensation_request_reasons=unhandled_condensation_request_reasons, condensations=condensations, ) From af64f9b8ca01db61f169495a6adeff889c1a872b Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 17:04:51 -0700 Subject: [PATCH 12/19] rolling condenser only continues on certain condensation request reasons --- .../openhands/sdk/context/condenser/base.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index a0b7c248f1..e6e568853d 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -2,7 +2,7 @@ from logging import getLogger from openhands.sdk.context.view import View -from openhands.sdk.event.condenser import Condensation +from openhands.sdk.event.condenser import Condensation, CondensationRequestReason from openhands.sdk.llm import LLM from openhands.sdk.utils.models import ( DiscriminatedUnionMixin, @@ -100,6 +100,19 @@ def get_condensation( ) -> Condensation: """Get the condensation from a view.""" + def is_continuable_reason(self, reason: CondensationRequestReason) -> bool: + """Determine if the given condensation request reason is continuable by this + condenser. + + Args: + reason: The reason for the condensation request. + Returns: + bool: True if the reason is continuable, False otherwise. + """ + if reason == CondensationRequestReason.MANUAL: + return True + return False + 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. @@ -109,7 +122,19 @@ def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensat except NoCondensationAvailableException as e: logger.debug(f"No condensation available: {e}") - return view + + # Check if all condensation requests are continuable. If so, or if there + # are no requests, we can safely return the uncondensed view. + all_continuable = all( + self.is_continuable_reason(reason) + for reason in view.unhandled_condensation_request_reasons + ) + if not view.unhandled_condensation_request or all_continuable: + return view + + # Otherwise re-raise the exception. + else: + raise e # Otherwise we're safe to just return the view. else: From 40f634fcdc025f4d7c6d634ccb6f885e26d5c76b Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 2 Jan 2026 17:05:18 -0700 Subject: [PATCH 13/19] linting --- openhands-sdk/openhands/sdk/agent/agent.py | 4 +++- openhands-sdk/openhands/sdk/context/condenser/base.py | 4 ++-- openhands-sdk/openhands/sdk/context/view.py | 4 ++-- openhands-sdk/openhands/sdk/event/condenser.py | 7 +++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 8985f9d103..1b2076e0e3 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -204,7 +204,9 @@ def step( logger.warning( "LLM raised context window exceeded error, triggering condensation" ) - on_event(CondensationRequest(reason=CondensationRequestReason.CONTEXT_LIMIT)) + on_event( + CondensationRequest(reason=CondensationRequestReason.CONTEXT_LIMIT) + ) return # No condenser available or doesn't handle requests; log helpful warning self._log_context_window_exceeded_warning() diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index e6e568853d..ed948b30b4 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -122,7 +122,7 @@ def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensat except NoCondensationAvailableException as e: logger.debug(f"No condensation available: {e}") - + # Check if all condensation requests are continuable. If so, or if there # are no requests, we can safely return the uncondensed view. all_continuable = all( @@ -131,7 +131,7 @@ def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensat ) if not view.unhandled_condensation_request or all_continuable: return view - + # Otherwise re-raise the exception. else: raise e diff --git a/openhands-sdk/openhands/sdk/context/view.py b/openhands-sdk/openhands/sdk/context/view.py index 67d807b3fa..fc999e1dbc 100644 --- a/openhands-sdk/openhands/sdk/context/view.py +++ b/openhands-sdk/openhands/sdk/context/view.py @@ -494,11 +494,11 @@ def from_events(events: Sequence[Event]) -> View: # end of the list than any condensation action. unhandled_condensation_request = False unhandled_condensation_request_reasons: list[CondensationRequestReason] = [] - + for event in reversed(events): if isinstance(event, Condensation): break - + if isinstance(event, CondensationRequest): unhandled_condensation_request = True diff --git a/openhands-sdk/openhands/sdk/event/condenser.py b/openhands-sdk/openhands/sdk/event/condenser.py index 50416c5fe4..27be6c677e 100644 --- a/openhands-sdk/openhands/sdk/event/condenser.py +++ b/openhands-sdk/openhands/sdk/event/condenser.py @@ -1,4 +1,5 @@ from enum import Enum + from pydantic import Field from rich.text import Text @@ -46,6 +47,7 @@ def visualize(self) -> Text: text.append(f"{self.summary}\n") return text + class CondensationRequestReason(Enum): MANUAL = "manual" """The condensation was requested manually by the user or system.""" @@ -56,16 +58,17 @@ class CondensationRequestReason(Enum): UNRECOVERABLE = "unrecoverable" """The condensation was requested due to an unrecoverable error.""" + class CondensationRequest(Event): """This action is used to request a condensation of the conversation history. Attributes: action (str): The action type, namely ActionType.CONDENSATION_REQUEST. """ - + source: SourceType = "environment" reason: CondensationRequestReason | None = None - + @property def visualize(self) -> Text: text = Text() From b4783da036cdfa13b7c6faa818f81439a6006d2b Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 10:08:38 -0700 Subject: [PATCH 14/19] reverting condensation reasons --- openhands-sdk/openhands/sdk/agent/agent.py | 5 +---- openhands-sdk/openhands/sdk/context/view.py | 10 +--------- .../sdk/conversation/impl/local_conversation.py | 5 +---- openhands-sdk/openhands/sdk/event/condenser.py | 14 -------------- 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 1b2076e0e3..893321f702 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -30,7 +30,6 @@ from openhands.sdk.event.condenser import ( Condensation, CondensationRequest, - CondensationRequestReason, ) from openhands.sdk.llm import ( LLMResponse, @@ -204,9 +203,7 @@ def step( logger.warning( "LLM raised context window exceeded error, triggering condensation" ) - on_event( - CondensationRequest(reason=CondensationRequestReason.CONTEXT_LIMIT) - ) + on_event(CondensationRequest()) return # No condenser available or doesn't handle requests; log helpful warning self._log_context_window_exceeded_warning() diff --git a/openhands-sdk/openhands/sdk/context/view.py b/openhands-sdk/openhands/sdk/context/view.py index fc999e1dbc..c80739e1ac 100644 --- a/openhands-sdk/openhands/sdk/context/view.py +++ b/openhands-sdk/openhands/sdk/context/view.py @@ -15,7 +15,6 @@ LLMConvertibleEvent, ) from openhands.sdk.event.base import Event, EventID -from openhands.sdk.event.condenser import CondensationRequestReason from openhands.sdk.event.llm_convertible import ( ActionEvent, ObservationBaseEvent, @@ -81,9 +80,6 @@ class View(BaseModel): unhandled_condensation_request: bool = False """Whether there is an unhandled condensation request in the view.""" - unhandled_condensation_request_reasons: list[CondensationRequestReason] = [] - """List of reasons for unhandled condensation requests in the view.""" - condensations: list[Condensation] = [] """A list of condensations that were processed to produce the view.""" @@ -493,7 +489,6 @@ 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 - unhandled_condensation_request_reasons: list[CondensationRequestReason] = [] for event in reversed(events): if isinstance(event, Condensation): @@ -501,13 +496,10 @@ def from_events(events: Sequence[Event]) -> View: if isinstance(event, CondensationRequest): unhandled_condensation_request = True - - if event.reason is not None: - unhandled_condensation_request_reasons.append(event.reason) + break return View( events=View.filter_unmatched_tool_calls(kept_events), unhandled_condensation_request=unhandled_condensation_request, - unhandled_condensation_request_reasons=unhandled_condensation_request_reasons, condensations=condensations, ) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 336553f546..984f01f846 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -30,7 +30,6 @@ PauseEvent, UserRejectObservation, ) -from openhands.sdk.event.condenser import CondensationRequestReason from openhands.sdk.event.conversation_error import ConversationErrorEvent from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback from openhands.sdk.llm import LLM, Message, TextContent @@ -647,9 +646,7 @@ def condense(self) -> None: ) # Add a condensation request event - condensation_request = CondensationRequest( - reason=CondensationRequestReason.MANUAL - ) + condensation_request = CondensationRequest() self._on_event(condensation_request) # Force the agent to take a single step to process the condensation request diff --git a/openhands-sdk/openhands/sdk/event/condenser.py b/openhands-sdk/openhands/sdk/event/condenser.py index 27be6c677e..6f58a45d17 100644 --- a/openhands-sdk/openhands/sdk/event/condenser.py +++ b/openhands-sdk/openhands/sdk/event/condenser.py @@ -1,5 +1,3 @@ -from enum import Enum - from pydantic import Field from rich.text import Text @@ -48,17 +46,6 @@ def visualize(self) -> Text: return text -class CondensationRequestReason(Enum): - MANUAL = "manual" - """The condensation was requested manually by the user or system.""" - - CONTEXT_LIMIT = "context_limit" - """The condensation was requested due to reaching the context window limit.""" - - UNRECOVERABLE = "unrecoverable" - """The condensation was requested due to an unrecoverable error.""" - - class CondensationRequest(Event): """This action is used to request a condensation of the conversation history. @@ -67,7 +54,6 @@ class CondensationRequest(Event): """ source: SourceType = "environment" - reason: CondensationRequestReason | None = None @property def visualize(self) -> Text: From aad0bdf83add27a376bebeb0eb08429ddc16bb0f Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 10:31:56 -0700 Subject: [PATCH 15/19] should_condense -> condensation_requirement --- .../openhands/sdk/context/condenser/base.py | 64 +++++++++++-------- .../condenser/llm_summarizing_condenser.py | 9 ++- .../test_llm_summarizing_condenser.py | 8 ++- .../condenser/test_rolling_condenser.py | 44 ++++++++----- 4 files changed, 77 insertions(+), 48 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index ed948b30b4..0766721f04 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod +from enum import Enum from logging import getLogger from openhands.sdk.context.view import View -from openhands.sdk.event.condenser import Condensation, CondensationRequestReason +from openhands.sdk.event.condenser import Condensation from openhands.sdk.llm import LLM from openhands.sdk.utils.models import ( DiscriminatedUnionMixin, @@ -76,6 +77,16 @@ class NoCondensationAvailableException(Exception): 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 @@ -84,15 +95,27 @@ class RollingCondenser(PipelinableCondenserBase, ABC): 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( @@ -100,36 +123,21 @@ def get_condensation( ) -> Condensation: """Get the condensation from a view.""" - def is_continuable_reason(self, reason: CondensationRequestReason) -> bool: - """Determine if the given condensation request reason is continuable by this - condenser. - - Args: - reason: The reason for the condensation request. - Returns: - bool: True if the reason is continuable, False otherwise. - """ - if reason == CondensationRequestReason.MANUAL: - return True - return False - 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): + 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}") - # Check if all condensation requests are continuable. If so, or if there - # are no requests, we can safely return the uncondensed view. - all_continuable = all( - self.is_continuable_reason(reason) - for reason in view.unhandled_condensation_request_reasons - ) - if not view.unhandled_condensation_request or all_continuable: + 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. diff --git a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py index b66f9d8d47..8f6ab3b944 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py +++ b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py @@ -5,6 +5,7 @@ from pydantic import Field, model_validator from openhands.sdk.context.condenser.base import ( + CondensationRequirement, NoCondensationAvailableException, RollingCondenser, ) @@ -87,9 +88,13 @@ 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() + if reasons != set(): + return CondensationRequirement.HARD + return None def _get_summary_event_content(self, view: View) -> str: """Extract the text content from the summary event in the view, if any. diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py index 995f074f40..ee1ccbda19 100644 --- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py +++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py @@ -4,6 +4,7 @@ import pytest from litellm.types.utils import ModelResponse +from openhands.sdk.context.condenser.base import CondensationRequirement from openhands.sdk.context.condenser.llm_summarizing_condenser import ( LLMSummarizingCondenser, Reason, @@ -108,13 +109,16 @@ def test_should_condense(mock_llm: LLM) -> None: small_events = [message_event(f"Event {i}") for i in range(max_size)] small_view = View.from_events(small_events) - assert not condenser.should_condense(small_view) + assert condenser.condensation_requirement(small_view) is None # Create events above the threshold large_events = [message_event(f"Event {i}") for i in range(max_size + 1)] large_view = View.from_events(large_events) - assert condenser.should_condense(large_view) + assert ( + condenser.condensation_requirement(large_view) + == CondensationRequirement.HARD + ) def test_condense_returns_view_when_no_condensation_needed(mock_llm: LLM) -> None: diff --git a/tests/sdk/context/condenser/test_rolling_condenser.py b/tests/sdk/context/condenser/test_rolling_condenser.py index df8b968af8..d7df33506e 100644 --- a/tests/sdk/context/condenser/test_rolling_condenser.py +++ b/tests/sdk/context/condenser/test_rolling_condenser.py @@ -3,6 +3,7 @@ import pytest from openhands.sdk.context.condenser.base import ( + CondensationRequirement, NoCondensationAvailableException, RollingCondenser, ) @@ -25,14 +26,16 @@ class MockRollingCondenser(RollingCondenser): def __init__( self, - should_condense_value: bool = True, + condensation_requirement_value: CondensationRequirement | None = None, raise_exception: bool = False, ): - self._should_condense_value = should_condense_value + self._condensation_requirement_value = condensation_requirement_value self._raise_exception = raise_exception - def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool: - return self._should_condense_value + def condensation_requirement( + self, view: View, agent_llm: LLM | None = None + ) -> CondensationRequirement | None: + return self._condensation_requirement_value def get_condensation( self, view: View, agent_llm: LLM | None = None @@ -51,10 +54,10 @@ def get_condensation( def test_rolling_condenser_returns_view_when_no_condensation_needed() -> None: - """Test that RollingCondenser returns the original view when should_condense returns - False. + """Test that RollingCondenser returns the original view when + condensation_requirement returns None. """ - condenser = MockRollingCondenser(should_condense_value=False) + condenser = MockRollingCondenser(condensation_requirement_value=None) events: list[Event] = [ message_event("Event 1"), @@ -70,10 +73,13 @@ def test_rolling_condenser_returns_view_when_no_condensation_needed() -> None: def test_rolling_condenser_returns_condensation_when_needed() -> None: - """Test that RollingCondenser returns a Condensation when should_condense returns - True. + """Test that RollingCondenser returns a Condensation when condensation_requirement + returns HARD. """ - condenser = MockRollingCondenser(should_condense_value=True, raise_exception=False) + condenser = MockRollingCondenser( + condensation_requirement_value=CondensationRequirement.HARD, + raise_exception=False, + ) events: list[Event] = [ message_event("Event 1"), @@ -92,13 +98,16 @@ def test_rolling_condenser_returns_view_on_no_condensation_available_exception() None ): """Test that RollingCondenser returns the original view when - NoCondensationAvailableException is raised. + NoCondensationAvailableException is raised with SOFT requirement. - This tests the exception handling added in base.py:105-110 which catches + This tests the exception handling for SOFT condensation requirements which catches NoCondensationAvailableException from get_condensation() and returns the original view as a fallback. """ - condenser = MockRollingCondenser(should_condense_value=True, raise_exception=True) + condenser = MockRollingCondenser( + condensation_requirement_value=CondensationRequirement.SOFT, + raise_exception=True, + ) events: list[Event] = [ message_event("Event 1"), @@ -107,8 +116,8 @@ def test_rolling_condenser_returns_view_on_no_condensation_available_exception() ] view = View.from_events(events) - # Even though should_condense returns True, the exception should be caught - # and the original view should be returned + # Even though condensation_requirement returns SOFT, the exception should be + # caught and the original view should be returned result = condenser.condense(view) assert isinstance(result, View) @@ -118,7 +127,10 @@ def test_rolling_condenser_returns_view_on_no_condensation_available_exception() def test_rolling_condenser_with_agent_llm() -> None: """Test that RollingCondenser works with optional agent_llm parameter.""" - condenser = MockRollingCondenser(should_condense_value=True, raise_exception=False) + condenser = MockRollingCondenser( + condensation_requirement_value=CondensationRequirement.HARD, + raise_exception=False, + ) events: list[Event] = [ message_event("Event 1"), From 0efffd9b9037e000e114210538e6eca5885215e3 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 10:32:21 -0700 Subject: [PATCH 16/19] linting --- openhands-sdk/openhands/sdk/context/condenser/base.py | 6 ++++-- .../sdk/context/condenser/test_llm_summarizing_condenser.py | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/base.py b/openhands-sdk/openhands/sdk/context/condenser/base.py index 0766721f04..28741f0c2a 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/base.py +++ b/openhands-sdk/openhands/sdk/context/condenser/base.py @@ -77,6 +77,7 @@ class NoCondensationAvailableException(Exception): 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.""" @@ -84,10 +85,11 @@ class CondensationRequirement(Enum): """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. @@ -106,7 +108,7 @@ 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 diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py index ee1ccbda19..1e4cda968a 100644 --- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py +++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py @@ -116,8 +116,7 @@ def test_should_condense(mock_llm: LLM) -> None: large_view = View.from_events(large_events) assert ( - condenser.condensation_requirement(large_view) - == CondensationRequirement.HARD + condenser.condensation_requirement(large_view) == CondensationRequirement.HARD ) From d2921850a093309a38eeba99f5b1428e616b89b2 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 11:14:20 -0700 Subject: [PATCH 17/19] llm summarizing condenser requirements logic updated to handle hard/soft reqs --- .../condenser/llm_summarizing_condenser.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py index 8f6ab3b944..8cbdd01df9 100644 --- a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py +++ b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py @@ -92,9 +92,26 @@ def condensation_requirement( self, view: View, agent_llm: LLM | None = None ) -> CondensationRequirement | None: reasons = self.get_condensation_reasons(view, agent_llm) - if 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 - return None def _get_summary_event_content(self, view: View) -> str: """Extract the text content from the summary event in the view, if any. From 965e13999aeb82239b81d505fff907b2dd415dec Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 11:23:21 -0700 Subject: [PATCH 18/19] tests --- .../test_llm_summarizing_condenser.py | 134 +++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py index 1e4cda968a..2078e410ce 100644 --- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py +++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py @@ -1,5 +1,5 @@ from typing import Any, cast -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from litellm.types.utils import ModelResponse @@ -111,12 +111,12 @@ def test_should_condense(mock_llm: LLM) -> None: assert condenser.condensation_requirement(small_view) is None - # Create events above the threshold + # Create events above the threshold (triggers EVENTS reason -> SOFT requirement) large_events = [message_event(f"Event {i}") for i in range(max_size + 1)] large_view = View.from_events(large_events) assert ( - condenser.condensation_requirement(large_view) == CondensationRequirement.HARD + condenser.condensation_requirement(large_view) == CondensationRequirement.SOFT ) @@ -574,3 +574,131 @@ def test_generate_condensation_raises_on_zero_events(mock_llm: LLM) -> None: # Verify the LLM was never called cast(MagicMock, mock_llm.completion).assert_not_called() + + +@pytest.mark.parametrize( + "reasons", + [set()], +) +def test_condensation_requirement_returns_none( + mock_llm: LLM, reasons: set[Reason] +) -> None: + """Test that condensation_requirement returns None when appropriate. + + Mocks get_condensation_reasons to test different reason combinations. + """ + condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) + events: list[Event] = [message_event(f"Event {i}") for i in range(10)] + view = View.from_events(events) + + with patch.object( + LLMSummarizingCondenser, "get_condensation_reasons", return_value=reasons + ): + result = condenser.condensation_requirement(view) + assert result is None + + +@pytest.mark.parametrize( + "reasons", + [ + {Reason.TOKENS}, + {Reason.EVENTS}, + {Reason.TOKENS, Reason.EVENTS}, + ], +) +def test_condensation_requirement_returns_soft( + mock_llm: LLM, reasons: set[Reason] +) -> None: + """Test that condensation_requirement returns SOFT for resource constraints. + + Mocks get_condensation_reasons to test different resource reason combinations. + """ + condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) + events: list[Event] = [message_event(f"Event {i}") for i in range(10)] + view = View.from_events(events) + + with patch.object( + LLMSummarizingCondenser, "get_condensation_reasons", return_value=reasons + ): + result = condenser.condensation_requirement(view) + assert result == CondensationRequirement.SOFT + + +@pytest.mark.parametrize( + "reasons", + [ + {Reason.REQUEST}, + {Reason.REQUEST, Reason.TOKENS}, + {Reason.REQUEST, Reason.EVENTS}, + {Reason.REQUEST, Reason.TOKENS, Reason.EVENTS}, + ], +) +def test_condensation_requirement_returns_hard( + mock_llm: LLM, reasons: set[Reason] +) -> None: + """Test that condensation_requirement returns HARD when REQUEST is present. + + Mocks get_condensation_reasons to test different combinations with REQUEST. + """ + condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) + events: list[Event] = [message_event(f"Event {i}") for i in range(10)] + view = View.from_events(events) + + with patch.object( + LLMSummarizingCondenser, "get_condensation_reasons", return_value=reasons + ): + result = condenser.condensation_requirement(view) + assert result == CondensationRequirement.HARD + + +def test_condense_with_hard_requirement_and_no_condensation_available( + mock_llm: LLM, +) -> None: + """Test that condense raises error with hard requirement but no condensation. + + When there's a hard requirement but no valid condensation range available + (e.g., entire view is a single atomic unit), should raise an exception. + """ + from openhands.sdk.context.condenser.base import NoCondensationAvailableException + + condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) + events: list[Event] = [message_event(f"Event {i}") for i in range(10)] + view = View.from_events(events) + + # Mock to return HARD requirement but no events to condense + with patch.object( + LLMSummarizingCondenser, + "get_condensation_reasons", + return_value={Reason.REQUEST}, + ), patch.object( + condenser, "_get_forgotten_events", return_value=([], 0) + ): + with pytest.raises(NoCondensationAvailableException): + condenser.condense(view) + + +def test_condense_with_soft_requirement_and_no_condensation_available( + mock_llm: LLM, +) -> None: + """Test that condense returns view with soft requirement but no condensation. + + When there's a soft requirement but no valid condensation range available, + should return the original view unchanged. + """ + condenser = LLMSummarizingCondenser(llm=mock_llm, max_size=100, keep_first=2) + events: list[Event] = [message_event(f"Event {i}") for i in range(10)] + view = View.from_events(events) + + # Mock to return SOFT requirement but no events to condense + with patch.object( + LLMSummarizingCondenser, + "get_condensation_reasons", + return_value={Reason.EVENTS}, + ), patch.object( + condenser, "_get_forgotten_events", return_value=([], 0) + ): + result = condenser.condense(view) + assert isinstance(result, View) + assert result == view + # LLM should not be called + cast(MagicMock, mock_llm.completion).assert_not_called() From 27f0bb962a04e11a497cfcf0dfafe6906103f771 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 5 Jan 2026 11:23:45 -0700 Subject: [PATCH 19/19] linting --- .../test_llm_summarizing_condenser.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py index 2078e410ce..ea91347330 100644 --- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py +++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py @@ -666,12 +666,13 @@ def test_condense_with_hard_requirement_and_no_condensation_available( view = View.from_events(events) # Mock to return HARD requirement but no events to condense - with patch.object( - LLMSummarizingCondenser, - "get_condensation_reasons", - return_value={Reason.REQUEST}, - ), patch.object( - condenser, "_get_forgotten_events", return_value=([], 0) + with ( + patch.object( + LLMSummarizingCondenser, + "get_condensation_reasons", + return_value={Reason.REQUEST}, + ), + patch.object(condenser, "_get_forgotten_events", return_value=([], 0)), ): with pytest.raises(NoCondensationAvailableException): condenser.condense(view) @@ -690,12 +691,13 @@ def test_condense_with_soft_requirement_and_no_condensation_available( view = View.from_events(events) # Mock to return SOFT requirement but no events to condense - with patch.object( - LLMSummarizingCondenser, - "get_condensation_reasons", - return_value={Reason.EVENTS}, - ), patch.object( - condenser, "_get_forgotten_events", return_value=([], 0) + with ( + patch.object( + LLMSummarizingCondenser, + "get_condensation_reasons", + return_value={Reason.EVENTS}, + ), + patch.object(condenser, "_get_forgotten_events", return_value=([], 0)), ): result = condenser.condense(view) assert isinstance(result, View)