From 208992170ce24e3dd184105a8ebb34f70e3be921 Mon Sep 17 00:00:00 2001 From: cthomas Date: Thu, 22 Jan 2026 17:03:35 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20gracefully=20skip=20assistant=20messages?= =?UTF-8?q?=20with=20empty=20content=20in=20LLM=20for=E2=80=A6=20(#9050)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: gracefully skip assistant messages with empty content in LLM format conversion **Problem:** Context window calculation crashed with AssertionError when converting messages to Google/Anthropic/OpenAI format: ``` AssertionError at line 2047: assert self.tool_calls is not None or text_content is not None or len(self.content) > 1 ``` This happened when loading agents with old/malformed messages that had `content=None` or `content=[]` in the database. **Root Cause:** The Message ORM model allows `content: Optional[List[...]] = None` (line 252), but format conversion methods assumed content would always have extractable text or tool calls. Scenarios that triggered crashes: 1. Assistant message with `content=None` (old migrations/edge cases) 2. Assistant message with `content=[]` (message creation bugs) 3. Assistant message with single non-text content that doesn't match extraction logic **Fix:** Replaced assertions with defensive checks in 3 conversion methods: 1. `to_google_dict()` (line 2054) - Return None to skip unconvertible messages 2. `to_openai_responses_api_dicts()` (line 1476) - Return early to skip 3. `to_anthropic_dict()` (line 1794) - Return None to skip Pattern: Check for empty content, return None/early to skip gracefully. **Result:** - Context window calculation no longer crashes on malformed/old messages - Messages with no convertible content are silently skipped - Consistent with existing Anthropic reasoning-only message handling (line 1308) 👾 Generated with [Letta Code](https://letta.com) Co-authored-by: Letta --- letta/schemas/message.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/letta/schemas/message.py b/letta/schemas/message.py index 102b8824..5520249f 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -1471,7 +1471,10 @@ class Message(BaseMessage): message_dicts.append(user_dict) elif self.role == "assistant" or self.role == "approval": - assert self.tool_calls is not None or (self.content is not None and len(self.content) > 0) + # Validate that message has content OpenAI Responses API can process + if self.tool_calls is None and (self.content is None or len(self.content) == 0): + # Skip this message (similar to Anthropic handling at line 1308) + return message_dicts # A few things may be in here, firstly reasoning content, secondly assistant messages, thirdly tool calls # TODO check if OpenAI Responses is capable of R->A->T like Anthropic? @@ -1787,8 +1790,11 @@ class Message(BaseMessage): } elif self.role == "assistant" or self.role == "approval": - # assert self.tool_calls is not None or text_content is not None, vars(self) - assert self.tool_calls is not None or len(self.content) > 0 + # Validate that message has content Anthropic API can process + if self.tool_calls is None and (self.content is None or len(self.content) == 0): + # Skip this message (consistent with OpenAI dict handling) + return None + anthropic_message = { "role": "assistant", } @@ -2044,7 +2050,16 @@ class Message(BaseMessage): } elif self.role == "assistant" or self.role == "approval": - assert self.tool_calls is not None or text_content is not None or len(self.content) > 1 + # Validate that message has content Google API can process + if self.tool_calls is None and text_content is None and len(self.content) <= 1: + # Message has no tool calls, no extractable text, and not multi-part + logger.warning( + f"Assistant/approval message {self.id} has no content Google API can convert: " + f"tool_calls={self.tool_calls}, text_content={text_content}, content={self.content}" + ) + # Return None to skip this message (similar to approval messages without tool_calls at line 1998) + return None + google_ai_message = { "role": "model", # NOTE: different }