fix: Prevent old instructions from being re-executed after conversation condensation (#11982)

This commit is contained in:
Nhan Nguyen 2025-12-17 07:05:10 -05:00 committed by GitHub
parent dc14624480
commit 435e537693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 197 additions and 18 deletions

View File

@ -194,9 +194,12 @@ class CodeActAgent(Agent):
# event we'll just return that instead of an action. The controller will # event we'll just return that instead of an action. The controller will
# immediately ask the agent to step again with the new view. # immediately ask the agent to step again with the new view.
condensed_history: list[Event] = [] condensed_history: list[Event] = []
# Track which event IDs have been forgotten/condensed
forgotten_event_ids: set[int] = set()
match self.condenser.condensed_history(state): match self.condenser.condensed_history(state):
case View(events=events): case View(events=events, forgotten_event_ids=forgotten_ids):
condensed_history = events condensed_history = events
forgotten_event_ids = forgotten_ids
case Condensation(action=condensation_action): case Condensation(action=condensation_action):
return condensation_action return condensation_action
@ -206,7 +209,9 @@ class CodeActAgent(Agent):
) )
initial_user_message = self._get_initial_user_message(state.history) initial_user_message = self._get_initial_user_message(state.history)
messages = self._get_messages(condensed_history, initial_user_message) messages = self._get_messages(
condensed_history, initial_user_message, forgotten_event_ids
)
params: dict = { params: dict = {
'messages': messages, 'messages': messages,
} }
@ -245,7 +250,10 @@ class CodeActAgent(Agent):
return initial_user_message return initial_user_message
def _get_messages( def _get_messages(
self, events: list[Event], initial_user_message: MessageAction self,
events: list[Event],
initial_user_message: MessageAction,
forgotten_event_ids: set[int],
) -> list[Message]: ) -> list[Message]:
"""Constructs the message history for the LLM conversation. """Constructs the message history for the LLM conversation.
@ -284,6 +292,7 @@ class CodeActAgent(Agent):
messages = self.conversation_memory.process_events( messages = self.conversation_memory.process_events(
condensed_history=events, condensed_history=events,
initial_user_action=initial_user_message, initial_user_action=initial_user_message,
forgotten_event_ids=forgotten_event_ids,
max_message_chars=self.llm.config.max_message_chars, max_message_chars=self.llm.config.max_message_chars,
vision_is_active=self.llm.vision_is_active(), vision_is_active=self.llm.vision_is_active(),
) )

View File

@ -76,6 +76,7 @@ class ConversationMemory:
self, self,
condensed_history: list[Event], condensed_history: list[Event],
initial_user_action: MessageAction, initial_user_action: MessageAction,
forgotten_event_ids: set[int] | None = None,
max_message_chars: int | None = None, max_message_chars: int | None = None,
vision_is_active: bool = False, vision_is_active: bool = False,
) -> list[Message]: ) -> list[Message]:
@ -85,16 +86,23 @@ class ConversationMemory:
Args: Args:
condensed_history: The condensed history of events to convert condensed_history: The condensed history of events to convert
initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
forgotten_event_ids: Set of event IDs that have been forgotten/condensed. If the initial user action's ID
is in this set, it will not be re-inserted to prevent re-execution of old instructions.
max_message_chars: The maximum number of characters in the content of an event included max_message_chars: The maximum number of characters in the content of an event included
in the prompt to the LLM. Larger observations are truncated. in the prompt to the LLM. Larger observations are truncated.
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included. vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
""" """
events = condensed_history events = condensed_history
# Default to empty set if not provided
if forgotten_event_ids is None:
forgotten_event_ids = set()
# Ensure the event list starts with SystemMessageAction, then MessageAction(source='user') # Ensure the event list starts with SystemMessageAction, then MessageAction(source='user')
self._ensure_system_message(events) self._ensure_system_message(events)
self._ensure_initial_user_message(events, initial_user_action) self._ensure_initial_user_message(
events, initial_user_action, forgotten_event_ids
)
# log visual browsing status # log visual browsing status
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}') logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
@ -827,9 +835,23 @@ class ConversationMemory:
) )
def _ensure_initial_user_message( def _ensure_initial_user_message(
self, events: list[Event], initial_user_action: MessageAction self,
events: list[Event],
initial_user_action: MessageAction,
forgotten_event_ids: set[int],
) -> None: ) -> None:
"""Checks if the second event is a user MessageAction and inserts the provided one if needed.""" """Checks if the second event is a user MessageAction and inserts the provided one if needed.
IMPORTANT: If the initial user action has been condensed (its ID is in forgotten_event_ids),
we do NOT re-insert it. This prevents old instructions from being re-executed after
conversation condensation. The condensation summary already contains the context of
what was requested and completed.
Args:
events: The list of events to modify in-place
initial_user_action: The initial user message action from the full history
forgotten_event_ids: Set of event IDs that have been forgotten/condensed
"""
if ( if (
not events not events
): # Should have system message from previous step, but safety check ): # Should have system message from previous step, but safety check
@ -837,6 +859,17 @@ class ConversationMemory:
# Or raise? Let's log for now, _ensure_system_message should handle this. # Or raise? Let's log for now, _ensure_system_message should handle this.
return return
# Check if the initial user action has been condensed/forgotten.
# If so, we should NOT re-insert it to prevent re-execution of old instructions.
# The condensation summary already contains the context of what was requested.
initial_user_action_id = initial_user_action.id
if initial_user_action_id in forgotten_event_ids:
logger.info(
f'Initial user action (id={initial_user_action_id}) has been condensed. '
'Not re-inserting to prevent re-execution of old instructions.'
)
return
# We expect events[0] to be SystemMessageAction after _ensure_system_message # We expect events[0] to be SystemMessageAction after _ensure_system_message
if len(events) == 1: if len(events) == 1:
# Only system message exists # Only system message exists

View File

@ -18,6 +18,8 @@ class View(BaseModel):
events: list[Event] events: list[Event]
unhandled_condensation_request: bool = False unhandled_condensation_request: bool = False
# Set of event IDs that have been forgotten/condensed
forgotten_event_ids: set[int] = set()
def __len__(self) -> int: def __len__(self) -> int:
return len(self.events) return len(self.events)
@ -90,4 +92,5 @@ class View(BaseModel):
return View( return View(
events=kept_events, events=kept_events,
unhandled_condensation_request=unhandled_condensation_request, unhandled_condensation_request=unhandled_condensation_request,
forgotten_event_ids=forgotten_event_ids,
) )

View File

@ -393,7 +393,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# 2. The action message # 2. The action message
# 3. The observation message # 3. The observation message
mock_state.history = [initial_user_message, action, observation] mock_state.history = [initial_user_message, action, observation]
messages = agent._get_messages(mock_state.history, initial_user_message) messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert len(messages) == 4 # System + initial user + action + observation assert len(messages) == 4 # System + initial user + action + observation
assert messages[0].role == 'system' # First message should be the system message assert messages[0].role == 'system' # First message should be the system message
assert ( assert (
@ -404,7 +404,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# The same should hold if the events are presented out-of-order # The same should hold if the events are presented out-of-order
mock_state.history = [initial_user_message, observation, action] mock_state.history = [initial_user_message, observation, action]
messages = agent._get_messages(mock_state.history, initial_user_message) messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert len(messages) == 4 assert len(messages) == 4
assert messages[0].role == 'system' # First message should be the system message assert messages[0].role == 'system' # First message should be the system message
assert ( assert (
@ -414,7 +414,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# If only one of the two events is present, then we should just get the system message # If only one of the two events is present, then we should just get the system message
# plus any valid message from the event # plus any valid message from the event
mock_state.history = [initial_user_message, action] mock_state.history = [initial_user_message, action]
messages = agent._get_messages(mock_state.history, initial_user_message) messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert ( assert (
len(messages) == 2 len(messages) == 2
) # System + initial user message, action is waiting for its observation ) # System + initial user message, action is waiting for its observation
@ -422,7 +422,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
assert messages[1].role == 'user' assert messages[1].role == 'user'
mock_state.history = [initial_user_message, observation] mock_state.history = [initial_user_message, observation]
messages = agent._get_messages(mock_state.history, initial_user_message) messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert ( assert (
len(messages) == 2 len(messages) == 2
) # System + initial user message, observation has no matching action ) # System + initial user message, observation has no matching action

View File

@ -80,7 +80,7 @@ def test_get_messages(codeact_agent: CodeActAgent):
history.append(message_action_5) history.append(message_action_5)
codeact_agent.reset() codeact_agent.reset()
messages = codeact_agent._get_messages(history, message_action_1) messages = codeact_agent._get_messages(history, message_action_1, set())
assert ( assert (
len(messages) == 6 len(messages) == 6
@ -122,7 +122,7 @@ def test_get_messages_prompt_caching(codeact_agent: CodeActAgent):
history.append(message_action_agent) history.append(message_action_agent)
codeact_agent.reset() codeact_agent.reset()
messages = codeact_agent._get_messages(history, initial_user_message) messages = codeact_agent._get_messages(history, initial_user_message, set())
# Check that only the last two user messages have cache_prompt=True # Check that only the last two user messages have cache_prompt=True
cached_user_messages = [ cached_user_messages = [

View File

@ -158,7 +158,8 @@ def test_ensure_initial_user_message_adds_if_only_system(
system_message = SystemMessageAction(content='System') system_message = SystemMessageAction(content='System')
system_message._source = EventSource.AGENT system_message._source = EventSource.AGENT
events = [system_message] events = [system_message]
conversation_memory._ensure_initial_user_message(events, initial_user_action) # Pass empty set for forgotten_event_ids (no events have been condensed)
conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 2 assert len(events) == 2
assert events[0] == system_message assert events[0] == system_message
assert events[1] == initial_user_action assert events[1] == initial_user_action
@ -177,7 +178,8 @@ def test_ensure_initial_user_message_correct_already_present(
agent_message, agent_message,
] ]
original_events = list(events) original_events = list(events)
conversation_memory._ensure_initial_user_message(events, initial_user_action) # Pass empty set for forgotten_event_ids (no events have been condensed)
conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert events == original_events assert events == original_events
@ -189,7 +191,8 @@ def test_ensure_initial_user_message_incorrect_at_index_1(
incorrect_second_message = MessageAction(content='Assistant') incorrect_second_message = MessageAction(content='Assistant')
incorrect_second_message._source = EventSource.AGENT incorrect_second_message._source = EventSource.AGENT
events = [system_message, incorrect_second_message] events = [system_message, incorrect_second_message]
conversation_memory._ensure_initial_user_message(events, initial_user_action) # Pass empty set for forgotten_event_ids (no events have been condensed)
conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 3 assert len(events) == 3
assert events[0] == system_message assert events[0] == system_message
assert events[1] == initial_user_action # Correct one inserted assert events[1] == initial_user_action # Correct one inserted
@ -206,7 +209,8 @@ def test_ensure_initial_user_message_correct_present_later(
# Correct initial message is present, but later in the list # Correct initial message is present, but later in the list
events = [system_message, incorrect_second_message] events = [system_message, incorrect_second_message]
conversation_memory._ensure_system_message(events) conversation_memory._ensure_system_message(events)
conversation_memory._ensure_initial_user_message(events, initial_user_action) # Pass empty set for forgotten_event_ids (no events have been condensed)
conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 3 # Should still insert at index 1, not remove the later one assert len(events) == 3 # Should still insert at index 1, not remove the later one
assert events[0] == system_message assert events[0] == system_message
assert events[1] == initial_user_action # Correct one inserted at index 1 assert events[1] == initial_user_action # Correct one inserted at index 1
@ -222,7 +226,8 @@ def test_ensure_initial_user_message_different_user_msg_at_index_1(
different_user_message = MessageAction(content='Different User Message') different_user_message = MessageAction(content='Different User Message')
different_user_message._source = EventSource.USER different_user_message._source = EventSource.USER
events = [system_message, different_user_message] events = [system_message, different_user_message]
conversation_memory._ensure_initial_user_message(events, initial_user_action) # Pass empty set for forgotten_event_ids (no events have been condensed)
conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 2 assert len(events) == 2
assert events[0] == system_message assert events[0] == system_message
assert events[1] == different_user_message # Original second message remains assert events[1] == different_user_message # Original second message remains
@ -1583,3 +1588,132 @@ def test_process_ipython_observation_with_vision_disabled(
assert isinstance(message.content[1], ImageContent) assert isinstance(message.content[1], ImageContent)
# Check that NO explanatory text about filtered images was added when vision is disabled # Check that NO explanatory text about filtered images was added when vision is disabled
assert 'invalid or empty image(s) were filtered' not in message.content[0].text assert 'invalid or empty image(s) were filtered' not in message.content[0].text
def test_ensure_initial_user_message_not_reinserted_when_condensed(
conversation_memory, initial_user_action
):
"""Test that initial user message is NOT re-inserted when it has been condensed.
This is a critical test for bug #11910: Old instructions should not be re-executed
after conversation condensation. If the initial user message has been condensed
(its ID is in the forgotten_event_ids set), we should NOT re-insert it to prevent
the LLM from seeing old instructions as fresh commands.
"""
system_message = SystemMessageAction(content='System')
system_message._source = EventSource.AGENT
# Simulate that the initial_user_action has been condensed by adding its ID
# to the forgotten_event_ids set
initial_user_action._id = 1 # Assign an ID to the initial user action
forgotten_event_ids = {1} # The initial user action's ID is in the forgotten set
events = [system_message] # Only system message, no user message
# Call _ensure_initial_user_message with the condensed event ID
conversation_memory._ensure_initial_user_message(
events, initial_user_action, forgotten_event_ids
)
# The initial user action should NOT be inserted because it was condensed
assert len(events) == 1
assert events[0] == system_message
# Verify the initial user action was NOT added
assert initial_user_action not in events
def test_ensure_initial_user_message_reinserted_when_not_condensed(
conversation_memory, initial_user_action
):
"""Test that initial user message IS re-inserted when it has NOT been condensed.
This ensures backward compatibility: when no condensation has happened,
the initial user message should still be inserted as before.
"""
system_message = SystemMessageAction(content='System')
system_message._source = EventSource.AGENT
# The initial user action has NOT been condensed
initial_user_action._id = 1
forgotten_event_ids = {5, 10, 15} # Different IDs, not including the initial action
events = [system_message]
# Call _ensure_initial_user_message with non-matching forgotten IDs
conversation_memory._ensure_initial_user_message(
events, initial_user_action, forgotten_event_ids
)
# The initial user action SHOULD be inserted because it was NOT condensed
assert len(events) == 2
assert events[0] == system_message
assert events[1] == initial_user_action
def test_process_events_does_not_reinsert_condensed_initial_message(
conversation_memory,
):
"""Test that process_events does not re-insert initial user message when condensed.
This is an integration test for the full process_events flow, verifying that
when the initial user message has been condensed, it is not re-inserted into
the conversation sent to the LLM.
"""
# Create a system message
system_message = SystemMessageAction(content='System message')
system_message._source = EventSource.AGENT
system_message._id = 0
# Create the initial user message (will be marked as condensed)
initial_user_message = MessageAction(content='Do task A, B, and C')
initial_user_message._source = EventSource.USER
initial_user_message._id = 1
# Create a condensation summary observation
from openhands.events.observation.agent import AgentCondensationObservation
condensation_summary = AgentCondensationObservation(
content='Summary: User requested tasks A, B, C. Task A was completed successfully.'
)
condensation_summary._id = 2
# Create a recent user message (not condensed)
recent_user_message = MessageAction(content='Now continue with task D')
recent_user_message._source = EventSource.USER
recent_user_message._id = 3
# Simulate condensed history: system + summary + recent message
# The initial user message (id=1) has been condensed/forgotten
condensed_history = [system_message, condensation_summary, recent_user_message]
# The initial user message's ID is in the forgotten set
forgotten_event_ids = {1}
messages = conversation_memory.process_events(
condensed_history=condensed_history,
initial_user_action=initial_user_message,
forgotten_event_ids=forgotten_event_ids,
max_message_chars=None,
vision_is_active=False,
)
# Verify the structure of messages
# Should have: system, condensation summary, recent user message
# Should NOT have the initial user message "Do task A, B, and C"
assert len(messages) == 3
assert messages[0].role == 'system'
assert messages[0].content[0].text == 'System message'
# The second message should be the condensation summary, NOT the initial user message
assert messages[1].role == 'user'
assert 'Summary: User requested tasks A, B, C' in messages[1].content[0].text
# The third message should be the recent user message
assert messages[2].role == 'user'
assert 'Now continue with task D' in messages[2].content[0].text
# Critically, the old instruction should NOT appear
for msg in messages:
for content in msg.content:
if hasattr(content, 'text'):
assert 'Do task A, B, and C' not in content.text