Agentic AI
The Agentic module provides abstractions for building AI systems that coordinate multiple agents. These systems go beyond simple single-model interactions, enabling complex workflows where multiple specialized agents collaborate to solve problems.
Overview
Agentic systems are built around the idea of composing multiple specialized agents into higher-level workflows. Each agent performs a specific task, like generating content, classifying input, scoring quality, and the agentic system orchestrates their execution.
The module distinguishes between two categories of agentic systems:
-
Workflows: deterministic orchestration patterns where the execution order is defined at design time (sequence, loop, parallel, conditional).
-
Pure Agents: LLM-driven orchestration where the model dynamically decides which agents to invoke (supervisor, planner).
| You do NOT need to implement agent interfaces. Quarkus LangChain4j automatically detects agent interfaces at build time, validates their configuration, and registers them as CDI beans. |
Getting started
Add the following dependency to your project:
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-agentic</artifactId>
</dependency>
Core Concepts
Agents
An agent is a Java interface with a method annotated with @Agent.
The method defines the agent’s task, typically via an LLM prompt using @UserMessage.
public interface CreativeWriter {
@UserMessage("""
You are a creative writer.
Generate a draft of a story long no more than 3 sentence around the given topic.
Return only the story and nothing else.
The topic is {{topic}}.
""")
@Agent(description = "Generate a story based on the given topic", outputKey = "story")
String generateStory(String topic);
}
@Agent attributes:
| Attribute | Description | Default |
|---|---|---|
|
Describes what the agent does. Essential for supervisor patterns where the LLM picks which agent to invoke. |
Empty |
|
The key under which the agent’s return value is stored in the |
Empty |
|
Unique identifier for the agent within a workflow. |
The method name |
AgenticScope
The AgenticScope is essentially a shared state through which agents exchange data.
Each agent reads its inputs from the scope (matched by the names of the arguments of the agentic method, that can be overwritten through the LangChain4j @V annotation) and writes its output under its outputKey.
For example, when CreativeWriter completes, its return value is stored in the scope under the key "story".
A subsequent AudienceEditor agent can then read that value by declaring a parameter String story.
Defining Agents
AI Agents
AI Agents are interfaces whose methods are backed by an LLM.
They combine @Agent with prompt annotations like @UserMessage and @SystemMessage.
public interface MedicalExpert {
@UserMessage("""
You are a medical expert.
Analyze the following user request under a medical point of view
and provide the best possible answer.
The user request is {{request}}.
""")
@Agent(description = "A medical expert", outputKey = "response")
String medical(String request);
}
Non-AI Agents
Plain Java classes (not backed by an LLM) can participate in agent workflows.
They are concrete classes with a static method annotated with @Agent:
public class PlainProcessListAgent {
@Agent(description = "Get first item from a list", outputKey = "output")
public static String getFirst(List<String> aList) {
return aList.get(0);
}
}
The static method can also use CDI beans, see Using CDI Beans in Agent Methods.
Workflow Patterns
Sequential Workflow
The @SequenceAgent annotation invokes sub-agents one after another.
Each agent receives the outputs of all prior agents via the AgenticScope.
public interface StoryCreator {
@SequenceAgent(
outputKey = "story",
subAgents = { CreativeWriter.class, AudienceEditor.class, StyleEditor.class })
String write(String topic, String style, String audience);
}
In this example, CreativeWriter generates a story and stores it under the key "story".
Then AudienceEditor reads "story" and "audience" from the scope, rewrites the story, and stores the result back under "story".
Finally, StyleEditor reads the updated "story" and "style", and produces the final version.
Inject and use the workflow:
@Inject
StoryCreator storyCreator;
String story = storyCreator.write("dragons and wizards", "fantasy", "young adults");
Loop Workflow
The @LoopAgent annotation repeatedly invokes sub-agents until an exit condition is met or maxIterations is reached.
public interface StyleReviewLoopAgent {
@LoopAgent(
description = "Review the given story to ensure it aligns with the specified style",
outputKey = "story",
maxIterations = 5,
subAgents = { StyleScorer.class, StyleEditor.class })
String write(String story);
@ExitCondition
static boolean exit(double score) {
return score >= 0.8;
}
}
At each iteration, StyleScorer scores the story and stores the result under "score".
Then StyleEditor rewrites the story.
The loop exits when the @ExitCondition method returns true (score >= 0.8) or after 5 iterations.
@ExitCondition rules:
-
Must be a
staticmethod -
Must return
boolean -
Parameters are resolved from the
AgenticScope
Composing Sequence and Loop
Workflows can be composed by nesting them. For example, a sequence that first generates a story, then runs a review loop:
public interface StoryCreatorWithReview {
@SequenceAgent(
outputKey = "story",
subAgents = { CreativeWriter.class, StyleReviewLoopAgent.class })
String write(String topic, String style);
}
@Inject
StoryCreatorWithReview storyCreatorWithReview;
String story = storyCreatorWithReview.write("dragons and wizards", "comedy");
Parallel Workflow
The @ParallelAgent annotation executes multiple independent agents simultaneously.
public interface FoodExpert {
@UserMessage("""
You are a great evening planner.
Propose a list of 3 meals matching the given mood.
The mood is {{mood}}.
For each meal, just give the name of the meal.
Provide a list with the 3 items and nothing else.
""")
@Agent(outputKey = "meals")
List<String> findMeal(String mood);
}
public interface MovieExpert {
@UserMessage("""
You are a great evening planner.
Propose a list of 3 movies matching the given mood.
The mood is {{mood}}.
Provide a list with the 3 items and nothing else.
""")
@Agent(outputKey = "movies")
List<String> findMovie(String mood);
}
public record EveningPlan(String movie, String meal) {
}
public interface EveningPlannerAgent {
@ParallelAgent(
outputKey = "plans",
subAgents = { FoodExpert.class, MovieExpert.class })
List<EveningPlan> plan(String mood);
@Output
static List<EveningPlan> createPlans(List<String> movies, List<String> meals) {
List<EveningPlan> moviesAndMeals = new ArrayList<>();
for (int i = 0; i < movies.size(); i++) {
if (i >= meals.size()) {
break;
}
moviesAndMeals.add(new EveningPlan(movies.get(i), meals.get(i)));
}
return moviesAndMeals;
}
}
Both FoodExpert and MovieExpert run at the same time. Their results are stored under "meals" and "movies" respectively. Then the @Output method createPlans combines those results into a list of EveningPlan.
Parallel Mapper
The @ParallelMapperAgent annotation maps a single sub-agent over a collection of items, executing each invocation in parallel:
public record Person(String name, String sign) {
}
public interface PersonAstrologyAgent {
@UserMessage("""
Generate the horoscope for {{person}}.
The person has a name and a zodiac sign.
Use both to create a personalized horoscope.
""")
@Agent(description = "An astrologist that generates horoscopes for a person", outputKey = "horoscope")
String horoscope(Person person);
}
public interface BatchHoroscopeAgent {
@ParallelMapperAgent(subAgent = PersonAstrologyAgent.class)
List<String> generateHoroscopes(List<Person> persons);
}
Each Person in the list is passed to a separate invocation of PersonAstrologyAgent, all running in parallel.
Conditional Workflow
The @ConditionalAgent annotation routes execution to different sub-agents based on conditions evaluated against the AgenticScope.
public interface ExpertsAgent {
@ConditionalAgent(
outputKey = "response",
subAgents = { MedicalExpert.class, TechnicalExpert.class, LegalExpert.class })
String askExpert(String request);
@ActivationCondition(MedicalExpert.class)
static boolean activateMedical(RequestCategory category) {
return category == RequestCategory.MEDICAL;
}
@ActivationCondition(TechnicalExpert.class)
static boolean activateTechnical(RequestCategory category) {
return category == RequestCategory.TECHNICAL;
}
@ActivationCondition(LegalExpert.class)
static boolean activateLegal(RequestCategory category) {
return category == RequestCategory.LEGAL;
}
}
Each sub-agent is guarded by an @ActivationCondition method. Only the agent whose condition returns true is invoked.
@ActivationCondition rules:
-
Must be a
staticmethod -
Must return
boolean -
Takes the
Classof the sub-agent it guards as the annotation value -
Parameters are resolved from the
AgenticScope
Router Pattern
A common pattern is to compose a classifier agent with a conditional agent in a sequence:
public interface CategoryRouter {
@UserMessage("""
Analyze the following user request and categorize it as 'legal', 'medical' or 'technical'.
In case the request doesn't belong to any of those categories categorize it as 'unknown'.
Reply with only one of those words and nothing else.
The user request is: '{{request}}'.
""")
@Agent(description = "Categorize a user request", outputKey = "category")
RequestCategory classify(String request);
}
public interface ExpertRouterAgent {
@SequenceAgent(
outputKey = "response",
subAgents = { CategoryRouter.class, ExpertsAgent.class })
ResultWithAgenticScope<String> ask(String request);
}
The CategoryRouter classifies the request and writes the "category" to the scope.
The ExpertsAgent then conditionally routes to the matching expert based on that category.
@Inject
ExpertRouterAgent expertRouterAgent;
ResultWithAgenticScope<String> result = expertRouterAgent.ask("I broke my leg what should I do");
RequestCategory category = result.agenticScope().readState("category"); // MEDICAL
String response = result.result();
Pure Agentic Patterns
Supervisor Agent
The @SupervisorAgent uses an LLM to dynamically decide which sub-agents to invoke.
Sub-agents are described to the LLM via their @Agent(description = "…").
public interface WithdrawAgent {
@SystemMessage("You are a banker that can only withdraw US dollars (USD) from a user account.")
@UserMessage("Withdraw {{amountInUSD}} USD from {{withdrawUser}}'s account and return the new balance.")
@Agent("A banker that withdraw USD from an account")
String withdraw(String withdrawUser, Double amountInUSD);
}
public interface CreditAgent {
@SystemMessage("You are a banker that can only credit US dollars (USD) to a user account.")
@UserMessage("Credit {{amountInUSD}} USD to {{creditUser}}'s account and return the new balance.")
@Agent("A banker that credit USD to an account")
String credit(String creditUser, Double amountInUSD);
}
public interface ExchangeAgent {
@UserMessage("""
You are an operator exchanging money in different currencies.
Use the tool to exchange {{amount}} {{originalCurrency}} into {{targetCurrency}}
returning only the final amount provided by the tool as it is and nothing else.
""")
@Agent(outputKey = "exchange",
description = "A money exchanger that converts a given amount of money from the original to the target currency")
Double exchange(String originalCurrency, Double amount, String targetCurrency);
}
public interface SupervisorBanker {
@SupervisorAgent(
responseStrategy = SupervisorResponseStrategy.SUMMARY,
subAgents = { WithdrawAgent.class, CreditAgent.class, ExchangeAgent.class })
String invoke(String request);
}
The supervisor LLM reads the descriptions of the sub-agents and decides which ones to call and in what order to fulfill the user request.
Response strategies:
| Strategy | Description |
|---|---|
|
Returns the last sub-agent’s output (default). |
|
The supervisor LLM summarizes all sub-agent outputs. |
Custom Supervisor Prompt
Use @SupervisorRequest to customize the prompt sent to the supervisor LLM:
public interface SupervisorStoryCreator {
@SupervisorAgent(
outputKey = "story",
responseStrategy = SupervisorResponseStrategy.LAST,
subAgents = { CreativeWriter.class, StyleReviewLoopAgent.class })
ResultWithAgenticScope<String> write(@V("topic") String topic, @V("style") String style);
@SupervisorRequest
static String request(String topic, String style) {
return "Write a story about " + topic + " in " + style + " style";
}
}
Planner Agent
The @PlannerAgent annotation uses a custom Planner implementation for orchestration logic.
This enables advanced patterns like goal-oriented planning and peer-to-peer agent coordination.
public interface MyPlannerAgent {
@PlannerAgent(
outputKey = "result",
subAgents = { AgentA.class, AgentB.class, AgentC.class })
String plan(String input);
@PlannerSupplier
static Planner planner() {
return new MyPlanner();
}
}
A planner agent doesn’t use a chat model itself, so an agentic system made of planners and agents that don’t use one, like non-AI agents or decision agents, needs no chat model.
A Planner that uses a model receives it as a parameter of the @PlannerSupplier method, see Using CDI Beans in Agent Methods:
@PlannerSupplier
static Planner planner(@ModelName("planning") ChatModel chatModel) {
return new MyLlmPlanner(chatModel);
}
Accessing AgenticScope
From the Return Value
Wrap the return type in ResultWithAgenticScope<T> to get both the result and the full scope:
public interface StoryCreatorWithReview {
@SequenceAgent(
outputKey = "story",
subAgents = { CreativeWriter.class, StyleReviewLoopAgent.class })
ResultWithAgenticScope<String> write(String topic, String style);
}
ResultWithAgenticScope<String> result = storyCreator.write("dragons", "comedy");
String story = result.result();
AgenticScope scope = result.agenticScope();
String topic = scope.readState("topic");
double score = scope.readState("score", 0.0);
From the Interface
Extend AgenticScopeAccess to get scope access methods directly on the agent interface:
public interface StyledWriter extends AgenticScopeAccess {
@Agent
ResultWithAgenticScope<String> writeStoryWithStyle(String topic, String style);
}
Memory IDs
Use @MemoryId for per-conversation isolation:
public interface MedicalExpertWithMemory {
@UserMessage("""
You are a medical expert.
Analyze the following user request under a medical point of view
and provide the best possible answer.
The user request is {{request}}.
""")
@Agent(description = "A medical expert", outputKey = "response")
String medical(@MemoryId String memoryId, String request);
}
Error Handling
Define error recovery strategies on workflow agents using @ErrorHandler:
public interface StoryCreatorWithErrorRecovery extends StoryCreator {
@ErrorHandler
static ErrorRecoveryResult errorHandler(ErrorContext errorContext) {
if (errorContext.agentName().equals("generateStory")
&& errorContext.exception() instanceof MissingArgumentException mEx
&& mEx.argumentName().equals("topic")) {
errorContext.agenticScope().writeState("topic", "dragons and wizards");
return ErrorRecoveryResult.retry();
}
return ErrorRecoveryResult.throwException();
}
}
@ErrorHandler rules:
-
Must be a
staticmethod -
Must take
ErrorContextas parameter -
Must return
ErrorRecoveryResult
Recovery options:
| Method | Description |
|---|---|
|
Propagates the error (default behavior). |
|
Retries the failed agent after corrective actions (e.g., writing missing values to the scope). |
|
Returns a fallback value, skipping the failed agent. |
Human-in-the-Loop
The @HumanInTheLoop annotation pauses workflow execution to wait for human input:
public interface AudienceRetriever {
@HumanInTheLoop(
description = "Ask for audience preference",
outputKey = "audience",
async = true)
static String humanResponse(AgenticScope scope, String topic) {
CompletableFuture<String> futureResult = new CompletableFuture<>();
PendingResponses.register(scope.memoryId(), futureResult);
try {
return futureResult.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
}
The optional async = true flag, used in this example, allows running this in a separate thread so the workflow engine is not blocked while waiting for the human response. Another agent or external trigger completes the CompletableFuture to resume execution.
A human-in-the-loop agent doesn’t use a chat model. This agent can be composed in a sequential workflow with other agents:
public interface StoryCreatorWithHumanInTheLoop {
@SequenceAgent(outputKey = "story", subAgents = {
AudienceRetriever.class,
CreativeWriter.class,
AudienceEditor.class })
String write(String topic);
}
Configuring the Chat Model
Default Model
By default, agents use the CDI ChatModel bean configured via application properties (e.g., quarkus.langchain4j.openai.api-key).
Only the AI agents and the supervisor agents use a chat model. Workflows, planner, human-in-the-loop, decision, non-AI and remote agents don’t, so an application without AI or supervisor agents needs no chat model.
Per-Agent Model with @ChatModelSupplier
Use @ChatModelSupplier on a static method to provide a specific ChatModel for an agent:
public interface CategoryRouter {
@UserMessage("Categorize as 'legal', 'medical' or 'technical': '{{request}}'")
@Agent(description = "Categorize a user request", outputKey = "category")
RequestCategory classify(@V("request") String request);
@ChatModelSupplier
static ChatModel chatModel() {
return OpenAiChatModel.builder()
.apiKey("...")
.modelName("gpt-4o")
.build();
}
}
Named Models with @ModelName
Use @ModelName to select a named model configuration, exactly like with AI Services:
public interface NamedModelAgent {
@UserMessage("Answer the following question: {{question}}")
@Agent(value = "An agent using a named model", outputKey = "answer")
@ModelName("mymodel")
String answer(@V("question") String question);
}
Configure the named model in application.properties:
quarkus.langchain4j.mymodel.chat-model.provider=openai
quarkus.langchain4j.openai.mymodel.api-key=sk-...
Dynamic Model Selection with @ChatModelSupplier
The @ChatModelSupplier method can also accept parameters, enabling dynamic model selection based on the current agent execution state. Parameters are resolved from the AgenticScope, meaning they can receive outputs produced by previously executed agents in the workflow. This allows an agent to pick a different ChatModel at runtime depending on the results of earlier steps.
public interface DynamicModelAgent {
@UserMessage("Answer the following question: {{text}}")
@Agent(value = "An agent using a dynamic model selection", outputKey = "answer")
String answer(String text);
@ChatModelSupplier
static ChatModel chatModel(Double threshold) {
return threshold > 0.5 ? baseModel() : enhancedModel();
}
}
Here the threshold parameter is resolved from the AgenticScope and the ChatModel to be used for the current invocation is selected based on its value.
The method can also receive CDI beans, for instance a @ModelName qualified ChatModel, see Using CDI Beans in Agent Methods.
This is particularly useful when:
-
Different inputs require models with different capabilities or cost profiles (e.g., using a cheaper model for simple requests and a more powerful one for complex ones).
-
The model selection depends on the outcome of a prior classification or routing step.
Decision Agents
A decision agent answers questions about its arguments with a decision model instead of a chat model.
Its implementation is a single call to a DecisionModel, which returns typed answers and their probabilities rather than generated text.
That makes it cheaper and faster than an AI agent, and a natural fit for the routing, gating and classification steps of an agentic system.
Decision agents and the DecisionModel API are experimental in LangChain4j and may change in future releases.
|
Declaring a Decision Agent
A decision agent is an interface whose @Agent method is annotated with @Decide, which contains the question:
public interface SpamGate {
@Decide("Is this message spam?")
@Agent(description = "Detects spam", outputKey = "spam")
boolean isSpam(@V("message") String message);
}
Like the arguments of any agent, the arguments of the method are read from the AgenticScope by name.
They are sent to the decision model as the input of the decision, objects being converted to maps.
The answer is written as it is to the output key, so that the activation and exit conditions and the agents that follow can read it:
public enum Team {
@Description("Payments, payouts, invoices and refunds")
BILLING,
@Description("Problems using the product")
SUPPORT
}
public interface TeamRouter {
@Decide("Which team should handle this request?")
@Agent(description = "Routes a request to a team", outputKey = "team")
Team route(@V("request") String request);
}
public interface HelpDesk {
@SequenceAgent(outputKey = "answer", subAgents = { TeamRouter.class, Desks.class })
String handle(@V("request") String request);
}
public interface Desks {
@ConditionalAgent(outputKey = "answer", subAgents = { BillingExpert.class, SupportExpert.class })
String answer(@V("request") String request);
@ActivationCondition(BillingExpert.class)
static boolean billing(@V("team") Team team) {
return team == Team.BILLING;
}
@ActivationCondition(SupportExpert.class)
static boolean support(@V("team") Team team) {
return team == Team.SUPPORT;
}
}
The return type of the method determines the questions asked:
-
boolean: a yes/no question, answeredtruewhen the probability of "yes" reaches the threshold, see Thresholds -
YesNoAnswer: a yes/no question, answered with the probability of "yes" -
an enum: a choice among its constants, described to the model by their
@Description, if any -
Choice<E>: a choice among the constants of the enumE, answered with the probability of each of them -
Scale<E>: a scale whose levels are the constants of the enumE, in declaration order -
a record or a class whose fields are annotated with
@Decideand of the types above: one question per field, all asked in a single call -
any of the above wrapped in
DecisionResult, to also get the response of the model
public record Triage(
@Decide("Which team should handle this ticket?") Team team,
@Decide("Does this ticket need attention today?") boolean urgent) {
}
public interface TicketTriage {
@Agent(description = "Triages a ticket", outputKey = "triage")
Triage triage(@V("ticket") String ticket);
}
When it returns such an object, the @Agent method is not annotated with @Decide, and the questions are the ones of its fields.
Decision agents are @ApplicationScoped beans that can be injected and used as sub-agents of any workflow, like AI agents.
A CompletableFuture return type is not supported: use @Agent(async = true) to run a decision agent asynchronously inside a workflow.
A decision agent has no prompt, chat memory, tools, retrieval augmentor or guardrails, so the build fails if it uses @UserMessage, @SystemMessage, @ChatModelSupplier, @ToolBox, @McpToolBox or @Skills.
An application whose only agents are decision agents doesn’t need a chat model.
| An injected decision agent can be invoked directly, outside of an agentic system, but each such invocation logs a warning. To ask a decision model a question from application code, use a Decision Service instead. |
Configuring the Decision Model
By default, a decision agent uses the default DecisionModel bean, configured through the decision model provider, for instance OpenAI or TypeSafe.
Use @ModelName on the @Agent method to select a named decision model:
public interface FraudGate {
@Decide("Does this transaction look fraudulent?")
@Agent(description = "Checks a transaction for fraud", outputKey = "fraud")
@ModelName("fraud")
boolean check(@V("transaction") Transaction transaction);
}
quarkus.langchain4j.openai.fraud.api-key=sk-...
quarkus.langchain4j.openai.fraud.decision-model.model-name=gpt-6-luna
A static method annotated with @DecisionModelSupplier provides the model programmatically.
Its parameters can be CDI beans, see Using CDI Beans in Agent Methods, and values read from the AgenticScope, in which case the method is invoked at each invocation of the agent to select the model:
public interface FraudGate {
@Decide("Does this transaction look fraudulent?")
@Agent(description = "Checks a transaction for fraud", outputKey = "fraud")
boolean check(@V("transaction") Transaction transaction);
@DecisionModelSupplier
static DecisionModel decisionModel(@V("amount") double amount, @CdiBean FraudModels models) {
return amount > 10_000 ? models.accurate() : models.fast();
}
}
@ModelName and @DecisionModelSupplier can’t be used on the same agent.
Thresholds
A boolean answer is true when the probability of "yes" is at least the threshold, which is 0.5 by default.
Thresholds are specific to a decision model and to its version, so they can be configured for each agent, and for each model answering it:
quarkus.langchain4j.agentic.decision-agent."spamGate".threshold=0.7
quarkus.langchain4j.agentic.decision-agent."spamGate".threshold-by-model."gpt-6-luna"=0.6
The key is the name of the agent: the name of @Agent, or the name of the method.
The threshold of the model that answered takes precedence over the one of the agent.
The configuration is read once at startup.
For a threshold that changes at runtime, declare a static method annotated with @DecisionThresholdSupplier instead, which is invoked at each invocation and takes precedence over the configuration:
public interface SpamGate {
@Decide("Is this message spam?")
@Agent(description = "Detects spam", outputKey = "spam")
boolean isSpam(@V("message") String message);
@DecisionThresholdSupplier
static double threshold(ThresholdContext context, @CdiBean SpamSettings settings) {
return settings.threshold(context.modelName());
}
}
The application fails to start when a threshold is not between 0 and 1, or when it is configured for an agent that has no boolean answer or that declares a @DecisionThresholdSupplier method, since it would have no effect.
A key that matches no decision agent logs a warning.
Listeners, Monitoring and Dev UI
The AgentListener beans receive the response of the decision model with AgentResponse.decisionResponse(), which carries the probabilities of the answers, the name of the model that answered and the token usage.
The AgentMonitor of a MonitoredAgent records it as well.
In the Dev UI, decision agents are listed with the DecisionAgent type, the Topology page marks them as decisions, and the Executions page shows the model, the token usage and the answers of each decision.
Interceptor bindings, like @Retry from SmallRye Fault Tolerance, can be applied to the @Agent method of a decision agent, and they are honored inside workflows too.
Routing and Cascading with a Decision Model
The langchain4j-agentic-patterns library provides planners based on a decision model:
-
DecisionRouterPlannerlets the decision model choose the sub-agents that handle a request -
ConfidenceCascadePlannertries its sub-agents from the cheapest to the most capable, until the decision model accepts an answer
They get the DecisionModel bean through a @CdiBean parameter of the @PlannerSupplier method:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-agentic-patterns</artifactId>
</dependency>
public interface ExpertRouter {
@PlannerAgent(outputKey = "answer", subAgents = { BillingExpert.class, SupportExpert.class })
String route(@V("request") String request);
@PlannerSupplier
static Planner planner(@CdiBean DecisionModel decisionModel) {
return new DecisionRouterPlanner(decisionModel);
}
}
public interface AnswerCascade {
@PlannerAgent(outputKey = "answer", subAgents = { SmallModelExpert.class, LargeModelExpert.class })
String answer(@V("request") String request);
@PlannerSupplier
static Planner planner(@CdiBean DecisionModel decisionModel) {
return new ConfidenceCascadePlanner(decisionModel, 0.6);
}
}
Discovering A2A Agents with Apicurio Registry
Apicurio Registry can act as a catalog of A2A agents. With the Apicurio Registry A2A extension, an application can publish its own agent card to the registry at startup, and discover the agents other applications have published there.
| This feature is experimental and may change in future releases. |
Dependencies
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-a2a-apicurio-registry</artifactId>
</dependency>
Configuration
quarkus.langchain4j.a2a.apicurio-registry.url=http://localhost:8080/apis/registry/v3
# Optional: a bearer token for the registry
quarkus.langchain4j.a2a.apicurio-registry.auth-token=my-token
Discovering Agents
The extension provides an ApicurioAgentsRegistry bean, a LangChain4j AgentsRegistry backed by the registry’s
AGENT_CARD artifacts:
@Inject
ApicurioAgentsRegistry agents;
public String describe(String name) {
AgentInstance agent = agents.getAgent(name);
return agent.description();
}
Each call to allAgents() or getAgent(…) queries the registry again, so newly published agents are found without a
restart. Discovery works as follows:
-
It reads every
AGENT_CARDartifact in every group, paging through the full result set. -
An agent is registered under the name in its card. When two artifacts declare the same name, neither is registered under that name. Each is registered under
<groupId>/<artifactId>instead, andgetAgentwith the shared name fails with an error that lists both qualified names. -
The agent’s URL comes from the artifact’s
a2a-agent-urllabel. Without that label, the firstJSONRPCentry in the card’ssupportedInterfacesis used. An agent with neither is skipped, with a warning in the log. -
Building an agent fetches its card from the agent itself. An agent that cannot be reached is skipped, with a warning in the log.
-
The inputs an agent expects are read from the comma-separated
a2a-input-keyslabel, or default toquarkus.langchain4j.a2a.apicurio-registry.default-input-keys(input).
Publishing an Agent Card
Annotate a class with @PublishToAgentRegistry to publish this application’s agent card to the registry at startup:
import io.quarkiverse.langchain4j.a2a.runtime.apicurio.PublishToAgentRegistry;
@PublishToAgentRegistry(
name = "Translator",
description = "Translates text between languages",
version = "1.0.0",
skills = {
@PublishToAgentRegistry.Skill(id = "translate", name = "Translate", description = "Translates text")
})
public class TranslatorCard {
}
The card is published as an AGENT_CARD artifact:
-
It is published in the group set by
quarkus.langchain4j.a2a.apicurio-registry.group-id(default). Its artifact ID is the agent name in lower case, with characters other than letters, digits and-replaced by-. -
The agent’s URL is
quarkus.langchain4j.a2a.apicurio-registry.agent-url, which defaults tohttp://localhost:8080. Set it to the URL other applications should use to reach this agent. -
Restarting with an unchanged card adds no version. A changed card adds a new version, even when
versionis unchanged. -
The artifact’s labels are updated with the agent URL (
a2a-agent-url) and skill names (a2a-agent-skills).
Publishing is best-effort. If it fails, for example because the registry cannot be reached or a registry rule rejects
the new card, the failure is logged at ERROR level and the application starts anyway. A card that a rule rejects is
not stored, so the previously published version stays the latest one. Configure rules in the registry, such as a
COMPATIBILITY rule on the group, to reject card changes that would break the agent’s callers, for example removing a
skill.
Using CDI Beans in Agent Methods
Agents are interfaces, so they can’t have injected fields.
Their static methods, however, can declare parameters resolved from CDI, by annotating them with @CdiBean or with a CDI qualifier, such as @ModelName.
These parameters can be mixed with the ones read from the AgenticScope and with the ones provided by the agentic framework, like the AgenticScope itself or the ErrorContext:
public interface StyleReviewLoop {
@LoopAgent(outputKey = "story", maxIterations = 5, subAgents = { StyleScorer.class, StyleEditor.class })
String review(@V("story") String story);
@ExitCondition
static boolean exit(@V("score") double score, @CdiBean ReviewPolicy policy) {
return score >= policy.minimumScore();
}
@Output
static String output(@V("story") String story, @CdiBean StoryRepository repository) {
repository.save(story);
return story;
}
}
The same works on the static method of a non-AI agent and on a @HumanInTheLoop method:
public class OrderLookup {
@Agent(description = "Find the status of an order", outputKey = "orderStatus")
public static String status(@V("orderId") String orderId, @CdiBean OrderService orders) {
return orders.statusOf(orderId);
}
}
public interface RefundApprover {
@HumanInTheLoop(description = "Ask a manager to approve the refund", outputKey = "approved")
static boolean approve(@V("orderId") String orderId, @CdiBean ApprovalService approvals) {
return approvals.waitForApproval(orderId);
}
}
CDI parameters can be declared on the methods annotated with:
-
any supplier annotation:
@ChatModelSupplier,@StreamingChatModelSupplier,@ChatMemorySupplier,@ChatMemoryProviderSupplier,@ToolsSupplier,@ToolProviderSupplier,@ContentRetrieverSupplier,@RetrievalAugmentorSupplier,@AgentListenerSupplier,@SystemMessageProviderSupplier,@UserMessageProviderSupplier,@McpClientSupplier,@PlannerSupplier,@A2AServerUrlSupplier,@ParallelExecutor,@DecisionModelSupplierand@DecisionThresholdSupplier -
@ExitCondition,@ActivationCondition,@Output,@BeforeCall,@ErrorHandler,@SupervisorRequestand@A2AClientCustomizer -
@Agent, on thestaticmethod of a non-AI agent, and@HumanInTheLoop
The methods can be declared on the agent type or on one of its parent interfaces.
A CDI parameter:
-
is looked up at each invocation of the method, so a
@RequestScopedbean is the one of the current request -
of type
ChatModel,DecisionModel,ModerationModelorImageModel, without qualifier or with@ModelName, gets the bean of the configured model provider, even if nothing else injects it -
is not an argument of the agent: it is not read from the
AgenticScope, not reported to listeners, and not described to a supervisor -
must resolve to exactly one bean, otherwise the build fails
|
The bean is looked up on the thread invoking the method.
A |
The methods stay static: agents can’t have injected fields, constructor injection or @PostConstruct callbacks, and interceptor bindings like @Transactional have no effect on these methods.
To use them, call a method of a bean.
Build-Time Validation
Quarkus validates agent configurations at build time, catching errors before the application starts:
-
All supplier methods (
@ToolsSupplier, etc.) must bestaticwith correct return types -
@ChatModelSuppliermethods must bestaticand returnChatModel(parameters are allowed for dynamic model selection) -
@ExitConditionand@ActivationConditionmethods must returnboolean -
@ErrorHandlermethods must takeErrorContextand returnErrorRecoveryResult -
@BeforeCallmethods must takeAgenticScopeand@ChatMemoryProviderSuppliermethods must take the memory id as anObject -
@ParallelExecutormethods must returnExecutor, and neither they nor@A2AServerUrlSuppliermethods can have parameters -
In all the rules above, the parameters annotated with
@CdiBeanor with a CDI qualifier are not counted, and each of them must resolve to exactly one bean -
@ModelNameand@ChatModelSuppliercannot be used on the same agent -
@DecisionModelSupplierand@DecisionThresholdSuppliermethods can only be declared on decision agents, must bestatic, and returnDecisionModel, anddoubleorDouble, respectively -
@DecisionThresholdSuppliermethods must take aThresholdContext, and can’t be declared on a decision agent that has nobooleananswer -
A decision agent can’t use
@UserMessage,@SystemMessage,@ChatModelSupplier,@StreamingChatModelSupplier,@ToolBox,@McpToolBoxor@Skills, nor both@ModelNameand@DecisionModelSupplier
Annotations Reference
| Annotation | Target | Purpose | Parameters |
|---|---|---|---|
|
method |
Declares a basic AI agent method, or the |
Agent arguments. On a non-AI agent, also CDI beans. |
|
method |
Sequential workflow of sub-agents. |
Agent arguments. |
|
method |
Iterative workflow with exit condition. |
Agent arguments. |
|
method |
Parallel execution of sub-agents. |
Agent arguments. |
|
method |
Maps one sub-agent over a collection. |
Agent arguments. |
|
method |
Conditional routing to sub-agents. |
Agent arguments. |
|
method |
LLM-driven dynamic orchestration. |
Agent arguments. |
|
method |
Custom planner-driven orchestration. |
Agent arguments. |
|
static method |
Pauses for human input. |
Values from the |
|
static method |
Loop exit predicate (returns |
Values from the |
|
static method |
Conditional routing predicate (returns |
Values from the |
|
static method |
Error recovery (takes |
The |
|
static method |
Custom output combiner for parallel workflows. |
Values from the |
|
static method |
Provides per-agent |
Values from the |
|
static method |
Custom prompt builder for supervisor. |
Values from the |
|
static method |
Provides |
CDI beans only. |
|
method, field |
The question of a decision agent, on its |
- |
|
static method |
Provides the |
Values from the |
|
static method |
Provides the threshold of the |
The |
|
parameter |
Resolves the parameter of a |
- |
Configuration Reference
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
The threshold of the Environment variable: |
double |
|
The threshold to use when the given decision model answered, keyed by model name, overriding Environment variable: |
Map<String,Double> |
Apicurio Registry A2A Integration
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
Whether the Apicurio Registry A2A agent discovery integration is enabled. Environment variable: |
boolean |
|
The base URL of the Apicurio Registry instance (e.g. http://localhost:8080/apis/registry/v3). Environment variable: |
string |
|
Optional authentication token for the registry. Environment variable: |
string |
|
Group ID to use when publishing and searching agent cards. Environment variable: |
string |
|
The base URL of this application’s A2A server endpoint, used when publishing the agent card. Environment variable: |
string |
|
The input keys used for agents discovered from the registry whose agent card artifact does not declare an A2A agent cards carry no input schema, so discovered agents are built as untyped agents and these keys define both the arguments exposed to a supervisor or planner and the keys read from the Environment variable: |
list of string |
|