From eddd167f4317b147cf90671d577c90bca1421a66 Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 12 Mar 2025 10:58:31 -0700 Subject: [PATCH] chore: remove message.text property (#1253) --- letta/agent.py | 24 +++-- letta/agents/low_latency_agent.py | 5 +- letta/functions/function_sets/base.py | 2 +- letta/llm_api/cohere.py | 2 +- letta/llm_api/llm_api_tools.py | 1 - letta/memory.py | 2 +- letta/schemas/message.py | 130 +++++++++++++----------- letta/server/server.py | 9 +- letta/services/summarizer/summarizer.py | 2 +- tests/integration_test_multi_agent.py | 4 +- tests/integration_test_summarizer.py | 6 +- tests/test_client_legacy.py | 2 +- tests/test_local_client.py | 2 +- tests/test_managers.py | 24 ++--- tests/test_server.py | 14 +-- tests/utils.py | 7 +- 16 files changed, 127 insertions(+), 109 deletions(-) diff --git a/letta/agent.py b/letta/agent.py index 023157d0..065dc717 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -38,7 +38,7 @@ from letta.orm.enums import ToolType from letta.schemas.agent import AgentState, AgentStepResponse, UpdateAgent from letta.schemas.block import BlockUpdate from letta.schemas.embedding_config import EmbeddingConfig -from letta.schemas.enums import MessageRole +from letta.schemas.enums import MessageContentType, MessageRole from letta.schemas.memory import ContextWindowOverview, Memory from letta.schemas.message import Message, ToolReturn from letta.schemas.openai.chat_completion_response import ChatCompletionResponse @@ -154,13 +154,14 @@ class Agent(BaseAgent): in_context_messages = self.agent_manager.get_in_context_messages(agent_id=self.agent_state.id, actor=self.user) for i in range(len(in_context_messages) - 1, -1, -1): msg = in_context_messages[i] - if msg.role == MessageRole.tool and msg.text: + if msg.role == MessageRole.tool and msg.content and len(msg.content) == 1 and msg.content[0].type == MessageContentType.text: + text_content = msg.content[0].text try: - response_json = json.loads(msg.text) + response_json = json.loads(text_content) if response_json.get("message"): return response_json["message"] except (json.JSONDecodeError, KeyError): - raise ValueError(f"Invalid JSON format in message: {msg.text}") + raise ValueError(f"Invalid JSON format in message: {text_content}") return None def update_memory_if_changed(self, new_memory: Memory) -> bool: @@ -1010,7 +1011,7 @@ class Agent(BaseAgent): err_msg, details={ "num_in_context_messages": len(self.agent_state.message_ids), - "in_context_messages_text": [m.text for m in in_context_messages], + "in_context_messages_text": [m.content for m in in_context_messages], "token_counts": token_counts, }, ) @@ -1164,14 +1165,17 @@ class Agent(BaseAgent): if ( len(in_context_messages) > 1 and in_context_messages[1].role == MessageRole.user - and isinstance(in_context_messages[1].text, str) + and in_context_messages[1].content + and len(in_context_messages[1].content) == 1 + and in_context_messages[1].content[0].type == MessageContentType.text # TODO remove hardcoding - and "The following is a summary of the previous " in in_context_messages[1].text + and "The following is a summary of the previous " in in_context_messages[1].content[0].text ): # Summary message exists - assert in_context_messages[1].text is not None - summary_memory = in_context_messages[1].text - num_tokens_summary_memory = count_tokens(in_context_messages[1].text) + text_content = in_context_messages[1].content[0].text + assert text_content is not None + summary_memory = text_content + num_tokens_summary_memory = count_tokens(text_content) # with a summary message, the real messages start at index 2 num_tokens_messages = ( num_tokens_from_messages(messages=in_context_messages_openai[2:], model=self.model) diff --git a/letta/agents/low_latency_agent.py b/letta/agents/low_latency_agent.py index d5d96f23..5d43b1e7 100644 --- a/letta/agents/low_latency_agent.py +++ b/letta/agents/low_latency_agent.py @@ -237,7 +237,8 @@ class LowLatencyAgent(BaseAgent): # TODO: This is a pretty brittle pattern established all over our code, need to get rid of this curr_system_message = in_context_messages[0] curr_memory_str = agent_state.memory.compile() - if curr_memory_str in curr_system_message.text: + curr_system_message_text = curr_system_message.content[0].text + if curr_memory_str in curr_system_message_text: # NOTE: could this cause issues if a block is removed? (substring match would still work) logger.debug( f"Memory hasn't changed for agent id={agent_state.id} and actor=({self.actor.id}, {self.actor.name}), skipping system prompt rebuild" @@ -251,7 +252,7 @@ class LowLatencyAgent(BaseAgent): in_context_memory_last_edit=memory_edit_timestamp, ) - diff = united_diff(curr_system_message.text, new_system_message_str) + diff = united_diff(curr_system_message_text, new_system_message_str) if len(diff) > 0: logger.info(f"Rebuilding system with new memory...\nDiff:\n{diff}") diff --git a/letta/functions/function_sets/base.py b/letta/functions/function_sets/base.py index 590f79c8..ef32e65a 100644 --- a/letta/functions/function_sets/base.py +++ b/letta/functions/function_sets/base.py @@ -56,7 +56,7 @@ def conversation_search(self: "Agent", query: str, page: Optional[int] = 0) -> O results_str = f"No results found." else: results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):" - results_formatted = [message.text for message in messages] + results_formatted = [message.content[0].text for message in messages] results_str = f"{results_pref} {json_dumps(results_formatted)}" return results_str diff --git a/letta/llm_api/cohere.py b/letta/llm_api/cohere.py index 5ee818f4..b154d8ff 100644 --- a/letta/llm_api/cohere.py +++ b/letta/llm_api/cohere.py @@ -321,7 +321,7 @@ def cohere_chat_completions_request( # See: https://docs.cohere.com/reference/chat # The chat_history parameter should not be used for SYSTEM messages in most cases. Instead, to add a SYSTEM role message at the beginning of a conversation, the preamble parameter should be used. assert msg_objs[0].role == "system", msg_objs[0] - preamble = msg_objs[0].text + preamble = msg_objs[0].content[0].text # data["messages"] = [m.to_cohere_dict() for m in msg_objs[1:]] data["messages"] = [] diff --git a/letta/llm_api/llm_api_tools.py b/letta/llm_api/llm_api_tools.py index 1d8a4af7..abfeffbf 100644 --- a/letta/llm_api/llm_api_tools.py +++ b/letta/llm_api/llm_api_tools.py @@ -596,7 +596,6 @@ def create( messages[0].content[ 0 ].text += f'Select best function to call simply by responding with a single json block with the keys "function" and "params". Use double quotes around the arguments.' - return get_chat_completion( model=llm_config.model, messages=messages, diff --git a/letta/memory.py b/letta/memory.py index e997be61..c260eda1 100644 --- a/letta/memory.py +++ b/letta/memory.py @@ -36,7 +36,7 @@ def get_memory_functions(cls: Memory) -> Dict[str, Callable]: def _format_summary_history(message_history: List[Message]): # TODO use existing prompt formatters for this (eg ChatML) - return "\n".join([f"{m.role}: {m.text}" for m in message_history]) + return "\n".join([f"{m.role}: {m.content[0].text}" for m in message_history]) def summarize_messages( diff --git a/letta/schemas/message.py b/letta/schemas/message.py index 7cf66366..f182422a 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -158,19 +158,6 @@ class Message(BaseMessage): del data["content"] return data - @property - def text(self) -> Optional[str]: - """ - Retrieve the first text content's text. - - Returns: - str: The text content, or None if no text content exists - """ - if not self.content: - return None - text_content = [content.text for content in self.content if content.type == MessageContentType.text] - return text_content[0] if text_content else None - def to_json(self): json_message = vars(self) if json_message["tool_calls"] is not None: @@ -227,17 +214,21 @@ class Message(BaseMessage): assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG, ) -> List[LettaMessage]: """Convert message object (in DB format) to the style used by the original Letta API""" + if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text: + text_content = self.content[0].text + else: + text_content = None messages = [] if self.role == MessageRole.assistant: - if self.text is not None: + if text_content is not None: # This is type InnerThoughts messages.append( ReasoningMessage( id=self.id, date=self.created_at, - reasoning=self.text, + reasoning=text_content, ) ) if self.tool_calls is not None: @@ -281,9 +272,9 @@ class Message(BaseMessage): # "message": response_string, # "time": formatted_time, # } - assert self.text is not None, self + assert text_content is not None, self try: - function_return = json.loads(self.text) + function_return = json.loads(text_content) status = function_return["status"] if status == "OK": status_enum = "success" @@ -292,7 +283,7 @@ class Message(BaseMessage): else: raise ValueError(f"Invalid status: {status}") except json.JSONDecodeError: - raise ValueError(f"Failed to decode function return: {self.text}") + raise ValueError(f"Failed to decode function return: {text_content}") assert self.tool_call_id is not None messages.append( # TODO make sure this is what the API returns @@ -300,7 +291,7 @@ class Message(BaseMessage): ToolReturnMessage( id=self.id, date=self.created_at, - tool_return=self.text, + tool_return=text_content, status=self.tool_returns[0].status if self.tool_returns else status_enum, tool_call_id=self.tool_call_id, stdout=self.tool_returns[0].stdout if self.tool_returns else None, @@ -309,23 +300,23 @@ class Message(BaseMessage): ) elif self.role == MessageRole.user: # This is type UserMessage - assert self.text is not None, self - message_str = unpack_message(self.text) + assert text_content is not None, self + message_str = unpack_message(text_content) messages.append( UserMessage( id=self.id, date=self.created_at, - content=message_str or self.text, + content=message_str or text_content, ) ) elif self.role == MessageRole.system: # This is type SystemMessage - assert self.text is not None, self + assert text_content is not None, self messages.append( SystemMessage( id=self.id, date=self.created_at, - content=self.text, + content=text_content, ) ) else: @@ -494,11 +485,15 @@ class Message(BaseMessage): """Go from Message class to ChatCompletion message object""" # TODO change to pydantic casting, eg `return SystemMessageModel(self)` + if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text: + text_content = self.content[0].text + else: + text_content = None if self.role == "system": assert all([v is not None for v in [self.role]]), vars(self) openai_message = { - "content": self.text, + "content": text_content, "role": self.role, } # Optional field, do not include if null @@ -506,9 +501,9 @@ class Message(BaseMessage): openai_message["name"] = self.name elif self.role == "user": - assert all([v is not None for v in [self.text, self.role]]), vars(self) + assert all([v is not None for v in [text_content, self.role]]), vars(self) openai_message = { - "content": self.text, + "content": text_content, "role": self.role, } # Optional field, do not include if null @@ -516,9 +511,9 @@ class Message(BaseMessage): openai_message["name"] = self.name elif self.role == "assistant": - assert self.tool_calls is not None or self.text is not None + assert self.tool_calls is not None or text_content is not None openai_message = { - "content": None if put_inner_thoughts_in_kwargs else self.text, + "content": None if put_inner_thoughts_in_kwargs else text_content, "role": self.role, } # Optional fields, do not include if null @@ -530,7 +525,7 @@ class Message(BaseMessage): openai_message["tool_calls"] = [ add_inner_thoughts_to_tool_call( tool_call, - inner_thoughts=self.text, + inner_thoughts=text_content, inner_thoughts_key=INNER_THOUGHTS_KWARG, ).model_dump() for tool_call in self.tool_calls @@ -544,7 +539,7 @@ class Message(BaseMessage): elif self.role == "tool": assert all([v is not None for v in [self.role, self.tool_call_id]]), vars(self) openai_message = { - "content": self.text, + "content": text_content, "role": self.role, "tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id, } @@ -565,6 +560,10 @@ class Message(BaseMessage): Args: inner_thoughts_xml_tag (str): The XML tag to wrap around inner thoughts """ + if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text: + text_content = self.content[0].text + else: + text_content = None def add_xml_tag(string: str, xml_tag: Optional[str]): # NOTE: Anthropic docs recommends using tag when using CoT + tool use @@ -573,34 +572,34 @@ class Message(BaseMessage): if self.role == "system": # NOTE: this is not for system instructions, but instead system "events" - assert all([v is not None for v in [self.text, self.role]]), vars(self) + assert all([v is not None for v in [text_content, self.role]]), vars(self) # Two options here, we would use system.package_system_message, # or use a more Anthropic-specific packaging ie xml tags - user_system_event = add_xml_tag(string=f"SYSTEM ALERT: {self.text}", xml_tag="event") + user_system_event = add_xml_tag(string=f"SYSTEM ALERT: {text_content}", xml_tag="event") anthropic_message = { "content": user_system_event, "role": "user", } elif self.role == "user": - assert all([v is not None for v in [self.text, self.role]]), vars(self) + assert all([v is not None for v in [text_content, self.role]]), vars(self) anthropic_message = { - "content": self.text, + "content": text_content, "role": self.role, } elif self.role == "assistant": - assert self.tool_calls is not None or self.text is not None + assert self.tool_calls is not None or text_content is not None anthropic_message = { "role": self.role, } content = [] # COT / reasoning / thinking - if self.text is not None and not put_inner_thoughts_in_kwargs: + if text_content is not None and not put_inner_thoughts_in_kwargs: content.append( { "type": "text", - "text": add_xml_tag(string=self.text, xml_tag=inner_thoughts_xml_tag), + "text": add_xml_tag(string=text_content, xml_tag=inner_thoughts_xml_tag), } ) # Tool calling @@ -610,7 +609,7 @@ class Message(BaseMessage): if put_inner_thoughts_in_kwargs: tool_call_input = add_inner_thoughts_to_tool_call( tool_call, - inner_thoughts=self.text, + inner_thoughts=text_content, inner_thoughts_key=INNER_THOUGHTS_KWARG, ).model_dump() else: @@ -639,7 +638,7 @@ class Message(BaseMessage): { "type": "tool_result", "tool_use_id": self.tool_call_id, - "content": self.text, + "content": text_content, } ], } @@ -656,6 +655,10 @@ class Message(BaseMessage): # type Content: https://ai.google.dev/api/rest/v1/Content / https://ai.google.dev/api/rest/v1beta/Content # parts[]: Part # role: str ('user' or 'model') + if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text: + text_content = self.content[0].text + else: + text_content = None if self.role != "tool" and self.name is not None: warnings.warn(f"Using Google AI with non-null 'name' field ({self.name}) not yet supported.") @@ -665,18 +668,18 @@ class Message(BaseMessage): # https://www.reddit.com/r/Bard/comments/1b90i8o/does_gemini_have_a_system_prompt_option_while/ google_ai_message = { "role": "user", # NOTE: no 'system' - "parts": [{"text": self.text}], + "parts": [{"text": text_content}], } elif self.role == "user": - assert all([v is not None for v in [self.text, self.role]]), vars(self) + assert all([v is not None for v in [text_content, self.role]]), vars(self) google_ai_message = { "role": "user", - "parts": [{"text": self.text}], + "parts": [{"text": text_content}], } elif self.role == "assistant": - assert self.tool_calls is not None or self.text is not None + assert self.tool_calls is not None or text_content is not None google_ai_message = { "role": "model", # NOTE: different } @@ -684,10 +687,10 @@ class Message(BaseMessage): # NOTE: Google AI API doesn't allow non-null content + function call # To get around this, just two a two part message, inner thoughts first then parts = [] - if not put_inner_thoughts_in_kwargs and self.text is not None: + if not put_inner_thoughts_in_kwargs and text_content is not None: # NOTE: ideally we do multi-part for CoT / inner thoughts + function call, but Google AI API doesn't allow it raise NotImplementedError - parts.append({"text": self.text}) + parts.append({"text": text_content}) if self.tool_calls is not None: # NOTE: implied support for multiple calls @@ -701,10 +704,10 @@ class Message(BaseMessage): raise UserWarning(f"Failed to parse JSON function args: {function_args}") function_args = {"args": function_args} - if put_inner_thoughts_in_kwargs and self.text is not None: + if put_inner_thoughts_in_kwargs and text_content is not None: assert "inner_thoughts" not in function_args, function_args assert len(self.tool_calls) == 1 - function_args[INNER_THOUGHTS_KWARG] = self.text + function_args[INNER_THOUGHTS_KWARG] = text_content parts.append( { @@ -715,8 +718,8 @@ class Message(BaseMessage): } ) else: - assert self.text is not None - parts.append({"text": self.text}) + assert text_content is not None + parts.append({"text": text_content}) google_ai_message["parts"] = parts elif self.role == "tool": @@ -731,9 +734,9 @@ class Message(BaseMessage): # NOTE: Google AI API wants the function response as JSON only, no string try: - function_response = json.loads(self.text) + function_response = json.loads(text_content) except: - function_response = {"function_response": self.text} + function_response = {"function_response": text_content} google_ai_message = { "role": "function", @@ -778,7 +781,10 @@ class Message(BaseMessage): # TODO: update this prompt style once guidance from Cohere on # embedded function calls in multi-turn conversation become more clear - + if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text: + text_content = self.content[0].text + else: + text_content = None if self.role == "system": """ The chat_history parameter should not be used for SYSTEM messages in most cases. @@ -787,26 +793,26 @@ class Message(BaseMessage): raise UserWarning(f"role 'system' messages should go in 'preamble' field for Cohere API") elif self.role == "user": - assert all([v is not None for v in [self.text, self.role]]), vars(self) + assert all([v is not None for v in [text_content, self.role]]), vars(self) cohere_message = [ { "role": "USER", - "message": self.text, + "message": text_content, } ] elif self.role == "assistant": # NOTE: we may break this into two message - an inner thought and a function call # Optionally, we could just make this a function call with the inner thought inside - assert self.tool_calls is not None or self.text is not None + assert self.tool_calls is not None or text_content is not None - if self.text and self.tool_calls: + if text_content and self.tool_calls: if inner_thoughts_as_kwarg: raise NotImplementedError cohere_message = [ { "role": "CHATBOT", - "message": self.text, + "message": text_content, }, ] for tc in self.tool_calls: @@ -820,7 +826,7 @@ class Message(BaseMessage): "message": f"{function_call_prefix} {function_call_text}", } ) - elif not self.text and self.tool_calls: + elif not text_content and self.tool_calls: cohere_message = [] for tc in self.tool_calls: # TODO better way to pack? @@ -831,11 +837,11 @@ class Message(BaseMessage): "message": f"{function_call_prefix} {function_call_text}", } ) - elif self.text and not self.tool_calls: + elif text_content and not self.tool_calls: cohere_message = [ { "role": "CHATBOT", - "message": self.text, + "message": text_content, } ] else: @@ -843,7 +849,7 @@ class Message(BaseMessage): elif self.role == "tool": assert all([v is not None for v in [self.role, self.tool_call_id]]), vars(self) - function_response_text = self.text + function_response_text = text_content cohere_message = [ { "role": function_response_role, diff --git a/letta/server/server.py b/letta/server/server.py index 8afee322..6dfa5057 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -601,11 +601,12 @@ class SyncServer(Server): if isinstance(message, Message): # Can't have a null text field - if message.text is None or len(message.text) == 0: - raise ValueError(f"Invalid input: '{message.text}'") + message_text = message.content[0].text + if message_text is None or len(message_text) == 0: + raise ValueError(f"Invalid input: '{message_text}'") # If the input begins with a command prefix, reject - elif message.text.startswith("/"): - raise ValueError(f"Invalid input: '{message.text}'") + elif message_text.startswith("/"): + raise ValueError(f"Invalid input: '{message_text}'") else: raise TypeError(f"Invalid input: '{message}' - type {type(message)}") diff --git a/letta/services/summarizer/summarizer.py b/letta/services/summarizer/summarizer.py index 7932aa56..a726ce01 100644 --- a/letta/services/summarizer/summarizer.py +++ b/letta/services/summarizer/summarizer.py @@ -96,7 +96,7 @@ class Summarizer: ) messages = await self.summarizer_agent.step(UserMessage(content=summary_request_text)) - current_summary = "\n".join([m.text for m in messages]) + current_summary = "\n".join([m.content[0].text for m in messages]) current_summary = f"{self.summary_prefix}{current_summary}" return updated_in_context_messages, current_summary, True diff --git a/tests/integration_test_multi_agent.py b/tests/integration_test_multi_agent.py index 30413d69..b4ff28cc 100644 --- a/tests/integration_test_multi_agent.py +++ b/tests/integration_test_multi_agent.py @@ -112,11 +112,11 @@ def test_send_message_to_agent(client, agent_obj, other_agent_obj): target_snippet = f"{other_agent_obj.agent_state.id} said:" for m in in_context_messages: - if target_snippet in m.text: + if target_snippet in m.content[0].text: found = True break - print(f"In context messages of the sender agent (without system):\n\n{"\n".join([m.text for m in in_context_messages[1:]])}") + print(f"In context messages of the sender agent (without system):\n\n{"\n".join([m.content[0].text for m in in_context_messages[1:]])}") if not found: raise Exception(f"Was not able to find an instance of the target snippet: {target_snippet}") diff --git a/tests/integration_test_summarizer.py b/tests/integration_test_summarizer.py index 606600aa..3c34b0a0 100644 --- a/tests/integration_test_summarizer.py +++ b/tests/integration_test_summarizer.py @@ -192,8 +192,8 @@ def test_auto_summarize(client, mock_e2b_api_key_none): def summarize_message_exists(messages: List[Message]) -> bool: for message in messages: - if message.text and "The following is a summary of the previous" in message.text: - print(f"Summarize message found after {message_count} messages: \n {message.text}") + if message.content[0].text and "The following is a summary of the previous" in message.content[0].text: + print(f"Summarize message found after {message_count} messages: \n {message.content[0].text}") return True return False @@ -277,4 +277,4 @@ def test_summarizer(config_filename, client, agent_state): # Invoke a summarize letta_agent.summarize_messages_inplace() in_context_messages = client.get_in_context_messages(agent_state.id) - assert SUMMARY_KEY_PHRASE in in_context_messages[1].text, f"Test failed for config: {config_filename}" + assert SUMMARY_KEY_PHRASE in in_context_messages[1].content[0].text, f"Test failed for config: {config_filename}" diff --git a/tests/test_client_legacy.py b/tests/test_client_legacy.py index 3a744fc2..3a3a1b3e 100644 --- a/tests/test_client_legacy.py +++ b/tests/test_client_legacy.py @@ -628,7 +628,7 @@ def test_initial_message_sequence(client: Union[LocalClient, RESTClient], agent: ), f"Expected {len(custom_sequence) + 1} messages, got {len(custom_agent_state.message_ids)}" # assert custom_agent_state.message_ids[1:] == [msg.id for msg in custom_sequence] # shoule be contained in second message (after system message) - assert custom_sequence[0].content in client.get_in_context_messages(custom_agent_state.id)[1].text + assert custom_sequence[0].content in client.get_in_context_messages(custom_agent_state.id)[1].content[0].text def test_add_and_manage_tags_for_agent(client: Union[LocalClient, RESTClient], agent: AgentState): diff --git a/tests/test_local_client.py b/tests/test_local_client.py index 7ea1c74d..0bd9a140 100644 --- a/tests/test_local_client.py +++ b/tests/test_local_client.py @@ -262,7 +262,7 @@ def test_recall_memory(client: LocalClient, agent: AgentState): in_context_messages = client.get_in_context_messages(agent.id) exists = False for m in in_context_messages: - if message_str in m.text: + if message_str in m.content[0].text: exists = True assert exists diff --git a/tests/test_managers.py b/tests/test_managers.py index 49744d62..ed86aa01 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -560,11 +560,11 @@ def test_create_agent_passed_in_initial_messages(server: SyncServer, default_use assert server.message_manager.size(agent_id=agent_state.id, actor=default_user) == 2 init_messages = server.agent_manager.get_in_context_messages(agent_id=agent_state.id, actor=default_user) # Check that the system appears in the first initial message - assert create_agent_request.system in init_messages[0].text - assert create_agent_request.memory_blocks[0].value in init_messages[0].text + assert create_agent_request.system in init_messages[0].content[0].text + assert create_agent_request.memory_blocks[0].value in init_messages[0].content[0].text # Check that the second message is the passed in initial message seq assert create_agent_request.initial_message_sequence[0].role == init_messages[1].role - assert create_agent_request.initial_message_sequence[0].content in init_messages[1].text + assert create_agent_request.initial_message_sequence[0].content in init_messages[1].content[0].text def test_create_agent_default_initial_message(server: SyncServer, default_user, default_block): @@ -585,8 +585,8 @@ def test_create_agent_default_initial_message(server: SyncServer, default_user, assert server.message_manager.size(agent_id=agent_state.id, actor=default_user) == 4 init_messages = server.agent_manager.get_in_context_messages(agent_id=agent_state.id, actor=default_user) # Check that the system appears in the first initial message - assert create_agent_request.system in init_messages[0].text - assert create_agent_request.memory_blocks[0].value in init_messages[0].text + assert create_agent_request.system in init_messages[0].content[0].text + assert create_agent_request.memory_blocks[0].value in init_messages[0].content[0].text def test_update_agent(server: SyncServer, comprehensive_test_agent_fixture, other_tool, other_source, other_block, default_user): @@ -1915,7 +1915,7 @@ def test_upsert_base_tools(server: SyncServer, default_user): def test_message_create(server: SyncServer, hello_world_message_fixture, default_user): """Test creating a message using hello_world_message_fixture fixture""" assert hello_world_message_fixture.id is not None - assert hello_world_message_fixture.text == "Hello, world!" + assert hello_world_message_fixture.content[0].text == "Hello, world!" assert hello_world_message_fixture.role == "user" # Verify we can retrieve it @@ -1925,7 +1925,7 @@ def test_message_create(server: SyncServer, hello_world_message_fixture, default ) assert retrieved is not None assert retrieved.id == hello_world_message_fixture.id - assert retrieved.text == hello_world_message_fixture.text + assert retrieved.content[0].text == hello_world_message_fixture.content[0].text assert retrieved.role == hello_world_message_fixture.role @@ -1934,7 +1934,7 @@ def test_message_get_by_id(server: SyncServer, hello_world_message_fixture, defa retrieved = server.message_manager.get_message_by_id(hello_world_message_fixture.id, actor=default_user) assert retrieved is not None assert retrieved.id == hello_world_message_fixture.id - assert retrieved.text == hello_world_message_fixture.text + assert retrieved.content[0].text == hello_world_message_fixture.content[0].text def test_message_update(server: SyncServer, hello_world_message_fixture, default_user, other_user): @@ -1942,9 +1942,9 @@ def test_message_update(server: SyncServer, hello_world_message_fixture, default new_text = "Updated text" updated = server.message_manager.update_message_by_id(hello_world_message_fixture.id, MessageUpdate(content=new_text), actor=other_user) assert updated is not None - assert updated.text == new_text + assert updated.content[0].text == new_text retrieved = server.message_manager.get_message_by_id(hello_world_message_fixture.id, actor=default_user) - assert retrieved.text == new_text + assert retrieved.content[0].text == new_text # Assert that orm metadata fields are populated assert retrieved.created_by_id == default_user.id @@ -2073,7 +2073,7 @@ def test_message_listing_text_search(server: SyncServer, hello_world_message_fix agent_id=sarah_agent.id, actor=default_user, query_text="Test message", limit=10 ) assert len(search_results) == 4 - assert all("Test message" in msg.text for msg in search_results) + assert all("Test message" in msg.content[0].text for msg in search_results) # Test no results search_results = server.message_manager.list_user_messages_for_agent( @@ -3056,7 +3056,7 @@ def test_job_messages_add(server: SyncServer, default_run, hello_world_message_f ) assert len(messages) == 1 assert messages[0].id == hello_world_message_fixture.id - assert messages[0].text == hello_world_message_fixture.text + assert messages[0].content[0].text == hello_world_message_fixture.content[0].text def test_job_messages_pagination(server: SyncServer, default_run, default_user, sarah_agent): diff --git a/tests/test_server.py b/tests/test_server.py index e5972724..bc9562e3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -662,7 +662,9 @@ def _test_get_messages_letta_format( print(f"Error: letta_message_index out of range. Expected more letta_messages for message {i}: {message.role}") raise ValueError(f"Mismatch in letta_messages length. Index: {letta_message_index}, Length: {len(letta_messages)}") - print(f"Processing message {i}: {message.role}, {message.text[:50] if message.text else 'null'}") + print( + f"Processing message {i}: {message.role}, {message.content[0].text[:50] if message.content and len(message.content) == 1 else 'null'}" + ) while letta_message_index < len(letta_messages): letta_message = letta_messages[letta_message_index] @@ -684,14 +686,14 @@ def _test_get_messages_letta_format( break letta_message = letta_messages[letta_message_index] - if message.text: + if message.content[0].text: assert isinstance(letta_message, ReasoningMessage) letta_message_index += 1 else: assert message.tool_calls is not None else: # Non-reverse handling - if message.text: + if message.content[0].text: assert isinstance(letta_message, ReasoningMessage) letta_message_index += 1 if letta_message_index >= len(letta_messages): @@ -714,17 +716,17 @@ def _test_get_messages_letta_format( elif message.role == MessageRole.user: assert isinstance(letta_message, UserMessage) - assert unpack_message(message.text) == letta_message.content + assert unpack_message(message.content[0].text) == letta_message.content letta_message_index += 1 elif message.role == MessageRole.system: assert isinstance(letta_message, SystemMessage) - assert message.text == letta_message.content + assert message.content[0].text == letta_message.content letta_message_index += 1 elif message.role == MessageRole.tool: assert isinstance(letta_message, ToolReturnMessage) - assert message.text == letta_message.tool_return + assert message.content[0].text == letta_message.tool_return letta_message_index += 1 else: diff --git a/tests/utils.py b/tests/utils.py index e16cd15a..6b27d804 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -11,6 +11,7 @@ from letta.config import LettaConfig from letta.data_sources.connectors import DataConnector from letta.schemas.enums import MessageRole from letta.schemas.file import FileMetadata +from letta.schemas.message import Message from letta.settings import TestSettings from .constants import TIMEOUT @@ -165,8 +166,12 @@ def wait_for_incoming_message( while time.time() < deadline: messages = client.server.message_manager.list_messages_for_agent(agent_id=agent_id, actor=client.user) + # Check for the system message containing `substring` - if any(message.role == MessageRole.system and substring in (message.text or "") for message in messages): + def get_message_text(message: Message) -> str: + return message.content[0].text if message.content and len(message.content) == 1 else "" + + if any(message.role == MessageRole.system and substring in get_message_text(message) for message in messages): return True time.sleep(sleep_interval)