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

custom-strategy-critic-result.mddocs/

⭐ Flagship pattern — Custom Strategy with Domain Modeling

This is the headline pattern of the demo. It upgrades the generic "planner → executor → critic" loop by replacing the critic's stringly-typed score with a domain-modeled, strongly typed object that flows between agents and drives routing.

CriticResult is not a langchain4j-agentic type. It is a plain, user-defined illustration of the object that passes between agents in this workflow — the verifier's output that the router branches on. Name it and shape it to fit your domain (ItineraryReview, ComplianceVerdict, whatever). The generic <T> below is just to show the idea is reusable; making it generic is not a requirement. The point is "a typed domain object between agents," not this specific class.

Status: a framework-correct reference implementation of the demo's "money slide" concept — reconcile exact agent/method names with the live demo source. The Planner/Action API matches the official LangChain4j tutorial (verified).

The slide

The slide image is bundled with the skill at assets/agentic-useacase.jpg.

Start → [Identify Problem] → [Fix Problem] → [Verify Solution] → [Adjust] → … → Finish
                                                    │
                                       CriticResult<T>
                                       ├─ successful == true  → Finish, emit input  (AccountIssueSolution)
                                       └─ successful == false → Adjust, pass feedback (String) → re-verify

Each box can itself be a sub-workflow (nest freely — every builder returns an agent).

1. Domain-model the object that flows between the agents

The whole point: the verifier does not emit a bare double or free text. It emits a typed object carrying the verdict, optional feedback, and the typed payload it judged — so the router can branch on real fields and the success path returns something usable.

CriticResult below is just an illustration of that object (the slide's name for it). It is ordinary application code, not a framework type — model it however fits your domain. A generic version makes it reusable across workflows, but a concrete per-domain type (e.g. ItineraryReview) is equally valid:

// Illustrative — your own type. Generic here only to show reusability.
public record CriticResult<T>(
        boolean successful,   // pass/fail verdict the router branches on
        String feedback,      // nullable — why it failed / how to adjust
        T input               // the typed artifact under review, e.g. the itinerary
) {
    public static <T> CriticResult<T> pending() {
        return new CriticResult<>(false, null, null);
    }
}

// Equally fine — a concrete, non-generic shape for one domain:
// public record ItineraryReview(boolean approved, String notes, TravelItinerary itinerary) {}

The slide's notation (successful: Boolean, feedback: String?, input: T) is Kotlin-style; use a Java record or a Kotlin data class — the framework parses either as structured output.

The domain artifact being verified is also a real type, not a string:

public record AccountIssueSolution(String accountId, String diagnosis, String resolution) {}

2. The agents

Verify Solution returns the typed CriticResult<AccountIssueSolution>. LangChain4j parses the model output into the record (structured output), so downstream routing reads real fields.

public interface SolutionCritic {
    @SystemMessage("You verify proposed account-issue solutions. Be strict.")
    @UserMessage("""
        Verify this proposed solution for the reported problem.
        Problem: {{problem}}
        Proposed solution: {{solution}}
        Set successful=true only if it fully resolves the problem.
        If not, set successful=false and give concrete feedback on what to adjust.
        """)
    @Agent(value = "Verifies a proposed solution and returns a typed verdict", outputKey = "criticResult")
    CriticResult<AccountIssueSolution> verify(@V("problem") String problem,
                                              @V("solution") AccountIssueSolution solution);
}

public interface Adjuster {
    @UserMessage("""
        The previous solution was rejected. Revise it using the feedback.
        Problem: {{problem}}
        Previous solution: {{solution}}
        Reviewer feedback: {{feedback}}
        """)
    @Agent(value = "Adjusts a solution based on critic feedback", outputKey = "solution")
    AccountIssueSolution adjust(@V("problem") String problem,
                                @V("solution") AccountIssueSolution solution,
                                @V("feedback") String feedback);
}
// plus IdentifyProblem (outputKey="problem") and FixProblem (outputKey="solution") agents

3a. Approach A — idiomatic loop with a typed exit condition (start here)

The simplest framework primitive that produces the slide's behavior: loop (adjust, verify) and exit when the typed result says so.

UntypedAgent verifyAdjustLoop = AgenticServices.loopBuilder()
        .subAgents(adjuster, critic)                 // adjust, then re-verify
        .maxIterations(5)
        .exitCondition(scope ->
                scope.readState("criticResult", CriticResult.<AccountIssueSolution>pending()).successful())
        .build();

UntypedAgent resolver = AgenticServices.sequenceBuilder()
        .subAgents(identifyProblem, fixProblem, critic, verifyAdjustLoop)
        .outputKey("criticResult")
        // emit the TYPED solution on success — not a summary string
        .output(scope ->
                scope.readState("criticResult", CriticResult.<AccountIssueSolution>pending()).input())
        .build();

AccountIssueSolution solution = (AccountIssueSolution) resolver.invoke(Map.of(
        "problem", "",
        "request", "Customer 8842 cannot log in after a password reset"));

The Adjuster reads feedback — pull it from the critic result into the scope between iterations (a tiny listener or an output/transform step), or have the critic also publish feedback to its own key. The exit condition reads criticResult.successful(); the system output is criticResult.input(), the fully typed AccountIssueSolution.

3b. Approach B — Custom Strategy via a Planner (this is the slide's title)

"Custom Strategy" = a custom Planner that branches on the typed result. Use this when you want explicit control over the Identify→Fix→Verify→Adjust routing rather than a fixed loop.

The official Planner API (verified against the tutorial):

public interface Planner {
    default void init(InitPlanningContext initPlanningContext) { }
    default Action firstAction(PlanningContext planningContext) { return nextAction(planningContext); }
    Action nextAction(PlanningContext planningContext);   // returns call(...) or done()
}
public class CriticDrivenStrategy implements Planner {
    private AgentInstance identify, fix, verify, adjust;
    private int iterations = 0;
    private final int maxIterations;

    public CriticDrivenStrategy(int maxIterations) { this.maxIterations = maxIterations; }

    @Override public void init(InitPlanningContext ctx) {
        List<AgentInstance> a = ctx.subagents();          // wire by name in real code
        this.identify = a.get(0); this.fix = a.get(1);
        this.verify   = a.get(2); this.adjust = a.get(3);
    }

    @Override public Action firstAction(PlanningContext ctx) {
        return call(identify);                            // Start → Identify Problem
    }

    @Override public Action nextAction(PlanningContext ctx) {
        AgenticScope scope = ctx.agenticScope();
        String last = ctx.previousAgentInvocation().agentName();

        if (last.equals(identify.name())) return call(fix);      // Identify → Fix
        if (last.equals(fix.name()))      return call(verify);   // Fix → Verify

        // After Verify (or after Adjust → re-verify), branch on the TYPED result
        CriticResult<AccountIssueSolution> result =
                scope.readState("criticResult", CriticResult.pending());

        if (result.successful()) return done();                  // successful==true → Finish
        if (++iterations > maxIterations) return done();         // safety bound

        if (last.equals(verify.name())) {                        // successful==false → Adjust
            scope.writeState("feedback", result.feedback());     // pass feedback (String)
            return call(adjust);
        }
        return call(verify);                                     // Adjust → re-verify
    }
}

Build the system from the strategy, emitting the typed solution on success:

UntypedAgent resolver = AgenticServices.plannerBuilder()
        .subAgents(identifyProblem, fixProblem, critic, adjuster)
        .planner(() -> new CriticDrivenStrategy(5))
        .outputKey("criticResult")
        .output(scope ->
                scope.readState("criticResult", CriticResult.<AccountIssueSolution>pending()).input())
        .build();

Why this is the "money" pattern — talking points

  • Domain modeling over stringly-typed control flow. Routing decisions read result.successful() and result.input() — real types — instead of Double.parseDouble(score) or scraping text. Refactors are compile-checked.
  • The verdict carries the payload. On success the system returns a fully typed AccountIssueSolution, ready to act on. The "input: T" edge in the slide is the typed artifact flowing straight to Finish.
  • Feedback is a first-class field, not a convention. The successful==false edge passes result.feedback() to Adjust, closing the refine loop deterministically.
  • It's just your domain object, modeled well. CriticResult isn't a framework type to adopt — it illustrates shaping the inter-agent payload to carry verdict + feedback + the typed artifact. Generic or concrete, both are fine; what matters is that it's typed, not a string or a score.
  • Deterministic, demoable, debuggable. Unlike a supervisor, the routing is explicit Java you can step through and narrate on stage. Pair it with AgentMonitor + HtmlReportGenerator for the topology/timeline reveal.

Demo wiring

  • Bigger model for SolutionCritic (judgment), smaller/faster for Adjuster — see tools-memory-and-build.md for model selection.
  • Add an AgentListener printing → identify / → fix / → verify (successful=…) / → adjust so the audience sees the branch decisions live (agent-configuration.md).
  • End with HtmlReportGenerator.generateReport(monitor, Path.of("resolver-run.html")).

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