Skip to content

Commit 42712a4

Browse files
csmith49Calvin Smith
and
Calvin Smith
authored
(fix): Condensation events to reconstruct contexts added to event stream (#7353)
Co-authored-by: Calvin Smith <[email protected]>
1 parent 76c992e commit 42712a4

17 files changed

+485
-422
lines changed

openhands/agenthub/codeact_agent/codeact_agent.py

+22-11
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
Action,
1212
AgentFinishAction,
1313
)
14+
from openhands.events.event import Event
1415
from openhands.llm.llm import LLM
1516
from openhands.memory.condenser import Condenser
17+
from openhands.memory.condenser.condenser import Condensation, View
1618
from openhands.memory.conversation_memory import ConversationMemory
1719
from openhands.runtime.plugins import (
1820
AgentSkillsRequirement,
@@ -92,6 +94,7 @@ def reset(self) -> None:
9294

9395
def step(self, state: State) -> Action:
9496
"""Performs one step using the CodeAct Agent.
97+
9598
This includes gathering info on previous steps and prompting the model to make a command to execute.
9699
97100
Parameters:
@@ -113,8 +116,23 @@ def step(self, state: State) -> Action:
113116
if latest_user_message and latest_user_message.content.strip() == '/exit':
114117
return AgentFinishAction()
115118

116-
# prepare what we want to send to the LLM
117-
messages = self._get_messages(state)
119+
# Condense the events from the state. If we get a view we'll pass those
120+
# to the conversation manager for processing, but if we get a condensation
121+
# event we'll just return that instead of an action. The controller will
122+
# immediately ask the agent to step again with the new view.
123+
condensed_history: list[Event] = []
124+
match self.condenser.condensed_history(state):
125+
case View(events=events):
126+
condensed_history = events
127+
128+
case Condensation(action=condensation_action):
129+
return condensation_action
130+
131+
logger.debug(
132+
f'Processing {len(condensed_history)} events from a total of {len(state.history)} events'
133+
)
134+
135+
messages = self._get_messages(condensed_history)
118136
params: dict = {
119137
'messages': self.llm.format_messages_for_llm(messages),
120138
}
@@ -127,7 +145,7 @@ def step(self, state: State) -> Action:
127145
self.pending_actions.append(action)
128146
return self.pending_actions.popleft()
129147

130-
def _get_messages(self, state: State) -> list[Message]:
148+
def _get_messages(self, events: list[Event]) -> list[Message]:
131149
"""Constructs the message history for the LLM conversation.
132150
133151
This method builds a structured conversation history by processing events from the state
@@ -143,7 +161,7 @@ def _get_messages(self, state: State) -> list[Message]:
143161
6. Adds environment reminders for non-function-calling mode
144162
145163
Args:
146-
state (State): The current state object containing conversation history and other metadata
164+
events: The list of events to convert to messages
147165
148166
Returns:
149167
list[Message]: A list of formatted messages ready for LLM consumption, including:
@@ -167,13 +185,6 @@ def _get_messages(self, state: State) -> list[Message]:
167185
with_caching=self.llm.is_caching_prompt_active()
168186
)
169187

170-
# Condense the events from the state.
171-
events = self.condenser.condensed_history(state)
172-
173-
logger.debug(
174-
f'Processing {len(events)} events from a total of {len(state.history)} events'
175-
)
176-
177188
# Use ConversationMemory to process events
178189
messages = self.conversation_memory.process_events(
179190
condensed_history=events,

openhands/controller/agent_controller.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
MessageAction,
5555
NullAction,
5656
)
57-
from openhands.events.action.agent import RecallAction
57+
from openhands.events.action.agent import CondensationAction, RecallAction
5858
from openhands.events.event import Event
5959
from openhands.events.observation import (
6060
AgentCondensationObservation,
@@ -305,6 +305,8 @@ def should_step(self, event: Event) -> bool:
305305
return True
306306
if isinstance(event, AgentDelegateAction):
307307
return True
308+
if isinstance(event, CondensationAction):
309+
return True
308310
return False
309311
if isinstance(event, Observation):
310312
if (

openhands/core/schema/action.py

+3
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,6 @@ class ActionType(str, Enum):
8080

8181
RECALL = 'recall'
8282
"""Retrieves content from a user workspace, microagent, or other source."""
83+
84+
CONDENSATION = 'condensation'
85+
"""Condenses a list of events into a summary."""

openhands/events/action/agent.py

+84
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,87 @@ def __str__(self) -> str:
111111
ret = '**RecallAction**\n'
112112
ret += f'QUERY: {self.query[:50]}'
113113
return ret
114+
115+
116+
@dataclass
117+
class CondensationAction(Action):
118+
"""This action indicates a condensation of the conversation history is happening.
119+
120+
There are two ways to specify the events to be forgotten:
121+
1. By providing a list of event IDs.
122+
2. By providing the start and end IDs of a range of events.
123+
124+
In the second case, we assume that event IDs are monotonically increasing, and that _all_ events between the start and end IDs are to be forgotten.
125+
126+
Raises:
127+
ValueError: If the optional fields are not instantiated in a valid configuration.
128+
"""
129+
130+
action: str = ActionType.CONDENSATION
131+
132+
forgotten_event_ids: list[int] | None = None
133+
"""The IDs of the events that are being forgotten (removed from the `View` given to the LLM)."""
134+
135+
forgotten_events_start_id: int | None = None
136+
"""The ID of the first event to be forgotten in a range of events."""
137+
138+
forgotten_events_end_id: int | None = None
139+
"""The ID of the last event to be forgotten in a range of events."""
140+
141+
summary: str | None = None
142+
"""An optional summary of the events being forgotten."""
143+
144+
summary_offset: int | None = None
145+
"""An optional offset to the start of the resulting view indicating where the summary should be inserted."""
146+
147+
def _validate_field_polymorphism(self) -> bool:
148+
"""Check if the optional fields are instantiated in a valid configuration."""
149+
# For the forgotton events, there are only two valid configurations:
150+
# 1. We're forgetting events based on the list of provided IDs, or
151+
using_event_ids = self.forgotten_event_ids is not None
152+
# 2. We're forgetting events based on the range of IDs.
153+
using_event_range = (
154+
self.forgotten_events_start_id is not None
155+
and self.forgotten_events_end_id is not None
156+
)
157+
158+
# Either way, we can only have one of the two valid configurations.
159+
forgotten_event_configuration = using_event_ids ^ using_event_range
160+
161+
# We also need to check that if the summary is provided, so is the
162+
# offset (and vice versa).
163+
summary_configuration = (
164+
self.summary is None and self.summary_offset is None
165+
) or (self.summary is not None and self.summary_offset is not None)
166+
167+
return forgotten_event_configuration and summary_configuration
168+
169+
def __post_init__(self):
170+
if not self._validate_field_polymorphism():
171+
raise ValueError('Invalid configuration of the optional fields.')
172+
173+
@property
174+
def forgotten(self) -> list[int]:
175+
"""The list of event IDs that should be forgotten."""
176+
# Start by making sure the fields are instantiated in a valid
177+
# configuration. We check this whenever the event is initialized, but we
178+
# can't make the dataclass immutable so we need to check it again here
179+
# to make sure the configuration is still valid.
180+
if not self._validate_field_polymorphism():
181+
raise ValueError('Invalid configuration of the optional fields.')
182+
183+
if self.forgotten_event_ids is not None:
184+
return self.forgotten_event_ids
185+
186+
# If we've gotten this far, the start/end IDs are not None.
187+
assert self.forgotten_events_start_id is not None
188+
assert self.forgotten_events_end_id is not None
189+
return list(
190+
range(self.forgotten_events_start_id, self.forgotten_events_end_id + 1)
191+
)
192+
193+
@property
194+
def message(self) -> str:
195+
if self.summary:
196+
return f'Summary: {self.summary}'
197+
return f'Condenser is dropping the events: {self.forgotten}.'

openhands/events/serialization/action.py

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AgentRejectAction,
99
AgentThinkAction,
1010
ChangeAgentStateAction,
11+
CondensationAction,
1112
RecallAction,
1213
)
1314
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
@@ -39,6 +40,7 @@
3940
RecallAction,
4041
ChangeAgentStateAction,
4142
MessageAction,
43+
CondensationAction,
4244
)
4345

4446
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
+13-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
import openhands.memory.condenser.impl # noqa F401 (we import this to get the condensers registered)
2-
from openhands.memory.condenser.condenser import Condenser, get_condensation_metadata
2+
from openhands.memory.condenser.condenser import (
3+
Condenser,
4+
get_condensation_metadata,
5+
View,
6+
Condensation,
7+
)
38

4-
__all__ = ['Condenser', 'get_condensation_metadata', 'CONDENSER_REGISTRY']
9+
__all__ = [
10+
'Condenser',
11+
'get_condensation_metadata',
12+
'CONDENSER_REGISTRY',
13+
'View',
14+
'Condensation',
15+
]

0 commit comments

Comments
 (0)