From 16d6781675608049262a67434d4aac4c91153823 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sun, 11 Oct 2026 07:41:41 +0000 Subject: [PATCH] Add ai-services module: prompt templates, per-id chat memory, tools, RAG, and the @MemoryId trap, each with a captured transcript Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk --- .gitignore | 4 + README.md | 31 ++++ ai-services/output/01-prompt-template.txt | 5 + ai-services/output/02-memory.txt | 11 ++ ai-services/output/03-tools.txt | 10 ++ ai-services/output/04-rag.txt | 5 + ai-services/output/05-memoryid-trap.txt | 2 + ai-services/pom.xml | 43 +++++ .../com/ankurm/lc4j/aiservices/ChatAgent.java | 15 ++ .../lc4j/aiservices/KnowledgeAgent.java | 6 + .../ankurm/lc4j/aiservices/OrderTools.java | 23 +++ .../lc4j/aiservices/ScriptedChatModel.java | 63 +++++++ .../ankurm/lc4j/aiservices/SupportAgent.java | 16 ++ .../lc4j/aiservices/AiServicesTest.java | 159 ++++++++++++++++++ .../ankurm/lc4j/aiservices/Transcript.java | 23 +++ pom.xml | 65 +++++++ 16 files changed, 481 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ai-services/output/01-prompt-template.txt create mode 100644 ai-services/output/02-memory.txt create mode 100644 ai-services/output/03-tools.txt create mode 100644 ai-services/output/04-rag.txt create mode 100644 ai-services/output/05-memoryid-trap.txt create mode 100644 ai-services/pom.xml create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java create mode 100644 ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java create mode 100644 ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8932492 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# langchain4j-demo + +Companion code for the ankurm.com LangChain4j posts. Every number and transcript quoted in a post +comes from a file in a module's `output/` folder, and the tests that write those files also assert +the same facts, so the build fails when a claim stops being true. + +| Module | Post | What it shows | +|---|---|---| +| `ai-services` | LangChain4j AI Services with Spring Boot 4: Declarative LLM Interfaces | prompt templates, chat memory, tools, RAG, and the `@MemoryId` trap | + +## Versions (verified on Maven Central, 2026-10-11) + +| Component | Version | Note | +|---|---|---| +| LangChain4j (`langchain4j`, `langchain4j-core`, BOM) | 1.22.0 | GA | +| LangChain4j beta modules (starters, embeddings, agentic) | 1.22.0-beta32 | beta only, no GA exists | +| JDK | 25 LTS | | +| JUnit | 6.1.3 | | + +## Run + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn test +``` + +No API key is needed. The tests use `ScriptedChatModel`, a deterministic stand-in for an LLM, so +they can assert exactly which messages LangChain4j sent. Replace it with `OpenAiChatModel` or any +other `ChatModel` and the AI Service interfaces do not change. + +Each module writes its transcripts to `/output/` when you run the tests. diff --git a/ai-services/output/01-prompt-template.txt b/ai-services/output/01-prompt-template.txt new file mode 100644 index 0000000..2069956 --- /dev/null +++ b/ai-services/output/01-prompt-template.txt @@ -0,0 +1,5 @@ +Messages LangChain4j sent to the model: +SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence. +USER: Customer asks: How do I reset my password? + +Reply: Reset it from Settings > Security. diff --git a/ai-services/output/02-memory.txt b/ai-services/output/02-memory.txt new file mode 100644 index 0000000..1c0fe3e --- /dev/null +++ b/ai-services/output/02-memory.txt @@ -0,0 +1,11 @@ +Messages the model saw on each call (memory window = 4 stored messages): +call 1: 1 messages +call 2: 3 messages +call 3: 1 messages +call 4: 5 messages +call 5: 5 messages + +Second call, in full: +USER: My name is Alice +AI: noted (1 messages seen) +USER: What is my name? diff --git a/ai-services/output/03-tools.txt b/ai-services/output/03-tools.txt new file mode 100644 index 0000000..007810f --- /dev/null +++ b/ai-services/output/03-tools.txt @@ -0,0 +1,10 @@ +Tool specification sent with the first request: [orderStatus - Looks up the shipping status of an order by its id] + +Second request (after the tool ran): +USER: Where is order A-1001? +AI: tool calls [orderStatus{"orderId":"A-1001"}] +TOOL_EXECUTION_RESULT: Order A-1001 shipped on 2026-10-08, arriving 2026-10-13 + +Java methods actually executed: [orderStatus(A-1001)] +Model round trips: 2 +Final reply: Your order is on its way and arrives on 2026-10-13. diff --git a/ai-services/output/04-rag.txt b/ai-services/output/04-rag.txt new file mode 100644 index 0000000..69dcbcb --- /dev/null +++ b/ai-services/output/04-rag.txt @@ -0,0 +1,5 @@ +What the model received after retrieval (maxResults=1): +USER: How long do refunds take? + +Answer using the following information: +Refunds are issued to the original payment method within 5 business days. diff --git a/ai-services/output/05-memoryid-trap.txt b/ai-services/output/05-memoryid-trap.txt new file mode 100644 index 0000000..238844d --- /dev/null +++ b/ai-services/output/05-memoryid-trap.txt @@ -0,0 +1,2 @@ +Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider: +IllegalConfigurationException: In order to use @MemoryId, please configure the ChatMemoryProvider on the 'com.ankurm.lc4j.aiservices.AiServicesTest$MemoryIdMixed'. diff --git a/ai-services/pom.xml b/ai-services/pom.xml new file mode 100644 index 0000000..7151f6b --- /dev/null +++ b/ai-services/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + + + ai-services + + + + dev.langchain4j + langchain4j + + + dev.langchain4j + langchain4j-embeddings-all-minilm-l6-v2 + ${langchain4j.beta.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java new file mode 100644 index 0000000..caeff26 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java @@ -0,0 +1,15 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.UserMessage; + +/** + * Kept separate from {@link SupportAgent} on purpose: a single @MemoryId parameter anywhere in an + * interface makes AiServices demand a ChatMemoryProvider for the whole interface (see the + * memoryIdTrap test and output/05-memoryid-trap.txt). + */ +public interface ChatAgent { + + /** With a ChatMemoryProvider, each @MemoryId gets its own conversation history. */ + String chat(@MemoryId String customerId, @UserMessage String message); +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java new file mode 100644 index 0000000..b3d83b8 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java @@ -0,0 +1,6 @@ +package com.ankurm.lc4j.aiservices; + +/** Used for the RAG example: same idea, the interface does not mention retrieval at all. */ +public interface KnowledgeAgent { + String ask(String question); +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java new file mode 100644 index 0000000..242a006 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java @@ -0,0 +1,23 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.util.ArrayList; +import java.util.List; + +/** A plain Java object. The model never runs this code; it only asks LangChain4j to run it. */ +public class OrderTools { + + private final List calls = new ArrayList<>(); + + @Tool("Looks up the shipping status of an order by its id") + public String orderStatus(@P("the order id, for example A-1001") String orderId) { + calls.add("orderStatus(" + orderId + ")"); + return "Order " + orderId + " shipped on 2026-10-08, arriving 2026-10-13"; + } + + public List calls() { + return calls; + } +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java new file mode 100644 index 0000000..66b77c9 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java @@ -0,0 +1,63 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * A deterministic stand-in for a real LLM. It lets every example run offline and lets the + * tests assert exactly which messages LangChain4j sent, which is the thing worth looking at. + * Swap it for OpenAiChatModel (or any other ChatModel) and the AI Service code does not change. + */ +public class ScriptedChatModel implements ChatModel { + + private final Function script; + private final List requests = new ArrayList<>(); + + public ScriptedChatModel(Function script) { + this.script = script; + } + + @Override + public ChatResponse doChat(ChatRequest request) { + requests.add(request); + return ChatResponse.builder().aiMessage(script.apply(request)).build(); + } + + /** Every request this model has received, oldest first. */ + public List requests() { + return requests; + } + + public ChatRequest lastRequest() { + return requests.get(requests.size() - 1); + } + + /** Renders the messages of a request as one line per message, for transcripts. */ + public static String render(List messages) { + StringBuilder sb = new StringBuilder(); + for (ChatMessage m : messages) { + sb.append(m.type()).append(": ").append(text(m)).append('\n'); + } + return sb.toString(); + } + + private static String text(ChatMessage m) { + return switch (m) { + case dev.langchain4j.data.message.SystemMessage s -> s.text(); + case dev.langchain4j.data.message.UserMessage u -> u.singleText(); + case AiMessage a -> a.hasToolExecutionRequests() + ? "tool calls " + a.toolExecutionRequests().stream() + .map(r -> r.name() + r.arguments()).toList() + : a.text(); + case dev.langchain4j.data.message.ToolExecutionResultMessage t -> t.text(); + default -> m.toString(); + }; + } +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java new file mode 100644 index 0000000..317dd41 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java @@ -0,0 +1,16 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * The whole point of an AI Service: you write an interface, LangChain4j writes the implementation. + * Annotations become the system message and the user message template. + */ +public interface SupportAgent { + + @SystemMessage("You are a concise support agent for {{product}}. Answer in one sentence.") + @UserMessage("Customer asks: {{question}}") + String answer(@V("product") String product, @V("question") String question); +} diff --git a/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java new file mode 100644 index 0000000..ce1e5a1 --- /dev/null +++ b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java @@ -0,0 +1,159 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiServicesTest { + + @Test + void promptTemplateBecomesSystemAndUserMessages() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("Reset it from Settings > Security.")); + SupportAgent agent = AiServices.create(SupportAgent.class, model); + + String reply = agent.answer("Acme Vault", "How do I reset my password?"); + + String sent = ScriptedChatModel.render(model.lastRequest().messages()); + Transcript.write("01-prompt-template.txt", "Messages LangChain4j sent to the model:\n" + sent + "\nReply: " + reply + "\n"); + + assertThat(sent).contains("SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence."); + assertThat(sent).contains("USER: Customer asks: How do I reset my password?"); + assertThat(reply).isEqualTo("Reset it from Settings > Security."); + } + + @Test + void memoryReplaysEarlierMessagesAndIsKeptPerMemoryId() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("noted (" + r.messages().size() + " messages seen)")); + ChatAgent agent = AiServices.builder(ChatAgent.class) + .chatModel(model) + .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(4)) + .build(); + + agent.chat("alice", "My name is Alice"); + agent.chat("alice", "What is my name?"); + agent.chat("bob", "Hello"); + agent.chat("alice", "third alice message"); + agent.chat("alice", "fourth alice message"); + + StringBuilder sb = new StringBuilder("Messages the model saw on each call (memory window = 4 stored messages):\n"); + int i = 1; + for (var req : model.requests()) { + sb.append("call ").append(i++).append(": ").append(req.messages().size()).append(" messages\n"); + } + sb.append("\nSecond call, in full:\n").append(ScriptedChatModel.render(model.requests().get(1).messages())); + Transcript.write("02-memory.txt", sb.toString()); + + // call 2 replays call 1 (user + ai) and adds the new user message + assertThat(model.requests().get(1).messages()).hasSize(3); + // bob has his own history, so his first call sees exactly one message + assertThat(model.requests().get(2).messages()).hasSize(1); + // The window caps what is STORED (4). The request carries the stored window plus the incoming + // user message, so the model sees 5. Observed on 1.22.0; see output/02-memory.txt. + assertThat(model.requests().get(4).messages()).hasSize(5); + } + + @Test + void memoryIdTrap() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok")); + Throwable failure = org.assertj.core.api.Assertions.catchThrowable( + () -> AiServices.create(MemoryIdMixed.class, model)); + + Transcript.write("05-memoryid-trap.txt", + "Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider:\n" + + failure.getClass().getSimpleName() + ": " + failure.getMessage() + "\n"); + + assertThat(failure).hasMessageContaining("@MemoryId").hasMessageContaining("ChatMemoryProvider"); + } + + /** One method uses @MemoryId, the other does not. The build still fails for both. */ + interface MemoryIdMixed { + String plain(String message); + + String remembered(@dev.langchain4j.service.MemoryId String id, @dev.langchain4j.service.UserMessage String message); + } + + @Test + void toolCallIsRunByLangChain4jNotByTheModel() { + int[] step = {0}; + ScriptedChatModel model = new ScriptedChatModel(r -> { + if (step[0]++ == 0) { + return AiMessage.from(dev.langchain4j.agent.tool.ToolExecutionRequest.builder() + .id("call-1").name("orderStatus").arguments("{\"orderId\":\"A-1001\"}").build()); + } + return AiMessage.from("Your order is on its way and arrives on 2026-10-13."); + }); + OrderTools tools = new OrderTools(); + ChatAgent agent = AiServices.builder(ChatAgent.class) + .chatModel(model) + .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(10)) + .tools(tools) + // 1.22.0 logs a notice that the defaults will change; set both explicitly + .toolArgumentsErrorHandler(dev.langchain4j.service.tool.ToolArgumentsErrorHandler.sendExceptionMessageToLlm()) + .toolExecutionErrorHandler(dev.langchain4j.service.tool.ToolExecutionErrorHandler.failInvocationUnlessVisibleToLlm()) + .build(); + + String reply = agent.chat("carol", "Where is order A-1001?"); + + StringBuilder sb = new StringBuilder(); + sb.append("Tool specification sent with the first request: ") + .append(model.requests().get(0).toolSpecifications().stream().map(t -> t.name() + " - " + t.description()).toList()) + .append('\n'); + sb.append("\nSecond request (after the tool ran):\n").append(ScriptedChatModel.render(model.requests().get(1).messages())); + sb.append("\nJava methods actually executed: ").append(tools.calls()).append('\n'); + sb.append("Model round trips: ").append(model.requests().size()).append('\n'); + sb.append("Final reply: ").append(reply).append('\n'); + Transcript.write("03-tools.txt", sb.toString()); + + assertThat(tools.calls()).containsExactly("orderStatus(A-1001)"); + assertThat(model.requests()).hasSize(2); + assertThat(reply).contains("2026-10-13"); + } + + @Test + void ragInjectsRetrievedTextIntoTheUserMessage() { + EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + InMemoryEmbeddingStore store = new InMemoryEmbeddingStore<>(); + List facts = List.of( + "Refunds are issued to the original payment method within 5 business days.", + "The Acme Vault desktop app supports Windows 11 and macOS 15.", + "Support is available Monday to Friday, 9am to 6pm IST."); + for (String f : facts) { + TextSegment seg = TextSegment.from(f); + Embedding e = embeddingModel.embed(seg).content(); + store.add(e, seg); + } + + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok")); + KnowledgeAgent agent = AiServices.builder(KnowledgeAgent.class) + .chatModel(model) + .contentRetriever(EmbeddingStoreContentRetriever.builder() + .embeddingStore(store) + .embeddingModel(embeddingModel) + .maxResults(1) + .build()) + .build(); + + agent.ask("How long do refunds take?"); + + String sent = ScriptedChatModel.render(model.lastRequest().messages()); + Transcript.write("04-rag.txt", "What the model received after retrieval (maxResults=1):\n" + sent); + + assertThat(sent).contains("How long do refunds take?"); + assertThat(sent).contains("Refunds are issued to the original payment method"); + assertThat(sent).doesNotContain("Windows 11"); + } +} diff --git a/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java new file mode 100644 index 0000000..30fe613 --- /dev/null +++ b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java @@ -0,0 +1,23 @@ +package com.ankurm.lc4j.aiservices; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Writes what a test observed to output/NN-name.txt so every figure in the post comes from a file. */ +final class Transcript { + + private static final Path DIR = Path.of("output"); + + private Transcript() { + } + + static void write(String name, String content) { + try { + Files.createDirectories(DIR); + Files.writeString(DIR.resolve(name), content); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..fa882fd --- /dev/null +++ b/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + pom + + + ai-services + + + + 25 + UTF-8 + + 1.22.0 + 1.22.0-beta32 + 6.1.3 + 3.27.6 + 2.0.17 + + + + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + +