Compare commits

...

8 Commits

Author SHA1 Message Date
openhands
52032a9f91 Fix pr #8153: Fix issue #8147: Fix empty string content handling in ConversationMemory 2025-05-03 23:08:46 +00:00
Engel Nyst
175c17f3bc Merge branch 'main' into openhands-fix-issue-8147 2025-04-29 23:10:25 +02:00
OpenHands Bot
ab950f9baf 🤖 Auto-fix Python linting issues 2025-04-29 21:10:13 +00:00
openhands
daddc42a9a Fix pr #8153: Fix issue #8147: Fix empty string content handling in ConversationMemory 2025-04-29 21:00:30 +00:00
Engel Nyst
0544660722 Merge branch 'main' into openhands-fix-issue-8147 2025-04-29 22:25:36 +02:00
OpenHands Bot
8943c7ea88 🤖 Auto-fix Python linting issues 2025-04-29 19:05:34 +00:00
Engel Nyst
2c936a6881 Merge branch 'main' into openhands-fix-issue-8147 2025-04-29 21:04:28 +02:00
openhands
d6d24daff7 Fix issue #8147: Fix empty string content handling in ConversationMemory 2025-04-29 13:58:00 +00:00
2 changed files with 170 additions and 3 deletions

View File

@@ -139,6 +139,13 @@ class ConversationMemory:
# and tool responses, for example
messages = list(ConversationMemory._filter_unmatched_tool_calls(messages))
# Filter out assistant messages with empty content lists, unless they have tool calls
messages = [
msg
for msg in messages
if msg.role != 'assistant' or msg.content or msg.tool_calls
]
# Apply final formatting
messages = self._apply_user_message_formatting(messages)
@@ -269,7 +276,12 @@ class ConversationMemory:
]
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
# Only include content if it's not None and not empty after stripping
content = (
[TextContent(text=action.content)]
if action.content and action.content.strip()
else []
)
if vision_is_active and action.image_urls:
content.append(ImageContent(image_urls=action.image_urls))
if role not in ('user', 'system', 'assistant', 'tool'):

View File

@@ -400,6 +400,50 @@ def test_process_events_with_unknown_observation(conversation_memory):
)
def test_process_events_with_empty_content_message(conversation_memory):
"""Test that empty content messages are handled correctly."""
# Create a message action with empty string content
empty_message = MessageAction(content='')
empty_message._source = EventSource.AGENT
# Create a message action with None content
none_message = MessageAction(content=None)
none_message._source = EventSource.AGENT
# Create a message action with whitespace content
whitespace_message = MessageAction(content=' \n \t ')
whitespace_message._source = EventSource.AGENT
# Create a valid message for comparison
valid_message = MessageAction(content='Valid content')
valid_message._source = EventSource.AGENT
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
# Process events
messages = conversation_memory.process_events(
condensed_history=[
empty_message,
none_message,
whitespace_message,
valid_message,
],
initial_user_action=initial_user_message,
max_message_chars=None,
vision_is_active=False,
)
# We expect 3 messages: system message, initial user message, and the valid message
# The empty, None, and whitespace messages should be filtered out
assert len(messages) == 3
assert messages[0].role == 'system' # System message
assert messages[1].role == 'user' # Initial user message
assert messages[2].role == 'assistant' # Valid message
assert len(messages[2].content) == 1
assert messages[2].content[0].text == 'Valid content'
def test_process_events_with_file_edit_observation(conversation_memory):
obs = FileEditObservation(
path='/test/file.txt',
@@ -1180,10 +1224,121 @@ def test_has_agent_in_earlier_events(conversation_memory):
)
def test_process_events_with_empty_content_and_tool_calls(conversation_memory):
"""Test that empty string content is handled correctly when tool calls are present.
The message should be kept because it has tool calls, even though content is empty."""
# Create a tool call for the execute_bash function
tool_call = ChatCompletionMessageToolCall(
id='test_call_id',
function={'name': 'execute_bash', 'arguments': '{"command": "test"}'},
type='function',
)
# Create a mock ModelResponse with empty string content but with tool calls
mock_response = {
'id': 'mock_response_id',
'choices': [{'message': {'content': '', 'tool_calls': [tool_call]}}],
'created': 0,
'model': '',
'object': '',
'usage': {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0},
}
# Create an action with empty content
action = CmdRunAction(command='test')
action._source = EventSource.AGENT
action.tool_call_metadata = ToolCallMetadata(
tool_call_id='test_call_id',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=1,
)
# Create a mock tool response observation
obs = CmdOutputObservation(
command='test',
content='Command output',
command_id=1,
exit_code=0,
)
obs.tool_call_metadata = ToolCallMetadata(
tool_call_id='test_call_id',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=1,
)
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
# Process events
messages = conversation_memory.process_events(
condensed_history=[action, obs], # Include both action and observation
initial_user_action=initial_user_message,
max_message_chars=None,
vision_is_active=False,
)
# Verify that the message with empty content but tool calls is kept
assert len(messages) == 4 # System + initial user + assistant with tool call + tool response
assert messages[2].role == 'assistant' # Assistant message with tool call
assert not messages[2].content # Content should be empty
assert messages[2].tool_calls # But tool_calls should be present
assert messages[2].tool_calls[0].id == 'test_call_id'
assert messages[3].role == 'tool' # Tool response
assert messages[3].tool_call_id == 'test_call_id'
assert messages[3].content[0].text == 'Command output'
def test_process_events_with_empty_content_no_tool_calls(conversation_memory):
"""Test that empty string content is filtered out when there are no tool calls."""
# Create a mock ModelResponse with empty string content and no tool calls
mock_response = {
'id': 'mock_response_id',
'choices': [{'message': {'content': '', 'tool_calls': []}}],
'created': 0,
'model': '',
'object': '',
'usage': {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0},
}
# Create an action with empty content and no tool calls
action = MessageAction(content='')
action._source = EventSource.AGENT
action.tool_call_metadata = ToolCallMetadata(
tool_call_id='test_call_id',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=0,
)
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
# Process events
messages = conversation_memory.process_events(
condensed_history=[action],
initial_user_action=initial_user_message,
max_message_chars=None,
vision_is_active=False,
)
# Verify that the empty content message is filtered out
assert len(messages) == 2 # Only system message and initial user message
assert messages[0].role == 'system'
assert messages[1].role == 'user'
assert messages[1].content[0].text == 'Initial user message'
class TestFilterUnmatchedToolCalls:
@pytest.fixture
def processor(self):
return ConversationMemory()
def processor(self, agent_config):
prompt_manager = MagicMock(spec=PromptManager)
prompt_manager.get_system_message.return_value = 'System message'
prompt_manager.build_workspace_context.return_value = (
'Formatted repository and runtime info'
)
return ConversationMemory(agent_config, prompt_manager)
def test_empty_is_unchanged(self):
assert list(ConversationMemory._filter_unmatched_tool_calls([])) == []