diff --git a/mem0/memory/utils.py b/mem0/memory/utils.py index 253f6b4b4..dbfd3384d 100644 --- a/mem0/memory/utils.py +++ b/mem0/memory/utils.py @@ -61,12 +61,18 @@ def ensure_json_instruction(system_prompt, user_prompt): def parse_messages(messages): response = "" for msg in messages: - if msg["role"] == "system": - response += f"system: {msg['content']}\n" - if msg["role"] == "user": - response += f"user: {msg['content']}\n" - if msg["role"] == "assistant": - response += f"assistant: {msg['content']}\n" + role = msg.get("role") + content = msg.get("content") + # Skip messages without textual content (e.g. assistant tool-call + # messages that carry `tool_calls` but no `content` key). + if content is None: + continue + if role == "system": + response += f"system: {content}\n" + elif role == "user": + response += f"user: {content}\n" + elif role == "assistant": + response += f"assistant: {content}\n" return response @@ -173,12 +179,19 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"): """ returned_messages = [] for msg in messages: - if msg["role"] == "system": + role = msg.get("role") + content = msg.get("content") + if role == "system": returned_messages.append(msg) continue + # Skip messages without content (e.g. assistant tool-call messages + # that carry `tool_calls` but no `content` key). + if content is None: + continue + # Handle message content - if isinstance(msg["content"], list): + if isinstance(content, list): if llm is None: text_parts = [ part["text"] for part in msg["content"] @@ -186,17 +199,17 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"): ] if not text_parts: continue - returned_messages.append({"role": msg["role"], "content": " ".join(text_parts)}) + returned_messages.append({"role": role, "content": " ".join(text_parts)}) else: description = get_image_description(msg, llm, vision_details) - returned_messages.append({"role": msg["role"], "content": description}) - elif isinstance(msg["content"], dict) and msg["content"].get("type") == "image_url": + returned_messages.append({"role": role, "content": description}) + elif isinstance(content, dict) and content.get("type") == "image_url": if llm is None: continue - image_url = msg["content"]["image_url"]["url"] + image_url = content["image_url"]["url"] try: description = get_image_description(image_url, llm, vision_details) - returned_messages.append({"role": msg["role"], "content": description}) + returned_messages.append({"role": role, "content": description}) except Exception: raise Exception(f"Error while downloading {image_url}.") else: diff --git a/tests/memory/test_memory_utils.py b/tests/memory/test_memory_utils.py index 8cf39475a..820729133 100644 --- a/tests/memory/test_memory_utils.py +++ b/tests/memory/test_memory_utils.py @@ -1,10 +1,50 @@ import pytest from unittest.mock import Mock -from mem0.memory.utils import parse_vision_messages, remove_spaces_from_entities, sanitize_relationship_for_cypher +from mem0.memory.utils import ( + parse_messages, + parse_vision_messages, + remove_spaces_from_entities, + sanitize_relationship_for_cypher, +) + + +class TestParseMessages: + def test_skips_message_without_content_key(self): + # Reproduces #5067: a FunctionCalling assistant message carries + # `tool_calls` but no `content` key -> used to raise KeyError. + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "tool_calls": [{"id": "1", "function": {"name": "search"}}]}, + {"role": "assistant", "content": "done"}, + ] + result = parse_messages(messages) + assert result == "user: hi\nassistant: done\n" + + def test_skips_explicit_none_content(self): + messages = [{"role": "assistant", "content": None}, {"role": "user", "content": "ok"}] + assert parse_messages(messages) == "user: ok\n" + + def test_plain_roles_pass_through(self): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "u"}, + {"role": "assistant", "content": "a"}, + ] + assert parse_messages(messages) == "system: sys\nuser: u\nassistant: a\n" class TestParseVisionMessages: + def test_skips_message_without_content_key(self): + # Reproduces #5067 for the vision parser path. + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "tool_calls": [{"id": "1", "function": {"name": "search"}}]}, + ] + result = parse_vision_messages(messages, llm=None) + assert len(result) == 1 + assert result[0] == {"role": "user", "content": "hi"} + def test_multimodal_list_without_llm_extracts_text(self): messages = [ {"role": "user", "content": [