course
In this tutorial, we'll walk through a small Java module that exposes custom tools, stores notes as JSON in Oracle AI Database, and retrieves them with full-text search.
If you're building AI agents, short-term chat history is usually not enough. Real assistants often need a small amount of durable memory so they can store important facts and retrieve them later.
This tutorial shows how to build that pattern with a minimal Java module. You'll use LangChain4j tools, a simple agent interface, and Oracle AI Database as the backing store. Instead of adding embeddings or a full retrieval pipeline, this sample keeps the design intentionally small: notes are saved as JSON documents and searched with Oracle Text.
By the end, you will understand how the module wires together:
- A
LangChain4jagent interface - Two custom
@Toolmethods - A
JDBCrepository for durable note storage - A console application that ties the whole flow together
What Is LangChain4j Tool-Based Memory?
LangChain4j lets you expose ordinary Java methods as tools that a model can call when it needs external capabilities. In this module, those capabilities are memory-oriented:
searchMemories(String question)finds relevant saved notes-
storeMemory(String note)writes a new durable note
That is different from standard chat history that stores chat messages in an in-memory context window.
Persistent tool-based memory decides when it needs to search or store notes, and the underlying data lives in a database.
This pattern is useful when you want an agent to remember facts like incident notes, runbook reminders, or follow-up actions without building a larger retrieval system too early.
Persistent, database-backed memory can also be accessed concurrently by one or more agents without data contention.

Tool-based memory lets the assistant retrieve existing notes or store new notes in a durable database.
Key Features of the LangChain4j and Oracle AI DB Module
This sample is small, but it demonstrates several important agent design ideas.
Custom LangChain4j tools
The MemoryTools class exposes two methods annotated with @Tool. That means LangChain4j can make them available to the agent without extra wrapper code.
// Retrieve relevant notes and format them for the assistant.
@Tool(
"Search saved memory notes when earlier context might help answer the user."
)
public String searchMemories(String question) {
List<MemoryRepository.Note> matches = repository.search(question, 3);
if (matches.isEmpty()) {
return "No saved memory matched that question.";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < matches.size(); i++) {
MemoryRepository.Note note = matches.get(i);
builder.append(i + 1)
.append(". [M")
.append(note.id())
.append("] ")
.append(note.text());
if (i + 1 < matches.size()) {
builder.append('\n');
}
}
return builder.toString();
}
This method does two useful things. It delegates retrieval to the repository, and it formats the results in a model-friendly way such as [M12] some note text.
That makes the tool output easy for the agent to cite in its final response.
Durable memory in Oracle AI Database
Notes are stored in an agent_memories table as native JSON documents. Each row contains:
- An identity column
id - A JSON column named
memory_doc - A
created_attimestamp
The module initializes the schema automatically:
-- Create a JSON-backed table for durable agent notes.
CREATE TABLE IF NOT EXISTS agent_memories (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
memory_doc JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
This keeps the storage model simple while still giving you structured data and database-managed durability.
Keyword search with Oracle Text
The repository does not generate embeddings. Instead, it extracts keywords from the user’s question, removes stop words, builds an Oracle Text expression with ACCUM, and searches the JSON document content with json_textcontains.
That is especially helpful for identifiers and operational terms like CHG2145, where exact or near-exact token matching often matters more than semantic similarity.
A minimal console workflow
The Application class creates the repository, initializes the schema, seeds a few notes, constructs the chat model, and starts a terminal loop. That makes the example easy to run and easy to adapt into another service later.
Getting Started With Your LangChain4j Memory Project
Before running the module, make sure your local environment matches the project’s requirements.
Prerequisites
You need:
- Java 21 or later
- Maven 3.9 or later
- Oracle AI Database Free running locally or remotely
- An
OPENAI_API_KEYenvironment variable
The Maven project depends on:
langchain4jlangchain4j-open-aiojdbc11- JUnit and
Testcontainersfor integration tests
Run the sample
Set your OpenAI API key first:
# Set the API key used by the chat model.
export OPENAI_API_KEY=<your key>
Then run the app from the project directory:
# Compile and run the sample with the database connection details.
mvn compile exec:java \
-Dexec.args="jdbc:oracle:thin:@localhost:1521/freepdb1 testuser testpwd"
The application expects exactly three arguments:
1. JDBC URL
2. Database username
3. Database password
If those are missing, Application.main() throws an IllegalArgumentException. If OPENAI_API_KEY is missing, it throws an IllegalStateException.
What happens on startup
When the app starts, it:
1. Creates a MemoryRepository
2. Initializes the database schema
3. Seeds three sample notes if the table is empty
4. Builds an OpenAiChatModel
5. Wires the model and tools into AiServices
6. Starts a CLI loop that reads user input until you type exit
That means you can try memory retrieval immediately without manually inserting rows first.
How to Build a Memory-Enabled Java Agent With LangChain4j
Now let’s walk through the module in the same order the application uses it.
Define the agent interface
LangChain4j uses a Java interface to describe the agent contract. In this project, that interface is MemoryAssistant.
// Define the interface that receives each user message.
public interface MemoryAssistant {
@SystemMessage("""
You are a simple assistant with access to durable memory notes.
Use searchMemories when earlier notes might help answer the user.
If the user explicitly asks you to remember something for later, call storeMemory.
Keep answers concise.
""")
@UserMessage("{{message}}")
String chat(@V("message") String message);
}
The important part is the system message. It tells the model when to use each tool:
- Search when earlier notes might help
- Store when the user explicitly asks the assistant to remember something
That is a good example of lightweight tool policy. The prompt is short, but it is specific enough to guide tool selection.
Expose memory operations as tools
The agent does not talk to the repository directly. Instead, it calls the MemoryTools layer.
Here is the storage method:
// Save a note and return its generated memory identifier.
@Tool(
"Store a new memory note when the user asks you to remember something "
+ "important for later."
)
public String storeMemory(String note) {
long id = repository.store(note);
return "Stored memory M" + id + ".";
}
This is intentionally compact. A user might say:
Remember that the next shift should verify the checkout retry queue stays below 50.
If the model follows the system prompt correctly, it calls storeMemory(), the repository persists the note, and the tool returns something like:
Stored memory M4.
Later, the user can ask:
What should the next shift verify?
At that point, the agent can call searchMemories() and use the returned note to answer.
Create the repository layer
The repository is where most of the durable memory logic lives. It is still plain JDBC, which makes the design easy to inspect.
At a high level, MemoryRepository is responsible for:
- Schema creation
- Optional seeding
- Note insertion
- Keyword extraction
Oracle Textsearch- Fallback retrieval of recent notes when there are no useful keywords
The insertion path normalizes and validates note text before storing it:
// Normalize the note, store its JSON document, and return the generated ID.
public long store(String note) {
String normalized = normalizeNote(note);
Instant createdAt = Instant.now();
try (
Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(
INSERT_SQL,
new String[]{"id"})
) {
statement.setObject(
1,
toJsonDocument(normalized, createdAt),
OracleTypes.JSON
);
statement.setTimestamp(2, Timestamp.from(createdAt));
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) {
return keys.getLong(1);
}
}
throw new IllegalStateException(
"No generated id returned for stored memory."
);
} catch (SQLException e) {
throw new RuntimeException("Failed to store memory", e);
}
}
The JSON document itself looks like this:
{
"noteText": "Runbook note: use section 3 to validate checkout recovery after a rollback.",
"createdAt": "2026-04-27T17:00:00Z"
}
This structure is simple, but it is enough to support both persistence and text search.
Understand the search strategy
The most interesting part of the repository is the search() method:
// Search by extracted keywords or fall back to recent notes.
public List<Note> search(String question, int maxResults) {
List<String> keywords = extractKeywords(question);
if (keywords.isEmpty()) {
return findRecent(maxResults);
}
return textSearch(buildTextExpression(keywords), maxResults);
}
This method follows a practical flow:
1. Tokenize the user question
2. Lowercase the tokens
3. Remove stop words such as the, what, and remember
4. Keep meaningful short tokens when they include digits
5. Build an Oracle Text expression with ACCUM
6. Search the JSON data with json_textcontains
That “digits are allowed” detail is important. It helps terms like CHG2145 survive keyword extraction, which is exactly the kind of identifier-heavy lookup many operational agents need.

The search workflow converts a question into a keyword expression and returns ranked memory notes.
The SQL search query is:
-- Rank matching notes by Oracle Text relevance.
SELECT id,
JSON_VALUE(
memory_doc,
'$.noteText' RETURNING VARCHAR2(2000 CHAR)
) AS note_text,
created_at,
SCORE(1) AS text_score
FROM agent_memories
WHERE JSON_TEXTCONTAINS(memory_doc, '$', ?, 1)
ORDER BY SCORE(1) DESC, id DESC
FETCH FIRST ? ROWS ONLY
If the question does not yield useful keywords, the repository falls back to recent notes. That gives the agent a reasonable default instead of returning an empty search too early.
Wire everything together in the application
The application ties the pieces together with AiServices.builder():
// Build the chat model, then attach the memory tools to the assistant.
ChatModel chatModel = OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-5-nano")
.build();
MemoryAssistant assistant = AiServices.builder(MemoryAssistant.class)
.chatModel(chatModel)
.tools(new MemoryTools(repository))
.build();
This is the full pattern in one place:
- Build a chat model
- Expose tool methods
- Bind those tools to an interface-driven agent
After that, the console loop simply passes user input into assistant.chat(input).
Try the demo prompts
The application prints a few starter prompts:
What do we know about CHG2145?
Remember that the next shift should verify the checkout retry queue stays below 50.
What should the next shift verify?
These prompts are useful because they exercise the full memory lifecycle:
- Retrieve an existing seeded note
- Store a new note
- Retrieve the newly stored note later
Tips and Troubleshooting
This module is intentionally narrow, which is a strength when you are learning the pattern. Still, there are a few practical details to keep in mind.
Keep the tool instructions explicit
The system prompt tells the model to store memory only when the user explicitly asks for it. That helps reduce accidental writes. If you broaden the prompt carelessly, the model may start persisting low-value notes.
Treat this as durable note memory, not full knowledge retrieval
This repository is best for short operational notes, reminders, and identifiers. It is not yet a full retrieval-augmented generation system with embeddings, chunking, hybrid search, or ranking.
Use Oracle Text when identifiers matter
For strings like incident IDs, ticket numbers, and change request references, keyword-oriented retrieval is often a better starting point than semantic search. This sample reflects that tradeoff clearly.
Validate your database and Docker setup separately
The project’s tests are integration tests powered by Testcontainers. If Docker is not available, mvn test will fail before the repository logic runs. That is an environment issue, not necessarily an application bug.
Run the tests with:
# Run the integration tests.
mvn test
The tests cover two important behaviors:
- Notes can be stored and retrieved through the repository
- Tool output includes memory IDs and relevant note text
Start simple before adding more memory features
A common mistake in agent projects is adding vector storage, conversation history, summarization, and retrieval policies all at once. This sample shows a better progression:
1. Define a concrete memory use case
2. Expose explicit tools
3. Back those tools with durable storage
4. Verify the retrieval behavior
5. Only then consider more advanced memory patterns
Keep Learning
If you want to keep building agent skills after this tutorial, these DataCamp resources are a good next step:
Conclusion
You have now seen how to build a small but useful memory-enabled agent with LangChain4j, Java, and Oracle AI Database. The core idea is simple: give the model explicit memory tools, store notes durably as JSON, and retrieve them with a straightforward search strategy.
This design is a strong foundation because it is easy to understand and easy to extend. Once this pattern is working, you can add better filtering, richer note schemas, user scoping, or even vector search if the use case demands it.
If you want to keep learning about agent design, the next good step is to compare this tool-based memory pattern with broader agent frameworks and orchestration workflows. In particular, it is worth exploring how explicit tools, retrieval patterns, and multi-step agents fit together in larger AI systems.
FAQs
What kind of memory does this tutorial implement?
This tutorial implements tool-based durable memory. Notes are stored in a database table and retrieved through explicit tool calls instead of being appended automatically to the prompt window.
Does this project use vector embeddings?
No. This sample deliberately avoids embeddings, vector search, and reranking. It uses Oracle Text with json_textcontains to search note content.
Why use tools for memory instead of keeping everything in chat history?
Tool-based memory gives you more control over what gets stored, when it gets stored, and how it gets retrieved. It also works better for durable notes that need to survive beyond a single conversation.
What do I need to run the live demo?
You need Java 21 or later, Maven 3.9 or later, access to Oracle AI Database, and an OPENAI_API_KEY in your shell.
Do the tests need Docker?
Yes. The integration tests use Testcontainers to start Oracle AI Database Free, so Docker must be available in your environment.
As a Developer Evangelist at Oracle, I help developers build modern apps, including microservices, event-driven systems, cloud-native architectures, and more. If you're working with tools like Kubernetes, Spring Boot, or Kafka, and wondering how Oracle fits into that world, that's where I come in.My goal is to share practical, hands-on guidance that helps you build faster, ship smarter, and make the most of Oracle's database tech. If you're using JSON, event streaming, vector search, or just figuring out how to get started, I'm here to make things clearer and more developer-friendly.


