Skip to content

Commit 1fbf867

Browse files
csmith49Calvin Smithxingyaoww
authored
fix(condenser): Retry on empty condensation (#1577)
Co-authored-by: Calvin Smith <[email protected]> Co-authored-by: Xingyao Wang <[email protected]>
1 parent 944beed commit 1fbf867

File tree

8 files changed

+407
-30
lines changed

8 files changed

+407
-30
lines changed

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
TokenEvent,
2828
UserRejectObservation,
2929
)
30-
from openhands.sdk.event.condenser import Condensation, CondensationRequest
30+
from openhands.sdk.event.condenser import (
31+
Condensation,
32+
CondensationRequest,
33+
)
3134
from openhands.sdk.llm import (
3235
LLMResponse,
3336
Message,

openhands-sdk/openhands/sdk/context/condenser/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from openhands.sdk.context.condenser.base import (
22
CondenserBase,
3+
NoCondensationAvailableException,
34
RollingCondenser,
45
)
56
from openhands.sdk.context.condenser.llm_summarizing_condenser import (
@@ -15,4 +16,5 @@
1516
"NoOpCondenser",
1617
"PipelineCondenser",
1718
"LLMSummarizingCondenser",
19+
"NoCondensationAvailableException",
1820
]

openhands-sdk/openhands/sdk/context/condenser/base.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from abc import ABC, abstractmethod
2+
from enum import Enum
23
from logging import getLogger
34

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

6869

70+
class NoCondensationAvailableException(Exception):
71+
"""Raised when a condenser is asked to provide a condensation but none is available.
72+
73+
This can happen if the condenser's `should_condense` method returns True, but due to
74+
API constraints no condensation can be generated.
75+
76+
When this exception is raised from a rolling condenser's `get_condensation` method,
77+
the agent will fall back to using the uncondensed view for the next agent step.
78+
"""
79+
80+
81+
class CondensationRequirement(Enum):
82+
"""The type of condensation required by a rolling condenser."""
83+
84+
HARD = "hard"
85+
"""Indicates that a condensation is required right now, and the agent cannot proceed
86+
without it.
87+
"""
88+
89+
SOFT = "soft"
90+
"""Indicates that a condensation is desired but not strictly required."""
91+
92+
6993
class RollingCondenser(PipelinableCondenserBase, ABC):
7094
"""Base class for a specialized condenser strategy that applies condensation to a
7195
rolling history.
7296
7397
The rolling history is generated by `View.from_events`, which analyzes all events in
7498
the history and produces a `View` object representing what will be sent to the LLM.
7599
76-
If `should_condense` says so, the condenser is then responsible for generating a
77-
`Condensation` object from the `View` object. This will be added to the event
78-
history which should -- when given to `get_view` -- produce the condensed `View` to
79-
be passed to the LLM.
100+
If `condensation_requirement` says so, the condenser is then responsible for
101+
generating a `Condensation` object from the `View` object. This will be added to the
102+
event history which should -- when given to `get_view` -- produce the condensed
103+
`View` to be passed to the LLM.
80104
"""
81105

82106
@abstractmethod
83-
def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool:
84-
"""Determine if a view should be condensed."""
107+
def condensation_requirement(
108+
self, view: View, agent_llm: LLM | None = None
109+
) -> CondensationRequirement | None:
110+
"""Determine how a view should be condensed.
111+
112+
Args:
113+
view: The current view of the conversation history.
114+
agent_llm: LLM instance used by the agent. Condensers use this for token
115+
counting purposes. Defaults to None.
116+
117+
Returns:
118+
CondensationRequirement | None: The type of condensation required, or None
119+
if no condensation is needed.
120+
"""
85121

86122
@abstractmethod
87123
def get_condensation(
@@ -92,8 +128,23 @@ def get_condensation(
92128
def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensation:
93129
# If we trigger the condenser-specific condensation threshold, compute and
94130
# return the condensation.
95-
if self.should_condense(view, agent_llm=agent_llm):
96-
return self.get_condensation(view, agent_llm=agent_llm)
131+
request = self.condensation_requirement(view, agent_llm=agent_llm)
132+
if request is not None:
133+
try:
134+
return self.get_condensation(view, agent_llm=agent_llm)
135+
136+
except NoCondensationAvailableException as e:
137+
logger.debug(f"No condensation available: {e}")
138+
139+
if request == CondensationRequirement.SOFT:
140+
# For soft requests, we can just return the uncondensed view. This
141+
# request will _eventually_ be handled, but it's not critical that
142+
# we do so immediately.
143+
return view
144+
145+
# Otherwise re-raise the exception.
146+
else:
147+
raise e
97148

98149
# Otherwise we're safe to just return the view.
99150
else:

openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from pydantic import Field, model_validator
66

7-
from openhands.sdk.context.condenser.base import RollingCondenser
7+
from openhands.sdk.context.condenser.base import (
8+
CondensationRequirement,
9+
NoCondensationAvailableException,
10+
RollingCondenser,
11+
)
812
from openhands.sdk.context.condenser.utils import (
913
get_suffix_length_for_token_reduction,
1014
get_total_token_count,
@@ -84,9 +88,30 @@ def get_condensation_reasons(
8488

8589
return reasons
8690

87-
def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool:
91+
def condensation_requirement(
92+
self, view: View, agent_llm: LLM | None = None
93+
) -> CondensationRequirement | None:
8894
reasons = self.get_condensation_reasons(view, agent_llm)
89-
return reasons != set()
95+
96+
# No reasons => no condensation needed.
97+
if reasons == set():
98+
return None
99+
100+
# If the reasons are for resource constraints, we can treat it as a soft
101+
# requirement. We want to condense when we can, but there's still space in the
102+
# context window or we'd also see Reason.REQUEST. That means we can delay the
103+
# condensation if there isn't one available (based on the view's manipulation
104+
# indices).
105+
resource_reasons = {Reason.TOKENS, Reason.EVENTS}
106+
if reasons.issubset(resource_reasons):
107+
return CondensationRequirement.SOFT
108+
109+
# Requests -- whether they come from the user or the agent -- are always hard
110+
# requirements. We need to condense now because:
111+
# 1. the user expects it
112+
# 2. the agent has no more room in the context window and can't continue
113+
if Reason.REQUEST in reasons:
114+
return CondensationRequirement.HARD
90115

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

134154
# Convert events to strings for the template
135155
event_strings = [str(forgotten_event) for forgotten_event in forgotten_events]
@@ -236,11 +256,19 @@ def get_condensation(
236256
) -> Condensation:
237257
# The condensation is dependent on the events we want to drop and the previous
238258
# summary.
239-
summary_event_content = self._get_summary_event_content(view)
240259
forgotten_events, summary_offset = self._get_forgotten_events(
241260
view, agent_llm=agent_llm
242261
)
243262

263+
if not forgotten_events:
264+
raise NoCondensationAvailableException(
265+
"Cannot condense 0 events. This typically occurs when a tool loop "
266+
"spans almost the entire view, leaving no valid range for forgetting "
267+
"events. Consider adjusting keep_first or max_size parameters."
268+
)
269+
270+
summary_event_content = self._get_summary_event_content(view)
271+
244272
return self._generate_condensation(
245273
summary_event_content=summary_event_content,
246274
forgotten_events=forgotten_events,

openhands-sdk/openhands/sdk/context/view.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,11 @@ def from_events(events: Sequence[Event]) -> View:
489489
# Check for an unhandled condensation request -- these are events closer to the
490490
# end of the list than any condensation action.
491491
unhandled_condensation_request = False
492+
492493
for event in reversed(events):
493494
if isinstance(event, Condensation):
494495
break
496+
495497
if isinstance(event, CondensationRequest):
496498
unhandled_condensation_request = True
497499
break

tests/integration/tests/t09_token_condenser.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class TokenCondenserTest(BaseIntegrationTest):
4040

4141
def __init__(self, *args, **kwargs):
4242
"""Initialize test with tracking variables."""
43-
self.condensation_count = 0
43+
self.condensations: list[Condensation] = []
4444
super().__init__(*args, **kwargs)
4545

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

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

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

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

108+
events_summarized = len(self.condensations[0].forgotten_event_ids)
108109
return TestResult(
109110
success=True,
110-
reason="Condensation triggered. Token counting works correctly.",
111+
reason=f"Condensation triggered, summarizing {events_summarized} events.",
111112
)

0 commit comments

Comments
 (0)