CtrlK
BlogDocsLog inGet started
Tessl Logo

gamussa/langchain4j-agentic

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

4.76x
Quality

89%

Does it follow best practices?

Impact

100%

4.76x

Average score across 2 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

declarative-api.mddocs/

Declarative API & Strongly Typed Keys

Every workflow pattern can also be expressed declaratively with annotations, producing more concise definitions.


Declarative workflows

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");

Conditional workflow, declaratively

@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;
    }
}

Per-agent supplier annotations

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.

AnnotationReturns
@ChatModelSupplierChatModel (fixed, or a function of AgenticScope)
@StreamingChatModelSupplierStreamingChatModel (fixed, or function of AgenticScope)
@ChatMemorySupplierChatMemory
@ChatMemoryProviderSupplierChatMemoryProvider (takes an Object memoryId argument)
@ContentRetrieverSupplierContentRetriever
@AgentListenerSupplierAgentListener
@RetrievalAugmentorSupplierRetrievalAugmentor
@ToolsSuppliera single Object or Object[] of tools
@ToolProviderSupplierToolProvider
public interface FoodExpert {
    @UserMessage("...")
    @Agent(outputKey = "meals")
    List<String> findMeal(@V("mood") String mood);

    @ChatModelSupplier
    static ChatModel chatModel() {
        return FOOD_MODEL;
    }
}

Mixing styles

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"));

Strongly typed input/output keys

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.

docs

agent-configuration.md

agents-and-scope.md

custom-strategy-critic-result.md

declarative-api.md

demo-storylines.md

gotchas.md

index.md

mcp-and-a2a.md

pure-agentic.md

tools-memory-and-build.md

workflow-patterns.md

README.md

tile.json