Compare commits

...

34 Commits

Author SHA1 Message Date
openhands
ed08a0dec2 Add persistent state reconstruction for RollingCondenser 2025-03-19 06:14:08 +00:00
openhands
b8f031eafb Add methods to reconstruct and reset RollingCondenser tracking variables 2025-03-19 06:07:34 +00:00
openhands
8ec2b4a5e5 Add documentation for RollingCondenser tracking variables 2025-03-19 06:04:22 +00:00
Engel Nyst
a345e30408 Merge branch 'main' into condenser-visibility 2025-03-19 05:48:31 +01:00
openhands
c551740cf4 Fix comment about AgentCondensationObservation usage 2025-03-19 04:48:06 +00:00
Engel Nyst
6a12b8eeaf tweak comment 2025-03-19 05:42:40 +01:00
Engel Nyst
d535ac8da6 adapt back to one action 2025-03-19 05:41:32 +01:00
Engel Nyst
c8be1a2cf2 Remove AgentCondensationObservation from LLM condenser
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-19 05:27:58 +01:00
Engel Nyst
5b59dfafe3 Update openhands/memory/condenser/impl/llm_summarizing_condenser.py 2025-03-19 05:01:08 +01:00
Engel Nyst
0db64d3aae Update openhands/memory/condenser/impl/browser_output_condenser.py 2025-03-19 04:58:40 +01:00
openhands
e612b14766 Use AgentCondensationObservation for controller truncation 2025-03-19 03:40:43 +00:00
openhands
b44819baf9 Use max_message_chars for AgentCondensationAction instead of hardcoded 10000 character limit 2025-03-19 03:17:01 +00:00
Engel Nyst
994a619485 Merge branch 'condenser-visibility' of github.com:All-Hands-AI/OpenHands into condenser-visibility 2025-03-19 01:12:13 +01:00
Engel Nyst
28a14b78c1 lock 2025-03-19 01:08:45 +01:00
Engel Nyst
c855dc5cd4 Merge branch 'main' of github.com:All-Hands-AI/OpenHands into condenser-visibility 2025-03-19 01:08:24 +01:00
Engel Nyst
a0ec4eed15 Merge branch 'main' into condenser-visibility 2025-03-19 01:06:05 +01:00
Engel Nyst
ab015f6acb Merge branch 'main' into condenser-visibility 2025-03-18 20:57:12 +01:00
Engel Nyst
c9fbf34a5d fix test 2025-03-18 19:33:55 +01:00
openhands
a6354981cc Apply formatting fixes to test files 2025-03-18 01:47:03 +00:00
Engel Nyst
2d817dee9e Merge branch 'main' into condenser-visibility 2025-03-18 02:38:50 +01:00
openhands
7b471341cb Apply formatting fixes to OpenHands modules 2025-03-18 01:38:28 +00:00
openhands
edff63c523 Fix type mismatch in AgentCondensationAction parameters 2025-03-18 01:37:10 +00:00
openhands
897eba5586 Fix type consistency in test_conversation_memory and update poetry.lock 2025-03-18 01:19:22 +00:00
Engel Nyst
ed7289b8fe Merge branch 'main' into condenser-visibility 2025-03-18 02:12:18 +01:00
openhands
466a675476 Add default values to AgentCondensationAction fields 2025-03-18 00:41:16 +00:00
openhands
053b919b14 WIP: Fix AgentCondensationAction and AgentCondensationObservation usage 2025-03-18 00:24:05 +00:00
Engel Nyst
9721bc4b86 Update openhands/memory/condenser/impl/llm_summarizing_condenser.py 2025-03-18 00:38:30 +01:00
openhands
ec9139a612 Update files to properly use AgentCondensationAction 2025-03-17 23:35:47 +00:00
openhands
e3bf65c561 Fix AgentCondensationAction summary access in conversation_memory.py 2025-03-17 23:34:34 +00:00
openhands
fd8d8f1ae3 Fix AgentCondensationAction unit test to use summary instead of metadata 2025-03-17 22:02:13 +00:00
openhands
d9faa86447 Replace metadata with summary field in AgentCondensationAction 2025-03-17 21:58:03 +00:00
openhands
9293ccc185 Add AgentCondensationAction to LLMSummarizingCondenser.condense() 2025-03-17 21:54:39 +00:00
openhands
c84ba30a9f Fix: Replace Dict from typing with built-in dict for Python 3.12 compatibility 2025-03-17 19:59:34 +00:00
openhands
23bfd9b16c Add AgentCondensationAction to track condensation events in agent history 2025-03-17 19:45:37 +00:00
19 changed files with 544 additions and 48 deletions

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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',

View File

@@ -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."""

View File

@@ -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]

View File

@@ -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

View File

@@ -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:

View File

@@ -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(

View File

@@ -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)

View File

@@ -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
View File

@@ -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"

View File

@@ -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 = "*"

View File

@@ -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',

View 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

View File

@@ -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}'

View File

@@ -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

View File

@@ -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)

View 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