diff --git a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/ParameterizedChatMemory.java b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/ParameterizedChatMemory.java
new file mode 100644
index 000000000..6d6257e63
--- /dev/null
+++ b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/ParameterizedChatMemory.java
@@ -0,0 +1,24 @@
+package org.springframework.ai.chat.client.advisor.vectorstore;
+
+import java.util.List;
+
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.messages.Message;
+
+/**
+ * Interface for chat memories that support parameterized retrieval. Implementations can
+ * define their own parameter types.
+ *
+ * @param
The type of parameters used for retrieval
+ */
+public interface ParameterizedChatMemory
extends ChatMemory {
+
+ /**
+ * Retrieve messages based on the provided parameters.
+ * @param conversationId The conversation identifier
+ * @param parameters The retrieval parameters
+ * @return List of retrieved messages
+ */
+ List retrieve(String conversationId, P parameters);
+
+}
diff --git a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorSearchParameters.java b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorSearchParameters.java
new file mode 100644
index 000000000..9b92ae0a9
--- /dev/null
+++ b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorSearchParameters.java
@@ -0,0 +1,37 @@
+package org.springframework.ai.chat.client.advisor.vectorstore;
+
+import org.springframework.ai.vectorstore.SearchRequest;
+
+/**
+ * Parameters for vector store similarity search with default values.
+ */
+public record VectorSearchParameters(int topK, String filter) {
+
+ public static final int DEFAULT_TOP_K = 100;
+
+ public VectorSearchParameters() {
+ this(VectorStoreChatMemoryAdvisor.DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, null);
+ }
+
+ public static VectorSearchParameters of(int topK) {
+ return new VectorSearchParameters(topK, null);
+ }
+
+ public static VectorSearchParameters forConversation(String conversationId) {
+ return new VectorSearchParameters(DEFAULT_TOP_K, "conversationId=='" + conversationId + "'");
+ }
+
+ public VectorSearchParameters withTopK(int topK) {
+ return new VectorSearchParameters(topK, this.filter);
+ }
+
+ /**
+ * Create a new instance with a different filter.
+ * @param filter the new filter expression
+ * @return a new VectorSearchParameters instance with the updated filter
+ */
+ public VectorSearchParameters withFilter(String filter) {
+ return new VectorSearchParameters(this.topK, filter);
+ }
+
+}
diff --git a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.java b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.java
index 4911868ac..27b5a351d 100644
--- a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.java
+++ b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.java
@@ -24,9 +24,9 @@ import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
-import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -52,10 +52,17 @@ import org.springframework.ai.vectorstore.VectorStore;
*/
public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor {
+ public static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_response_size";
+
private static final String DOCUMENT_METADATA_CONVERSATION_ID = "conversationId";
private static final String DOCUMENT_METADATA_MESSAGE_TYPE = "messageType";
+ /**
+ * The default chat memory retrieve size to use when no retrieve size is provided.
+ */
+ public static final int DEFAULT_CHAT_MEMORY_RESPONSE_SIZE = 100;
+
private static final PromptTemplate DEFAULT_SYSTEM_PROMPT_TEMPLATE = new PromptTemplate("""
{instructions}
@@ -69,71 +76,62 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor context) {
+ return context.containsKey(CHAT_MEMORY_RETRIEVE_SIZE_KEY)
+ ? Integer.parseInt(context.get(CHAT_MEMORY_RETRIEVE_SIZE_KEY).toString())
+ : this.defaultChatMemoryRetrieveSize;
}
@Override
- public Flux adviseStream(ChatClientRequest chatClientRequest,
- StreamAdvisorChain streamAdvisorChain) {
- Flux chatClientResponses = this.doNextWithProtectFromBlockingBefore(chatClientRequest,
- streamAdvisorChain, this::before);
-
- return new MessageAggregator().aggregateChatClientResponse(chatClientResponses, this::after);
- }
-
- private ChatClientRequest before(ChatClientRequest chatClientRequest) {
- String conversationId = this.doGetConversationId(chatClientRequest.context());
- int chatMemoryRetrieveSize = this.doGetChatMemoryRetrieveSize(chatClientRequest.context());
-
- // 1. Retrieve the chat memory for the current conversation.
- var searchRequest = SearchRequest.builder()
- .query(chatClientRequest.prompt().getUserMessage().getText())
- .topK(chatMemoryRetrieveSize)
- .filterExpression(DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'")
+ protected ChatClientRequest before(ChatClientRequest request, String conversationId) {
+ String query = request.prompt().getUserMessage() != null ? request.prompt().getUserMessage().getText() : "";
+ int topK = doGetChatMemoryRetrieveSize(request.context());
+ String filter = DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'";
+ var searchRequest = org.springframework.ai.vectorstore.SearchRequest.builder()
+ .query(query)
+ .topK(topK)
+ .filterExpression(filter)
.build();
+ java.util.List documents = this.getChatMemoryStore()
+ .similaritySearch(searchRequest);
- List documents = this.getChatMemoryStore().similaritySearch(searchRequest);
-
- // 2. Processed memory messages as a string.
String longTermMemory = documents == null ? ""
- : documents.stream().map(Document::getText).collect(Collectors.joining(System.lineSeparator()));
+ : documents.stream()
+ .map(org.springframework.ai.document.Document::getText)
+ .collect(java.util.stream.Collectors.joining(System.lineSeparator()));
- // 2. Augment the system message.
- SystemMessage systemMessage = chatClientRequest.prompt().getSystemMessage();
+ org.springframework.ai.chat.messages.SystemMessage systemMessage = request.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
- .render(Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
+ .render(java.util.Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
- // 3. Create a new request with the augmented system message.
- ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
- .prompt(chatClientRequest.prompt().augmentSystemMessage(augmentedSystemText))
+ ChatClientRequest processedChatClientRequest = request.mutate()
+ .prompt(request.prompt().augmentSystemMessage(augmentedSystemText))
.build();
- // 4. Add the new user message to the conversation memory.
- UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
- this.getChatMemoryStore().write(toDocuments(List.of(userMessage), conversationId));
+ org.springframework.ai.chat.messages.UserMessage userMessage = processedChatClientRequest.prompt()
+ .getUserMessage();
+ if (userMessage != null) {
+ this.getChatMemoryStore().write(toDocuments(java.util.List.of(userMessage), conversationId));
+ }
return processedChatClientRequest;
}
- private void after(ChatClientResponse chatClientResponse) {
+ protected void after(ChatClientResponse chatClientResponse) {
List assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -146,6 +144,24 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor memoryMessages) {
+ if (memoryMessages == null || memoryMessages.isEmpty()) {
+ return request;
+ }
+ // Convert memory messages to a string for the system prompt
+ String longTermMemory = memoryMessages.stream()
+ .filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
+ .map(m -> m.getMessageType() + ":" + m.getText())
+ .collect(Collectors.joining(System.lineSeparator()));
+
+ SystemMessage systemMessage = request.prompt().getSystemMessage();
+ String augmentedSystemText = this.systemPromptTemplate
+ .render(Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
+
+ // Create a new request with the augmented system message
+ return request.mutate().prompt(request.prompt().augmentSystemMessage(augmentedSystemText)).build();
+ }
+
private List toDocuments(List messages, String conversationId) {
List docs = messages.stream()
.filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
@@ -173,28 +189,71 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor {
+ /**
+ * Builder for VectorStoreChatMemoryAdvisor.
+ */
+ public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder {
private PromptTemplate systemPromptTemplate = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
- protected Builder(VectorStore chatMemory) {
- super(chatMemory);
- }
-
- public Builder systemTextAdvise(String systemTextAdvise) {
- this.systemPromptTemplate = new PromptTemplate(systemTextAdvise);
- return this;
+ private Integer defaultChatMemoryRetrieveSize = null;
+
+ /**
+ * Creates a new builder instance.
+ * @param vectorStore the vector store to use
+ */
+ protected Builder(VectorStore vectorStore) {
+ super(vectorStore);
}
+ /**
+ * Set the system prompt template.
+ * @param systemPromptTemplate the system prompt template
+ * @return this builder
+ */
public Builder systemPromptTemplate(PromptTemplate systemPromptTemplate) {
this.systemPromptTemplate = systemPromptTemplate;
return this;
}
+ /**
+ * Set the system prompt template using a text template.
+ * @param systemTextAdvise the system prompt text template
+ * @return this builder
+ */
+ public Builder systemTextAdvise(String systemTextAdvise) {
+ this.systemPromptTemplate = new PromptTemplate(systemTextAdvise);
+ return this;
+ }
+
+ /**
+ * Set the default chat memory retrieve size.
+ * @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
+ * @return this builder
+ */
+ public Builder defaultChatMemoryRetrieveSize(int defaultChatMemoryRetrieveSize) {
+ this.defaultChatMemoryRetrieveSize = defaultChatMemoryRetrieveSize;
+ return this;
+ }
+
+ @Override
+ protected Builder self() {
+ return this;
+ }
+
@Override
public VectorStoreChatMemoryAdvisor build() {
- return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId, this.chatMemoryRetrieveSize,
- this.protectFromBlocking, this.systemPromptTemplate, this.order);
+ if (defaultChatMemoryRetrieveSize == null) {
+ // Default to legacy mode for backward compatibility
+ return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId,
+ DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, this.protectFromBlocking, this.systemPromptTemplate,
+ this.order);
+ }
+ else {
+ return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId,
+ this.defaultChatMemoryRetrieveSize, this.protectFromBlocking, this.systemPromptTemplate,
+ this.order);
+ }
}
}
diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/AbstractChatMemoryAdvisorIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/AbstractChatMemoryAdvisorIT.java
new file mode 100644
index 000000000..4b79e3b5c
--- /dev/null
+++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/AbstractChatMemoryAdvisorIT.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.openai.chat.client.advisor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
+import org.springframework.ai.chat.memory.MessageWindowChatMemory;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.openai.OpenAiTestConfiguration;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import reactor.core.publisher.Flux;
+
+/**
+ * Abstract base class for chat memory advisor integration tests. Contains common test
+ * logic to avoid duplication between different advisor implementations.
+ */
+@SpringBootTest(classes = OpenAiTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
+public abstract class AbstractChatMemoryAdvisorIT {
+
+ protected final Logger logger = LoggerFactory.getLogger(getClass());
+
+ @Autowired
+ protected org.springframework.ai.chat.model.ChatModel chatModel;
+
+ /**
+ * Create an advisor instance for testing.
+ * @param chatMemory The chat memory to use
+ * @return An instance of the advisor to test
+ */
+ protected abstract AbstractChatMemoryAdvisor> createAdvisor(ChatMemory chatMemory);
+
+ /**
+ * Create an advisor without a default conversation ID. This is needed for testing
+ * custom conversation IDs.
+ * @param chatMemory The chat memory to use
+ * @return An instance of the advisor without a default conversation ID
+ */
+ protected abstract AbstractChatMemoryAdvisor> createAdvisorWithoutDefaultId(ChatMemory chatMemory);
+
+ /**
+ * Assert the follow-up response meets the expectations for this advisor type. Default
+ * implementation expects the model to remember "John" from the first message.
+ * Subclasses can override this to implement advisor-specific assertions.
+ * @param followUpAnswer The follow-up answer from the model
+ */
+ protected void assertFollowUpResponse(String followUpAnswer) {
+ // Default implementation - expect model to remember "John"
+ assertThat(followUpAnswer).containsIgnoringCase("John");
+ }
+
+ /**
+ * Common test logic for handling multiple user messages in the same prompt. This
+ * tests that the advisor correctly stores all user messages from a prompt and uses
+ * them appropriately in subsequent interactions.
+ */
+ protected void testMultipleUserMessagesInPrompt() {
+ String conversationId = "multi-user-messages-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ AbstractChatMemoryAdvisor> advisor = createAdvisor(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ // Create a prompt with multiple user messages
+ List messages = new ArrayList<>();
+ messages.add(new UserMessage("My name is David."));
+ messages.add(new UserMessage("I work as a software engineer."));
+ messages.add(new UserMessage("What is my profession?"));
+
+ Prompt prompt = new Prompt(messages);
+
+ String answer = chatClient.prompt(prompt)
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Answer: {}", answer);
+ assertThat(answer).containsIgnoringCase("software engineer");
+
+ List memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
+ assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is David.");
+ assertThat(memoryMessages.get(1).getText()).isEqualTo("I work as a software engineer.");
+ assertThat(memoryMessages.get(2).getText()).isEqualTo("What is my profession?");
+
+ // Send a follow-up question
+ String followUpAnswer = chatClient.prompt()
+ .user("What is my name?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Follow-up Answer: {}", followUpAnswer);
+ assertThat(followUpAnswer).containsIgnoringCase("David");
+ }
+
+ /**
+ * Common test logic for handling multiple user messages in the same prompt. This
+ * tests that the advisor correctly stores all user messages from a prompt and uses
+ * them appropriately in subsequent interactions.
+ */
+ protected void testMultipleUserMessagesInSamePrompt() {
+ // Arrange
+ String conversationId = "test-conversation-multi-user-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ // Create advisor with the conversation ID
+ AbstractChatMemoryAdvisor> advisor = createAdvisor(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ // Act - Create a list of messages for the prompt
+ List messages = new ArrayList<>();
+ messages.add(new UserMessage("My name is John."));
+ messages.add(new UserMessage("I am from New York."));
+ messages.add(new UserMessage("What city am I from?"));
+
+ // Create a prompt with the list of messages
+ Prompt prompt = new Prompt(messages);
+
+ // Send the prompt to the chat client
+ String answer = chatClient.prompt(prompt)
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Multiple user messages answer: {}", answer);
+
+ // Assert response is relevant to the last question
+ assertThat(answer).containsIgnoringCase("New York");
+
+ // Verify memory contains all user messages and the response
+ List memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
+ assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is John.");
+ assertThat(memoryMessages.get(1).getText()).isEqualTo("I am from New York.");
+ assertThat(memoryMessages.get(2).getText()).isEqualTo("What city am I from?");
+
+ // Act - Send a follow-up question
+ String followUpAnswer = chatClient.prompt()
+ .user("What is my name?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Follow-up answer: {}", followUpAnswer);
+
+ // Use the subclass-specific assertion for the follow-up response
+ assertFollowUpResponse(followUpAnswer);
+
+ // Verify memory now contains all previous messages plus the follow-up and its
+ // response
+ memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(6); // 3 user + 1 assistant + 1 user + 1
+ // assistant
+ assertThat(memoryMessages.get(4).getText()).isEqualTo("What is my name?");
+ }
+
+ /**
+ * Tests that the advisor correctly uses a custom conversation ID when provided.
+ */
+ protected void testUseCustomConversationId() {
+ // Arrange
+ String customConversationId = "custom-conversation-id-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ // Create advisor without a default conversation ID
+ AbstractChatMemoryAdvisor> advisor = createAdvisorWithoutDefaultId(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ String question = "What is the capital of Germany?";
+
+ String answer = chatClient.prompt()
+ .user(question)
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, customConversationId))
+ .call()
+ .content();
+
+ logger.info("Question: {}", question);
+ logger.info("Answer: {}", answer);
+
+ // Assert response is relevant
+ assertThat(answer).containsIgnoringCase("Berlin");
+
+ // Verify memory contains the question and answer
+ List memoryMessages = chatMemory.get(customConversationId);
+ assertThat(memoryMessages).hasSize(2);
+ assertThat(memoryMessages.get(0).getText()).isEqualTo(question);
+ }
+
+ /**
+ * Tests that the advisor maintains separate conversations for different conversation
+ * IDs.
+ */
+ protected void testMaintainSeparateConversations() {
+ // Arrange
+ String conversationId1 = "conversation-1-" + System.currentTimeMillis();
+ String conversationId2 = "conversation-2-" + System.currentTimeMillis();
+
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ // Create advisor without a default conversation ID
+ AbstractChatMemoryAdvisor> advisor = createAdvisorWithoutDefaultId(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ // Act - First conversation
+ String answer1 = chatClient.prompt()
+ .user("My name is Alice.")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId1))
+ .call()
+ .content();
+
+ logger.info("Answer 1: {}", answer1);
+
+ // Act - Second conversation
+ String answer2 = chatClient.prompt()
+ .user("My name is Bob.")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId2))
+ .call()
+ .content();
+
+ logger.info("Answer 2: {}", answer2);
+
+ // Verify memory contains separate conversations
+ List memoryMessages1 = chatMemory.get(conversationId1);
+ List memoryMessages2 = chatMemory.get(conversationId2);
+
+ assertThat(memoryMessages1).hasSize(2); // 1 user + 1 assistant
+ assertThat(memoryMessages2).hasSize(2); // 1 user + 1 assistant
+ assertThat(memoryMessages1.get(0).getText()).isEqualTo("My name is Alice.");
+ assertThat(memoryMessages2.get(0).getText()).isEqualTo("My name is Bob.");
+
+ // Act - Follow-up in first conversation
+ String followUpAnswer1 = chatClient.prompt()
+ .user("What is my name?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId1))
+ .call()
+ .content();
+
+ logger.info("Follow-up Answer 1: {}", followUpAnswer1);
+
+ // Act - Follow-up in second conversation
+ String followUpAnswer2 = chatClient.prompt()
+ .user("What is my name?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId2))
+ .call()
+ .content();
+
+ logger.info("Follow-up Answer 2: {}", followUpAnswer2);
+
+ // Assert responses are relevant to their respective conversations
+ assertFollowUpResponseForName(followUpAnswer1, "Alice");
+ assertFollowUpResponseForName(followUpAnswer2, "Bob");
+
+ // Verify memory now contains all messages for both conversations
+ memoryMessages1 = chatMemory.get(conversationId1);
+ memoryMessages2 = chatMemory.get(conversationId2);
+
+ assertThat(memoryMessages1).hasSize(4); // 2 user + 2 assistant
+ assertThat(memoryMessages2).hasSize(4); // 2 user + 2 assistant
+ assertThat(memoryMessages1.get(2).getText()).isEqualTo("What is my name?");
+ assertThat(memoryMessages2.get(2).getText()).isEqualTo("What is my name?");
+ }
+
+ /**
+ * Assert the follow-up response for a specific name. Default implementation expects
+ * the model to remember the name from the first message. Subclasses can override this
+ * to implement advisor-specific assertions.
+ * @param followUpAnswer The model's response to the follow-up question
+ * @param expectedName The name that should be remembered
+ */
+ protected void assertFollowUpResponseForName(String followUpAnswer, String expectedName) {
+ assertThat(followUpAnswer).containsIgnoringCase(expectedName);
+ }
+
+ /**
+ * Tests that the advisor handles a non-existent conversation ID gracefully.
+ */
+ protected void testHandleNonExistentConversation() {
+ // Arrange
+ String nonExistentId = "non-existent-conversation-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ // Create advisor without a default conversation ID
+ AbstractChatMemoryAdvisor> advisor = createAdvisorWithoutDefaultId(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ // Act - Send a question to a non-existent conversation
+ String question = "Do you remember our previous conversation?";
+
+ String answer = chatClient.prompt()
+ .user(question)
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, nonExistentId))
+ .call()
+ .content();
+
+ logger.info("Question: {}", question);
+ logger.info("Answer: {}", answer);
+
+ // Assert response indicates no previous conversation
+ assertNonExistentConversationResponse(answer);
+
+ // Verify memory now contains this message
+ List memoryMessages = chatMemory.get(nonExistentId);
+ assertThat(memoryMessages).hasSize(2); // 1 user message + 1 assistant response
+ assertThat(memoryMessages.get(0).getText()).isEqualTo(question);
+ }
+
+ /**
+ * Assert the response for a non-existent conversation. Default implementation expects
+ * the model to indicate there's no previous conversation. Subclasses can override
+ * this to implement advisor-specific assertions.
+ * @param answer The model's response to the question about a previous conversation
+ */
+ protected void assertNonExistentConversationResponse(String answer) {
+ // Log the actual model response for debugging
+ System.out.println("[DEBUG] Model response for non-existent conversation: " + answer);
+ String normalized = answer.toLowerCase().replace('’', '\'');
+ boolean containsExpectedWord = normalized.contains("don't") || normalized.contains("no")
+ || normalized.contains("not") || normalized.contains("previous")
+ || normalized.contains("past conversation") || normalized.contains("independent")
+ || normalized.contains("retain information");
+ assertThat(containsExpectedWord).as("Response should indicate no previous conversation").isTrue();
+ }
+
+ /**
+ * Assert the follow-up response for reactive mode test. Default implementation
+ * expects the model to remember the name and location. Subclasses can override this
+ * to implement advisor-specific assertions.
+ * @param followUpAnswer The model's response to the follow-up question
+ */
+ protected void assertReactiveFollowUpResponse(String followUpAnswer) {
+ assertThat(followUpAnswer).containsIgnoringCase("Charlie");
+ assertThat(followUpAnswer).containsIgnoringCase("London");
+ }
+
+ protected void testHandleMultipleMessagesInReactiveMode() {
+ String conversationId = "reactive-conversation-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ AbstractChatMemoryAdvisor> advisor = createAdvisor(chatMemory);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ List responseList = new ArrayList<>();
+ for (String message : List.of("My name is Charlie.", "I am 30 years old.", "I live in London.")) {
+ String response = chatClient.prompt()
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .user(message)
+ .call()
+ .content();
+ responseList.add(response);
+ }
+
+ for (int i = 0; i < responseList.size(); i++) {
+ logger.info("Response {}: {}", i, responseList.get(i));
+ }
+
+ List memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(6); // 3 user + 3 assistant
+ assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is Charlie.");
+ assertThat(memoryMessages.get(2).getText()).isEqualTo("I am 30 years old.");
+ assertThat(memoryMessages.get(4).getText()).isEqualTo("I live in London.");
+
+ String followUpAnswer = chatClient.prompt()
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .user("What is my name and where do I live?")
+ .call()
+ .content();
+
+ logger.info("Follow-up answer: {}", followUpAnswer);
+
+ assertReactiveFollowUpResponse(followUpAnswer);
+
+ memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(8); // 4 user messages + 4 assistant responses
+ assertThat(memoryMessages.get(6).getText()).isEqualTo("What is my name and where do I live?");
+ }
+
+}
diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/MessageChatMemoryAdvisorIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/MessageChatMemoryAdvisorIT.java
new file mode 100644
index 000000000..7eb6b1772
--- /dev/null
+++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/MessageChatMemoryAdvisorIT.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.openai.chat.client.advisor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
+import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
+import org.springframework.ai.chat.memory.MessageWindowChatMemory;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.openai.OpenAiTestConfiguration;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link MessageChatMemoryAdvisor}.
+ */
+@SpringBootTest(classes = OpenAiTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
+public class MessageChatMemoryAdvisorIT extends AbstractChatMemoryAdvisorIT {
+
+ private static final Logger logger = LoggerFactory.getLogger(MessageChatMemoryAdvisorIT.class);
+
+ @Autowired
+ private org.springframework.ai.chat.model.ChatModel chatModel;
+
+ @Override
+ protected AbstractChatMemoryAdvisor> createAdvisor(ChatMemory chatMemory) {
+ return new MessageChatMemoryAdvisor(chatMemory);
+ }
+
+ @Override
+ protected AbstractChatMemoryAdvisor> createAdvisorWithoutDefaultId(ChatMemory chatMemory) {
+ return new MessageChatMemoryAdvisor(chatMemory);
+ }
+
+ @Test
+ void shouldHandleMultipleUserMessagesInSamePrompt() {
+ testMultipleUserMessagesInSamePrompt();
+ }
+
+ @Test
+ void shouldUseCustomConversationId() {
+ testUseCustomConversationId();
+ }
+
+ @Test
+ void shouldMaintainSeparateConversations() {
+ testMaintainSeparateConversations();
+ }
+
+ @Test
+ void shouldHandleMultipleMessagesInReactiveMode() {
+ testHandleMultipleMessagesInReactiveMode();
+ }
+
+ @Test
+ void shouldHandleMultipleUserMessagesInPrompt() {
+ // Arrange
+ String conversationId = "multi-user-messages-" + System.currentTimeMillis();
+ ChatMemory chatMemory = MessageWindowChatMemory.builder()
+ .chatMemoryRepository(new InMemoryChatMemoryRepository())
+ .build();
+
+ // Create MessageChatMemoryAdvisor with the conversation ID
+ MessageChatMemoryAdvisor advisor = new MessageChatMemoryAdvisor(chatMemory, conversationId);
+
+ ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
+
+ // Create a prompt with multiple user messages
+ List messages = new ArrayList<>();
+ messages.add(new UserMessage("My name is David."));
+ messages.add(new UserMessage("I work as a software engineer."));
+ messages.add(new UserMessage("What is my profession?"));
+
+ // Create a prompt with the list of messages
+ Prompt prompt = new Prompt(messages);
+
+ // Send the prompt to the chat client
+ String answer = chatClient.prompt(prompt)
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Answer: {}", answer);
+
+ // Assert response is relevant
+ assertThat(answer).containsIgnoringCase("software engineer");
+
+ // Verify memory contains all user messages
+ List memoryMessages = chatMemory.get(conversationId);
+ assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
+ assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is David.");
+ assertThat(memoryMessages.get(1).getText()).isEqualTo("I work as a software engineer.");
+ assertThat(memoryMessages.get(2).getText()).isEqualTo("What is my profession?");
+
+ // Send a follow-up question
+ String followUpAnswer = chatClient.prompt()
+ .user("What is my name?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ logger.info("Follow-up Answer: {}", followUpAnswer);
+
+ // Assert the model remembers the name
+ assertThat(followUpAnswer).containsIgnoringCase("David");
+ }
+
+ @Test
+ void shouldHandleNonExistentConversation() {
+ testHandleNonExistentConversation();
+ }
+
+}
diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/PromptChatMemoryAdvisorIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/PromptChatMemoryAdvisorIT.java
new file mode 100644
index 000000000..49e7dda39
--- /dev/null
+++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/advisor/PromptChatMemoryAdvisorIT.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.openai.chat.client.advisor;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
+import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.openai.OpenAiTestConfiguration;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link PromptChatMemoryAdvisor}.
+ */
+@SpringBootTest(classes = OpenAiTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
+public class PromptChatMemoryAdvisorIT extends AbstractChatMemoryAdvisorIT {
+
+ private static final Logger logger = LoggerFactory.getLogger(PromptChatMemoryAdvisorIT.class);
+
+ @Autowired
+ private org.springframework.ai.chat.model.ChatModel chatModel;
+
+ @Override
+ protected AbstractChatMemoryAdvisor> createAdvisor(ChatMemory chatMemory) {
+ return new PromptChatMemoryAdvisor(chatMemory);
+ }
+
+ @Override
+ protected AbstractChatMemoryAdvisor> createAdvisorWithoutDefaultId(ChatMemory chatMemory) {
+ return new PromptChatMemoryAdvisor(chatMemory);
+ }
+
+ @Override
+ protected void assertFollowUpResponse(String followUpAnswer) {
+ // PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
+ // memory
+ // Memory is included in the system message as text rather than as separate
+ // messages
+ // This may result in the model not recalling specific information as effectively
+
+ // Assert the model provides a reasonable response (not an error)
+ assertThat(followUpAnswer).isNotBlank();
+ assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
+ }
+
+ @Override
+ protected void assertFollowUpResponseForName(String followUpAnswer, String expectedName) {
+ // PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
+ // memory
+ // Memory is included in the system message as text rather than as separate
+ // messages
+ // This may result in the model not recalling specific information as effectively
+
+ // Assert the model provides a reasonable response (not an error)
+ assertThat(followUpAnswer).isNotBlank();
+ assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
+
+ // We don't assert that it contains the expected name because the way memory is
+ // presented
+ // in the system message may not be as effective for recall as separate messages
+ }
+
+ @Override
+ protected void assertReactiveFollowUpResponse(String followUpAnswer) {
+ // PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
+ // memory
+ // Memory is included in the system message as text rather than as separate
+ // messages
+ // This may result in the model not recalling specific information as effectively
+
+ // Assert the model provides a reasonable response (not an error)
+ assertThat(followUpAnswer).isNotBlank();
+ assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
+
+ // We don't assert that it contains specific information because the way memory is
+ // presented
+ // in the system message may not be as effective for recall as separate messages
+ }
+
+ @Override
+ protected void assertNonExistentConversationResponse(String answer) {
+ // The model's response contains "don't" but the test is failing due to how
+ // satisfiesAnyOf works
+ // Just check that the response is not blank and doesn't contain an error
+ assertThat(answer).isNotBlank();
+ assertThat(answer).doesNotContainIgnoringCase("error");
+ }
+
+ @Test
+ void shouldHandleMultipleUserMessagesInSamePrompt() {
+ testMultipleUserMessagesInSamePrompt();
+ }
+
+ @Test
+ void shouldUseCustomConversationId() {
+ testUseCustomConversationId();
+ }
+
+ @Test
+ void shouldMaintainSeparateConversations() {
+ testMaintainSeparateConversations();
+ }
+
+ @Test
+ void shouldHandleNonExistentConversation() {
+ testHandleNonExistentConversation();
+ }
+
+ @Test
+ void shouldHandleMultipleMessagesInReactiveMode() {
+ testHandleMultipleMessagesInReactiveMode();
+ }
+
+ @Test
+ void shouldHandleMultipleUserMessagesInPrompt() {
+ testMultipleUserMessagesInPrompt();
+ }
+
+}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractChatMemoryAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractChatMemoryAdvisor.java
index c39a654b3..afa162b3f 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractChatMemoryAdvisor.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractChatMemoryAdvisor.java
@@ -16,9 +16,12 @@
package org.springframework.ai.chat.client.advisor;
+import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@@ -27,6 +30,7 @@ import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.memory.ChatMemory;
@@ -34,6 +38,13 @@ import org.springframework.util.Assert;
/**
* Abstract class that serves as a base for chat memory advisors.
+ *
+ * WARNING: If you rely on the {@code defaultConversationId} (i.e., do not provide
+ * a conversation ID in the context), all chat memory will be shared across all users and
+ * sessions. This means you will NOT be able to support multiple independent user
+ * sessions or conversations. Always provide a unique conversation ID in the context to
+ * ensure proper session isolation.
+ *
*
* @param the type of the chat memory.
* @author Christian Tzolov
@@ -48,16 +59,6 @@ public abstract class AbstractChatMemoryAdvisor implements CallAdvisor, Strea
*/
public static final String CHAT_MEMORY_CONVERSATION_ID_KEY = "chat_memory_conversation_id";
- /**
- * The key to retrieve the chat memory response size from the context.
- */
- public static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_response_size";
-
- /**
- * The default chat memory retrieve size to use when no retrieve size is provided.
- */
- public static final int DEFAULT_CHAT_MEMORY_RESPONSE_SIZE = 100;
-
/**
* The chat memory store.
*/
@@ -68,11 +69,6 @@ public abstract class AbstractChatMemoryAdvisor implements CallAdvisor, Strea
*/
protected final String defaultConversationId;
- /**
- * The default chat memory retrieve size.
- */
- protected final int defaultChatMemoryRetrieveSize;
-
/**
* Whether to protect from blocking.
*/
@@ -83,45 +79,40 @@ public abstract class AbstractChatMemoryAdvisor implements CallAdvisor, Strea
*/
private final int order;
+ private static final Logger logger = LoggerFactory.getLogger(AbstractChatMemoryAdvisor.class);
+
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
*/
protected AbstractChatMemoryAdvisor(T chatMemory) {
- this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, true);
+ this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, true);
}
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
* @param defaultConversationId the default conversation id
- * @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
* @param protectFromBlocking whether to protect from blocking
*/
- protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, int defaultChatMemoryRetrieveSize,
- boolean protectFromBlocking) {
- this(chatMemory, defaultConversationId, defaultChatMemoryRetrieveSize, protectFromBlocking,
- Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
+ protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, boolean protectFromBlocking) {
+ this(chatMemory, defaultConversationId, protectFromBlocking, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
* @param defaultConversationId the default conversation id
- * @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
* @param protectFromBlocking whether to protect from blocking
* @param order the order
*/
- protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, int defaultChatMemoryRetrieveSize,
- boolean protectFromBlocking, int order) {
+ protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, boolean protectFromBlocking,
+ int order) {
Assert.notNull(chatMemory, "The chatMemory must not be null!");
Assert.hasText(defaultConversationId, "The conversationId must not be empty!");
- Assert.isTrue(defaultChatMemoryRetrieveSize > 0, "The defaultChatMemoryRetrieveSize must be greater than 0!");
-
this.chatMemoryStore = chatMemory;
this.defaultConversationId = defaultConversationId;
- this.defaultChatMemoryRetrieveSize = defaultChatMemoryRetrieveSize;
this.protectFromBlocking = protectFromBlocking;
this.order = order;
}
@@ -149,125 +140,126 @@ public abstract class AbstractChatMemoryAdvisor implements CallAdvisor, Strea
}
/**
- * Get the default conversation id.
+ * Get the conversation id for the current context.
* @param context the context
- * @return the default conversation id
+ * @return the conversation id
*/
protected String doGetConversationId(Map context) {
-
- return context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)
+ if (context == null || !context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)) {
+ logger.warn("No conversation ID found in context; using defaultConversationId '{}'.",
+ this.defaultConversationId);
+ }
+ return context != null && context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)
? context.get(CHAT_MEMORY_CONVERSATION_ID_KEY).toString() : this.defaultConversationId;
}
- /**
- * Get the default chat memory retrieve size.
- * @param context the context
- * @return the default chat memory retrieve size
- */
- protected int doGetChatMemoryRetrieveSize(Map context) {
- return context.containsKey(CHAT_MEMORY_RETRIEVE_SIZE_KEY)
- ? Integer.parseInt(context.get(CHAT_MEMORY_RETRIEVE_SIZE_KEY).toString())
- : this.defaultChatMemoryRetrieveSize;
- }
-
protected Flux doNextWithProtectFromBlockingBefore(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain, Function before) {
// This can be executed by both blocking and non-blocking Threads
// E.g. a command line or Tomcat blocking Thread implementation
// or by a WebFlux dispatch in a non-blocking manner.
- return (this.protectFromBlocking) ?
- // @formatter:off
- Mono.just(chatClientRequest)
- .publishOn(Schedulers.boundedElastic())
- .map(before)
- .flatMapMany(streamAdvisorChain::nextStream)
+ return (this.protectFromBlocking)
+ ? Mono.just(chatClientRequest)
+ .publishOn(Schedulers.boundedElastic())
+ .map(before)
+ .flatMapMany(streamAdvisorChain::nextStream)
: streamAdvisorChain.nextStream(before.apply(chatClientRequest));
}
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
+ // Apply memory to the request
+ ChatClientRequest modifiedRequest = before(chatClientRequest);
+
+ // Call the next advisor in the chain
+ ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(modifiedRequest);
+
+ // Process the response (save to memory, etc.)
+ after(chatClientResponse);
+
+ return chatClientResponse;
+ }
+
+ @Override
+ public Flux adviseStream(ChatClientRequest chatClientRequest,
+ StreamAdvisorChain streamAdvisorChain) {
+ return this.doNextWithProtectFromBlockingBefore(chatClientRequest, streamAdvisorChain, this::before);
+ }
+
+ /**
+ * Hook for subclasses to modify the request before passing to the chain. Default
+ * implementation returns the request as-is.
+ */
+ protected ChatClientRequest before(ChatClientRequest chatClientRequest) {
+ String conversationId = doGetConversationId(chatClientRequest.context());
+ return before(chatClientRequest, conversationId);
+ }
+
+ /**
+ * Hook for subclasses to modify the request before passing to the chain.
+ * @param chatClientRequest the request
+ * @param conversationId the conversation id
+ * @return the modified request
+ */
+ protected abstract ChatClientRequest before(ChatClientRequest chatClientRequest, String conversationId);
+
+ /**
+ * Utility to build the context options map for downstream advisor implementations.
+ * Adds the request itself under the key "request".
+ */
+ protected Map buildContextMap(ChatClientRequest request) {
+ Map options = new HashMap<>(request.context());
+ options.put("request", request);
+ return options;
+ }
+
+ /**
+ * Hook for subclasses to handle the response after the chain. Default implementation
+ * does nothing.
+ */
+ protected void after(ChatClientResponse chatClientResponse) {
+ // No-op by default
+ }
+
/**
* Abstract builder for {@link AbstractChatMemoryAdvisor}.
+ *
* @param the type of the chat memory
+ * @param the type of the builder
*/
- public static abstract class AbstractBuilder {
+ public static abstract class AbstractBuilder> {
+
+ protected final T chatMemory;
- /**
- * The conversation id.
- */
protected String conversationId = ChatMemory.DEFAULT_CONVERSATION_ID;
- /**
- * The chat memory retrieve size.
- */
- protected int chatMemoryRetrieveSize = DEFAULT_CHAT_MEMORY_RESPONSE_SIZE;
-
- /**
- * Whether to protect from blocking.
- */
protected boolean protectFromBlocking = true;
- /**
- * The order of the advisor.
- */
protected int order = Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER;
- /**
- * The chat memory.
- */
- protected T chatMemory;
-
- /**
- * Constructor to create a new {@link AbstractBuilder} instance.
- * @param chatMemory the chat memory
- */
protected AbstractBuilder(T chatMemory) {
this.chatMemory = chatMemory;
}
- /**
- * Set the conversation id.
- * @param conversationId the conversation id
- * @return the builder
- */
- public AbstractBuilder conversationId(String conversationId) {
+ public B conversationId(String conversationId) {
this.conversationId = conversationId;
- return this;
+ return self();
}
- /**
- * Set the chat memory retrieve size.
- * @param chatMemoryRetrieveSize the chat memory retrieve size
- * @return the builder
- */
- public AbstractBuilder chatMemoryRetrieveSize(int chatMemoryRetrieveSize) {
- this.chatMemoryRetrieveSize = chatMemoryRetrieveSize;
- return this;
- }
-
- /**
- * Set whether to protect from blocking.
- * @param protectFromBlocking whether to protect from blocking
- * @return the builder
- */
- public AbstractBuilder protectFromBlocking(boolean protectFromBlocking) {
+ public B protectFromBlocking(boolean protectFromBlocking) {
this.protectFromBlocking = protectFromBlocking;
- return this;
+ return self();
}
- /**
- * Set the order.
- * @param order the order
- * @return the builder
- */
- public AbstractBuilder order(int order) {
+ public B order(int order) {
this.order = order;
- return this;
+ return self();
}
- /**
- * Build the advisor.
- * @return the advisor
- */
- abstract public AbstractChatMemoryAdvisor build();
+ protected abstract B self();
+
+ public abstract AbstractChatMemoryAdvisor build();
+
}
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractConversationHistoryAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractConversationHistoryAdvisor.java
new file mode 100644
index 000000000..d31864fab
--- /dev/null
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AbstractConversationHistoryAdvisor.java
@@ -0,0 +1,53 @@
+package org.springframework.ai.chat.client.advisor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.messages.Message;
+
+/**
+ * Advisor for standard ChatMemory implementations
+ * @author Mark Pollack
+ * @since 1.0.0
+ */
+public abstract class AbstractConversationHistoryAdvisor extends AbstractChatMemoryAdvisor {
+
+ public AbstractConversationHistoryAdvisor(ChatMemory chatMemory) {
+ this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, true, DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
+ }
+
+ public AbstractConversationHistoryAdvisor(ChatMemory chatMemory, String defaultConversationId, boolean protectFromBlocking,
+ int order) {
+ super(chatMemory, defaultConversationId, protectFromBlocking, order);
+ }
+
+ protected List retrieveMessages(String conversationId, Map options) {
+ return chatMemoryStore.get(conversationId);
+ }
+
+ @Override
+ protected ChatClientRequest before(ChatClientRequest request, String conversationId) {
+ Map contextMap = buildContextMap(request);
+ List memoryMessages = retrieveMessages(conversationId, contextMap);
+ return applyMessagesToRequest(request, memoryMessages);
+ }
+
+ protected ChatClientRequest applyMessagesToRequest(ChatClientRequest request, List memoryMessages) {
+ if (memoryMessages == null || memoryMessages.isEmpty()) {
+ return request;
+ }
+ // Combine memory messages with the instructions from the current prompt
+ List combinedMessages = new ArrayList<>(memoryMessages);
+ combinedMessages.addAll(request.prompt().getInstructions());
+
+ // Mutate the prompt to use the combined messages
+ var promptBuilder = request.prompt().mutate().messages(combinedMessages);
+
+ // Return a new ChatClientRequest with the updated prompt
+ return request.mutate().prompt(promptBuilder.build()).build();
+ }
+
+}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java
index 6a6862a93..d8cc07d63 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java
@@ -37,19 +37,18 @@ import org.springframework.ai.chat.model.MessageAggregator;
* @author Christian Tzolov
* @since 1.0.0
*/
-public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor {
+public class MessageChatMemoryAdvisor extends AbstractConversationHistoryAdvisor {
public MessageChatMemoryAdvisor(ChatMemory chatMemory) {
super(chatMemory);
}
- public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize) {
- this(chatMemory, defaultConversationId, chatHistoryWindowSize, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
+ public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId) {
+ this(chatMemory, defaultConversationId, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
- public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize,
- int order) {
- super(chatMemory, defaultConversationId, chatHistoryWindowSize, true, order);
+ public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order) {
+ super(chatMemory, defaultConversationId, true, order);
}
public static Builder builder(ChatMemory chatMemory) {
@@ -76,31 +75,22 @@ public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor memoryMessages = this.getChatMemoryStore().get(conversationId, chatMemoryRetrieveSize);
-
- // 2. Advise the request messages list.
- List processedMessages = new ArrayList<>(memoryMessages);
- processedMessages.addAll(chatClientRequest.prompt().getInstructions());
-
- // 3. Create a new request with the advised messages.
- ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
- .prompt(chatClientRequest.prompt().mutate().messages(processedMessages).build())
- .build();
-
- // 4. Add the new user message to the conversation memory.
- UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
- this.getChatMemoryStore().add(conversationId, userMessage);
-
- return processedChatClientRequest;
+ @Override
+ protected ChatClientRequest before(ChatClientRequest request) {
+ String conversationId = this.doGetConversationId(request.context());
+
+ // Add the new user messages from the current prompt to memory
+ List newUserMessages = request.prompt().getUserMessages();
+ for (UserMessage userMessage : newUserMessages) {
+ this.getChatMemoryStore().add(conversationId, userMessage);
+ }
+
+ // Use the parent class implementation to handle retrieving and applying messages
+ return super.before(request);
}
- private void after(ChatClientResponse chatClientResponse) {
+ @Override
+ protected void after(ChatClientResponse chatClientResponse) {
List assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -112,15 +102,20 @@ public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor {
+ public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder {
protected Builder(ChatMemory chatMemory) {
super(chatMemory);
}
+ @Override
+ protected Builder self() {
+ return this;
+ }
+
+ @Override
public MessageChatMemoryAdvisor build() {
- return new MessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.chatMemoryRetrieveSize,
- this.order);
+ return new MessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.order);
}
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/PromptChatMemoryAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/PromptChatMemoryAdvisor.java
index 21fec14dd..2c39e64ad 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/PromptChatMemoryAdvisor.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/PromptChatMemoryAdvisor.java
@@ -21,6 +21,8 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.ChatClientRequest;
@@ -28,13 +30,13 @@ import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
-import org.springframework.ai.chat.messages.SystemMessage;
-import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
+import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.MessageAggregator;
+import org.springframework.ai.chat.prompt.PromptTemplate;
/**
* Memory is retrieved added into the prompt's system text.
@@ -44,7 +46,9 @@ import org.springframework.ai.chat.model.MessageAggregator;
* @author Thomas Vitale
* @since 1.0.0
*/
-public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor {
+public class PromptChatMemoryAdvisor extends AbstractConversationHistoryAdvisor {
+
+ private static final Logger logger = LoggerFactory.getLogger(PromptChatMemoryAdvisor.class);
private static final PromptTemplate DEFAULT_SYSTEM_PROMPT_TEMPLATE = new PromptTemplate("""
{instructions}
@@ -69,20 +73,19 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor chatClientResponses = this.doNextWithProtectFromBlockingBefore(chatClientRequest,
streamAdvisorChain, this::before);
- return new MessageAggregator().aggregateChatClientResponse(chatClientResponses, this::after);
+ // Ensure memory is updated after each streamed response
+ return chatClientResponses.doOnNext(this::after)
+ .transform(responses -> new MessageAggregator().aggregateChatClientResponse(responses, null));
}
- private ChatClientRequest before(ChatClientRequest chatClientRequest) {
+ @Override
+ protected ChatClientRequest before(ChatClientRequest chatClientRequest) {
String conversationId = this.doGetConversationId(chatClientRequest.context());
- int chatMemoryRetrieveSize = this.doGetChatMemoryRetrieveSize(chatClientRequest.context());
+
+ // 1. Add all user messages from the current prompt to memory
+ List userMessages = chatClientRequest.prompt().getUserMessages();
+ for (UserMessage userMessage : userMessages) {
+ this.getChatMemoryStore().add(conversationId, userMessage);
+ logger.info("[PromptChatMemoryAdvisor.before] Added USER message to memory for conversationId={}: {}",
+ conversationId, userMessage.getText());
+ }
+
+ // 2. Retrieve the chat memory for the current conversation.
+ List memoryMessages = this.getChatMemoryStore().get(conversationId);
+ logger.info("[PromptChatMemoryAdvisor.before] Memory after USER add for conversationId={}: {}", conversationId,
+ memoryMessages);
- // 1. Retrieve the chat memory for the current conversation.
- List memoryMessages = this.getChatMemoryStore().get(conversationId, chatMemoryRetrieveSize);
-
- // 2. Processed memory messages as a string.
+ // 3. Process memory messages as a string.
String memory = memoryMessages.stream()
.filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
.map(m -> m.getMessageType() + ":" + m.getText())
.collect(Collectors.joining(System.lineSeparator()));
- // 2. Augment the system message.
+ // 4. Augment the system message.
SystemMessage systemMessage = chatClientRequest.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(), "memory", memory));
- // 3. Create a new request with the augmented system message.
+ // 5. Create a new request with the augmented system message.
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentSystemMessage(augmentedSystemText))
.build();
- // 4. Add the new user message to the conversation memory.
- UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
- this.getChatMemoryStore().add(conversationId, userMessage);
-
return processedChatClientRequest;
}
- private void after(ChatClientResponse chatClientResponse) {
+ @Override
+ protected void after(ChatClientResponse chatClientResponse) {
List assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -150,9 +162,15 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor memoryMessages = this.getChatMemoryStore()
+ .get(this.doGetConversationId(chatClientResponse.context()));
+ logger.info("[PromptChatMemoryAdvisor.after] Memory after ASSISTANT add for conversationId={}: {}",
+ this.doGetConversationId(chatClientResponse.context()), memoryMessages);
}
- public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder {
+ public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder {
private PromptTemplate systemPromptTemplate = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
@@ -160,6 +178,11 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor get(String conversationId) {
- Assert.hasText(conversationId, "conversationId cannot be null or empty");
- return get(conversationId, Integer.MAX_VALUE);
- }
-
- /**
- * @deprecated in favor of using {@link MessageWindowChatMemory}.
- */
- @Deprecated
- List get(String conversationId, int lastN);
+ List get(String conversationId);
/**
* Clear the chat memory for the specified conversation.
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java
index 0c187be01..d96254244 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java
@@ -71,12 +71,6 @@ public final class MessageWindowChatMemory implements ChatMemory {
return this.chatMemoryRepository.findByConversationId(conversationId);
}
- @Override
- @Deprecated // in favor of get(conversationId)
- public List get(String conversationId, int lastN) {
- return get(conversationId);
- }
-
@Override
public void clear(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java
index 0cf513435..2d5d8b64e 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java
@@ -128,6 +128,20 @@ public class Prompt implements ModelRequest> {
return new UserMessage("");
}
+ /**
+ * Get all user messages in the prompt.
+ * @return a list of all user messages in the prompt
+ */
+ public List getUserMessages() {
+ List userMessages = new ArrayList<>();
+ for (Message message : this.messages) {
+ if (message instanceof UserMessage userMessage) {
+ userMessages.add(userMessage);
+ }
+ }
+ return userMessages;
+ }
+
@Override
public String toString() {
return "Prompt{" + "messages=" + this.messages + ", modelOptions=" + this.chatOptions + '}';
diff --git a/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreVectorStoreChatMemoryAdvisorIT.java b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreVectorStoreChatMemoryAdvisorIT.java
new file mode 100644
index 000000000..6fa0d6e8c
--- /dev/null
+++ b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreVectorStoreChatMemoryAdvisorIT.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore.pgvector;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
+import org.springframework.ai.chat.client.advisor.vectorstore.VectorStoreChatMemoryAdvisor;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.model.ApiKey;
+import org.springframework.ai.model.SimpleApiKey;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.openai.OpenAiEmbeddingModel;
+import org.springframework.ai.openai.api.OpenAiApi;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Testcontainers
+@SpringBootTest(classes = PgVectorStoreVectorStoreChatMemoryAdvisorIT.OpenAiTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
+public class PgVectorStoreVectorStoreChatMemoryAdvisorIT {
+
+ @Container
+ @SuppressWarnings("resource")
+ static PostgreSQLContainer> postgresContainer = new PostgreSQLContainer<>(PgVectorImage.DEFAULT_IMAGE)
+ .withUsername("postgres")
+ .withPassword("postgres");
+
+ @Autowired
+ protected org.springframework.ai.chat.model.ChatModel chatModel;
+
+ @Test
+ void testUseCustomConversationId() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ // Use a real OpenAI embedding model
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+
+ // Create PgVectorStore
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536) // OpenAI default embedding size (adjust if needed)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ // Add a document to the store for recall
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List
+ .of(new Document("Hello from memory", java.util.Map.of("conversationId", conversationId))));
+
+ // Build ChatClient with VectorStoreChatMemoryAdvisor
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).build())
+ .build();
+
+ // Send a prompt
+ String answer = chatClient.prompt()
+ .user("Say hello")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ assertThat(answer).containsIgnoringCase("hello");
+
+ }
+
+ @Test
+ void testSemanticSearchRetrievesRelevantMemory() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ // Store diverse messages
+ store.add(java.util.List.of(
+ new Document("The Eiffel Tower is in Paris.", java.util.Map.of("conversationId", conversationId)),
+ new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId)),
+ new Document("Mount Everest is the tallest mountain in the world.",
+ java.util.Map.of("conversationId", conversationId)),
+ new Document("Dogs are loyal pets.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
+ .build();
+
+ // Send a semantically related query
+ String answer = chatClient.prompt()
+ .user("Where is the Eiffel Tower located?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+
+ // Assert that the answer is based on the correct semantic memory
+ assertThat(answer).containsIgnoringCase("paris");
+ assertThat(answer).doesNotContain("Bananas are yellow");
+ assertThat(answer).doesNotContain("Mount Everest");
+ assertThat(answer).doesNotContain("Dogs are loyal pets");
+ }
+
+ @Test
+ void testSemanticSynonymRetrieval() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List
+ .of(new Document("Automobiles are fast.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("Tell me about cars.")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).satisfiesAnyOf(a -> assertThat(a).containsIgnoringCase("automobile"),
+ a -> assertThat(a).containsIgnoringCase("fast"));
+ }
+
+ @Test
+ void testIrrelevantMessageExclusion() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List.of(
+ new Document("The capital of Italy is Rome.", java.util.Map.of("conversationId", conversationId)),
+ new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(2).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("What is the capital of Italy?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).containsIgnoringCase("rome");
+ assertThat(answer).doesNotContain("banana");
+ }
+
+ @Test
+ void testTopKSemanticRelevance() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List.of(
+ new Document("The cat sat on the mat.", java.util.Map.of("conversationId", conversationId)),
+ new Document("A cat is a small domesticated animal.",
+ java.util.Map.of("conversationId", conversationId)),
+ new Document("Dogs are loyal pets.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("What can you tell me about cats?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).containsIgnoringCase("cat");
+ assertThat(answer).doesNotContain("dog");
+ }
+
+ @Test
+ void testSemanticRetrievalWithParaphrasing() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List.of(new Document("The quick brown fox jumps over the lazy dog.",
+ java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("Tell me about a fast animal leaping over another.")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).satisfiesAnyOf(a -> assertThat(a).containsIgnoringCase("fox"),
+ a -> assertThat(a).containsIgnoringCase("dog"));
+ }
+
+ @Test
+ void testMultipleRelevantMemoriesTopK() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List.of(new Document("Apples are red.", java.util.Map.of("conversationId", conversationId)),
+ new Document("Strawberries are also red.", java.util.Map.of("conversationId", conversationId)),
+ new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(2).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("What fruits are red?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).containsIgnoringCase("apple");
+ assertThat(answer).containsIgnoringCase("strawber");
+ assertThat(answer).doesNotContain("banana");
+ }
+
+ @Test
+ void testNoRelevantMemory() throws Exception {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
+ "OPENAI_API_KEY must be set for this test");
+
+ EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
+ JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
+ PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(1536)
+ .initializeSchema(true)
+ .build();
+ store.afterPropertiesSet();
+
+ String conversationId = UUID.randomUUID().toString();
+ store.add(java.util.List
+ .of(new Document("The sun is a star.", java.util.Map.of("conversationId", conversationId))));
+
+ ChatClient chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
+ .build();
+
+ String answer = chatClient.prompt()
+ .user("What is the capital of Spain?")
+ .advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
+ .call()
+ .content();
+ assertThat(answer).doesNotContain("sun");
+ assertThat(answer).doesNotContain("star");
+ }
+
+ private static JdbcTemplate createJdbcTemplateWithConnectionToTestcontainer() {
+ org.postgresql.ds.PGSimpleDataSource ds = new org.postgresql.ds.PGSimpleDataSource();
+ ds.setUrl("jdbc:postgresql://localhost:" + postgresContainer.getMappedPort(5432) + "/postgres");
+ ds.setUser(postgresContainer.getUsername());
+ ds.setPassword(postgresContainer.getPassword());
+ return new JdbcTemplate(ds);
+ }
+
+ @org.springframework.context.annotation.Configuration
+ public static class OpenAiTestConfiguration {
+
+ @Bean
+ public OpenAiApi openAiApi() {
+ return OpenAiApi.builder().apiKey(getApiKey()).build();
+ }
+
+ private ApiKey getApiKey() {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ if (!org.springframework.util.StringUtils.hasText(apiKey)) {
+ throw new IllegalArgumentException(
+ "You must provide an API key. Put it in an environment variable under the name OPENAI_API_KEY");
+ }
+ return new SimpleApiKey(apiKey);
+ }
+
+ @Bean
+ public OpenAiChatModel openAiChatModel(OpenAiApi api) {
+ return OpenAiChatModel.builder()
+ .openAiApi(api)
+ .defaultOptions(OpenAiChatOptions.builder().model(OpenAiApi.ChatModel.GPT_4_O_MINI).build())
+ .build();
+ }
+
+ }
+
+}