In Part 1 of this series, we built a functional AI agent using Java, Spring AI, and Amazon Bedrock. However, we discovered a critical limitation: the agent couldn’t remember previous conversations. When we asked “What is my name?” after introducing ourselves, the agent had no recollection of our earlier interaction.

This lack of memory creates a frustrating user experience and limits the agent’s usefulness for real-world applications. Imagine a customer service agent that forgets your issue every time you send a message, or a travel assistant that can’t recall your preferences from previous conversations.

In this post, we’ll enhance our AI agent with a three-tier memory architecture that provides both short-term conversation context and long-term user knowledge. We’ll implement this incrementally, starting with persistent session memory, then adding conversation summaries, and finally user preferences—all backed by PostgreSQL for production reliability.

Overview of the Solution

What is Spring AI Chat Memory?

Spring AI provides Chat Memory components that automatically manage conversation history:

  • MessageWindowChatMemory: Maintains a sliding window of recent messages
  • Multiple Storage Options: InMemory, JDBC, Cassandra, MongoDB, Neo4j, Redis
  • Automatic Context Injection: Messages are automatically included in prompts to the AI model

The framework handles the complexity of managing conversation state, allowing you to focus on your application logic.

Choosing the Right Memory Store

Spring AI supports multiple memory storage backends:

Storage Type Use Case Pros Cons
InMemory Development, testing Fast, simple setup Lost on restart, not scalable
JDBC Production apps with existing DB Reliable, ACID compliant, familiar Requires database
Redis High-performance caching Very fast, distributed Additional infrastructure
Cassandra Massive scale Highly scalable Complex setup
MongoDB Document-oriented apps Flexible schema Additional infrastructure
Neo4j Graph-based relationships Rich relationship queries Specialized use case

We chose JDBC (PostgreSQL) because:

✅ Most Spring Boot applications already use a relational database
✅ No additional infrastructure needed
✅ ACID compliance ensures data reliability
✅ Familiar SQL tooling for debugging and monitoring
✅ Easy migration to managed services like Amazon Aurora

Prerequisites

Before you start, ensure you have:

  • Completed Part 1 of this series with the working ai-agent application
  • Java 21 JDK installed (Amazon Corretto 21)
  • Maven 3.6+ installed
  • Docker Desktop running (for Testcontainers to manage PostgreSQL)
  • AWS CLI configured with access to Amazon Bedrock

Navigate to your project directory from Part 1:

1
cd ai-agent

PostgreSQL with Testcontainers

We need a database for persistent memory storage. Manually installing and configuring PostgreSQL is tedious and error-prone. We want something that “just works” for development and easily transitions to production.

Why Testcontainers?

Testcontainers automatically manages Docker containers for your application. With container reuse enabled, the PostgreSQL container persists between application restarts—perfect for development!

Benefits:

  • ✅ No manual PostgreSQL installation
  • ✅ Automatic startup and configuration
  • ✅ Data persists between app restarts (with reuse)
  • ✅ Easy migration to production databases

When deploying to production, switch to Amazon Aurora PostgreSQL or any managed database by setting environment variables:

1
2
3
export SPRING_DATASOURCE_URL=jdbc:postgresql://aurora-cluster.region.rds.amazonaws.com:5432/ai_agent_db
export SPRING_DATASOURCE_USERNAME=admin
export SPRING_DATASOURCE_PASSWORD=secure_password

No code changes needed!

Add Dependencies

Open pom.xml and add these dependencies to the <dependencies> section:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- JDBC Memory -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

<!-- Testcontainers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>

Configure Database Properties

Add PostgreSQL and JDBC memory configuration to src/main/resources/application.properties:

1
2
3
4
5
6
7
8
9
10
11
cat >> src/main/resources/application.properties << 'EOF'

# PostgreSQL Configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/ai_agent_db
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver

# JDBC Memory Configuration
spring.ai.chat.memory.repository.jdbc.initialize-schema=always
EOF

Create TestAiAgentApplication

Create a test application that starts PostgreSQL automatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
mkdir -p src/test/java/com/example/ai/agent
cat <<'EOF' > src/test/java/com/example/ai/agent/TestAiAgentApplication.java
package com.example.ai.agent;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
import java.time.Duration;

/**
* Test application that starts with Testcontainers PostgreSQL.
* Run with: ./mvnw spring-boot:test-run
*
* Container reuse enabled: same container persists between restarts.
* To enable reuse: add "testcontainers.reuse.enable=true" to ~/.testcontainers.properties
*/
public class TestAiAgentApplication {

public static void main(String[] args) {
SpringApplication
.from(AiAgentApplication::main)
.with(TestcontainersConfig.class)
.run(args);
}

@TestConfiguration(proxyBeanMethods = false)
static class TestcontainersConfig {
@Bean
@ServiceConnection // Auto-configures DataSource from container
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("pgvector/pgvector:pg16"))
.withDatabaseName("ai_agent_db")
.withUsername("postgres")
.withPassword("postgres")
.withStartupTimeout(Duration.ofMinutes(5))
.withCreateContainerCmdModifier(cmd -> cmd.withName("ai-agent-postgres"))
.withReuse(true) // Reuse container between restarts
.waitingFor(Wait.defaultWaitStrategy());
}
}
}
EOF

To enable container reuse, ensure you have testcontainers.reuse.enable=true in ~/.testcontainers.properties:

1
echo "testcontainers.reuse.enable=true" >> ~/.testcontainers.properties

Test Testcontainers

Start the application with Testcontainers:

1
./mvnw spring-boot:test-run

You should see:

1
2
3
4
Creating container for image: pgvector/pgvector:pg16
Container pgvector/pgvector:pg16 is starting...
Container pgvector/pgvector:pg16 started
Started AiAgentApplication in X.XXX seconds

Success! The PostgreSQL container is now running!

You can verify with:

1
docker ps | grep ai-agent-postgres

Stop the application (Ctrl+C) and next time when you start again — the same container is reused with data intact!

Commit Changes

1
2
git add .
git commit -m "Add Testcontainers for PostgreSQL"

Three-Tier Memory Architecture

Real applications need multiple types of memory for different purposes. Users expect the agent to remember who they are (preferences), what they’ve discussed (context), and the current conversation flow (session).

Why Three Tiers?

Each tier serves a distinct purpose:

Tier Storage Purpose Example
Session 20 messages Current conversation flow “What did we just talk about?”
Context 10 summaries Historical discussions “What topics have we covered?”
Preferences 1 profile Static user information “Who is this user?”

This separation allows efficient memory management—session handles immediate context, context tracks decisions over time, and preferences store unchanging user details (name, email, dietary restrictions). All three tiers use JDBC for persistence across restarts.

Architecture Overview

1
2
3
4
5
6
7
8
9
10
11
User Question

[Session Memory] ← JDBC/PostgreSQL (20 recent messages)

[Context Memory] ← JDBC/PostgreSQL (10 conversation summaries)

[Preferences Memory] ← JDBC/PostgreSQL (1 user profile)

AI Model (Amazon Bedrock)

Response

Create ChatMemoryService

Create the service that manages all three memory tiers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
mkdir -p src/main/java/com/example/ai/agent/service
cat <<'EOF' > src/main/java/com/example/ai/agent/service/ChatMemoryService.java
package com.example.ai.agent.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.chat.memory.repository.jdbc.PostgresChatMemoryRepositoryDialect;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.sql.DataSource;
import java.util.List;

@Service
public class ChatMemoryService {
private static final Logger logger = LoggerFactory.getLogger(ChatMemoryService.class);
private static final int MAX_SESSION_MESSAGES = 20;
private static final int MAX_CONTEXT_SUMMARIES = 10;
private static final int MAX_PREFERENCES = 1;

// Three-tier memory: Session (recent), Context (summaries), Preferences (profile)
private final MessageWindowChatMemory sessionMemory;
private final MessageWindowChatMemory contextMemory;
private final MessageWindowChatMemory preferencesMemory;
private final ThreadLocal<String> currentUserId = ThreadLocal.withInitial(() -> "user1");

public ChatMemoryService(DataSource dataSource) {
// Single JDBC repository shared by all three memory tiers
var jdbcRepository = JdbcChatMemoryRepository.builder()
.dataSource(dataSource)
.dialect(new PostgresChatMemoryRepositoryDialect())
.build();

this.sessionMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepository)
.maxMessages(MAX_SESSION_MESSAGES)
.build();

this.contextMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepository)
.maxMessages(MAX_CONTEXT_SUMMARIES)
.build();

this.preferencesMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepository)
.maxMessages(MAX_PREFERENCES)
.build();
}

public Flux<String> callWithMemory(ChatClient chatClient, String prompt) {
String conversationId = getCurrentConversationId();

// 1. Auto-load previous context on first message
if (sessionMemory.get(conversationId).isEmpty()) {
loadPreviousContext(conversationId, chatClient);
}

// 2. Add user message to session memory
sessionMemory.add(conversationId, new UserMessage(prompt));

// 3. Stream AI response with full conversation history
StringBuilder fullResponse = new StringBuilder();
return chatClient
.prompt(new Prompt(sessionMemory.get(conversationId)))
.stream()
.content()
.doOnNext(fullResponse::append)
.doOnComplete(() -> {
// 4. Save complete response to session memory
String responseText = fullResponse.toString();
if (!responseText.isEmpty()) {
sessionMemory.add(conversationId, new AssistantMessage(responseText));
logger.info("Saved response to memory: {} chars", responseText.length());
}
});
}

private void loadPreviousContext(String conversationId, ChatClient chatClient) {
logger.info("Loading previous context for: {}", conversationId);

// 1. Load preferences (userId_preferences) and context (userId_context)
List<Message> preferences = preferencesMemory.get(conversationId + "_preferences");
String preferencesText = preferences.isEmpty() ? "" : preferences.get(0).getText();
List<Message> summaries = contextMemory.get(conversationId + "_context");

if (summaries.isEmpty() && preferencesText.isEmpty()) {
logger.info("No previous context found");
return;
}

logger.info("Found {} summaries, {} preferences", summaries.size(), preferences.isEmpty() ? 0 : 1);

// 2. Combine preferences and summaries
StringBuilder contextBuilder = new StringBuilder();
if (!preferencesText.isEmpty()) {
contextBuilder.append("User Preferences:\n").append(preferencesText).append("\n\n");
}
if (!summaries.isEmpty()) {
contextBuilder.append("Previous Conversations:\n");
summaries.forEach(msg -> contextBuilder.append(msg.getText()).append("\n\n"));
}

// 3. Summarize combined context with AI
var chatResponse = chatClient.prompt()
.user("Summarize this user information concisely:\n\n" + contextBuilder)
.call()
.chatResponse();

String contextSummary = (chatResponse != null &&
chatResponse.getResult() != null &&
chatResponse.getResult().getOutput() != null)
? chatResponse.getResult().getOutput().getText()
: null;

if (contextSummary == null || contextSummary.isEmpty()) {
logger.warn("Failed to generate context summary");
return;
}

// 4. Add context as system message to session memory
String contextMessage = String.format(
"You are continuing a conversation with this user. Here is what you know:\n\n%s\n\n" +
"Use this information to provide personalized responses.",
contextSummary
);
sessionMemory.add(conversationId, new SystemMessage(contextMessage));
logger.info("Loaded context summary");
}

// Getters and setters
public MessageWindowChatMemory getSessionMemory() {
return sessionMemory;
}

public MessageWindowChatMemory getContextMemory() {
return contextMemory;
}

public MessageWindowChatMemory getPreferencesMemory() {
return preferencesMemory;
}

public void setCurrentUserId(String userId) {
this.currentUserId.set(userId.toLowerCase());
}

public String getCurrentConversationId() {
return currentUserId.get();
}
}
EOF

Update ChatService

Update ChatService to use memory:

src/main/java/com/example/ai/agent/service/ChatService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
private final ChatMemoryService chatMemoryService;

public ChatService(ChatMemoryService chatMemoryService,
ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder
.defaultSystem(SYSTEM_PROMPT)
.build();

this.chatMemoryService = chatMemoryService;
}

public Flux<String> processChat(String prompt) {
logger.info("Processing chat: '{}'", prompt);
try {
return chatMemoryService.callWithMemory(chatClient, prompt);
} catch (Exception e) {
logger.error("Error processing chat", e);
return Flux.just("I don't know - there was an error processing your request.");
}
}
...

Conversation Summary

Session memory holds 20 recent messages, but long conversations exceed this limit. We need to summarize conversations and extract two types of information:

  1. Context: What was discussed, decisions made, topics covered
  2. Preferences: Who the user is, their preferences, static information

Why Separate Context and Preferences?

  • Context changes: Each conversation adds new topics and decisions
  • Preferences persist: User’s name, email, dietary restrictions don’t change that often
  • Efficient updates: Only update what changed, preserve what didn’t

Create ConversationSummaryService

Create a service that extracts both context and preferences:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
cat <<'EOF' > src/main/java/com/example/ai/agent/service/ConversationSummaryService.java
package com.example.ai.agent.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

@Service
public class ConversationSummaryService {
private static final Logger logger = LoggerFactory.getLogger(ConversationSummaryService.class);
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd HHmmss");

private final ChatMemoryService chatMemoryService;
private final ChatClient chatClient;

public ConversationSummaryService(ChatMemoryService chatMemoryService,
ChatClient.Builder chatClientBuilder) {
this.chatMemoryService = chatMemoryService;
this.chatClient = chatClientBuilder.build();
}

public String summarizeAndSave(String conversationId) {
logger.info("Summarizing conversation: {}", conversationId);

// 1. Get session messages and existing preferences
List<Message> messages = chatMemoryService.getSessionMemory().get(conversationId);
if (messages.isEmpty()) {
return "No conversation to summarize";
}

List<Message> existingPrefs = chatMemoryService.getPreferencesMemory().get(conversationId + "_preferences");
String existingPrefsText = existingPrefs.isEmpty() ? "" : existingPrefs.get(0).getText();

// 2. Convert messages to text
String messagesText = messages.stream()
.filter(msg -> msg.getText() != null && !msg.getText().isEmpty())
.map(msg -> msg.getMessageType() + ": " + msg.getText())
.reduce((a, b) -> a + "\n" + b)
.orElse("");

if (messagesText.isEmpty()) {
return "No valid conversation content to summarize";
}

logger.info("Summarizing {} characters", messagesText.length());

// 3. Generate AI summary (preferences + context)
String preferencesPrompt = existingPrefsText.isEmpty()
? "Extract static user information (name, email, preferences). If none, return empty."
: "Merge preferences. Keep existing:\n" + existingPrefsText +
"\nAdd new from conversation. Update only if explicitly changed. Keep existing if unchanged.";

var chatResponse = chatClient.prompt()
.user("Analyze this conversation and provide TWO separate summaries:\n\n" +
"PREFERENCES:\n" + preferencesPrompt + "\n\n" +
"CONTEXT:\n" +
"Summarize: topics discussed, questions asked, decisions made, pending items.\n" +
"DO NOT include: prices, dates, flight numbers, hotel names, policies.\n\n" +
"Output format:\n===PREFERENCES===\n[preferences]\n===CONTEXT===\n[context]\n\n" +
"Conversation:\n" + messagesText)
.call()
.chatResponse();

String response = (chatResponse != null &&
chatResponse.getResult() != null &&
chatResponse.getResult().getOutput() != null)
? chatResponse.getResult().getOutput().getText()
: null;

if (response == null || response.trim().isEmpty()) {
throw new RuntimeException("Failed to generate summary");
}

// 4. Parse preferences and context
String preferences = extractSection(response, "===PREFERENCES===", "===CONTEXT===");
String context = extractSection(response, "===CONTEXT===", null);

if (context.isEmpty() && preferences.isEmpty()) {
context = response; // Fallback: use full response as context
}

logger.info("Extracted preferences: {} chars, context: {} chars", preferences.length(), context.length());

// 5. Save to memory tiers
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);

if (!preferences.isEmpty()) {
chatMemoryService.getPreferencesMemory()
.add(conversationId + "_preferences", new AssistantMessage(preferences));
}

if (!context.isEmpty()) {
chatMemoryService.getContextMemory()
.add(conversationId + "_context", new AssistantMessage("[" + timestamp + "] " + context));
}

// 6. Clear session memory
chatMemoryService.getSessionMemory().clear(conversationId);
logger.info("Summary saved, session cleared");

return formatSummaryResponse(timestamp, preferences, context);
}

private String extractSection(String response, String startMarker, String endMarker) {
try {
if (!response.contains(startMarker)) {
return "";
}
int start = response.indexOf(startMarker) + startMarker.length();
int end = endMarker != null && response.contains(endMarker)
? response.indexOf(endMarker)
: response.length();
return end > start ? response.substring(start, end).trim() : "";
} catch (Exception e) {
logger.warn("Failed to extract section: {}", startMarker, e);
return "";
}
}

private String formatSummaryResponse(String timestamp, String preferences, String context) {
return "**Summary:** [" + timestamp + "]\n\n" +
"**Preferences:**\n" + (preferences.isEmpty() ? "None" : preferences) +
"\n\n**Context:**\n" + context;
}
}
EOF

Update ChatController and WebViewController

Update ChatController to add multi-user support and the summarize endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
cat > src/main/java/com/example/ai/agent/controller/ChatController.java <<'EOF'
package com.example.ai.agent.controller;

import com.example.ai.agent.service.ChatService;
import com.example.ai.agent.service.ChatMemoryService;
import com.example.ai.agent.service.ConversationSummaryService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import java.security.Principal;

@RestController
@RequestMapping("api/chat")
public class ChatController {
private static final Logger logger = LoggerFactory.getLogger(ChatController.class);

private final ChatService chatService;
private final ChatMemoryService chatMemoryService;
private final ConversationSummaryService summaryService;

public ChatController(ChatService chatService,
ChatMemoryService chatMemoryService,
ConversationSummaryService summaryService) {
this.chatService = chatService;
this.chatMemoryService = chatMemoryService;
this.summaryService = summaryService;
}

@PostMapping(value = "message", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Flux<String> chat(@RequestBody ChatRequest request, Principal principal) {
String userId = getUserId(request.userId(), principal);
chatMemoryService.setCurrentUserId(userId);
return chatService.processChat(request.prompt());
}

@PostMapping("summarize")
public String summarize(@RequestBody(required = false) SummarizeRequest request, Principal principal) {
try {
String userId = getUserId(request != null ? request.userId() : null, principal);
return summaryService.summarizeAndSave(userId);
} catch (Exception e) {
logger.error("Error summarizing conversation", e);
return "Failed to summarize conversation. Please try again.";
}
}

private String getUserId(String requestUserId, Principal principal) {
// Production: use authenticated user from Spring Security
if (principal != null) {
return principal.getName().toLowerCase();
}
// Development: use provided userId or default
return requestUserId != null ? requestUserId.toLowerCase() : "user1";
}

public record ChatRequest(String prompt, String userId) {}
public record SummarizeRequest(String userId) {}
}
EOF

Update WebViewController to enable multi-user UI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cat > src/main/java/com/example/ai/agent/controller/WebViewController.java <<'EOF'
package com.example.ai.agent.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.ui.Model;
import org.springframework.beans.factory.annotation.Value;

@Controller
public class WebViewController {

@Value("${ui.features.multi-user:true}")
private boolean multiUserEnabled;

@GetMapping("/")
public String index(Model model) {
model.addAttribute("multiUserEnabled", multiUserEnabled);
return "chat";
}
}
EOF

Test Complete Three-Tier Memory

Start the application:

1
./mvnw spring-boot:test-run

Test the complete flow with Alice:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Alice introduces herself
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "Hi, my name is Alice and my email is [email protected]", "userId": "alice"}' \
--no-buffer

# Alice shares travel plans
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "I am planning a trip to Paris next week for a conference", "userId": "alice"}' \
--no-buffer

# Summarize Alice's conversation
curl -X POST http://localhost:8080/api/chat/summarize \
-H "Content-Type: application/json" \
-d '{"userId": "alice"}'

You should see output like:

1
2
3
4
5
6
7
8
**Summary:** [20251108 162743]

**Preferences:**
Name: Alice
Email: [email protected]

**Context:**
Alice is planning a business conference trip to Paris next week. The assistant offered comprehensive travel assistance and asked for additional details including travel dates, conference venue location, accommodation status, departure city, expense reimbursement information, budget planning needs, travel preferences, and whether she needs recommendations or policy guidance. Alice has not yet provided responses to these questions, leaving multiple planning aspects pending.%

You can also test in the UI at http://localhost:8080 - use the “📝 Summarize” button and switch between users with the User ID field.

Bob chat

Test with Bob to verify multi-user isolation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Bob introduces himself
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "Hi, my name is Bob and my email is [email protected]", "userId": "bob"}' \
--no-buffer

# Bob shares different travel plans
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "I need to book a flight to Tokyo for next month", "userId": "bob"}' \
--no-buffer

# Summarize Bob's conversation
curl -X POST http://localhost:8080/api/chat/summarize \
-H "Content-Type: application/json" \
-d '{"userId": "bob"}'

Bob chat summary

Now test if Alice’s agent remembers her information (not Bob’s):

1
2
3
4
5
6
7
8
9
10
11
12
13
# Ask about Alice's email
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "What is my email address?", "userId": "alice"}' \
--no-buffer
# Response: "Your email address is [email protected]"

# Ask about Alice's travel plans
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "What are my recent travel plans?", "userId": "alice"}' \
--no-buffer
# Response includes: Paris, conference, next week (NOT Tokyo)

Success! All three memory tiers work together with proper user isolation!

Let’s continue the chat:

1
2
3
4
5
curl -X POST http://localhost:8080/api/chat/message \
-H "Content-Type: application/json" \
-d '{"prompt": "What is the hotel budget for France?", "userId": "alice"}' \
--no-buffer
# Response: "I don't have information about your specific hotel budget for France or your company's travel policy limits for French accommodations."

Problem: The agent doesn’t have access to company-specific knowledge like travel policies.

Alice chat problem

We’ll address this in the next part of the series! Stay tuned!

Cleanup

To stop the application, press Ctrl+C in the terminal where it’s running.

The PostgreSQL container will continue running (due to withReuse(true)). If necessary, stop and remove it:

1
2
docker stop ai-agent-postgres
docker rm ai-agent-postgres

(Optional) To remove all data and start fresh:

1
docker volume prune

Commit Changes

1
2
git add .
git commit -m "Add conversation summarization with context and preferences"

Conclusion

In this post, we’ve enhanced our AI agent with a production-ready three-tier memory system:

  • Session Memory: 20 recent messages persisted in PostgreSQL
  • Context Memory: 10 conversation summaries with timestamps
  • Preferences Memory: Long-term user profile information
  • Auto-load: Previous context loaded automatically on first message
  • Multi-user support: Isolated conversations per user
  • AI-powered summarization: Extracts both context and preferences
  • Testcontainers: Zero-configuration PostgreSQL for development

The three-tier memory architecture provides the foundation for personalized, context-aware AI agents that remember both who users are and what they’ve discussed.

What’s Next

Add knowledge to our AI agent. The agent will finally be able to answer questions like “What is the hotel budget for France?” by searching through travel and expense policies.

Challenges and Solutions - Part 2

Learn More

Let’s continue building intelligent Java applications with Spring AI!

Comments