04-tool-calling-mcp-agent-evaluation

Agentic Patterns: ReAct, Plan-and-Execute & Reflection by Hand

Hand-roll multi-step reasoning with ChatClient and ChatMemory — ReAct, Plan-and-Execute, Reflection, sequential chains, and parallel fan-out — and know when to stop hand-rolling.

August 13, 2026
spring-aireactplan-and-executereflectionagentic-patternschatmemory

SupportAgentService from the last guide handles refund questions well — ToolCallingAdvisor quietly loops between model and tools until it has an answer. Then compliance flags a requirement: for any refund decision over $500, Legal needs an audit trail — not just the final answer, but each step the bot took and why, reviewable after the fact.

ToolCallingAdvisor's loop is a black box by design — you get the final ChatResponse, not a structured record of "considered X, called Y, observed Z, therefore concluded W." Getting that trail means taking the loop out of the framework's hands and writing it yourself. That's what every pattern in this guide actually is: the same think → act → observe cycle from the last guide, made explicit enough that your application can inspect, log, or interrupt each step.


1. ReAct: Reasoning + Acting, One Visible Step at a Time

ReAct structures each iteration as three parts: a Thought (why the model is doing what it's about to do), an Action (a tool to call), and an Observation (the tool's result) — repeated until the model produces a Final Answer instead of another action. You get this almost for free by asking for structured output at each step, reusing the .entity() pattern from the second guide of this roadmap.

java
public record AgentStep(
    String thought,
    String action,        // tool name, or "FINAL_ANSWER"
    String actionInput,   // arguments, or the final answer text
    boolean isFinal
) {}
java
public List<AgentStep> runReActLoop(String question, int maxSteps) {
    List<AgentStep> trace = new ArrayList<>();
    ChatMemory scratchpad = MessageWindowChatMemory.builder().build();
    String conversationId = UUID.randomUUID().toString();
 
    for (int step = 0; step < maxSteps; step++) {
        AgentStep next = chatClient.prompt()
            .system("""
                Answer by reasoning step by step. At each step, decide whether to call
                a tool (getRefundStatus, calculateProratedRefund) or give a final answer.
                Always fill in 'thought' explaining your reasoning before acting.
                """)
            .user(step == 0 ? question : "Continue reasoning based on the observation so far.")
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
            .call()
            .entity(AgentStep.class);
 
        trace.add(next);
        if (next.isFinal()) {
            return trace; // audit trail: every thought, action, and observation
        }
 
        String observation = executeToolByName(next.action(), next.actionInput());
        scratchpad.add(conversationId, new UserMessage("Observation: " + observation));
    }
 
    trace.add(new AgentStep("Exceeded max steps", "FINAL_ANSWER",
        "I wasn't able to resolve this — escalating to a human agent.", true));
    return trace;
}

trace is exactly what Legal asked for: a List<AgentStep> you can log, persist, or render in an internal review tool, with every reasoning step visible instead of buried inside ToolCallingAdvisor's internals.

⚠️

Notice the hard maxSteps cap and the explicit "give up gracefully" branch. This is the single most important line in a hand-rolled loop — without it, a model that never settles on a final answer loops until you run out of budget, time, or both. ToolCallingAdvisor has its own internal safeguards; a hand-rolled loop doesn't, until you write one.


2. Plan-and-Execute: Decide the Whole Plan Up Front

ReAct decides one step at a time, discovering the plan as it goes. Plan-and-Execute asks for the entire plan first, then executes it — better suited to requests where the steps are largely predictable and you'd rather validate the plan (or show it to a human) before any tool actually runs.

java
public record PlanStep(String description, String tool, String toolInput) {}
public record Plan(List<PlanStep> steps) {}
java
Plan plan = chatClient.prompt()
    .user("Determine if order 4521 qualifies for a refund and, if so, the amount.")
    .call()
    .entity(Plan.class);
 
// plan.steps() might be:
// 1. { "Look up the order's refund eligibility", "getRefundStatus", "orderId=4521" }
// 2. { "Calculate the prorated amount if eligible", "calculateProratedRefund", "..." }
 
List<String> observations = new ArrayList<>();
for (PlanStep step : plan.steps()) {
    observations.add(executeToolByName(step.tool(), step.toolInput()));
}
 
String finalAnswer = chatClient.prompt()
    .user("Given these results: " + observations + ", summarize the outcome for the customer.")
    .call()
    .content();

The plan is a first-class object here — nothing stops you from logging it, running it past a human approver for anything above the $500 threshold, or rejecting a plan that references a tool that doesn't exist, all before a single tool executes. That upfront visibility is Plan-and-Execute's main advantage over ReAct; the tradeoff is that it can't adapt mid-execution the way ReAct does when an observation changes what should happen next.


3. Reflection: Critiquing Your Own Answer

Reflection adds one more pass after a draft answer exists: ask the model (or, better, a separately-configured ChatClient with a stricter system prompt) to critique the draft against explicit criteria, and revise if it fails.

java
public record Critique(boolean passesReview, String issues, String revisedAnswer) {}
java
String draft = chatClient.prompt()
    .user("Explain to the customer why order 4521's refund was denied.")
    .call()
    .content();
 
Critique critique = reviewerChatClient.prompt()
    .user("""
        Review this draft customer response for tone and policy accuracy.
        It must not mention internal system names or promise anything not in policy.
 
        Draft: %s
        """.formatted(draft))
    .call()
    .entity(Critique.class);
 
String finalAnswer = critique.passesReview() ? draft : critique.revisedAnswer();

Give the reviewer pass its own ChatClient instance (reviewerChatClient), built with a different, stricter system prompt — or even a different, stronger model. A single ChatClient critiquing its own immediately-prior output tends to rubber-stamp itself; a genuinely separate reviewer configuration catches more.

Reflection is the pattern most directly related to the next guide in this phase: a hand-rolled reflection pass is "evaluate, then maybe fix"; Agent Evaluation (next) is "evaluate, then measure and track" — the same judging idea, aimed at CI instead of a live response.


4. Sequential Chains and Parallel Fan-Out

Not every multi-step flow needs a full agent loop. Two simpler shapes cover a lot of ground:

Sequential chain — one prompt's output is the next prompt's input, no branching or tool decisions involved:

java
String extractedFacts = chatClient.prompt()
    .user("Extract the key facts from this customer message as a bullet list: " + message)
    .call()
    .content();
 
String draftReply = chatClient.prompt()
    .user("Draft a support reply addressing these facts: " + extractedFacts)
    .call()
    .content();

Parallel fan-out — independent questions, answered concurrently instead of one after another:

java
CompletableFuture<String> policyAnswer = CompletableFuture.supplyAsync(() ->
    chatClient.prompt().user("What's our refund policy for premium tier?").call().content());
 
CompletableFuture<RefundStatus> orderStatus = CompletableFuture.supplyAsync(() ->
    chatClient.prompt().user("Look up refund status for order 4521")
        .tools(orderTools).call().entity(RefundStatus.class));
 
CompletableFuture.allOf(policyAnswer, orderStatus).join();
String combined = "Policy: " + policyAnswer.join() + " | Status: " + orderStatus.join();

Checking policy and checking a specific order's status don't depend on each other — running them concurrently with CompletableFuture cuts wall-clock latency roughly in half compared to awaiting one, then the other. There's no special Spring AI API for this; it's the same ChatClient you already have, called from ordinary Java concurrency.


5. When to Stop Hand-Rolling

Every pattern in this guide shares the same shape: a Java for loop or a chain of CompletableFutures, ChatMemory as a scratchpad, structured output to keep each step typed instead of parsed from strings. That's genuinely enough for most support-agent workflows — including the audit-trail requirement that opened this guide.

It stops being enough when the branching logic itself gets complex: several tools with overlapping preconditions, a plan that needs to replan when a mid-execution observation invalidates an earlier step, or routing across multiple specialized sub-agents rather than one flat tool list. At that point you're re-implementing a planner by hand, one if statement at a time — which is exactly the problem the next phase's framework, Embabel, solves with a real Goal-Oriented Action Planner instead of hand-written control flow. This guide's patterns are the right default; Embabel is what you reach for once the hand-rolled version's for loop has become an unmaintainable state machine.


What's Next

You've hand-rolled every major agentic control-flow pattern with tools you already know: ChatClient, ChatMemory, and structured output. The next guide connects your agent to tools and data sources outside your own codebase — the Model Context Protocol.

Frequently asked questions

Should I use ReAct or Plan-and-Execute by default?

Default to ReAct (or plain tool calling from the last guide) when steps genuinely depend on what the previous observation revealed. Reach for Plan-and-Execute when the steps are largely predictable ahead of time and you specifically want to inspect or approve the plan before anything executes — the audit and approval angle is Plan-and-Execute's real advantage, not raw capability.

Isn't a hand-rolled ReAct loop just reimplementing what ToolCallingAdvisor already does?

Functionally, yes, for the core loop — think, act, observe, repeat. The reason to hand-roll it anyway is visibility and control: ToolCallingAdvisor doesn't expose a structured trace of intermediate steps, doesn't let you approve a step before it executes, and doesn't let you swap in a different stopping condition. Reach for the built-in advisor when you just need tools to work; hand-roll when you need to observe or govern the loop itself.

How do I pick maxSteps for a ReAct loop?

Start low — 5 to 8 — and log every time a real conversation hits the cap. A well-scoped agent with 3-4 tools rarely needs more than 3-4 steps to reach a final answer; consistently hitting your cap is a signal the tools or system prompt need work, not that the cap should simply go up.

Does parallel fan-out work with tool calling and RAG advisors attached?

Yes — each CompletableFuture.supplyAsync call is an independent ChatClient.prompt() call and can have its own tools, advisors, and options, exactly like a sequential call would. Just make sure any shared mutable state (like a ChatMemory instance) is safe to read from multiple threads concurrently, which Spring AI's built-in implementations are.