diff --git a/letta/services/message_manager.py b/letta/services/message_manager.py index c3580bbc..91a9b71f 100644 --- a/letta/services/message_manager.py +++ b/letta/services/message_manager.py @@ -163,6 +163,8 @@ class MessageManager: # Fall back to direct attribute access for types without .to_text() or that return None if hasattr(content_item, "text") and content_item.text: extracted_text = content_item.text + elif hasattr(content_item, "reasoning") and content_item.reasoning: + extracted_text = content_item.reasoning elif hasattr(content_item, "content") and content_item.content: extracted_text = content_item.content @@ -256,11 +258,35 @@ class MessageManager: and any(tc.id == next_msg.tool_call_id for tc in current_msg.tool_calls) ): # combine the messages - get raw content to avoid double-processing - assistant_text = current_msg.content[0].text if current_msg.content else "" + if current_msg.content and len(current_msg.content) > 0: + # Use to_text() method or fall back to appropriate attribute + content_item = current_msg.content[0] + assistant_text = content_item.to_text() if hasattr(content_item, "to_text") and content_item.to_text() else "" + if not assistant_text: + if hasattr(content_item, "text"): + assistant_text = content_item.text or "" + elif hasattr(content_item, "reasoning"): + assistant_text = content_item.reasoning or "" + elif hasattr(content_item, "content"): + assistant_text = content_item.content or "" + else: + assistant_text = "" # for non-send_message tools, include tool result if next_msg.name != DEFAULT_MESSAGE_TOOL: - tool_result_text = next_msg.content[0].text if next_msg.content else "" + if next_msg.content and len(next_msg.content) > 0: + # Use to_text() method or fall back to appropriate attribute + content_item = next_msg.content[0] + tool_result_text = content_item.to_text() if hasattr(content_item, "to_text") and content_item.to_text() else "" + if not tool_result_text: + if hasattr(content_item, "text"): + tool_result_text = content_item.text or "" + elif hasattr(content_item, "reasoning"): + tool_result_text = content_item.reasoning or "" + elif hasattr(content_item, "content"): + tool_result_text = content_item.content or "" + else: + tool_result_text = "" # get the tool call that matches this result (we know it exists from the condition above) matching_tool_call = next((tc for tc in current_msg.tool_calls if tc.id == next_msg.tool_call_id), None) diff --git a/tests/integration_test_turbopuffer.py b/tests/integration_test_turbopuffer.py index efa8e515..6387d95a 100644 --- a/tests/integration_test_turbopuffer.py +++ b/tests/integration_test_turbopuffer.py @@ -801,7 +801,7 @@ def test_should_use_tpuf_for_messages_settings(): def test_message_text_extraction(server, default_user): - """Test extraction of text from various message content structures""" + """Test extraction of text from various message content structures including ReasoningContent""" manager = server.message_manager # Test 1: List with single string-like TextContent @@ -876,6 +876,76 @@ def test_message_text_extraction(server, default_user): == '{"content": "User said: Tool call: search({\\n \\"query\\": \\"test\\"\\n}) Tool result: Found 5 results I should help the user"}' ) + # Test 7: ReasoningContent only (edge case) + msg7 = PydanticMessage( + role=MessageRole.assistant, + content=[ReasoningContent(is_native=True, reasoning="This is my internal reasoning process", signature="reasoning-abc123")], + agent_id="test-agent", + ) + text7 = manager._extract_message_text(msg7) + assert "This is my internal reasoning process" in text7 + + # Test 8: ReasoningContent with empty reasoning (should handle gracefully) + msg8 = PydanticMessage( + role=MessageRole.assistant, + content=[ + ReasoningContent( + is_native=True, + reasoning="", # Empty reasoning + signature="empty-reasoning", + ), + TextContent(text="But I have text content"), + ], + agent_id="test-agent", + ) + text8 = manager._extract_message_text(msg8) + assert "But I have text content" in text8 + + # Test 9: Multiple ReasoningContent items + msg9 = PydanticMessage( + role=MessageRole.assistant, + content=[ + ReasoningContent(is_native=True, reasoning="First thought", signature="step-1"), + ReasoningContent(is_native=True, reasoning="Second thought", signature="step-2"), + TextContent(text="Final answer"), + ], + agent_id="test-agent", + ) + text9 = manager._extract_message_text(msg9) + assert "First thought" in text9 + assert "Second thought" in text9 + assert "Final answer" in text9 + + # Test 10: ReasoningContent in _combine_assistant_tool_messages + assistant_with_reasoning = PydanticMessage( + id="message-c19dbdc7-ba2f-4bf2-a469-64b5aed2c01d", + role=MessageRole.assistant, + content=[ReasoningContent(is_native=True, reasoning="I need to search for information", signature="reasoning-xyz")], + agent_id="test-agent", + tool_calls=[ + {"id": "call-456", "type": "function", "function": {"name": "web_search", "arguments": '{"query": "Python tutorials"}'}} + ], + ) + + tool_response = PydanticMessage( + id="message-16134e76-40fa-48dd-92a8-3e0d9256d79a", + role=MessageRole.tool, + name="web_search", + tool_call_id="call-456", + content=[TextContent(text="Found 10 Python tutorials")], + agent_id="test-agent", + ) + + # Test that combination preserves reasoning content + combined_msgs = manager._combine_assistant_tool_messages([assistant_with_reasoning, tool_response]) + assert len(combined_msgs) == 1 + combined_text = combined_msgs[0].content[0].text + + # Should contain the reasoning text + assert "search for information" in combined_text or "I need to" in combined_text + assert "web_search" in combined_text + assert "Found 10 Python tutorials" in combined_text + @pytest.mark.asyncio @pytest.mark.skipif(not settings.tpuf_api_key, reason="Turbopuffer API key not configured")