Build and demo Java AI agent systems with langchain4j-agentic: workflow patterns, supervisor, custom Planner strategies (incl. the flagship typed-verdict / CriticResult-style critic pattern), plus MCP tools, A2A remote agents, build setup, and conference-demo storylines. Pinned to 1.15.0 / 1.15.0-beta25.
84
89%
Does it follow best practices?
Impact
100%
4.76xAverage score across 2 eval scenarios
Passed
No known issues
Every workflow pattern can also be expressed declaratively with annotations, producing more concise definitions.
The parallel EveningPlannerAgent written declaratively:
public interface EveningPlannerAgent {
@ParallelAgent(outputKey = "plans", subAgents = { FoodExpert.class, MovieExpert.class })
List<EveningPlan> plan(@V("mood") String mood);
@ParallelExecutor
static Executor executor() {
return Executors.newFixedThreadPool(2);
}
@Output
static List<EveningPlan> createPlans(@V("movies") List<String> movies, @V("meals") 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;
}
}@SequenceAgent, @ParallelAgent, @LoopAgent, @ConditionalAgent annotate the workflow method, listing subAgents and outputKey.@Output static method combines sub-agent outputs (same role as the programmatic output(...)).@ParallelExecutor static method supplies the Executor.Instantiate with createAgenticSystem, providing the default model for all sub-agents:
EveningPlannerAgent eveningPlannerAgent = AgenticServices
.createAgenticSystem(EveningPlannerAgent.class, BASE_MODEL);
List<EveningPlan> plans = eveningPlannerAgent.plan("romantic");@ActivationCondition(AgentClass...) marks a static predicate that activates the listed agent class(es) when it returns true:
public interface ExpertsAgent {
@ConditionalAgent(outputKey = "response",
subAgents = { MedicalExpert.class, TechnicalExpert.class, LegalExpert.class })
String askExpert(@V("request") String request);
@ActivationCondition(MedicalExpert.class)
static boolean activateMedical(@V("category") RequestCategory category) {
return category == RequestCategory.MEDICAL;
}
@ActivationCondition(TechnicalExpert.class)
static boolean activateTechnical(@V("category") RequestCategory category) {
return category == RequestCategory.TECHNICAL;
}
@ActivationCondition(LegalExpert.class)
static boolean activateLegal(@V("category") RequestCategory category) {
return category == RequestCategory.LEGAL;
}
}Configure an agent's collaborators declaratively with static methods. They take no arguments unless noted; model/streaming suppliers may take an AgenticScope for dynamic selection.
| Annotation | Returns |
|---|---|
@ChatModelSupplier | ChatModel (fixed, or a function of AgenticScope) |
@StreamingChatModelSupplier | StreamingChatModel (fixed, or function of AgenticScope) |
@ChatMemorySupplier | ChatMemory |
@ChatMemoryProviderSupplier | ChatMemoryProvider (takes an Object memoryId argument) |
@ContentRetrieverSupplier | ContentRetriever |
@AgentListenerSupplier | AgentListener |
@RetrievalAugmentorSupplier | RetrievalAugmentor |
@ToolsSupplier | a single Object or Object[] of tools |
@ToolProviderSupplier | ToolProvider |
public interface FoodExpert {
@UserMessage("...")
@Agent(outputKey = "meals")
List<String> findMeal(@V("mood") String mood);
@ChatModelSupplier
static ChatModel chatModel() {
return FOOD_MODEL;
}
}Programmatic and declarative styles mix freely. You can define agents declaratively (with their own @ChatModelSupplier) and then compose them programmatically by passing the classes as sub-agents:
UntypedAgent novelCreator = AgenticServices.sequenceBuilder()
.subAgents(CreativeWriter.class, AudienceEditor.class)
.outputKey("story")
.build();
String story = (String) novelCreator.invoke(Map.of(
"topic", "dragons and wizards", "audience", "young adults"));String keys are error-prone (typos, no type binding, casts on read). The TypedKey interface gives compile-safe keys:
public static class UserRequest implements TypedKey { } // String by default
public static class ExpertResponse implements TypedKey { }
public static class Category implements TypedKey {
@Override public Category defaultValue() { return Category.UNKNOWN; }
}Use @K(KeyClass.class) on arguments and typedOutputKey on @Agent. The prompt placeholder defaults to the key class's simple name (override via name()):
public interface CategoryRouter {
@UserMessage("""
Analyze the following user request and categorize it as 'legal', 'medical' or 'technical'.
...
The user request is: '{{UserRequest}}'.
""")
@Agent(description = "Categorizes a user request", typedOutputKey = Category.class)
RequestCategory classify(@K(UserRequest.class) String request);
}Wiring the system — note reads need no cast:
MedicalExpert medicalExpert = AgenticServices.agentBuilder(MedicalExpert.class)
.chatModel(baseModel())
.outputKey(ExpertResponse.class)
.build();
// ... legalExpert, technicalExpert similarly
UntypedAgent expertsAgent = AgenticServices.conditionalBuilder()
.subAgents(scope -> scope.readState(Category.class) == Category.MEDICAL, medicalExpert)
.subAgents(scope -> scope.readState(Category.class) == Category.LEGAL, legalExpert)
.subAgents(scope -> scope.readState(Category.class) == Category.TECHNICAL, technicalExpert)
.build();
ExpertChatbot expertChatbot = AgenticServices.sequenceBuilder(ExpertChatbot.class)
.subAgents(routerAgent, expertsAgent)
.outputKey(ExpertResponse.class)
.build();The router declares its output via typedOutputKey on the annotation, so it doesn't repeat outputKey programmatically; the experts (whose interfaces don't declare it) set outputKey(ExpertResponse.class) on the builder. Either approach is valid.