mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
34 Commits
draft/remo
...
condenser-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed08a0dec2 | ||
|
|
b8f031eafb | ||
|
|
8ec2b4a5e5 | ||
|
|
a345e30408 | ||
|
|
c551740cf4 | ||
|
|
6a12b8eeaf | ||
|
|
d535ac8da6 | ||
|
|
c8be1a2cf2 | ||
|
|
5b59dfafe3 | ||
|
|
0db64d3aae | ||
|
|
e612b14766 | ||
|
|
b44819baf9 | ||
|
|
994a619485 | ||
|
|
28a14b78c1 | ||
|
|
c855dc5cd4 | ||
|
|
a0ec4eed15 | ||
|
|
ab015f6acb | ||
|
|
c9fbf34a5d | ||
|
|
a6354981cc | ||
|
|
2d817dee9e | ||
|
|
7b471341cb | ||
|
|
edff63c523 | ||
|
|
897eba5586 | ||
|
|
ed7289b8fe | ||
|
|
466a675476 | ||
|
|
053b919b14 | ||
|
|
9721bc4b86 | ||
|
|
ec9139a612 | ||
|
|
e3bf65c561 | ||
|
|
fd8d8f1ae3 | ||
|
|
d9faa86447 | ||
|
|
9293ccc185 | ||
|
|
c84ba30a9f | ||
|
|
23bfd9b16c |
@@ -38,6 +38,7 @@ from openhands.events import (
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
AgentCondensationAction,
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
@@ -291,6 +292,8 @@ class AgentController:
|
||||
return True
|
||||
if isinstance(event, AgentDelegateAction):
|
||||
return True
|
||||
if isinstance(event, AgentCondensationAction):
|
||||
return True
|
||||
return False
|
||||
if isinstance(event, Observation):
|
||||
if (
|
||||
@@ -300,6 +303,8 @@ class AgentController:
|
||||
> 0 # NullObservation has cause > 0 (RecallAction), not 0 (user message)
|
||||
):
|
||||
return True
|
||||
if isinstance(event, AgentCondensationObservation):
|
||||
return True
|
||||
if isinstance(event, AgentStateChangedObservation) or isinstance(
|
||||
event, NullObservation
|
||||
):
|
||||
@@ -1021,10 +1026,10 @@ class AgentController:
|
||||
if self.state.history:
|
||||
self.state.start_id = self.state.history[0].id
|
||||
|
||||
# Add an error event to trigger another step by the agent
|
||||
# Add an observation event to trigger another step by the agent
|
||||
self.event_stream.add_event(
|
||||
AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
content='Trimming prompt to meet context window limitations',
|
||||
),
|
||||
EventSource.AGENT,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.action.commands import IPythonRunCellAction
|
||||
from openhands.events.action.empty import NullAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
@@ -9,7 +10,6 @@ from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
IPythonRunCellObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
@@ -318,7 +318,7 @@ class StuckDetector:
|
||||
|
||||
This happens when we repeatedly get context window errors and try to trim,
|
||||
but the trimming doesn't work, causing us to get more context window errors.
|
||||
The pattern is repeated AgentCondensationObservation events without any other
|
||||
The pattern is repeated AgentCondensationAction events without any other
|
||||
events between them.
|
||||
|
||||
Args:
|
||||
@@ -327,11 +327,11 @@ class StuckDetector:
|
||||
Returns:
|
||||
bool: True if we detect a context window error loop
|
||||
"""
|
||||
# Look for AgentCondensationObservation events
|
||||
# Look for AgentCondensationAction events
|
||||
condensation_events = [
|
||||
(i, event)
|
||||
for i, event in enumerate(filtered_history)
|
||||
if isinstance(event, AgentCondensationObservation)
|
||||
if isinstance(event, AgentCondensationAction)
|
||||
]
|
||||
|
||||
# Need at least 10 condensation events to detect a loop
|
||||
@@ -349,7 +349,7 @@ class StuckDetector:
|
||||
# Look for any non-condensation events between these two
|
||||
has_other_events = False
|
||||
for event in filtered_history[start_idx + 1 : end_idx]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
if not isinstance(event, AgentCondensationAction):
|
||||
has_other_events = True
|
||||
break
|
||||
|
||||
|
||||
@@ -85,5 +85,8 @@ class ActionTypeSchema(BaseModel):
|
||||
RECALL: str = Field(default='recall')
|
||||
"""Retrieves content from a user workspace, microagent, or other source."""
|
||||
|
||||
CONDENSE: str = Field(default='condense')
|
||||
"""Records a condensation event in the agent's history."""
|
||||
|
||||
|
||||
ActionType = ActionTypeSchema()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from openhands.events.action.action import Action, ActionConfirmationStatus
|
||||
from openhands.events.action.agent import (
|
||||
AgentCondensationAction,
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
@@ -31,6 +32,7 @@ __all__ = [
|
||||
'AgentRejectAction',
|
||||
'AgentDelegateAction',
|
||||
'AgentSummarizeAction',
|
||||
'AgentCondensationAction',
|
||||
'ChangeAgentStateAction',
|
||||
'IPythonRunCellAction',
|
||||
'MessageAction',
|
||||
|
||||
@@ -109,6 +109,30 @@ class AgentDelegateAction(Action):
|
||||
return f"I'm asking {self.agent} for help with this task."
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCondensationAction(Action):
|
||||
"""Action to record condensation of the agent's history.
|
||||
|
||||
This action stores information about a condensation event, including the range of events
|
||||
that were condensed and the summary of those events.
|
||||
"""
|
||||
|
||||
start_id: int = -1
|
||||
end_id: int = -1
|
||||
summary: str = ''
|
||||
action: str = ActionType.CONDENSE
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return f'Condensed events from {self.start_id} to {self.end_id}'
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = '**AgentCondensationAction**\n'
|
||||
ret += f'RANGE: [{self.start_id}..{self.end_id}]\n'
|
||||
ret += f'SUMMARY: {self.summary}'
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecallAction(Action):
|
||||
"""This action is used for retrieving content, e.g., from the global directory or user workspace."""
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from openhands.core.exceptions import LLMMalformedActionError
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.agent import (
|
||||
AgentCondensationAction,
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
@@ -39,6 +40,7 @@ actions = (
|
||||
RecallAction,
|
||||
ChangeAgentStateAction,
|
||||
MessageAction,
|
||||
AgentCondensationAction,
|
||||
)
|
||||
|
||||
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
|
||||
|
||||
@@ -151,6 +151,23 @@ class RollingCondenser(Condenser, ABC):
|
||||
condenser.condensed_history(state)
|
||||
|
||||
will result in second call to `condensed_history` passing `condensation + [event4, event5]` to the `condense` method.
|
||||
|
||||
Attributes:
|
||||
_condensation: A list of Event objects representing the result of the previous condensation.
|
||||
This is used to avoid reprocessing the entire history on each call to condensed_history.
|
||||
When a condensation occurs, this list will contain any AgentCondensationAction objects
|
||||
that summarize previously condensed events.
|
||||
|
||||
_last_history_length: An integer tracking the length of state.history at the time of the
|
||||
last condensation. This is used to identify new events that have been added since the
|
||||
last condensation and to detect if the history has been truncated.
|
||||
|
||||
Note:
|
||||
These tracking variables are automatically reconstructed from the state history when
|
||||
the condenser is initialized with an existing history containing AgentCondensationAction
|
||||
events. This ensures that the condenser can properly resume operation after an application
|
||||
restart without losing context. The reconstruction process examines the most recent
|
||||
AgentCondensationAction in the history and uses it to rebuild the internal state.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -158,21 +175,107 @@ class RollingCondenser(Condenser, ABC):
|
||||
self._last_history_length: int = 0
|
||||
|
||||
super().__init__()
|
||||
|
||||
def reset_tracking(self) -> None:
|
||||
"""Reset the tracking variables to their initial state.
|
||||
|
||||
This forces the condenser to process the entire history on the next call
|
||||
to condensed_history, rather than just the new events.
|
||||
"""
|
||||
self._condensation = []
|
||||
self._last_history_length = 0
|
||||
|
||||
@override
|
||||
def condensed_history(self, state: State) -> list[Event]:
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
|
||||
# If we're loading a fresh instance but have history, try to reconstruct state
|
||||
if not self._condensation and self._last_history_length == 0 and state.history:
|
||||
# Find the most recent AgentCondensationAction
|
||||
condensation_action = None
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentCondensationAction):
|
||||
condensation_action = event
|
||||
break
|
||||
|
||||
if condensation_action is not None:
|
||||
# Find where in the history we left off
|
||||
last_processed_index = 0
|
||||
for i, e in enumerate(state.history):
|
||||
if hasattr(e, 'id') and e.id == condensation_action.end_id:
|
||||
last_processed_index = i + 1
|
||||
break
|
||||
|
||||
# Reconstruct the condensation result based on the condenser's structure
|
||||
# For LLMSummarizingCondenser, this would be head + condensation_action
|
||||
head = state.history[:min(self.keep_first, len(state.history))] if hasattr(self, 'keep_first') else []
|
||||
self._condensation = head + [condensation_action]
|
||||
self._last_history_length = last_processed_index
|
||||
|
||||
# The history should grow monotonically -- if it doesn't, something has
|
||||
# truncated the history and we need to reset our tracking.
|
||||
if len(state.history) < self._last_history_length:
|
||||
self._condensation = []
|
||||
self._last_history_length = 0
|
||||
# Reset tracking variables if history has been truncated
|
||||
self.reset_tracking()
|
||||
|
||||
new_events = state.history[self._last_history_length :]
|
||||
# Extract only the new events that have been added since the last condensation
|
||||
# This is an optimization to avoid reprocessing the entire history
|
||||
new_events = state.history[self._last_history_length:]
|
||||
|
||||
with self.metadata_batch(state):
|
||||
# Combine the previous condensation result with new events
|
||||
# This allows incremental processing of the history
|
||||
results = self.condense(self._condensation + new_events)
|
||||
|
||||
# Store the condensation result for the next call
|
||||
self._condensation = results
|
||||
# Update the history length tracker to the current length
|
||||
self._last_history_length = len(state.history)
|
||||
|
||||
return results
|
||||
|
||||
def reconstruct_tracking_variables(self, state: State) -> tuple[list[Event], int]:
|
||||
"""Reconstruct the tracking variables from the state history.
|
||||
|
||||
This method analyzes the state history to reconstruct what the _condensation and
|
||||
_last_history_length variables would be if the condenser had processed this state.
|
||||
This is useful for debugging or understanding the condenser's state.
|
||||
|
||||
Args:
|
||||
state: The state containing the history to analyze.
|
||||
|
||||
Returns:
|
||||
A tuple containing:
|
||||
- The reconstructed _condensation list
|
||||
- The reconstructed _last_history_length value
|
||||
|
||||
Note:
|
||||
This method does not modify the condenser's actual tracking variables.
|
||||
It only returns what they would be based on the given state.
|
||||
"""
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
|
||||
# Find the most recent AgentCondensationAction
|
||||
condensation_action = None
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentCondensationAction):
|
||||
condensation_action = event
|
||||
break
|
||||
|
||||
if condensation_action is None:
|
||||
# No condensation has occurred yet
|
||||
return [], 0
|
||||
|
||||
# Find where in the history we left off
|
||||
last_processed_index = 0
|
||||
for i, event in enumerate(state.history):
|
||||
if hasattr(event, 'id') and event.id == condensation_action.end_id:
|
||||
last_processed_index = i + 1
|
||||
break
|
||||
|
||||
# Reconstruct the condensation result based on the condenser's structure
|
||||
# For LLMSummarizingCondenser, this would be head + condensation_action
|
||||
head = state.history[:min(self.keep_first, len(state.history))] if hasattr(self, 'keep_first') else []
|
||||
condensation = head + [condensation_action]
|
||||
|
||||
return condensation, last_processed_index
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import BrowserOutputCondenserConfig
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import BrowserOutputObservation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.memory.condenser.condenser import Condenser
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ class BrowserOutputCondenser(Condenser):
|
||||
isinstance(event, BrowserOutputObservation)
|
||||
and cnt >= self.attention_window
|
||||
):
|
||||
# Create a condensation action with the summary
|
||||
results.append(
|
||||
AgentCondensationObservation(
|
||||
f'Current URL: {event.url}\nContent Omitted'
|
||||
AgentCondensationAction(
|
||||
start_id=event.id,
|
||||
end_id=event.id,
|
||||
summary=f'Current URL: {event.url}\nContent Omitted',
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import LLMSummarizingCondenserConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.llm import LLM
|
||||
from openhands.memory.condenser.condenser import RollingCondenser
|
||||
|
||||
@@ -43,16 +43,22 @@ class LLMSummarizingCondenser(RollingCondenser):
|
||||
events_from_tail = target_size - len(head)
|
||||
tail = events[-events_from_tail:]
|
||||
|
||||
summary_event = (
|
||||
events[self.keep_first]
|
||||
if isinstance(events[self.keep_first], AgentCondensationObservation)
|
||||
else AgentCondensationObservation('No events summarized')
|
||||
# Check if the first event after keep_first is a condensation action
|
||||
has_existing_summary = isinstance(
|
||||
events[self.keep_first], AgentCondensationAction
|
||||
)
|
||||
if has_existing_summary:
|
||||
summary_action = events[self.keep_first]
|
||||
else:
|
||||
# Use placeholder IDs since we don't have real events to reference yet
|
||||
summary_action = AgentCondensationAction(
|
||||
start_id=-1, end_id=-1, summary='No events summarized'
|
||||
)
|
||||
|
||||
# Identify events to be forgotten (those not in head or tail)
|
||||
forgotten_events = []
|
||||
for event in events[self.keep_first : -events_from_tail]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
if not isinstance(event, AgentCondensationAction):
|
||||
forgotten_events.append(event)
|
||||
|
||||
# Construct prompt for summarization
|
||||
@@ -82,11 +88,16 @@ CHANGES: str(val) replaces f"{val:.16G}"
|
||||
DEPS: None modified
|
||||
INTENT: Fix precision while maintaining FITS compliance"""
|
||||
|
||||
prompt + '\n\n'
|
||||
prompt += '\n\n'
|
||||
|
||||
prompt += ('\n' + summary_event.message + '\n') if summary_event.message else ''
|
||||
# Add the summary from the action if it exists
|
||||
if (
|
||||
isinstance(summary_action, AgentCondensationAction)
|
||||
and summary_action.summary
|
||||
):
|
||||
prompt += '\n' + summary_action.summary + '\n'
|
||||
|
||||
prompt + '\n\n'
|
||||
prompt += '\n\n'
|
||||
|
||||
for forgotten_event in forgotten_events:
|
||||
prompt += str(forgotten_event) + '\n\n'
|
||||
@@ -101,7 +112,26 @@ INTENT: Fix precision while maintaining FITS compliance"""
|
||||
self.add_metadata('response', response.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return head + [AgentCondensationObservation(summary)] + tail
|
||||
# Check if the first event after keep_first is a condensation action
|
||||
has_existing_summary = isinstance(
|
||||
events[self.keep_first], AgentCondensationAction
|
||||
)
|
||||
|
||||
# Determine the start_id based on whether there's an existing summary
|
||||
start_index = self.keep_first + 1 if has_existing_summary else self.keep_first
|
||||
|
||||
# Get the IDs of the first and last events being condensed
|
||||
start_id = events[start_index].id if start_index < len(events) else -1
|
||||
end_id = events[-events_from_tail - 1].id if -events_from_tail - 1 >= 0 else -1
|
||||
|
||||
# Create the condensation action with the summary
|
||||
condensation_action = AgentCondensationAction(
|
||||
summary=summary, start_id=start_id, end_id=end_id
|
||||
)
|
||||
|
||||
# Return the events with just the condensation action in the middle
|
||||
# No need for a separate observation as the action is sufficient
|
||||
return head + [condensation_action] + tail
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import ObservationMaskingCondenserConfig
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import Observation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.memory.condenser.condenser import Condenser
|
||||
|
||||
|
||||
@@ -23,7 +23,13 @@ class ObservationMaskingCondenser(Condenser):
|
||||
isinstance(event, Observation)
|
||||
and i < len(events) - self.attention_window
|
||||
):
|
||||
results.append(AgentCondensationObservation('<MASKED>'))
|
||||
# Create a condensation action with the summary
|
||||
results.append(
|
||||
AgentCondensationAction(
|
||||
start_id=event.id, end_id=event.id, summary='<MASKED>'
|
||||
)
|
||||
)
|
||||
# No need to add an observation as the action itself is sufficient
|
||||
else:
|
||||
results.append(event)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from openhands.core.message import ImageContent, Message, TextContent
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AgentCondensationAction,
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentThinkAction,
|
||||
@@ -84,6 +85,7 @@ class ConversationMemory:
|
||||
action=event,
|
||||
pending_tool_call_action_messages=pending_tool_call_action_messages,
|
||||
vision_is_active=vision_is_active,
|
||||
max_message_chars=max_message_chars,
|
||||
)
|
||||
elif isinstance(event, Observation):
|
||||
messages_to_add = self._process_observation(
|
||||
@@ -147,6 +149,7 @@ class ConversationMemory:
|
||||
action: Action,
|
||||
pending_tool_call_action_messages: dict[str, Message],
|
||||
vision_is_active: bool = False,
|
||||
max_message_chars: int | None = None,
|
||||
) -> list[Message]:
|
||||
"""Converts an action into a message format that can be sent to the LLM.
|
||||
|
||||
@@ -171,6 +174,9 @@ class ConversationMemory:
|
||||
|
||||
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included
|
||||
|
||||
max_message_chars: The maximum number of characters in the content of an action included
|
||||
in the prompt to the LLM. Larger content is truncated.
|
||||
|
||||
Returns:
|
||||
list[Message]: A list containing the formatted message(s) for the action.
|
||||
May be empty if the action is handled as a tool call in function calling mode.
|
||||
@@ -181,7 +187,13 @@ class ConversationMemory:
|
||||
tool call results are available.
|
||||
"""
|
||||
# create a regular message from an event
|
||||
if isinstance(
|
||||
if isinstance(action, AgentCondensationAction):
|
||||
# For condensation actions, create a message with the summary
|
||||
text = truncate_content(
|
||||
action.summary, max_message_chars
|
||||
) # Use the same max length as other messages
|
||||
return [Message(role='user', content=[TextContent(text=text)])]
|
||||
elif isinstance(
|
||||
action,
|
||||
(
|
||||
AgentDelegateAction,
|
||||
@@ -383,6 +395,7 @@ class ConversationMemory:
|
||||
text += '\n[Last action has been rejected by the user]'
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, AgentCondensationObservation):
|
||||
# These observations are created by the controller during system-initiated truncation
|
||||
text = truncate_content(obs.content, max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif (
|
||||
|
||||
8
poetry.lock
generated
8
poetry.lock
generated
@@ -3036,7 +3036,7 @@ version = "2.0.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["evaluation", "test"]
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
@@ -5370,7 +5370,7 @@ version = "1.5.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["evaluation", "test"]
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
@@ -6150,7 +6150,7 @@ version = "8.3.5"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["evaluation", "test"]
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"},
|
||||
{file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"},
|
||||
@@ -9316,4 +9316,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d3ec6b8a6c7e48420d76b7e17d5f1a3f253fa603205f90d4a8e4a614ab5e2c67"
|
||||
content-hash = "d9e23fd9eed8971e3ca8c290ae02058bc07ac071d54b88ce32256e3eaa9a78ef"
|
||||
|
||||
@@ -78,6 +78,7 @@ qtconsole = "^5.6.1"
|
||||
memory-profiler = "^0.61.0"
|
||||
daytona-sdk = "0.10.4"
|
||||
python-json-logger = "^3.2.1"
|
||||
pytest = "^8.3.5"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.11.0"
|
||||
@@ -99,6 +100,7 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@@ -127,6 +129,7 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AgentCondensationAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
BrowseInteractiveAction,
|
||||
@@ -369,6 +370,18 @@ def test_agent_microagent_action_serialization_deserialization():
|
||||
serialization_deserialization(original_action_dict, RecallAction)
|
||||
|
||||
|
||||
def test_agent_condensation_action_serialization_deserialization():
|
||||
original_action_dict = {
|
||||
'action': 'condense',
|
||||
'args': {
|
||||
'start_id': 10,
|
||||
'end_id': 20,
|
||||
'summary': 'Condensed history',
|
||||
},
|
||||
}
|
||||
serialization_deserialization(original_action_dict, AgentCondensationAction)
|
||||
|
||||
|
||||
def test_file_read_action_legacy_serialization():
|
||||
original_action_dict = {
|
||||
'action': 'read',
|
||||
|
||||
140
tests/unit/test_agent_condensation_action.py
Normal file
140
tests/unit/test_agent_condensation_action.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.memory.conversation_memory import ConversationMemory
|
||||
from openhands.utils.prompt import PromptManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_config():
|
||||
"""Create a basic agent config for testing."""
|
||||
return AgentConfig(
|
||||
llm_config='test-model',
|
||||
enable_prompt_extensions=True,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_condensation_action_with_max_message_chars(agent_config):
|
||||
"""Test that AgentCondensationAction respects max_message_chars parameter."""
|
||||
# Use a mock for the prompt manager
|
||||
prompt_manager = MagicMock(spec=PromptManager)
|
||||
prompt_manager.get_system_message.return_value = 'System message'
|
||||
memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Create some events to process
|
||||
event0 = MessageAction(content='Message 0')
|
||||
event0._id = 0 # ignore [attr-defined]
|
||||
|
||||
# Create a mock condenser that will return our condensation action with a very long summary
|
||||
mock_condenser = MagicMock(spec=LLMSummarizingCondenser)
|
||||
|
||||
# Create a long summary (15,000 characters)
|
||||
long_summary = 'A' * 15000
|
||||
|
||||
condensation_action = AgentCondensationAction(
|
||||
start_id=1,
|
||||
end_id=5,
|
||||
summary=long_summary,
|
||||
)
|
||||
|
||||
# Set up the mock condenser to return our condensation action
|
||||
mock_condenser.condensed_history.return_value = [
|
||||
event0, # Keep first event
|
||||
condensation_action, # Condensation action with long summary
|
||||
]
|
||||
|
||||
# Create a state with our events
|
||||
state = State()
|
||||
state.history = [event0]
|
||||
|
||||
# Process the events with a max_message_chars limit
|
||||
max_chars = 1000
|
||||
messages = memory.process_events(
|
||||
condensed_history=mock_condenser.condensed_history(state),
|
||||
initial_messages=[
|
||||
Message(role='system', content=[TextContent(text='System message')])
|
||||
],
|
||||
max_message_chars=max_chars,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that the condensation action was processed correctly and truncated
|
||||
assert len(messages) == 3 # system message + initial message + condensation action
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[2].role == 'user'
|
||||
|
||||
# The content should be truncated
|
||||
condensed_content = messages[2].content[0].text
|
||||
assert len(condensed_content) < 15000
|
||||
assert '[... Observation truncated due to length ...]' in condensed_content
|
||||
|
||||
# The truncated content should be approximately max_chars in length
|
||||
# (half from beginning, half from end, plus the truncation message)
|
||||
truncation_message = '\n[... Observation truncated due to length ...]\n'
|
||||
expected_length = (max_chars // 2) * 2 + len(truncation_message)
|
||||
assert abs(len(condensed_content) - expected_length) <= 1 # Allow for rounding
|
||||
|
||||
|
||||
def test_agent_condensation_action_without_max_message_chars(agent_config):
|
||||
"""Test that AgentCondensationAction doesn't truncate when max_message_chars is None."""
|
||||
# Use a mock for the prompt manager
|
||||
prompt_manager = MagicMock(spec=PromptManager)
|
||||
prompt_manager.get_system_message.return_value = 'System message'
|
||||
memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Create some events to process
|
||||
event0 = MessageAction(content='Message 0')
|
||||
event0._id = 0 # ignore [attr-defined]
|
||||
|
||||
# Create a mock condenser that will return our condensation action
|
||||
mock_condenser = MagicMock(spec=LLMSummarizingCondenser)
|
||||
|
||||
# Create a summary
|
||||
summary = 'This is a condensed summary of the conversation'
|
||||
|
||||
condensation_action = AgentCondensationAction(
|
||||
start_id=1,
|
||||
end_id=5,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
# Set up the mock condenser to return our condensation action
|
||||
mock_condenser.condensed_history.return_value = [
|
||||
event0, # Keep first event
|
||||
condensation_action, # Condensation action
|
||||
]
|
||||
|
||||
# Create a state with our events
|
||||
state = State()
|
||||
state.history = [event0]
|
||||
|
||||
# Process the events without a max_message_chars limit
|
||||
messages = memory.process_events(
|
||||
condensed_history=mock_condenser.condensed_history(state),
|
||||
initial_messages=[
|
||||
Message(role='system', content=[TextContent(text='System message')])
|
||||
],
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that the condensation action was processed correctly and not truncated
|
||||
assert len(messages) == 3 # system message + initial message + condensation action
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[2].role == 'user'
|
||||
|
||||
# The content should not be truncated
|
||||
condensed_content = messages[2].content[0].text
|
||||
assert condensed_content == summary
|
||||
assert '[... Observation truncated due to length ...]' not in condensed_content
|
||||
@@ -16,9 +16,11 @@ from openhands.core.config.condenser_config import (
|
||||
)
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.observation import BrowserOutputObservation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
|
||||
# Removed AgentCondensationObservation import as it's no longer used
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.llm import LLM
|
||||
from openhands.memory.condenser import Condenser
|
||||
@@ -334,18 +336,21 @@ def test_llm_summarizing_condenser_forgets_and_summarizes(mock_llm, mock_state):
|
||||
|
||||
# We should have exactly 3 events:
|
||||
# 1. First event (keep_first = 1)
|
||||
# 2. Summary event
|
||||
# 2. Summary event (AgentCondensationAction)
|
||||
# 3. Most recent event
|
||||
assert len(results) == 3, f'Expected 3 events, got {len(results)}: {results}'
|
||||
assert (
|
||||
results[0] == first_event
|
||||
), f'First event should be {first_event}, got {results[0]}'
|
||||
|
||||
# Check for AgentCondensationAction
|
||||
assert isinstance(
|
||||
results[1], AgentCondensationObservation
|
||||
), f'Second event should be a summary, got {results[1]}'
|
||||
results[1], AgentCondensationAction
|
||||
), f'Second event should be a condensation action, got {results[1]}'
|
||||
assert (
|
||||
results[1].content == 'Summary of forgotten events'
|
||||
), f"Summary content should be 'Summary of forgotten events', got {results[1].content}"
|
||||
results[1].summary == 'Summary of forgotten events'
|
||||
), f"Summary content should be 'Summary of forgotten events', got {results[1].summary}"
|
||||
|
||||
assert results[2] == event, f'Last event should be {event}, got {results[2]}'
|
||||
|
||||
|
||||
@@ -410,7 +415,7 @@ def test_llm_summarizing_condenser_resets_when_given_truncated_history(
|
||||
|
||||
# We should have exactly 3 events:
|
||||
# 1. First event (keep_first = 1)
|
||||
# 2. Summary event
|
||||
# 2. AgentCondensationAction with summary
|
||||
# 3. Most recent event
|
||||
assert len(results) == 3, f'Expected 3 events, got {len(results)}: {results}'
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.files import FileEditObservation, FileReadObservation
|
||||
from openhands.events.observation.reject import UserRejectObservation
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.memory.conversation_memory import ConversationMemory
|
||||
from openhands.utils.prompt import PromptManager, RepositoryInfo, RuntimeInfo
|
||||
|
||||
@@ -1050,3 +1053,67 @@ def test_has_agent_in_earlier_events(conversation_memory):
|
||||
conversation_memory._has_agent_in_earlier_events('non_existent', 3, events)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_process_events_with_agent_condensation_action(agent_config):
|
||||
"""Test that process_events correctly handles AgentCondensationAction."""
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
|
||||
# Use a mock for the prompt manager
|
||||
prompt_manager = MagicMock(spec=PromptManager)
|
||||
prompt_manager.get_system_message.return_value = 'System message'
|
||||
memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Create some events to process
|
||||
event0 = MessageAction(content='Message 0')
|
||||
event0._id = 0 # ignore [attr-defined]
|
||||
event1 = MessageAction(content='Message 1')
|
||||
event1._id = 1 # ignore [attr-defined]
|
||||
event2 = MessageAction(content='Message 2')
|
||||
event2._id = 2 # ignore [attr-defined]
|
||||
event3 = MessageAction(content='Message 3')
|
||||
event3._id = 3 # ignore [attr-defined]
|
||||
event4 = MessageAction(content='Message 4')
|
||||
event4._id = 4 # ignore [attr-defined]
|
||||
event5 = MessageAction(content='Message 5')
|
||||
event5._id = 5 # ignore [attr-defined]
|
||||
|
||||
# Create a mock condenser that will return our condensation action
|
||||
mock_condenser = MagicMock(spec=LLMSummarizingCondenser)
|
||||
condensation_action = AgentCondensationAction(
|
||||
start_id=1,
|
||||
end_id=5,
|
||||
summary='This is condensed content',
|
||||
)
|
||||
|
||||
# Set up the mock condenser to return our condensation action
|
||||
mock_condenser.condensed_history.return_value = [
|
||||
event0, # Keep first event
|
||||
condensation_action, # Condensation action
|
||||
event5, # Most recent event
|
||||
]
|
||||
|
||||
# Create a state with our events
|
||||
state = State()
|
||||
state.history = [event0, event1, event2, event3, event4, event5]
|
||||
|
||||
# Process the events
|
||||
messages = memory.process_events(
|
||||
condensed_history=mock_condenser.condensed_history(state),
|
||||
initial_messages=[
|
||||
Message(role='system', content=[TextContent(text='System message')])
|
||||
],
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that the condensation action was processed correctly
|
||||
assert (
|
||||
len(messages) == 4
|
||||
) # system message + +initial message + condensation action + most recent event
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[2].role == 'user'
|
||||
assert 'This is condensed content' in messages[2].content[0].text
|
||||
assert messages[3].role == 'assistant'
|
||||
assert 'Message 5' in messages[3].content[0].text
|
||||
|
||||
@@ -8,12 +8,12 @@ from openhands.controller.agent_controller import AgentController
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.controller.stuck import StuckDetector
|
||||
from openhands.events.action import CmdRunAction, FileReadAction, MessageAction
|
||||
from openhands.events.action.agent import AgentCondensationAction
|
||||
from openhands.events.action.commands import IPythonRunCellAction
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.observation.commands import IPythonRunCellObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
@@ -616,8 +616,10 @@ class TestStuckDetector:
|
||||
|
||||
# Add ten consecutive condensation events (should detect as stuck)
|
||||
for _ in range(10):
|
||||
condensation = AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
condensation = AgentCondensationAction(
|
||||
start_id=0,
|
||||
end_id=0,
|
||||
summary='Trimming prompt to meet context window limitations',
|
||||
)
|
||||
state.history.append(condensation)
|
||||
|
||||
@@ -641,8 +643,10 @@ class TestStuckDetector:
|
||||
# Add 10 condensation events with other events between them
|
||||
for i in range(10):
|
||||
# Add a condensation event
|
||||
condensation = AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
condensation = AgentCondensationAction(
|
||||
start_id=0,
|
||||
end_id=0,
|
||||
summary='Trimming prompt to meet context window limitations',
|
||||
)
|
||||
state.history.append(condensation)
|
||||
|
||||
@@ -682,8 +686,10 @@ class TestStuckDetector:
|
||||
|
||||
# Add only nine condensation events (should not detect as stuck)
|
||||
for _ in range(9):
|
||||
condensation = AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
condensation = AgentCondensationAction(
|
||||
start_id=0,
|
||||
end_id=0,
|
||||
summary='Trimming prompt to meet context window limitations',
|
||||
)
|
||||
state.history.append(condensation)
|
||||
|
||||
@@ -709,8 +715,10 @@ class TestStuckDetector:
|
||||
# Add condensation events with user messages between them (total of 10)
|
||||
for i in range(10):
|
||||
# Add a condensation event
|
||||
condensation = AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
condensation = AgentCondensationAction(
|
||||
start_id=0,
|
||||
end_id=0,
|
||||
summary='Trimming prompt to meet context window limitations',
|
||||
)
|
||||
state.history.append(condensation)
|
||||
|
||||
@@ -740,8 +748,10 @@ class TestStuckDetector:
|
||||
|
||||
# Add condensation events first
|
||||
for _ in range(10):
|
||||
condensation = AgentCondensationObservation(
|
||||
content='Trimming prompt to meet context window limitations'
|
||||
condensation = AgentCondensationAction(
|
||||
start_id=0,
|
||||
end_id=0,
|
||||
summary='Trimming prompt to meet context window limitations',
|
||||
)
|
||||
state.history.append(condensation)
|
||||
|
||||
|
||||
67
tests/unit/test_truncate_content.py
Normal file
67
tests/unit/test_truncate_content.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
|
||||
|
||||
def test_truncate_content_no_truncation():
|
||||
"""Test that truncate_content returns the original content when it's shorter than max_chars."""
|
||||
content = 'This is a short message'
|
||||
max_chars = 100
|
||||
result = truncate_content(content, max_chars)
|
||||
assert result == content
|
||||
|
||||
|
||||
def test_truncate_content_none_max_chars():
|
||||
"""Test that truncate_content returns the original content when max_chars is None."""
|
||||
content = 'This is a message of any length'
|
||||
result = truncate_content(content, None)
|
||||
assert result == content
|
||||
|
||||
|
||||
def test_truncate_content_negative_max_chars():
|
||||
"""Test that truncate_content returns the original content when max_chars is negative."""
|
||||
content = 'This is a message of any length'
|
||||
result = truncate_content(content, -1)
|
||||
assert result == content
|
||||
|
||||
|
||||
def test_truncate_content_truncation():
|
||||
"""Test that truncate_content truncates the middle of the content when it's longer than max_chars."""
|
||||
content = 'This is a very long message that should be truncated in the middle'
|
||||
max_chars = 20
|
||||
result = truncate_content(content, max_chars)
|
||||
|
||||
# The result should be the first 10 chars + truncation message + last 10 chars
|
||||
expected_prefix = content[:10]
|
||||
expected_suffix = content[-10:]
|
||||
truncation_message = '\n[... Observation truncated due to length ...]\n'
|
||||
|
||||
assert result.startswith(expected_prefix)
|
||||
assert result.endswith(expected_suffix)
|
||||
assert truncation_message in result
|
||||
assert len(result) == 10 + len(truncation_message) + 10
|
||||
|
||||
|
||||
def test_truncate_content_exact_length():
|
||||
"""Test that truncate_content doesn't truncate when content length equals max_chars."""
|
||||
content = 'Exact length'
|
||||
max_chars = len(content)
|
||||
result = truncate_content(content, max_chars)
|
||||
assert result == content
|
||||
|
||||
|
||||
def test_truncate_content_with_agent_condensation_action():
|
||||
"""Test that truncate_content works correctly with a long summary from AgentCondensationAction."""
|
||||
# Simulate a long summary that would come from an AgentCondensationAction
|
||||
long_summary = 'A' * 15000 # 15,000 characters
|
||||
max_chars = 10000 # The limit we want to enforce
|
||||
|
||||
result = truncate_content(long_summary, max_chars)
|
||||
|
||||
# Verify the result is truncated correctly
|
||||
assert len(result) < 15000
|
||||
assert '\n[... Observation truncated due to length ...]\n' in result
|
||||
|
||||
# The result should be approximately max_chars in length
|
||||
# (half from beginning, half from end, plus the truncation message)
|
||||
truncation_message = '\n[... Observation truncated due to length ...]\n'
|
||||
expected_length = (max_chars // 2) * 2 + len(truncation_message)
|
||||
assert abs(len(result) - expected_length) <= 1 # Allow for rounding
|
||||
Reference in New Issue
Block a user