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

value / description

Describes what the agent does. Essential for supervisor patterns where the LLM picks which agent to invoke.

Empty

outputKey

The key under which the agent’s return value is stored in the AgenticScope.

Empty

name

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.

Inject and Use

Agent interfaces are automatically registered as @ApplicationScoped CDI beans. Simply inject them:

@Inject
CreativeWriter writer;

String story = writer.generateStory("dragons and wizards");

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 static method

  • 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 static method

  • Must return boolean

  • Takes the Class of 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

LAST

Returns the last sub-agent’s output (default).

SUMMARY

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 static method

  • Must take ErrorContext as parameter

  • Must return ErrorRecoveryResult

Recovery options:

Method Description

ErrorRecoveryResult.throwException()

Propagates the error (default behavior).

ErrorRecoveryResult.retry()

Retries the failed agent after corrective actions (e.g., writing missing values to the scope).

ErrorRecoveryResult.result(Object)

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, answered true when 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 enum E, answered with the probability of each of them

  • Scale<E>: a scale whose levels are the constants of the enum E, in declaration order

  • a record or a class whose fields are annotated with @Decide and 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:

  • DecisionRouterPlanner lets the decision model choose the sub-agents that handle a request

  • ConfidenceCascadePlanner tries 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_CARD artifact 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, and getAgent with the shared name fails with an error that lists both qualified names.

  • The agent’s URL comes from the artifact’s a2a-agent-url label. Without that label, the first JSONRPC entry in the card’s supportedInterfaces is 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-keys label, or default to quarkus.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 to http://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 version is 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, @DecisionModelSupplier and @DecisionThresholdSupplier

  • @ExitCondition, @ActivationCondition, @Output, @BeforeCall, @ErrorHandler, @SupervisorRequest and @A2AClientCustomizer

  • @Agent, on the static method 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 @RequestScoped bean is the one of the current request

  • of type ChatModel, DecisionModel, ModerationModel or ImageModel, 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 @RequestScoped bean can’t be used by a method invoked on another thread, like the subagents of a parallel workflow or an async agent, because the request context is not propagated there. A @Dependent bean is created at each invocation and never destroyed: prefer a normal scope, like @ApplicationScoped.

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 be static with correct return types

  • @ChatModelSupplier methods must be static and return ChatModel (parameters are allowed for dynamic model selection)

  • @ExitCondition and @ActivationCondition methods must return boolean

  • @ErrorHandler methods must take ErrorContext and return ErrorRecoveryResult

  • @BeforeCall methods must take AgenticScope and @ChatMemoryProviderSupplier methods must take the memory id as an Object

  • @ParallelExecutor methods must return Executor, and neither they nor @A2AServerUrlSupplier methods can have parameters

  • In all the rules above, the parameters annotated with @CdiBean or with a CDI qualifier are not counted, and each of them must resolve to exactly one bean

  • @ModelName and @ChatModelSupplier cannot be used on the same agent

  • @DecisionModelSupplier and @DecisionThresholdSupplier methods can only be declared on decision agents, must be static, and return DecisionModel, and double or Double, respectively

  • @DecisionThresholdSupplier methods must take a ThresholdContext, and can’t be declared on a decision agent that has no boolean answer

  • A decision agent can’t use @UserMessage, @SystemMessage, @ChatModelSupplier, @StreamingChatModelSupplier, @ToolBox, @McpToolBox or @Skills, nor both @ModelName and @DecisionModelSupplier

Annotations Reference

Annotation Target Purpose Parameters

@Agent

method

Declares a basic AI agent method, or the static method of a non-AI agent.

Agent arguments. On a non-AI agent, also CDI beans.

@SequenceAgent

method

Sequential workflow of sub-agents.

Agent arguments.

@LoopAgent

method

Iterative workflow with exit condition.

Agent arguments.

@ParallelAgent

method

Parallel execution of sub-agents.

Agent arguments.

@ParallelMapperAgent

method

Maps one sub-agent over a collection.

Agent arguments.

@ConditionalAgent

method

Conditional routing to sub-agents.

Agent arguments.

@SupervisorAgent

method

LLM-driven dynamic orchestration.

Agent arguments.

@PlannerAgent

method

Custom planner-driven orchestration.

Agent arguments.

@HumanInTheLoop

static method

Pauses for human input.

Values from the AgenticScope, and CDI beans.

@ExitCondition

static method

Loop exit predicate (returns boolean).

Values from the AgenticScope, the loop counter, and CDI beans.

@ActivationCondition

static method

Conditional routing predicate (returns boolean).

Values from the AgenticScope, and CDI beans.

@ErrorHandler

static method

Error recovery (takes ErrorContext, returns ErrorRecoveryResult).

The ErrorContext, and CDI beans.

@Output

static method

Custom output combiner for parallel workflows.

Values from the AgenticScope, and CDI beans.

@ChatModelSupplier

static method

Provides per-agent ChatModel.

Values from the AgenticScope, and CDI beans.

@SupervisorRequest

static method

Custom prompt builder for supervisor.

Values from the AgenticScope, and CDI beans.

@ParallelExecutor

static method

Provides Executor for parallel workflows.

CDI beans only.

@Decide

method, field

The question of a decision agent, on its @Agent method, or on the fields of the object it returns.

-

@DecisionModelSupplier

static method

Provides the DecisionModel of a decision agent.

Values from the AgenticScope, and CDI beans.

@DecisionThresholdSupplier

static method

Provides the threshold of the boolean answers of a decision agent.

The ThresholdContext, and CDI beans.

@CdiBean

parameter

Resolves the parameter of a static agent method from CDI, see Using CDI Beans in Agent Methods.

-

Configuration Reference

Configuration property fixed at build time - All other configuration properties are overridable at runtime

Configuration property

Type

Default

The threshold of the boolean answers of this agent, between 0 and 1: an answer is true when the probability of "yes" is at least the threshold. 0.5 when absent.

Environment variable: QUARKUS_LANGCHAIN4J_AGENTIC_DECISION_AGENT__AGENT_NAME__THRESHOLD

double

The threshold to use when the given decision model answered, keyed by model name, overriding threshold. Thresholds are specific to a decision model and to its version.

Environment variable: QUARKUS_LANGCHAIN4J_AGENTIC_DECISION_AGENT__AGENT_NAME__THRESHOLD_BY_MODEL__MODEL_NAME_

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: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_ENABLED

boolean

true

The base URL of the Apicurio Registry instance (e.g. http://localhost:8080/apis/registry/v3).

Environment variable: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_URL

string

Optional authentication token for the registry.

Environment variable: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_AUTH_TOKEN

string

Group ID to use when publishing and searching agent cards.

Environment variable: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_GROUP_ID

string

default

The base URL of this application’s A2A server endpoint, used when publishing the agent card.

Environment variable: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_AGENT_URL

string

The input keys used for agents discovered from the registry whose agent card artifact does not declare an a2a-input-keys label.

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 AgenticScope when invoking the remote agent.

Environment variable: QUARKUS_LANGCHAIN4J_A2A_APICURIO_REGISTRY_DEFAULT_INPUT_KEYS

list of string

input