This module provides integration between Micronaut and Langchain4j.
Note
This module is regarded as experimental and subject to change since the underlying technology (AI) is volatile and subject to change.
Various modules are provided that allow automatically configuring common Langchain4j types like ChatModel, ImageModel etc. Refer to the sections below for the supported Langchain4j extensions.
2 Quick Start
Add the following annotation processor dependency:
Add the necessary configuration to configure the model name you want to use:
Configuring the Model Name
langchain4j.ollama.model-name=orca-mini
langchain4j.ollama.model-name: orca-mini
"langchain4j.ollama.model-name" = "orca-mini"
langchain4j.ollama.modelName = 'orca-mini'
{langchain4j.ollama.model-name = "orca-mini"}
{ "langchain4j.ollama.model-name": "orca-mini"}
3 Response Streaming
It is possible to use response streaming.
First, you need to configure a streaming chat model with langchain4j.*.streaming-chat-model.*. For example, with OpenAI:
Requires a chat model with a non-blocking implementation (doChatAsync)
java.util.concurrent.Flow.Publisher<String> or Flow.Publisher<AiServiceStreamingEvent>
Streaming chat model
Requires a streaming chat model with a non-blocking implementation
The AI service uses the chat model, the streaming chat model or both, depending on the return types of its methods.
The non-blocking implementations of LangChain4j 1.21 are those of the OpenAI and Anthropic models, which use the Micronaut HTTP client without blocking a thread. Asynchronous methods also run the tools without blocking when the @Tool methods return a CompletableFuture or a CompletionStage.
Tip
In Kotlin, add the dev.langchain4j:langchain4j-kotlin dependency to return a kotlinx.coroutines.flow.Flow<String> from an AI service method.
In Python an AI service is an abstract class (abc.ABC) whose methods are declared with … bodies; the decorators mirror the LangChain4j annotations.
Note
LangChain4j builds the service implementation reflectively from the Java interface generated for the Python class and reads the @SystemMessage/@UserMessage annotations from it. @AiService implies the @AllowsReflection hint (io.micronaut.core.annotation), so the Python compiler copies the annotations onto the generated interface without any @AllowsReflection or micronaut.introspection.allow-reflection option.
You can now inject the @AiService definition into any Micronaut component including tests:
@MicronautTest(startApplication=False, environments=["ollama"])class AiServiceTest: friend: Annotated[Friend, Inject] language_model: Annotated[ChatModel, Inject] @Test def test_ai_service(self): result = self.friend.chat("Hello") assert result is not None assert self.language_model is not None
Retrieval is opt-in. An @AiService performs retrieval augmented generation (RAG) only when the application declares one of:
a dev.langchain4j.rag.RetrievalAugmentor bean, or
a dev.langchain4j.rag.content.retriever.ContentRetriever bean, which LangChain4j wraps in a DefaultRetrievalAugmentor.
A RetrievalAugmentor bean takes precedence over a ContentRetriever bean. For either type, a bean @Named after the service (for example @Named("expert") for @AiService("expert")) is preferred over the default bean. If several candidates exist and none is named after the service or marked @Primary, no retrieval is configured.
Defining an @AiService that returns Result<String>
Declaring a ContentRetriever bean
Retrieved content reaches the model and the result
To configure a single service, declare an AiServiceCustomizer bean for it and call contentRetriever(…) or retrievalAugmentor(…) on AiServiceCreationContext.aiServices(), see Customizing AI Services.
Note
Before 2.3.0 an EmbeddingStoreContentRetriever was attached to every @AiService automatically whenever an EmbeddingModel bean and an EmbeddingStore bean existed. Because provider modules expose an embedding model and the in-memory embedding store is enabled by default, that was the case in most applications, causing an embedding round trip on every call and failures against models without embedding support. Declare a ContentRetriever bean as shown above to restore the previous behaviour.
Configuring Retrieval
Content retrievers and the ingestion of documents can also be configured, without code.
A content retriever configured under langchain4j.rag.content-retrievers.<name> is an EmbeddingStoreContentRetriever bean qualified with <name>, so the @AiService with that name uses it:
An ingestion configured under langchain4j.rag.ingestion.<name> loads the documents of its sources when the application starts, splits them into segments, embeds them and adds them to the embedding store. The sources are directories of the file system, or of the classpath with the classpath: prefix. The documents are parsed with the DocumentParser that LangChain4j finds on the classpath (for example dev.langchain4j:langchain4j-document-parser-apache-tika for PDF and Office documents), and as text otherwise.
Both use the default EmbeddingStore and EmbeddingModel beans unless embedding-store and embedding-model name other beans. Each ingestion also creates an EmbeddingStoreIngestor bean qualified with its name, to ingest other documents programmatically. Set ingest-on-startup to false to only create the ingestor.
Note
The in-memory embedding store keeps the segments in memory only, so the documents are ingested again on every start. With a persistent embedding store, disable ingest-on-startup once the documents have been ingested, or ingest them with the EmbeddingStoreIngestor bean when they change.
A retrieval augmentor configured under langchain4j.rag.retrieval-augmentors.<name> retrieves content from one or more content retrievers and re-ranks it with a scoring model, so that only the most relevant content reaches the model. It is a RetrievalAugmentor bean qualified with <name>, which takes precedence over a content retriever with the same name:
The content retriever retrieves 10 segments by embedding similarity, and the scoring model passes the 3 most relevant ones with a score of at least 0.7 to the model.
Multimodal Parameters
An AI service can send images, PDF files, audio and video to the models that support them, together with the user message. Annotate a String, URI or URL parameter, or a collection of them, with @ImageUrl, @PdfUrl, @AudioUrl or @VideoUrl: the argument is sent as the LangChain4j content of that type.
Parameters of the LangChain4j content types (ImageContent, PdfFileContent, AudioContent, VideoContent, or a List of Content) annotated with @UserMessage are also supported, for example to send an image read from a file.
A test can check the contents that the model receives:
@Property(name = "spec.name", value = "PictureDescriberTest")@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false)internal class PictureDescriberTest { @Test fun sendsThePicture(describer: PictureDescriber) { assertEquals("text:What is it?|image:https://example.com/cat.png", describer.describe("What is it?", "https://example.com/cat.png")) } @Test fun sendsTheDocument(describer: PictureDescriber) { assertEquals("text:Summarize the risks of this document|pdf:https://example.com/report.pdf", describer.summarize("risks", URI.create("https://example.com/report.pdf"))) } /** * Answers with the contents of the user message. */ @Singleton @Primary @Requires(property = "spec.name", value = "PictureDescriberTest") class EchoChatModel : ChatModel { override fun doChat(request: ChatRequest): ChatResponse { val message = request.messages().last() as UserMessage val described = message.contents().joinToString("|") { when (it) { is TextContent -> "text:${it.text()}" is ImageContent -> "image:${it.image().url()}" is PdfFileContent -> "pdf:${it.pdfFile().url()}" else -> it.type().name } } return ChatResponse.builder().aiMessage(AiMessage.from(described)).build() } }}
@Property(name = "spec.name", value = "PictureDescriberTest")@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false)class PictureDescriberTest { @Test void sendsThePicture(PictureDescriber describer) { assertEquals("text:What is it?|image:https://example.com/cat.png", describer.describe("What is it?", "https://example.com/cat.png")) } @Test void sendsTheDocument(PictureDescriber describer) { assertEquals("text:Summarize the risks of this document|pdf:https://example.com/report.pdf", describer.summarize("risks", URI.create("https://example.com/report.pdf"))) } /** * Answers with the contents of the user message. */ @Singleton @Primary @Requires(property = "spec.name", value = "PictureDescriberTest") static class EchoChatModel implements ChatModel { @Override ChatResponse doChat(ChatRequest request) { UserMessage message = (UserMessage) request.messages().last() String described = message.contents().collect { Content content -> if (content instanceof TextContent) { return "text:" + content.text() } if (content instanceof ImageContent) { return "image:" + content.image().url() } if (content instanceof PdfFileContent) { return "pdf:" + content.pdfFile().url() } content.type().name() }.join("|") ChatResponse.builder().aiMessage(AiMessage.from(described)).build() } }}
Customizing AI Services
An AiServiceCustomizer bean customizes the LangChain4j AiServices builder of an AI service before the service is built, for any setting that the annotations and the configuration do not cover:
@AiServiceinterface Concierge { @SystemMessage("You are the concierge of a hotel.") String ask(String question)}
The builder of the model beans (for example OpenAiChatModel.OpenAiChatModelBuilder) can be customized the same way with a BeanCreatedEventListener.
Moderation
The methods of an AI service annotated with LangChain4j’s @Moderate check the user message with the ModerationModel bean, in parallel with the call to the chat model, and throw a ModerationException when it is flagged:
The moderation model of the AI service is the ModerationModel bean qualified with the name of the service, otherwise the default one, for example the OpenAI moderation model:
Annotate an interface with AgenticService and declare methods using LangChain4j Agentic annotations.
Note
In Python an agentic service is an abstract class (abc.ABC) whose methods are declared with … bodies; the decorators mirror the LangChain4j annotations.
Note
LangChain4j builds the agents reflectively from the Java interface generated for the Python class and reads the @Agent, @UserMessage, @SequenceAgent, … annotations from it. @AgenticService and the LangChain4j annotations imply the @AllowsReflection hint, so the Python compiler copies them onto the generated interfaces, including those of sub-agents and workflows declared without @AgenticService: no @AllowsReflection or micronaut.introspection.allow-reflection option is needed. The hint for the LangChain4j annotations is added by micronaut-langchain4j-processor, which must be on the compile classpath.
The integration interprets your annotations and uses Micronaut DI to wire the underlying LangChain4j builders.
It supports:
Typed agents built via AgenticServices.agentBuilder(Class) with @Agent methods.
Declarative workflow agents via AgenticServices.createAgenticSystem(…).
Micronaut model, memory, RAG, tool, and lifecycle integration for each generated agent builder.
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServicepublic interface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") String greet(@V("name") String name);}
# Minimal typed Agentic service used by the test-suite to validate Micronaut integration.@AgenticServiceclass GreeterAgent(ABC): @UserMessage("Say hello to {{name}}") @Agent(description="Greets a person by name") @abstractmethod def greet(self, name: Annotated[str, V("name")]) -> str: ...
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServiceinterface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") fun greet(@V("name") name: String): String}
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServiceinterface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") String greet(@V("name") String name)}
@MicronautTest(startApplication=False, environments=["agentic-test"])class AgenticServiceTest: agent: Annotated[GreeterAgent, Inject] @Test def test_agentic_greeter(self): result = self.agent.greet("John") assert result is not None
@MicronautTest(startApplication = false, environments = ["agentic-test"])@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class AgenticServiceTest { @Test fun testAgenticGreeter(agent: GreeterAgent) { val result = agent.greet("John") assertNotNull(result) }}
You can build workflows using LangChain4j’s declarative annotations. The integration routes construction through Micronaut DI so that supported workflow builders are Micronaut-managed beans (allowing listeners and customization).
The Micronaut-managed workflow builder lifecycle applies to the workflow types listed above. Supervisor and planner agents (SupervisorAgent, PlannerAgent) are supported too: see Supervisor agents.
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = "agentic-test")@TestInstance(TestInstance.Lifecycle.PER_CLASS)class ParallelPlanningAgentTest { @Test void testDeclarativeParallel(EveningPlanner agent) { String plan = agent.plan("jazz", "romantic"); System.out.println("parallel plan = " + plan); assertFalse(plan.isEmpty()); } @AgenticService public interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") String suggestBand(@V("style") String style); } @AgenticService public interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") String suggestMenu(@V("mood") String mood); } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") public interface EveningPlanner { @ParallelAgent( subAgents = { MusicPlanner.class, DinnerPlanner.class }, outputKey = "plan", name = "planEvening" ) String plan(@V("style") String style, @V("mood") String mood); // Use a shared executor for parallel execution @ParallelExecutor static Executor executor() { return ForkJoinPool.commonPool(); } // Aggregate the parallel outputs into a single "plan" string @Output static String aggregate(@V("band") String band, @V("menu") String menu) { String c = band == null ? "" : band.trim(); String r = menu == null ? "" : menu.trim(); if (c.isEmpty() && r.isEmpty()) { return ""; } if (c.isEmpty()) { return "Play: " + r; } if (r.isEmpty()) { return "Menu: " + c; } return "Play: " + c + " | Menu: " + r; } }}
@AgenticServiceclass MusicPlanner(ABC): @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey="band") @abstractmethod def suggest_band(self, style: Annotated[str, V("style")]) -> str: ...@AgenticServiceclass DinnerPlanner(ABC): @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey="menu") @abstractmethod def suggest_menu(self, mood: Annotated[str, V("mood")]) -> str: ...# Demonstrates declarative ParallelAgent orchestration using Micronaut DI.@AgenticService(outputKey="plan")class EveningPlanner(ABC): @ParallelAgent( subAgents=[ MusicPlanner, DinnerPlanner ], outputKey="plan", name="planEvening" ) @abstractmethod def plan(self, style: Annotated[str, V("style")], mood: Annotated[str, V("mood")]) -> str: ... # Use a shared executor for parallel execution @ParallelExecutor @staticmethod def executor() -> Executor: return ForkJoinPool.commonPool() # Aggregate the parallel outputs into a single "plan" string @Output @staticmethod def aggregate(band: Annotated[str, V("band")], menu: Annotated[str, V("menu")]) -> str: c = "" if band is None else band.strip() r = "" if menu is None else menu.strip() if c == "" and r == "": return "" if c == "": return f"Play: {r}" if r == "": return f"Menu: {c}" return f"Play: {c} | Menu: {r}"# Validates declarative ParallelAgent workflow wiring through @AgenticService.# Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan.@MicronautTest(startApplication=False, environments=["agentic-test"])class ParallelPlanningAgentTest: agent: Annotated[EveningPlanner, Inject] @Test def test_declarative_parallel(self): plan = self.agent.plan("jazz", "romantic") print(f"parallel plan = {plan}") assert plan != ""
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = ["agentic-test"])@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class ParallelPlanningAgentTest { @Test fun testDeclarativeParallel(agent: EveningPlanner) { val plan = agent.plan("jazz", "romantic") println("parallel plan = $plan") assertFalse(plan.isEmpty()) } @AgenticService interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") fun suggestBand(@V("style") style: String): String } @AgenticService interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") fun suggestMenu(@V("mood") mood: String): String } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") interface EveningPlanner { @ParallelAgent( subAgents = [ MusicPlanner::class, DinnerPlanner::class ], outputKey = "plan", name = "planEvening" ) fun plan(@V("style") style: String, @V("mood") mood: String): String companion object { // Use a shared executor for parallel execution @JvmStatic @ParallelExecutor fun executor(): Executor = ForkJoinPool.commonPool() // Aggregate the parallel outputs into a single "plan" string @JvmStatic @Output fun aggregate(@V("band") band: String?, @V("menu") menu: String?): String { val c = band?.trim() ?: "" val r = menu?.trim() ?: "" if (c.isEmpty() && r.isEmpty()) { return "" } if (c.isEmpty()) { return "Play: $r" } if (r.isEmpty()) { return "Menu: $c" } return "Play: $c | Menu: $r" } } }}
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = "agentic-test")@TestInstance(TestInstance.Lifecycle.PER_CLASS)class ParallelPlanningAgentTest { @Test void testDeclarativeParallel(EveningPlanner agent) { String plan = agent.plan("jazz", "romantic") println("parallel plan = " + plan) assertFalse(plan.isEmpty()) } @AgenticService static interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") String suggestBand(@V("style") String style) } @AgenticService static interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") String suggestMenu(@V("mood") String mood) } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") static interface EveningPlanner { @ParallelAgent( subAgents = [ MusicPlanner, DinnerPlanner ], outputKey = "plan", name = "planEvening" ) String plan(@V("style") String style, @V("mood") String mood) // Use a shared executor for parallel execution @ParallelExecutor static Executor executor() { ForkJoinPool.commonPool() } // Aggregate the parallel outputs into a single "plan" string @Output static String aggregate(@V("band") String band, @V("menu") String menu) { String c = band == null ? "" : band.trim() String r = menu == null ? "" : menu.trim() if (c.isEmpty() && r.isEmpty()) { return "" } if (c.isEmpty()) { return "Play: " + r } if (r.isEmpty()) { return "Menu: " + c } return "Play: " + c + " | Menu: " + r } }}
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")public interface LoopingPlannerAgent { @LoopAgent( subAgents = { TranslatorAgent.class }, outputKey = "text", maxIterations = 3 ) String translatesInLoop(@V("text") String text); interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") String translate(@V("text") String text); } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener implements BeanCreatedEventListener<LoopAgentService<?>> { @Override public LoopAgentService<?> onCreated(@NonNull BeanCreatedEvent<LoopAgentService<?>> event) { LoopAgentService<?> builder = event.getBean(); builder.exitCondition((scope, idx) -> idx == 3); return builder; } }}
class TranslatorAgent(ABC): @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey="text") @abstractmethod def translate(self, text: Annotated[str, V("text")]) -> str: ...# Demonstrates declarative LoopAgent orchestration using Micronaut DI.@AgenticService(outputKey="translation")class LoopingPlannerAgent(ABC): @LoopAgent( subAgents=[ TranslatorAgent ], outputKey="text", maxIterations=3 ) @abstractmethod def translates_in_loop(self, text: Annotated[str, V("text")]) -> str: ...@Singleton@Requires(property="spec.name", value="LoopingPlannerAgentTest")class LoopBuilderListener(BeanCreatedEventListener[LoopAgentService]): def onCreated(self, event: BeanCreatedEvent[LoopAgentService]) -> LoopAgentService: builder = event.getBean() builder.exitCondition(lambda scope, idx: idx == 3) return builder
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")interface LoopingPlannerAgent { @LoopAgent( subAgents = [ TranslatorAgent::class ], outputKey = "text", maxIterations = 3 ) fun translatesInLoop(@V("text") text: String): String interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") fun translate(@V("text") text: String): String } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener : BeanCreatedEventListener<LoopAgentService<*>> { override fun onCreated(event: BeanCreatedEvent<LoopAgentService<*>>): LoopAgentService<*> { val builder = event.bean builder.exitCondition { _, idx -> idx == 3 } return builder } }}
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")interface LoopingPlannerAgent { @LoopAgent( subAgents = [ TranslatorAgent ], outputKey = "text", maxIterations = 3 ) String translatesInLoop(@V("text") String text) interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") String translate(@V("text") String text) } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener implements BeanCreatedEventListener<LoopAgentService<?>> { @Override LoopAgentService<?> onCreated(BeanCreatedEvent<LoopAgentService<?>> event) { LoopAgentService<?> builder = event.bean builder.exitCondition { scope, idx -> idx == 3 } builder } }}
Configuration
You can select which ChatModel bean to use per agent via configuration. The agent id is derived from the interface name by:
stripping a trailing "Agent" suffix
converting UpperCamel to lower-kebab (e.g. CreativeWriterAgent → creative-writer)
If the property is not set and there is exactly one ChatModel bean in the context, it will be used automatically.
Memory
By default, agentic services reuse the core MessageWindowChatMemory built from the configured ChatMemoryStore. The core module publishes a MessageWindowChatMemory.Builder per available ChatMemoryStore (via @EachBean(ChatMemoryStore)), so agents automatically use the default builder when a single memory store is configured.
Notes:
- If memory.store is not set, the default MessageWindowChatMemory.Builder is used (as resolved by Micronaut DI).
- If multiple memory stores are available, set memory.store to select the store for an agent.
- If memory.max-messages is not set, the global core setting langchain4j.chat-memory-store.message-window.max-messages applies.
Retrieval is opt-in. An agent performs retrieval augmented generation only when the application declares a dev.langchain4j.rag.RetrievalAugmentor bean or a dev.langchain4j.rag.content.retriever.ContentRetriever bean. A RetrievalAugmentor takes precedence over a ContentRetriever. For either type, a bean @Named after the agent id (for example @Named("greeter") for GreeterAgent) is preferred over the default bean. Embedding model and embedding store beans are never paired automatically.
Tools
You can request specific tool beans to be registered with the agent builder using the tools attribute.
Tool classes should be Micronaut beans containing methods annotated with dev.langchain4j.agent.tool.Tool.
Customization
You can customize builders using Micronaut lifecycle listeners. Because workflow builders are created as Micronaut beans during declarative system construction, your listeners can adjust names, exit conditions, parallelism, etc., before the system executes.
Typed agents can also be customized via BeanCreatedEventListener<AgentBuilder<?, ?>>; the LoopingPlannerAgent snippet above shows the same Micronaut lifecycle technique applied to LoopAgentService.
For example, this listener customizes every generated declarative agent builder before LangChain4j builds the agentic system:
Workflow builders can be customized the same way. This example changes the loop exit condition for all LoopAgentService builders created by the agentic integration:
The same approach applies to Micronaut-managed workflow services: SequentialAgentService, ParallelAgentService, ParallelMapperService, ConditionalAgentService, and LoopAgentService.
Supervisor agents
A supervisor agent lets the chat model decide which of its sub-agents to invoke, and with which arguments, until the request is addressed. Declare it with LangChain4j’s @SupervisorAgent on a method of an @AgenticService:
/** * A supervisor agent: the chat model plans which sub-agent to invoke next until the request is addressed. */@AgenticServicepublic interface TravelSupervisorAgent { @SupervisorAgent( subAgents = { TravelRecommenderAgent.class, RecipeAdvisorAgent.class }, responseStrategy = SupervisorResponseStrategy.SUMMARY, maxAgentsInvocations = 5 ) String organize(@V("request") String request);}
# A supervisor agent: the chat model plans which sub-agent to invoke next until the request is addressed.@AgenticServiceclass TravelSupervisorAgent(ABC): @SupervisorAgent( subAgents=[ TravelRecommenderAgent, RecipeAdvisorAgent ], responseStrategy=SupervisorResponseStrategy.SUMMARY, maxAgentsInvocations=5 ) @abstractmethod def organize(self, request: Annotated[str, V("request")]) -> str: ...
/** * A supervisor agent: the chat model plans which sub-agent to invoke next until the request is addressed. */@AgenticServiceinterface TravelSupervisorAgent { @SupervisorAgent( subAgents = [ TravelRecommenderAgent::class, RecipeAdvisorAgent::class ], responseStrategy = SupervisorResponseStrategy.SUMMARY, maxAgentsInvocations = 5 ) fun organize(@V("request") request: String): String}
/** * A supervisor agent: the chat model plans which sub-agent to invoke next until the request is addressed. */@AgenticServiceinterface TravelSupervisorAgent { @SupervisorAgent( subAgents = [ TravelRecommenderAgent, RecipeAdvisorAgent ], responseStrategy = SupervisorResponseStrategy.SUMMARY, maxAgentsInvocations = 5 ) String organize(@V("request") String request)}
The supervisor plans with the chat model of the agentic service (see Configuration), and its sub-agents receive the Micronaut model, memory, retrieval and tool configuration of their agent id like any other agent. responseStrategy selects whether the supervisor answers with the last response of a sub-agent (LAST, the default), with the summary written by the planner (SUMMARY) or with the better of both (SCORED).
Tests can script the planner with a chat model bean that answers with the agent invocations:
@Property(name="langchain4j.ollama.enabled", value="false")@MicronautTest(startApplication=False, environments=["supervisor-test"])class TravelSupervisorAgentTest: supervisor: Annotated[TravelSupervisorAgent, Inject] @Test def supervisor_invokes_the_sub_agents(self): assert self.supervisor.organize("I love jazz, plan my holidays") == "Visit Italy and cook a pizza"def _answer(text: str) -> ChatResponse: return ChatResponse.builder().aiMessage(AiMessage.from_(text)).build()# Plays the planner of the supervisor: recommends a country, then a recipe, then answers.@Singleton@Primary@Requires(env="supervisor-test")class ScriptedPlannerChatModel(ChatModel): def doChat(self, request: ChatRequest) -> ChatResponse: messages = request.messages() user = messages.get(messages.size() - 1).singleText() planner = any(isinstance(message, SystemMessage) and "planner" in message.text() for message in messages) if not planner: return _answer("Italy" if "travel expert" in user else "Italy - Pizza") if "'Italy - Pizza'" in user: return _answer('{"agentName": "done", "arguments": {"response": "Visit Italy and cook a pizza"}}') if "'Italy'" in user: return _answer('{"agentName": "suggest_recipe", "arguments": {"country": "Italy"}}') return _answer('{"agentName": "recommend_country", "arguments": {"topic": "jazz"}}')
@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false, environments = ["supervisor-test"])internal class TravelSupervisorAgentTest { @Test fun supervisorInvokesTheSubAgents(supervisor: TravelSupervisorAgent) { assertEquals("Visit Italy and cook a pizza", supervisor.organize("I love jazz, plan my holidays")) } /** * Plays the planner of the supervisor: recommends a country, then a recipe, then answers. */ @Singleton @Primary @Requires(env = ["supervisor-test"]) class ScriptedPlannerChatModel : ChatModel { override fun doChat(request: ChatRequest): ChatResponse { val user = (request.messages().last() as UserMessage).singleText() val planner = request.messages().any { it is SystemMessage && it.text().contains("planner") } if (!planner) { return answer(if (user.contains("travel expert")) "Italy" else "Italy - Pizza") } return when { user.contains("'Italy - Pizza'") -> answer("""{"agentName": "done", "arguments": {"response": "Visit Italy and cook a pizza"}}""") user.contains("'Italy'") -> answer("""{"agentName": "suggestRecipe", "arguments": {"country": "Italy"}}""") else -> answer("""{"agentName": "recommendCountry", "arguments": {"topic": "jazz"}}""") } } private fun answer(text: String): ChatResponse = ChatResponse.builder().aiMessage(AiMessage.from(text)).build() }}
@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false, environments = "supervisor-test")class TravelSupervisorAgentTest { @Test void supervisorInvokesTheSubAgents(TravelSupervisorAgent supervisor) { assertEquals("Visit Italy and cook a pizza", supervisor.organize("I love jazz, plan my holidays")) } /** * Plays the planner of the supervisor: recommends a country, then a recipe, then answers. */ @Singleton @Primary @Requires(env = "supervisor-test") static class ScriptedPlannerChatModel implements ChatModel { @Override ChatResponse doChat(ChatRequest request) { String user = ((UserMessage) request.messages().last()).singleText() boolean planner = request.messages().any { it instanceof SystemMessage && it.text().contains("planner") } if (!planner) { return answer(user.contains("travel expert") ? "Italy" : "Italy - Pizza") } if (user.contains("'Italy - Pizza'")) { return answer('{"agentName": "done", "arguments": {"response": "Visit Italy and cook a pizza"}}') } if (user.contains("'Italy'")) { return answer('{"agentName": "suggestRecipe", "arguments": {"country": "Italy"}}') } answer('{"agentName": "recommendCountry", "arguments": {"topic": "jazz"}}') } private static ChatResponse answer(String text) { ChatResponse.builder().aiMessage(AiMessage.from(text)).build() } }}
Supplier methods
LangChain4j agents can declare static supplier methods, such as @ChatModelSupplier, @ToolsSupplier, @ToolProviderSupplier, @ContentRetrieverSupplier, @ChatMemoryProviderSupplier, @ParallelExecutor or @McpClientSupplier. With Micronaut, the parameters of these methods are beans: annotate a parameter with @Named to select a bean by name.
Note
LangChain4j calls a @ChatModelSupplier or @StreamingChatModelSupplier method that has parameters for each invocation of the agent within a workflow: a standalone typed agent ignores it.
MCP client agents
An MCP client agent calls a tool of an MCP server as a step of a workflow. Add the LangChain4j agentic MCP dependency, together with an MCP client, for example the one of Micronaut MCP:
The @McpClientSupplier method receives the McpClient bean of the server:
Note
In Python, a class with a static function compiles to a Java interface only when it is an introduction type, so the MCP client agent above is an @AgenticService too.
A2A client agents
LangChain4j’s @A2AClientAgent invokes a remote agent with the Agent2Agent (A2A) protocol. It requires the dev.langchain4j:langchain4j-agentic-a2a dependency, and the URL of the remote agent server is set with the a2aServerUrl attribute or returned by a static @A2AServerUrlSupplier method without parameters. A static @A2AClientCustomizer method can customize the A2A client.
6 Structured Outputs
When an @AiService or @AgenticService method returns a POJO (or a List or Set of POJOs), LangChain4j asks the model for a JSON answer: it derives a JSON schema from the return type reflectively and sends it as the response format, or appends format instructions to the user message when the model does not support JSON schemas.
With Micronaut JSON Schema, the schema is generated at compile time instead, and the generated schema is the one the model receives. Compared with the schema LangChain4j derives:
the descriptions come from the Javadoc (or KDoc, or Python docstrings) of the type and its properties, and the validation constraints (format, pattern, minimum, …) are passed on to the model in the descriptions;
no reflection is needed to build the schema, which suits GraalVM native images;
the schema only contains the properties of the type, whereas the reflective schema of a class generated by a compiler (for example, the Java class of a Python class) can contain synthetic or internal fields.
Add the JSON Schema annotation processor and the runtime support:
Then annotate the types returned by your services with io.micronaut.jsonschema.JsonSchema:
A structured output type
Note
The Groovy compiler does not keep the Groovydoc of the classes, so set the description with the description member of @JsonSchema.
Note
The class is declared @Introspected so that LangChain4j, which parses the answer of the model with Jackson, can construct it. In Python, do not name the @JsonSchema types in the micronaut.introspection.allow-reflection compiler option (the schema would be generated twice, failing the compilation): the integration does not need the annotation at runtime.
Note
Micronaut JSON Schema describes the properties of a type: the components of a record, the accessors of a bean, or the fields of a class annotated with @Introspected(accessKind = Introspected.AccessKind.FIELD). LangChain4j also reads and writes plain fields, which are not properties for Micronaut. For a class whose state is only held in such fields, the generated schema has no properties: a warning is logged, and the schema LangChain4j derives is sent instead.
No other change is needed: the AI services and agents returning these types send the generated schemas.
An @AiService returning structured outputs
Checking the schema received by the model
The schema of a type returned directly, in a List or a Set, or wrapped in Result, CompletableFuture or CompletionStage is replaced. References to the schemas of other @JsonSchema types are resolved and inlined, except recursive ones, which are sent as definitions ($defs). LangChain4j still parses the answer and decides whether the response format is sent; streaming methods are unaffected.
LangChain4j only sends the schema to the chat models that declare the RESPONSE_FORMAT_JSON_SCHEMA capability. For the other models, it appends format instructions derived from the type to the user message, and the generated schema is not used.
Some models accept a JSON schema on each request but do not always declare the capability. Google AI Gemini declares it only when a JSON response format is configured on the model, yet it honors the response format of each request: with the micronaut-langchain4j-googleai-gemini module, the services returning a type with a generated schema send it to Gemini without further configuration. Providers declare such models with a bean of type JsonSchemaResponseFormatSupport.
For other models, set langchain4j.structured-output.force-json-schema-response-format to true to send the schema anyway, or to false to never send it to the models that do not declare the capability:
Only use it with models that support a JSON schema response format: a model that does not may ignore it or reject the request.
To use the schemas LangChain4j derives instead of the generated ones, set langchain4j.structured-output.enabled to false.
7 Testing
Micronaut LangChain4j includes a small evaluation API for asserting AI responses in tests.
The EvaluationRequest record captures the original user text, optional grounding context, and generated response.
An Evaluator consumes that request and returns an EvaluationResult.
Built-in evaluators include:
RelevancyEvaluator for checking whether the response answers the user request.
FactCheckingEvaluator for checking whether the response is grounded in the supplied context.
When an AI service returns dev.langchain4j.service.Result<T>, you can reuse retrieved sources as evaluation context:
Defining an @AiService that returns Result<String>
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServicepublic interface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") Result<String> chat(String userMessage);}
@Requires(property="spec.name", value="AiServiceEvaluationExample")@AiServiceclass EvaluatingFriend(ABC): @SystemMessage("You are a good friend of mine. Answer using slang.") @abstractmethod def chat(self, user_message: str) -> Result[str]: ...
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServiceinterface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") fun chat(userMessage: String): Result<String>}
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServiceinterface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") Result<String> chat(String userMessage)}
@Property(name = "spec.name", value = "AiServiceEvaluationExample")@Testcontainers(disabledWithoutDocker = true)@MicronautTest(startApplication = false)@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class AiServiceEvaluationExample : OllamaTestPropertyProvider { @Test fun evaluatesAiServiceResponse(friend: EvaluatingFriend, chatModel: ChatModel) { val userText = "Reply with exactly: Micronaut is a JVM framework." val response = friend.chat(userText) val evaluator = RelevancyEvaluator(chatModel) val evaluation = evaluator.evaluate(EvaluationRequest.from(userText, response)) assertNotNull(evaluation) assertFalse(evaluation.feedback().isBlank()) }}
A sample file lists the inputs to send to the AI service, with the optional context that the answers must be grounded in, for example src/test/resources/samples/support.yml:
samples: - name: framework input: What is Micronaut? context: Micronaut is a modern JVM framework for building microservices and serverless applications. - name: native input: Can a Micronaut application be compiled to a native executable? context: Micronaut applications can be compiled to native executables with GraalVM.
A JUnit parameterized test then evaluates the answer to each sample:
Note
In Python, the test loops over the samples, since JUnit parameterized tests are not available.
ScriptedChatModel can also request tool calls and stream:
whenUserMessageContains(text), whenToolResult() and when(predicate) select the requests, and respond(…) or callTool(name, argumentsJson) answer them, in order. otherwise(…) answers the other requests.
ScriptedChatModel.respondingWith(responses…) answers the requests with the responses, in order.
streaming() returns a StreamingChatModel with the same rules, which streams the text of the responses word by word.
requests() returns the requests received, for example to check the messages sent to the model.
8 Tools
Tools allow AI models to request specific actions that extends beyond their built-in capabilities.
A model would have no way to know the last date when the PRIVACY document was updated. Hence, it is a good candidate for a tool.
<3> The dates are harcoded for the purpose of this example, but they could have been retrieved from a database or external API.
The tool is registered under the method name, which the model sees, so the Python example keeps the Java name lastUpdatePrivacy.
The tools are not discovered by LangChain4j scanning the tool class: Micronaut builds the ToolSpecification of each @Tool method from its compile-time metadata (the method and parameter names, @Tool, @P, @ToolMemoryId) and invokes the method through its Micronaut ExecutableMethod. Tool classes therefore need no reflection, neither in a GraalVM native image nor for classes written in Python. The tools of a service are also available programmatically, as LangChain4j AiServiceTool instances, from tools.ToolRegistry#getAiServiceTools(java.util.Set).
Note
LangChain4j’s @CompensateFor compensating actions are not supported, since LangChain4j only registers them for the tools it discovers reflectively. A warning is logged when a tool bean used by a service declares one, because the action never runs, even with compensateOnToolErrors.
Tool Providers
Tools can also be provided dynamically by a LangChain4j ToolProvider bean, for example the tool provider of the Micronaut MCP LangChain4j client, which provides the tools of the configured MCP servers.
Name the tool providers of an AI service with the toolProviders member of @AiService:
An AI service uses the following tool providers:
The ToolProvider beans named by the toolProviders member of @AiService. Set the member to an empty array to use no tool provider.
Otherwise, the ToolProvider bean qualified with the name of the service, given by the named member of @AiService.
Otherwise, the default ToolProvider bean: the only ToolProvider bean without a qualifier (or annotated with @Primary). Tool providers qualified with a name are only used by the services that select them, and when there are several unqualified tool providers none of them is used implicitly, so that tools are never exposed to a service by accident.
The following LangChain4j beans are also applied to AI services, preferring a bean qualified with the name of the service over the default bean:
ToolExecutionErrorHandler - converts an exception thrown by a tool into the result sent to the model.
ToolArgumentsErrorHandler - handles arguments the model sent that cannot be parsed.
ToolSearchStrategy - selects the tools sent to the model when there are many tools.
Tool Calling Configuration
The tool calling loop of the AI services can be configured under langchain4j.ai-services. The default entry applies to every AI service, an entry with the name of a service replaces it for that service:
The client creates a LangChain4j McpClient bean for each configured MCP server, named after its configuration, and a ToolProvider bean that provides the tools of all of them.
This ToolProvider is unqualified, so when it is the only unqualified one it is the default tool provider of the AI services that do not select tool providers (see the previous section): they receive the tools of every configured MCP server. To restrict a service to some MCP servers, name their clients with the mcpClients member of @AiService:
When mcpClients is set, no ToolProvider bean is applied implicitly. Combine it with toolProviders to add other tool providers. The mcpClients member requires dev.langchain4j:langchain4j-mcp on the classpath, which the Micronaut MCP LangChain4j client provides.
Guardrails validate the input of an AI service before it is sent to the model, and the output of the model before it is returned. They are declared with LangChain4j’s @InputGuardrails and @OutputGuardrails annotations, on an AI service method or on the AI service to apply to all its methods.
The guardrail classes are resolved as Micronaut beans, so they can inject other beans:
Output guardrails are declared the same way with @OutputGuardrails. They can ask the model to retry or reprompt it, up to the maxRetries of the annotation.
A guardrail class that is not a bean is instantiated with its public no-argument constructor, which requires reflection in a GraalVM native image. The guardrails provided by LangChain4j’s dev.langchain4j:langchain4j-guardrails module (for example the prompt injection and moderation guardrails) can be used this way, or registered as beans with a factory to configure them.
LangChain4j implements AI services and agents with java.lang.reflect.Proxy and reads their annotations (@SystemMessage, @UserMessage, @V, @Agent, @SequenceAgent, @Output, …), parameters and return types reflectively. Micronaut LangChain4j takes care of what this requires, so applications declare no reflection configuration:
Tools are not discovered reflectively. The ToolSpecification of each @Tool method is built from the compile-time metadata of the method, and the method is invoked through its Micronaut ExecutableMethod, so tool classes need no reflection at all.
GraalVM Native Image: micronaut-langchain4j-processor writes the native image metadata (META-INF/native-image/io.micronaut.langchain4j/<type>/reachability-metadata.json) of every @AiService, every @AgenticService and every type declaring @Agent, workflow (@SequenceAgent, @ParallelAgent, @LoopAgent, …) or other declarative agent (@HumanInTheLoop, @A2AClientAgent, @McpClientAgent, @RegistryAgent) methods: the reflective access to their methods, and the proxy classes LangChain4j creates for them.
Python: @AiService, @AgenticService and the LangChain4j annotations imply the @AllowsReflection hint (io.micronaut.core.annotation), so the Python compiler copies the annotations onto the Java types it generates. Neither @AllowsReflection nor the micronaut.introspection.allow-reflection compiler option is needed. The hint for the LangChain4j annotations is added by micronaut-langchain4j-processor, which must be on the compile classpath of the Python sources.
Structured outputs and tool types: LangChain4j derives the JSON schema of the structured outputs of AI services and agents (the records or POJOs their methods return), and of the parameters and results of tools, from their fields, and maps them with Jackson, reflectively. The processor also writes the native image metadata of these types and of the types of their fields, so they need neither @ReflectiveAccess nor hand-written metadata. With micronaut-langchain4j-serde they are mapped with Micronaut Serialization instead of Jackson, see Micronaut Serialization.
Note
The processor must be on the annotation processor path of the classes declaring the services and agents (annotationProcessor("io.micronaut.langchain4j:micronaut-langchain4j-processor") in Gradle) for the native image metadata to be generated.
Native Image Support by Module
Several LangChain4j providers map their REST APIs with Jackson, reflectively, and do not ship native image metadata. The Micronaut modules of these providers (Ollama, Mistral AI, Google AI Gemini, Hugging Face, Cohere, Jina and Voyage AI) ship the reachability metadata of their request and response types, so applications need no reflection configuration for them either. The test-suite-graal project verifies the following in a native image:
Module
Status
micronaut-langchain4j-openai
Tested: chat, streaming chat, embeddings
micronaut-langchain4j-anthropic
Tested: chat
micronaut-langchain4j-mistralai
Tested: chat, streaming chat, embeddings
micronaut-langchain4j-ollama
Tested: chat, streaming chat, embeddings, tool calling through an AI service
micronaut-langchain4j-googleai-gemini
Tested: chat, streaming chat, embeddings
micronaut-langchain4j-bedrock
Tested: model creation. The AWS SDK provides its own native image metadata.
micronaut-langchain4j-cohere
Tested: scoring, embeddings
micronaut-langchain4j-jina
Tested: scoring
micronaut-langchain4j-voyageai
Tested: scoring, embeddings
micronaut-langchain4j-hugging-face
Reachability metadata provided, not tested
micronaut-langchain4j-azure
Not tested. Known issue: the Azure SDK (azure-core) initializes SLF4J and Logback at image build time, which fails the image build when another class with a static logger is also initialized at build time (seen together with micronaut-langchain4j-agentic)
Not tested: they rely on the native image support of the Google Cloud and OCI SDKs
micronaut-langchain4j-jlama
Not supported: Jlama runs the model in the JVM with the Vector API
In-memory embedding store (core)
Tested
Other embedding stores
Not tested: they rely on the native image support of their client libraries
11 Micronaut Serialization
LangChain4j maps the structured outputs of AI services and agents (the records or POJOs their methods return), the arguments and results of tools and the arguments of agents to and from JSON with Jackson, reflectively. With the following dependency, it maps them with Micronaut Serialization instead:
Micronaut Serialization maps a type with the introspection generated at compile time for it, without reflection, when the type is annotated with @Serdeable or imported with @SerdeImport. With micronaut-serde-processor and micronaut-langchain4j-processor on the annotation processor path, the public records returned by AI services and agents, and the public records used as parameters or results of tools, are imported automatically, together with the records they are made of.
The other types are still mapped by Micronaut Serialization: with their introspection when they are @Introspected, and otherwise with an introspection that micronaut-reflection builds reflectively from their fields, of any visibility, and their accessors, as LangChain4j’s Jackson mapping does. Enable the DEBUG level of the io.micronaut.langchain4j.serde logger to see the types mapped reflectively. Jackson annotations (@JsonProperty, @JsonCreator, …) are only honoured on the types with a compile-time introspection: Micronaut Serialization maps them onto the introspection when it is generated.
The mapping follows the defaults of LangChain4j’s codec rather than the configuration of the application, whose JSON mapping is left unchanged: unknown properties are rejected (LangChain4j relies on it to find the JSON in the answers of the models), enums are read case insensitively, null values are written, and LocalDate, LocalTime and LocalDateTime are also read in the object shape of the JSON schema LangChain4j derives for them ({"year": 2001, "month": 2, "day": 3}).
Note
Micronaut Serialization replaces Jackson for these mappings only. Jackson remains a dependency of LangChain4j, which also uses it for the requests and responses of the model providers, the persistence of chat memories and agentic scopes, and to read the Jackson annotations of the types it derives JSON schemas from.
12 Observability
Every chat model, streaming chat model and embedding model whose LangChain4j builder accepts listeners receives the ChatModelListener (or embedding model listener) beans of the application. Define a listener as a bean to observe the requests, responses and errors of all models:
When there is a MeterRegistry bean, LangChain4j’s MicrometerMetricsChatModelListener is registered. It records:
gen_ai.client.token.usage - the input and output tokens, tagged with the provider, the operation, the request and response model names and the token type.
gen_ai.client.operation.duration - the duration of the chat model requests.
When there is also an ObservationRegistry bean (for example with micronaut-micrometer-observation) and dev.langchain4j:langchain4j-observation is on the classpath, LangChain4j’s ObservationChatModelListener is registered instead. It records each chat model request as a Micrometer observation, which produces the same metrics and, with a tracing bridge, spans.
Set langchain4j.micrometer.enabled to false to disable these listeners.
AI Service Events
The AI services publish LangChain4j’s AI service events as Micronaut application events. Observe them with @EventListener methods or ApplicationEventListener beans:
The following events are published: AiServiceStartedEvent, AiServiceRequestIssuedEvent, AiServiceResponseReceivedEvent, AiServiceCompletedEvent, AiServiceErrorEvent, InputGuardrailExecutedEvent, OutputGuardrailExecutedEvent, ToolExecutedEvent and ToolCompensatedEvent (all in the dev.langchain4j.observability.api.event package). A listener of a super type, such as AiServiceEvent, receives all of them. An event type is only published when there is a listener for it.
Beans implementing LangChain4j’s AiServiceListener (for example AiServiceCompletedListener) are also registered with every AI service.
13 HTTP Client
The OpenAI, Anthropic, Ollama, Mistral AI, Google AI Gemini, Hugging Face, Cohere, Jina and Voyage AI models send their requests with the Micronaut HTTP client instead of the HTTP client of LangChain4j. These modules exclude LangChain4j’s JDK HTTP client, so add a Micronaut HTTP client implementation to your application:
Because the requests go through the Micronaut HTTP client:
The micronaut.http.client.* configuration applies to them, for example the proxy, the SSL configuration, the connection pool and the event loop.
The timeout of a model sets the read timeout of its requests.
HTTP client filters apply to them, for example to add headers or to record requests:
HTTP client observability, such as Micrometer metrics and the tracing of Micronaut Tracing, records the requests.
Model Credentials
The credentials of a model are usually its configured API key. To send other credentials with each request, for example a short-lived token or the token of the current user, declare a ModelAuthProvider bean. The providers are asked in order for each request of the models that use the Micronaut HTTP client: the first value that is not null replaces the Authorization header.
The provider is called on the thread that sends the request, also for the streaming and asynchronous requests, so it can read the context of the current HTTP request.
Token Propagation
When the application is secured with Micronaut Security, the bearer token of the user that calls the application can be sent to the models in place of their API key, for example to an OpenAI-compatible gateway that authorizes each user:
uri-regex limits the propagation to the models whose URLs match. The token is the one that Micronaut Security authenticated for the current request.
Note
The token propagation of Micronaut Security itself (micronaut.security.token.propagation) does not apply to the models: it never replaces the Authorization header that a request already has, and the models send their API key.
Streaming and asynchronous requests
The streaming responses (server-sent events) and the non-blocking requests of the asynchronous AI services are also handled by the Micronaut HTTP client.
The other providers (Azure OpenAI, Bedrock, Vertex AI, OCI GenAI and Google Gen AI) use the HTTP client of their cloud SDK.
14 Image Models
Image models generate images from a text prompt. The OpenAI, Azure OpenAI, Vertex AI and Google Gen AI modules configure a dev.langchain4j.model.image.ImageModel bean, under langchain4j.<provider>.image-model (and named ones under langchain4j.<provider>.image-models.<name>):
Inject the image model where images are generated:
15 Test Resources
Micronaut Test Resources starts the services the tests need in containers. Micronaut LangChain4j provides test resources for Ollama and Qdrant, and the embedding and chat memory stores backed by a database (PGVector, Oracle, Redis, Neo4j, Cassandra, MongoDB, JDBC) can use the database test resources of Micronaut.
Add the test resources of Micronaut LangChain4j to the test resources service classpath of your Gradle build or Maven build.
When langchain4j.ollama.base-url is not configured, an Ollama container with the configured model is started, and the base URL is resolved from it. The image is langchain4j/ollama-<model-name>, which bundles the model:
When the host and port of the Qdrant embedding store are not configured, a Qdrant container is started with a collection of 384 dimensions (cosine distance) named after collection-name, and langchain4j.qdrant.embedding-store.host and port are resolved from it:
The features of this section integrate experimental LangChain4j APIs: they are annotated with @Experimental and can change in a minor release, following LangChain4j.
Decision Services
A decision service answers questions with a LangChain4j DecisionModel, a model that returns the probability of each possible answer instead of generated text: yes/no questions, a choice between options, or a position on a scale. Annotate an interface with @DecisionService and its methods with LangChain4j’s @Decide:
The parameters are sent to the model as the input, by name, so name them with @V unless the code is compiled with -parameters.
The constants of the enum can be described with LangChain4j’s @Description:
Note
A Python Enum compiles to a Java enum whose constants have no @Description: give them self-explanatory names.
@Property(name="spec.name", value="TicketTriageTest")@Property(name="langchain4j.ollama.enabled", value="false")@MicronautTest(startApplication=False)class TicketTriageTest: triage: Annotated[TicketTriage, Inject] @Test def triages_tickets(self): assert not self.triage.is_spam("I was charged twice") assert self.triage.route("I was charged twice") == Team.BILLING assert self.triage.route_with_probabilities("I was charged twice").probabilityOf(Team.BILLING) == 0.9# Answers no to the yes/no questions and chooses the first option of the other questions.@Singleton@Requires(property="spec.name", value="TicketTriageTest")class FirstOptionDecisionModel(DecisionModel): def doDecide(self, request: DecisionRequest) -> DecisionResponse: response = DecisionResponse.builder() for name in request.questions().keySet(): question = request.questions().get(name) if isinstance(question, ChoiceQuestion): options = list(question.options().keySet()) answer = ChoiceAnswer.builder().value(options[0]).options(question.options().keySet()) for option in options: answer.probability(option, 0.9 if option == options[0] else 0.1) response.answer(name, answer.build()) else: response.answer(name, YesNoAnswer.of(0.1)) return response.build()
@Property(name = "spec.name", value = "TicketTriageTest")@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false)internal class TicketTriageTest { @Test fun triagesTickets(triage: TicketTriage) { assertFalse(triage.isSpam("I was charged twice")) assertEquals(Team.BILLING, triage.route("I was charged twice")) assertEquals(0.9, triage.routeWithProbabilities("I was charged twice").probabilityOf(Team.BILLING)) } /** * Answers no to the yes/no questions and chooses the first option of the other questions. */ @Singleton @Requires(property = "spec.name", value = "TicketTriageTest") class FirstOptionDecisionModel : DecisionModel { override fun doDecide(request: DecisionRequest): DecisionResponse { val response = DecisionResponse.builder() request.questions().forEach { (name, question) -> if (question is ChoiceQuestion) { val first = question.options().keys.first() val answer = ChoiceAnswer.builder().value(first).options(question.options().keys) question.options().keys.forEach { answer.probability(it, if (it == first) 0.9 else 0.1) } response.answer(name, answer.build()) } else { response.answer(name, YesNoAnswer.of(0.1)) } } return response.build() } }}
@Property(name = "spec.name", value = "TicketTriageTest")@Property(name = "langchain4j.ollama.enabled", value = "false")@MicronautTest(startApplication = false)class TicketTriageTest { @Test void triagesTickets(TicketTriage triage) { assertFalse(triage.isSpam("I was charged twice")) assertEquals(Team.BILLING, triage.route("I was charged twice")) assertEquals(0.9d, triage.routeWithProbabilities("I was charged twice").probabilityOf(Team.BILLING)) } /** * Answers no to the yes/no questions and chooses the first option of the other questions. */ @Singleton @Requires(property = "spec.name", value = "TicketTriageTest") static class FirstOptionDecisionModel implements DecisionModel { @Override DecisionResponse doDecide(DecisionRequest request) { DecisionResponse.Builder response = DecisionResponse.builder() request.questions().each { name, question -> if (question instanceof ChoiceQuestion) { String first = question.options().keySet().first() ChoiceAnswer.Builder answer = ChoiceAnswer.builder().value(first).options(question.options().keySet()) question.options().keySet().each { answer.probability(it, it == first ? 0.9d : 0.1d) } response.answer(name, answer.build()) } else { response.answer(name, YesNoAnswer.of(0.1d)) } } response.build() } }}
Routing Chat Models
A routing chat model is a ChatModel that sends each request to one of several chat models, for example a small model for simple requests and a large one for the others. Configure it under langchain4j.routing-chat-models.<name>: the model is a ChatModel bean of that name, which an @AiService with the same name uses.
langchain4j.routing-chat-models.assistant.routes.fast=smalllangchain4j.routing-chat-models.assistant.routes.expert=largelangchain4j.routing-chat-models.assistant.descriptions.fast=Greetings, small talk and simple questionslangchain4j.routing-chat-models.assistant.descriptions.expert=Mathematics, reasoning and codelangchain4j.routing-chat-models.assistant.default-route=fast
langchain4j: routing-chat-models: assistant: routes: fast: small expert: large descriptions: fast: Greetings, small talk and simple questions expert: Mathematics, reasoning and code default-route: fast
[langchain4j.routing-chat-models.assistant]default-route = "fast"[langchain4j.routing-chat-models.assistant.routes]fast = "small"expert = "large"[langchain4j.routing-chat-models.assistant.descriptions]fast = "Greetings, small talk and simple questions"expert = "Mathematics, reasoning and code"
langchain4j { routingChatModels { assistant { routes { fast = 'small' expert = 'large' } descriptions { fast = 'Greetings, small talk and simple questions' expert = 'Mathematics, reasoning and code' } defaultRoute = 'fast' } }}
routes maps each route to the name of a ChatModel bean, and descriptions describes the requests of each route. By default, the route is chosen by asking a DecisionModel which description matches the request: the DecisionModel bean named by decision-model, otherwise the default one. question, min-probability and fallback-strategy (DEFAULT_ROUTE or FAIL) tune that choice. Set router to the name of a ChatModelRouter bean to choose the route in another way.
Agent Skills
Agent Skills are folders of instructions and resources that a model loads when a request needs them, instead of keeping them all in its context. Add the LangChain4j Skills dependency:
And a local SLM (Small Language Model) such as Ollama in the test classpath:
src/test/resources/application-test.properties
langchain4j.open-ai.enabled=falselangchain4j.ollama.model-name=tinyllamalangchain4j.ollama.chat-model.timeout=5m# Ollama 0.32.10 changed the default from 1.1 to 1.0; without the penalty tinyllama ignores the chat historylangchain4j.ollama.chat-model.repeat-penalty=1.1langchain4j.ollama.chat-model.log-requests=truelangchain4j.ollama.chat-model.log-responses=true
Moreover, you can also register a bean of type BeanCreatedEventListener to configure the Chat Model builder programmatically if configuration is not enough.
Models are stateless by design. Chat memory serves as container for previous messages,
helping you maintain context in a conversation, but the model itself is not aware of this memory;
it relies on you to include the relevant messages in each request for coherent and contextually relevant responses.
Langchain4J provides an API ChatMemory to help you manage chat memory. You can provide your own implementation or use one of the provided implementations.
The default implementation of ChatMemory, dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore, stores ChatMessage instances in memory.
To use the Redis implementation dev.langchain4j.community.store.memory.chat.redis.RedisChatMemoryStore, add the following dependency:
The table must already exist. By default, the expected schema is CHAT_MEMORY(MEMORY_ID, CONTENT).
The segment under oracle (for example default) maps to the datasource name.
For multiple datasources, configure multiple entries such as
langchain4j.chat-memory-store.oracle.reporting.*.
To use the JDBC implementation dev.langchain4j.community.store.memory.chat.sql.SQLChatMemoryStore (PostgreSQL, MySQL, MariaDB or H2), add the following dependency:
Then configure the chat memory store of a datasource. The SQL dialect is detected from the database, and auto-create-table creates the table if it does not exist:
Then configure the database and the collection, and optionally expire-after-seconds to delete conversations after some time. The store uses the default MongoDB client, or the one of the server named:
The AI services whose methods have a @MemoryId parameter keep a memory per memory ID. Their default ChatMemoryProvider keeps the conversations in the chat memory store named by langchain4j.chat-memory.store, otherwise in the persistent chat memory store if the application has exactly one (Redis, Neo4j, Cassandra, Oracle, JDBC or MongoDB), otherwise in memory. A ChatMemoryProvider bean named after the service, or annotated with @Primary, replaces it.
By default the memory keeps the last 20 messages. A token window memory keeps the last messages that fit in a number of tokens instead, counted with the TokenCountEstimator bean of the application (for example OpenAiTokenCountEstimator):
You could invoke the previous class as illustrated in the following test:
@Testvoid chatWithMemory(AssistantWithMemory assistant) { MemoryIdAndResponse johnConversation = assistant.chat("Let me introduce myself. My name is John"); String johnConversationId = johnConversation.memoryId(); assertNotNull(johnConversationId); MemoryIdAndResponse aegonConversation = assistant.chat("Let me introduce myself. My name is Dan"); String aegonConversationId = aegonConversation.memoryId(); assertNotNull(aegonConversationId); MemoryIdAndResponse answer = assistant.chat(johnConversationId, "What's my name?"); assertTrue(answer.response().toLowerCase().contains("john"), answer.response()); answer = assistant.chat(aegonConversationId, "What's my name?"); assertTrue(answer.response().toLowerCase().contains("dan"), answer.response());}
@Testdef chat_with_memory(self): john_conversation = self.assistant.chat("Let me introduce myself. My name is John") john_conversation_id = john_conversation.memory_id assert john_conversation_id is not None aegon_conversation = self.assistant.chat("Let me introduce myself. My name is Dan") aegon_conversation_id = aegon_conversation.memory_id assert aegon_conversation_id is not None answer = self.assistant.chat("What's my name?", john_conversation_id) assert "john" in answer.response.lower(), answer.response answer = self.assistant.chat("What's my name?", aegon_conversation_id) assert "dan" in answer.response.lower(), answer.response
@Testfun chatWithMemory(assistant: AssistantWithMemory) { val johnConversation = assistant.chat("Let me introduce myself. My name is John") val johnConversationId = johnConversation.memoryId assertNotNull(johnConversationId) val aegonConversation = assistant.chat("Let me introduce myself. My name is Dan") val aegonConversationId = aegonConversation.memoryId assertNotNull(aegonConversationId) var answer = assistant.chat(johnConversationId, "What's my name?") assertTrue(answer.response.lowercase().contains("john"), answer.response) answer = assistant.chat(aegonConversationId, "What's my name?") assertTrue(answer.response.lowercase().contains("dan"), answer.response)}
@Testvoid chatWithMemory(AssistantWithMemory assistant) { MemoryIdAndResponse johnConversation = assistant.chat("Let me introduce myself. My name is John") String johnConversationId = johnConversation.memoryId assertNotNull(johnConversationId) MemoryIdAndResponse aegonConversation = assistant.chat("Let me introduce myself. My name is Dan") String aegonConversationId = aegonConversation.memoryId assertNotNull(aegonConversationId) MemoryIdAndResponse answer = assistant.chat(johnConversationId, "What's my name?") assertTrue(answer.response.toLowerCase().contains("john"), answer.response) answer = assistant.chat(aegonConversationId, "What's my name?") assertTrue(answer.response.toLowerCase().contains("dan"), answer.response)}
The audio transcription model (dev.langchain4j.model.audio.AudioTranscriptionModel) is only created when it is configured, for example with langchain4j.azure-open-ai.audio-transcription-model.deployment-name.
The model-id is the Amazon Bedrock model ID or inference profile ID to invoke. There is no default value, choose a model that is enabled for your account and region.
The models use the Micronaut HTTP client. Add a Micronaut HTTP client implementation to your application, for example the default Netty implementation:
Jlama uses the incubating Vector API of the JDK: run the application with --add-modules jdk.incubator.vector --enable-preview. Jlama is not supported in a GraalVM native image.
Provider modules use Micronaut’s HTTP client API and exclude LangChain4j’s JDK HTTP client.
Add a concrete Micronaut HTTP client implementation to your application, for example the default Netty implementation:
The audio transcription model (dev.langchain4j.model.audio.AudioTranscriptionModel) and the text-to-speech model (dev.langchain4j.model.audio.TextToSpeechModel) are only created when they are configured:
The chat models of the Responses API are configured under langchain4j.open-ai-responses, and share the API key and other common settings of langchain4j.open-ai. Since the Chat Completions model is the default OpenAI chat model, configure the Responses models with a name, and select them with the name, for example with @AiService(named = "assistant"):
The Responses API client of LangChain4j appends /responses to the base URL. The default base URL, https://api.openai.com/v1, suits both APIs; set a custom base-url (of langchain4j.open-ai or of the Responses models) without a trailing slash.
The Google Gen AI module uses the Google Gen AI SDK, which works with both the Gemini API and Vertex AI. It provides a chat model, a streaming chat model, an embedding model and an image model. Add the following dependency:
To use Vertex AI, configure the project and the location. The requests are authenticated with the GoogleCredentials bean of the application if there is one, otherwise with the application default credentials:
The models share a com.google.genai.Client bean configured under langchain4j.google-gen-ai, including the connection pool of its HTTP client:
The SDK uses OkHttp. To customize the HTTP client, for example to add interceptors, register a BeanCreatedEventListener for ClientOptions.Builder (or HttpOptions.Builder):
Note
The labels of the requests are not configurable, because they are only supported by Vertex AI. Set them with a BeanCreatedEventListener for the model builder, for example GoogleGenAiChatModel.Builder.
To provide explicit Google Cloud credentials, register a GoogleCredentials bean. When no such bean is present, the Vertex AI client uses Application Default Credentials.
To provide explicit Google Cloud credentials, register a GoogleCredentials bean. When no such bean is present, the Vertex AI Gemini client uses Application Default Credentials.
Scoring models (also called re-ranking models) score the relevance of text segments to a query. They are used to re-rank the content retrieved for retrieval augmented generation, see Configuring Retrieval.
Each module configures a dev.langchain4j.model.scoring.ScoringModel bean under langchain4j.<provider>.scoring-model (and named ones under langchain4j.<provider>.scoring-models.<name>), and an EmbeddingModel bean under langchain4j.<provider>.embedding-model. The models use the Micronaut HTTP client.
18.1 Cohere
Add the following dependency to use the Cohere models:
An in-memory embedding store is enabled by default, set the following property
langchain4j.in-memory.embedding-store.enabled with value false to disable it.
The store is not attached to AI services automatically. See the Retrieval Augmented Generation section of the AI Service guide to declare a ContentRetriever bean.
The dimension must match the dimension of the embeddings of your embedding model. Milvus is eventually consistent by default: set consistency-level to STRONG to search the embeddings right after they are added.
datasources.default.dialect: postgreslangchain4j.pgvector.embedding-stores.default.table: "mytable"langchain4j.pgvector.embedding-stores.default.dimension: 384# Add this if you plan to use testresourcestest-resources.containers.postgres.image-name: pgvector/pgvector:pg16
# Omitt the following 2 properties if you use Test resourceslangchain4j.qdrant.embedding-store.host: localhostlangchain4j.qdrant.embedding-store.port: 6334# Minimal configuration required for Test resourceslangchain4j.qdrant.embedding-store.collection-name: mycollection
This section documents breaking changes between Micronaut LangChain4j versions.
2.4.0
Updated default model names
The default model names of several providers referenced models that are retired or about to be retired. They now default to current models:
Provider
Model type
Previous default
New default
Anthropic
Chat
claude-3-haiku-20240307
claude-haiku-4-5-20251001
Mistral AI
Chat
mistral-tiny
mistral-small-latest
OpenAI
Chat
gpt-3.5-turbo
gpt-4.1-mini
OpenAI
Embedding
text-embedding-ada-002
text-embedding-3-small
OpenAI
Image
dall-e-3
gpt-image-1
Vertex AI Gemini
Chat
gemini-1.5-flash-001
gemini-3.8-flash
Set the model-name property of the model to keep using a previous model. Note that embeddings produced by different embedding models are not compatible, so applications that stored embeddings created with the previous OpenAI default must either set model-name: text-embedding-ada-002 or re-embed their content.
Bedrock model property removed
The langchain4j.bedrock.model property was never applied to the Bedrock models. It has been removed. Configure the model with langchain4j.bedrock.chat-model.model-id (and the equivalent property of the streaming chat and embedding models) instead.
Chat memory of the AI services
The default chat memory provider of the AI services kept the conversations in memory, even when a persistent chat memory store (Redis, Neo4j, Cassandra, Oracle) was configured. It now keeps them in the persistent chat memory store if the application has exactly one. Set langchain4j.chat-memory.store: inMemory to keep them in memory.
Hugging Face uses the Micronaut HTTP client
The Hugging Face models use the Micronaut HTTP client instead of LangChain4j’s JDK HTTP client, like the OpenAI, Anthropic, Ollama, Mistral AI and Google AI Gemini models. Add a Micronaut HTTP client implementation, such as io.micronaut:micronaut-http-client, to the runtime classpath.
LangChain4j Community 1.22
The LangChain4j community modules (Redis, Neo4j, OCI GenAI) have been upgraded from 1.16.0-beta26 to 1.22.0-beta32, aligned with the LangChain4j 1.22.0 BOM. Refer to the LangChain4j Community release notes for changes.
Nullness annotations
Every package of Micronaut LangChain4j is now annotated with JSpecify @NullMarked: the types of the API are non-null, unless annotated with @Nullable. Kotlin reads these annotations, so Kotlin code sees non-null and nullable types where it previously saw platform types. For example, the getters of the optional properties of PgVectorEmbeddingStoreConfig now return nullable types.
21 Repository
You can find the source code of this project in this repository: