Agentic Patterns
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21, spring-ai-agent-utils 0.12.0 and Embabel 1.5.2 (all verified 2026-09-29). Spring AI
itself has no agent runtime. It has the parts an agent is made of — a model behind ChatClient, tools, memory, advisors, structured output — and a
short guide, Building Effective Agents, that shows how to combine them. Two
libraries go further: spring-ai-agent-utils, a Spring AI Community project that ports the building blocks of coding-agent products to ChatClient
tools, and Embabel, a separate JVM framework that plans a sequence of typed actions toward a goal. This page adds the agentic capability to DocsAssistant:
first as workflows you write yourself, then as an agent that plans, loads skills, delegates and remembers. The concepts are on the sibling pages
Workflow Patterns and Agent Loop & Architectures; this page is the
Spring AI code.
Workflow or agent
A workflow is code that decides the steps and calls the model inside them. An agent is a model that decides the steps, using tools, until it thinks it is done. Workflows are cheaper, faster and easier to test; agents handle open-ended tasks that cannot be written as a fixed sequence. Start with the simplest thing that works and add autonomy only where a fixed sequence fails. The rest of this page follows that order.
The five workflows as Spring AI code
The five patterns come from Anthropic’s essay on building effective agents and are implemented for Spring AI in the
agentic-patterns folder of the spring-ai-examples repository. The classes
below are written for DocsAssistant, follow the same shape and use only ChatClient and structured output. Each is a Spring bean that takes the auto-configured
ChatClient.Builder, so the model is whatever spring.ai.model.chat selects; no class knows which provider it runs on.
| Pattern | Use it when | Concept page |
|---|---|---|
Prompt chaining |
The task splits into fixed steps and each step is easier when the previous one is done |
|
Routing |
Different inputs need different prompts, tools or models |
|
Parallelization |
Sub-tasks are independent (sectioning), or you want several answers to vote on |
|
Orchestrator-workers |
You cannot list the sub-tasks in advance, so a model decides them per input |
|
Evaluator-optimizer |
A clear quality criterion exists and a second pass with feedback improves the result |
Prompt chaining
Each step is a system prompt, and the output of one call is the user message of the next. Between steps you can add a programmatic gate (check the length, parse the JSON) and stop early; the loop below shows only the chain.
package com.example.docsassistant;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/** Prompt chaining: each step receives the previous step's output. */
@Component
public class DocsChainWorkflow {
private static final List<String> STEPS = List.of(
"Extract the technical claims from the text below as a bullet list.",
"Remove every claim that is not about Spring AI, then keep the bullet list format.",
"Rewrite the bullets as a two-sentence summary for a release note.");
private final ChatClient chatClient;
public DocsChainWorkflow(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String run(String text) {
String current = text;
for (String step : STEPS) {
current = this.chatClient.prompt()
.system(step)
.user(current)
.call()
.content();
}
return current;
}
}
Source: Spring AI: Building Effective Agents.
Routing
Routing is a typed classification followed by a specialist prompt. entity(Classification.class) asks the model for JSON and converts it, as described on
Structured Output. The fallback to HOW_TO matters: a model can return nothing usable, and a router that throws on a bad
classification turns a formatting slip into an outage. Routing is also where a cheap model belongs; classify with a small model and answer with a larger one by
building two ChatClient instances from two models.
package com.example.docsassistant;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/** Routing: classify first with a typed reply, then answer with the specialist prompt. */
@Component
public class DocsRoutingWorkflow {
public enum Route { HOW_TO, REFERENCE, TROUBLESHOOTING }
record Classification(Route route, String reason) { }
private static final Map<Route, String> SPECIALISTS = Map.of(
Route.HOW_TO, "You write short step-by-step guides. Use numbered steps.",
Route.REFERENCE, "You answer with exact names and property keys, no prose around them.",
Route.TROUBLESHOOTING, "You ask for the error message first, then list the three likeliest causes.");
private final ChatClient chatClient;
public DocsRoutingWorkflow(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String run(String question) {
Classification c = this.chatClient.prompt()
.system("Classify the question into one route: HOW_TO, REFERENCE or TROUBLESHOOTING.")
.user(question)
.call()
.entity(Classification.class);
Route route = c == null || c.route() == null ? Route.HOW_TO : c.route();
return this.chatClient.prompt()
.system(SPECIALISTS.get(route))
.user(question)
.call()
.content();
}
}
Source: Spring AI: Building Effective Agents.
Parallelization
Parallelization runs independent calls at once. Java 21 makes the plumbing small: a virtual thread per call, joined in the original order. Threads are cheap but the provider’s rate limit is not, so a large fan-out needs the limits from Production & Deployment. The example is the sectioning variant; the voting variant sends the same prompt several times and compares the answers.
package com.example.docsassistant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/** Parallelisation (sectioning): the same instruction applied to independent inputs at once. */
@Component
public class DocsParallelWorkflow {
private final ChatClient chatClient;
// one virtual thread per call; the model provider's rate limit is the real bound
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public DocsParallelWorkflow(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public List<String> summarise(List<String> pages) {
List<CompletableFuture<String>> futures = pages.stream()
.map(page -> CompletableFuture.supplyAsync(() -> this.chatClient.prompt()
.system("Summarise the page in one sentence.")
.user(page)
.call()
.content(), this.executor))
.toList();
return futures.stream().map(CompletableFuture::join).toList();
}
}
Source: Spring AI: Building Effective Agents.
Orchestrator-workers
The orchestrator asks the model for a plan whose length it does not know in advance, then a worker call per sub-task. Compared with parallelization, the sub-tasks are
chosen at run time. The cap in the system prompt ("at most four") is a cost control; the model does not enforce it, so enforce it in code if it matters. A defensive version truncates
plan.tasks().
package com.example.docsassistant;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/** Orchestrator-workers: the model decides the sub-tasks at run time, workers execute them. */
@Component
public class DocsOrchestratorWorkers {
record Plan(String analysis, List<SubTask> tasks) { }
record SubTask(String title, String instruction) { }
private final ChatClient chatClient;
public DocsOrchestratorWorkers(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String run(String request) {
Plan plan = this.chatClient.prompt()
.system("Split the documentation request into at most four independent sub-tasks.")
.user(request)
.call()
.entity(Plan.class);
if (plan == null || plan.tasks() == null) {
return "";
}
StringBuilder result = new StringBuilder();
for (SubTask task : plan.tasks()) {
String section = this.chatClient.prompt()
.system("You are a technical writer. Do exactly the sub-task you are given.")
.user(task.instruction())
.call()
.content();
result.append("## ").append(task.title()).append("\n\n").append(section).append("\n\n");
}
return result.toString();
}
}
Source: Spring AI: Building Effective Agents.
Evaluator-optimizer
One call writes a draft, another judges it against the task, and the critique goes back into the next draft. The loop needs a stop rule other than "the judge is happy", because judges are
not always satisfied; MAX_ROUNDS bounds the cost. The judge here is a plain model call with a typed verdict. The evaluators that Spring AI ships (RelevancyEvaluator,
FactCheckingEvaluator) can take its place and are covered on Evaluation.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/** Evaluator-optimizer: generate, judge, feed the critique back, stop on PASS or after a fixed budget. */
@Component
public class DocsEvaluatorOptimizer {
public enum Verdict { PASS, NEEDS_WORK }
record Evaluation(Verdict verdict, String feedback) { }
private static final int MAX_ROUNDS = 3;
private final ChatClient chatClient;
public DocsEvaluatorOptimizer(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String run(String task) {
String draft = this.chatClient.prompt().user(task).call().content();
for (int round = 1; round < MAX_ROUNDS; round++) {
Evaluation evaluation = this.chatClient.prompt()
.system("Judge the draft against the task. Reply PASS only if it is complete and accurate.")
.user("Task: " + task + "\n\nDraft:\n" + draft)
.call()
.entity(Evaluation.class);
if (evaluation == null || evaluation.verdict() == Verdict.PASS) {
break;
}
draft = this.chatClient.prompt()
.user("Task: " + task + "\n\nPrevious draft:\n" + draft
+ "\n\nFeedback to address:\n" + evaluation.feedback())
.call()
.content();
}
return draft;
}
}
Source: Spring AI: Building Effective Agents.
Test them without a model
All five are ordinary beans, so a scripted ChatModel tests them: the scripted replies stand in for the model, and the recorded prompts show what each step received. The tests
below ran against the classes above (five tests, all passing). The stub is the one from Testing; a plain lambda is enough when no tool is involved.
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
class WorkflowsTest {
@Test
void chainPassesEachOutputToTheNextStep() {
ScriptedChatModel model = new ScriptedChatModel(false)
.then(ScriptedChatModel.text("claims"))
.then(ScriptedChatModel.text("spring ai claims"))
.then(ScriptedChatModel.text("summary"));
String result = new DocsChainWorkflow(ChatClient.builder(model)).run("input");
assertThat(result).isEqualTo("summary");
assertThat(model.prompts.get(1).getContents()).contains("claims");
assertThat(model.prompts.get(2).getContents()).contains("spring ai claims");
}
@Test
void routingUsesTheSpecialistForTheClassifiedRoute() {
ScriptedChatModel model = new ScriptedChatModel(false)
.then(ScriptedChatModel.text("{\"route\":\"REFERENCE\",\"reason\":\"asks for a key\"}"))
.then(ScriptedChatModel.text("spring.ai.retry.max-attempts"));
String result = new DocsRoutingWorkflow(ChatClient.builder(model)).run("Which key sets retries?");
assertThat(result).isEqualTo("spring.ai.retry.max-attempts");
assertThat(model.prompts.get(1).getSystemMessage().getText()).contains("exact names");
}
@Test
void orchestratorRunsOneWorkerPerSubTask() {
ScriptedChatModel model = new ScriptedChatModel(false)
.then(ScriptedChatModel.text("{\"analysis\":\"two parts\",\"tasks\":["
+ "{\"title\":\"Setup\",\"instruction\":\"write setup\"},"
+ "{\"title\":\"Usage\",\"instruction\":\"write usage\"}]}"))
.then(ScriptedChatModel.text("setup text"))
.then(ScriptedChatModel.text("usage text"));
String result = new DocsOrchestratorWorkers(ChatClient.builder(model)).run("document the starter");
assertThat(result).contains("## Setup", "setup text", "## Usage", "usage text");
assertThat(model.prompts).hasSize(3);
}
@Test
void evaluatorOptimizerStopsOnPassAndOtherwiseAfterThreeRounds() {
ScriptedChatModel passes = new ScriptedChatModel(false)
.then(ScriptedChatModel.text("draft 1"))
.then(ScriptedChatModel.text("{\"verdict\":\"PASS\",\"feedback\":\"\"}"));
assertThat(new DocsEvaluatorOptimizer(ChatClient.builder(passes)).run("task")).isEqualTo("draft 1");
assertThat(passes.prompts).hasSize(2);
ScriptedChatModel stubborn = new ScriptedChatModel(false)
.then(ScriptedChatModel.text("draft 1"))
.then(ScriptedChatModel.text("{\"verdict\":\"NEEDS_WORK\",\"feedback\":\"add units\"}"))
.then(ScriptedChatModel.text("draft 2"))
.then(ScriptedChatModel.text("{\"verdict\":\"NEEDS_WORK\",\"feedback\":\"add example\"}"))
.then(ScriptedChatModel.text("draft 3"));
assertThat(new DocsEvaluatorOptimizer(ChatClient.builder(stubborn)).run("task")).isEqualTo("draft 3");
assertThat(stubborn.prompts).hasSize(5);
}
@Test
void parallelWorkflowKeepsInputOrder() {
// a model that answers with the page it was given, so the order can be checked
org.springframework.ai.chat.model.ChatModel echo = prompt -> ScriptedChatModel.text(
"sum:" + prompt.getUserMessage().getText());
List<String> result = new DocsParallelWorkflow(ChatClient.builder(echo)).summarise(List.of("a", "b", "c"));
assertThat(result).containsExactly("sum:a", "sum:b", "sum:c");
}
}
Source (the workflows under test): Spring AI: Building Effective Agents.
The evaluator test is the important one. It shows both exits of the loop: a PASS after one round and the round limit when the judge never approves.
From workflows to an agent: spring-ai-agent-utils
In the 2026 Spring AI Agentic Patterns series on the Spring blog, the Spring AI team built the pieces that coding agents such as Claude Code rely on and published them as the community library
spring-ai-agent-utils. It is not part of the Spring AI BOM and it is versioned separately: the
current release on Maven Central is 0.12.0 (2026-08-30), for Java 17+ and Spring AI 2.0.0 or later. Because it is pre-1.0, expect changes between releases: the blog posts show
TaskToolCallbackProvider, which 0.12.0 no longer has, and the working form is TaskTool.builder().
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>spring-ai-agent-utils</artifactId>
<version>0.12.0</version>
</dependency>
| Part | Class in 0.12.0 | What the model gets |
|---|---|---|
|
A tool whose description lists each skill’s name and description (from |
|
|
A way to ask the user a clarifying question with options before acting; you supply a |
|
|
A to-do list it rewrites as it works, so a long task does not lose its plan; a |
|
|
A tool that starts a named subagent in its own context window and returns only its result; subagents are Markdown files with front matter |
|
community |
An agent exposed over the Agent2Agent protocol, or a remote agent used as a subagent |
|
|
Six file tools ( |
|
community |
Event-sourced short-term memory with turn-safe compaction, meant to replace |
A small DocsAssistant agent
The configuration below turns the DocsAssistant ChatClient into an agent with five extras: skills loaded on demand from src/main/resources/skills, subagents defined in
src/main/resources/agents, a to-do list, a memory directory per user, and the existing searchDocs tool and chat memory. It compiled against 0.12.0; it was not run against a
real model, so how well a given model uses the tools is yours to measure. The subagents bring a shell with them (see the notes after the listing), so this configuration belongs in a sandbox.
package com.example.docsassistant;
import org.springaicommunity.agent.advisors.AutoMemoryToolsAdvisor;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springaicommunity.agent.tools.TodoWriteTool;
import org.springaicommunity.agent.tools.task.TaskTool;
import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentReferences;
import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentType;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* DocsAssistant as a small agent: skills loaded on demand, a task tool that delegates to subagents defined in
* Markdown files, a to-do list the model keeps up to date, and a per-user memory directory it can read and write through six tools.
* The main agent has no ShellTools or FileSystemTools, but TaskTool with ClaudeSubagentType always adds the built-in
* Bash and general-purpose subagents, which have a shell and file writes: run this only inside a sandbox.
*/
@Configuration(proxyBeanMethods = false)
class DocsAgentConfig {
@Bean
ChatClient docsAgent(ChatClient.Builder builder, ChatMemory chatMemory, DocsTools docsTools) {
ToolCallback skills = SkillsTool.builder()
.addSkillsDirectory("src/main/resources/skills")
.build();
// the "default" builder every subagent starts from; tools set here reach every subagent
ChatClient.Builder subagentBuilder = builder.clone().defaultTools(docsTools);
ToolCallback task = TaskTool.builder()
.subagentTypes(ClaudeSubagentType.builder()
.chatClientBuilder("default", subagentBuilder)
.build())
.subagentReferences(ClaudeSubagentReferences.fromRootDirectory("src/main/resources/agents"))
.build();
return builder
.defaultSystem("You are DocsAssistant. Plan with the to-do list, load a skill before using its topic, "
+ "and delegate research to a subagent instead of doing it in this conversation.")
.defaultTools(docsTools, skills, task,
TodoWriteTool.builder().build())
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
/**
* The six memory tools and their system prompt, confined to one user's directory. This client is shared by every
* user, so attach this per request: docsAgent.prompt(question).advisors(memoryFor(userId)).call().content().
*/
static AutoMemoryToolsAdvisor memoryFor(String userId) {
// an internal id, not an e-mail address: it becomes a directory name
if (!userId.matches("[A-Za-z0-9_-]{1,64}")) {
throw new IllegalArgumentException("userId is not a safe directory name");
}
return AutoMemoryToolsAdvisor.builder().memoriesRootDirectory("build/agent-memory/" + userId).build();
}
}
Source: spring-ai-community/spring-ai-agent-utils and Spring blog: Agentic Patterns, Part 4: Subagent Orchestration.
A skill is a folder with a SKILL.md; a subagent is one Markdown file. Both start with front matter:
---
name: release-notes
description: How DocsAssistant writes release notes. Use when asked to summarise changes between versions.
---
# Release notes
1. Group changes as Added, Changed, Fixed.
2. Name each property key exactly as it appears in the docs.
3. Never invent a version number; ask the user if it is missing.
---
name: docs-researcher
description: Finds and quotes the documentation sections that answer a question. Use for research before writing.
tools: searchDocs
---
You research documentation. Call searchDocs as often as needed, then return the section names and one sentence each.
Do not answer the user's question yourself.
A few points about how these pieces behave, checked against the 0.12.0 jar and, for the tool sets, by running the subagent lookup with a scripted model:
-
ClaudeSubagentTyperequires aChatClient.Builderregistered under the keydefault; subagents start from a clone of it, so its default tools and advisors reach every subagent. Other keys are chosen by the subagent’smodelfront matter, and the library maps the aliasesopus,haikuandsonnetto fixed Claude model names that date from 2025, so with a local or a current hosted model usedefaultor register your own keys. -
The
toolsline is a permission, and leaving it out grants everything.ClaudeSubagentTypebrings a set of built-in tools, and the subagent’stoolsanddisallowedToolsfilter that set by name. Tools already on the registered builder, likesearchDocshere, are always present. The tool names the model saw in the run were:Table 3. Tools the subagent’s model was offered, by front matter Front matter Tools offered to the subagent no
toolslinesearchDocsplusTodoWrite,Grep,Glob,Bash,BashOutput,KillShell,Edit,Write,Read,WebFetchtools: Grep, GlobsearchDocs,Grep,Globtools: searchDocssearchDocsonlySo a subagent file that forgets the line gets a shell, file writes and web fetches. Name the tools it needs, and only those. A name that matches no built-in tool, as in
tools: searchDocs, turns the built-ins off. -
TaskTooladds four subagents of its own. When any subagent type is aClaudeSubagentType,TaskTool.builder().build()also registers the built-ingeneral-purpose,Explore,PlanandBashsubagents from the jar, whatever theagentsdirectory holds, and 0.12.0 has no switch to leave them out.BashandPlanlistBashin theirtoolsline, andgeneral-purposeandExplorehave none, so the main agent can reach a shell and file writes by delegating to one of them. Treat an agent withTaskToolas one that hasShellTools. -
The subagent runs in a separate context and only its final text comes back. This is the point (see Sub-agent isolation) and also the limit: what the subagent found but did not say is gone.
What not to give an agent
The blog examples also use ShellTools and FileSystemTools, which are the right tools for a coding agent working in a checkout and the wrong tools for a service. ShellTools
runs commands and a skill’s scripts run on the host without a sandbox, as the skills post itself warns; FileSystemTools reads and writes anywhere the process can. The configuration
above leaves both off the main agent, but its TaskTool brings the same power through the built-in Bash and general-purpose subagents, so it needs the same treatment.
Run such an agent in a container with a read-only root file system, a dedicated user and no credentials it does not need, and put the approval step
for writes in front of it, as in Security & Guardrails and Human in the Loop. The agent’s openIssue tool keeps its @PreAuthorize and its approval queue however clever the planning becomes.
AutoMemoryTools is different: its paths are relative and confined to the memory directory, so a path outside it is refused. It still stores whatever the model decides is worth keeping,
including things a user said, and every conversation that uses a directory can read what the others stored there. That is why the configuration above builds the advisor per user and
attaches it per request instead of as a default on the shared client; one fixed directory is only safe for a single-user tool. Treat each directory as personal data: control who
can read it, decide how long it lives and give users a way to delete their entries. Long-term memory of this kind, and the difference between it and ChatMemory, is the subject of Agent Memory.
Agents over A2A
The Agent2Agent protocol lets a ChatClient be served as an agent that other agents discover through an Agent Card, and lets a remote agent act as a subagent. Spring AI’s core has no A2A support;
the community spring-ai-a2a-server (0.3.0, built on the A2A Java SDK 0.3.3, so on the pre-1.0 protocol) provides the server side and spring-ai-agent-utils-a2a provides the client side. The
protocol, the Agent Card and the version caveat are covered with code on Spring AI and A2A; this page does not repeat them.
The Session API
ChatMemory stores a list of messages and drops the oldest when the window is full, which can separate a tool call from its result or a question from its answer. The community
spring-ai-session project (org.springaicommunity:spring-ai-starter-session-jdbc, 0.10.0 on Maven Central on 2026-09-29; the blog post used 0.2.0)
keeps an append-only log of events and compacts it at turn boundaries, with sliding-window, turn-window, token-count and summarising strategies. The Spring AI upgrade notes say it is planned
to replace ChatMemory in Spring AI 2.1. Until then the supported memory is the one on Chat Memory and
Chat Memory Repositories. Adopting a 0.x library for the part of the application that holds user conversations is a decision to make deliberately, with a
pinned version and a migration test.
Embabel: plan toward a goal
Embabel (1.5.2 on Maven Central, 2026-09-16) is a separate framework by Rod Johnson and colleagues, built on Spring and Spring AI: its embabel-agent-api 1.5.2
depends on Spring Boot 4.1.1 and Spring AI 2.0.1, the versions used on this page. Where the workflows above are fixed by your code and a tool-calling agent is steered by the model, Embabel
puts a planner between them. You declare typed actions and a goal, and the platform works out the order.
The vocabulary, from the Embabel guide:
-
An action is a method with
@Action. Its parameter types are its preconditions and its return type is its effect. -
A goal is an action marked
@AchievesGoal. The platform looks for a path of actions from the objects it has to one that produces the goal’s type. -
A condition (
@Condition) is an explicit check that actions can require; most conditions are inferred from types. -
The blackboard holds the typed objects produced so far.
-
@Agentmarks the class as an agent. Itsplannerattribute chooses betweenGOAP,UTILITY,HYBRIDandSUPERVISOR(PlannerTypein 1.5.2).
GOAP, goal-oriented action planning, is a technique from game AI: search (Embabel uses A*) for the cheapest sequence of actions whose effects reach the goal from the current state. The planner is ordinary code, not a model, so the same inputs give the same plan, and after every action it can re-plan from what actually happened. The model is used inside actions, to draft or to extract, and not to decide the sequence. That is what separates it from a tool-calling agent, and it is also the limit: the space of actions is the one you wrote.
package com.example.docsassistant;
import com.embabel.agent.api.annotation.AchievesGoal;
import com.embabel.agent.api.annotation.Action;
import com.embabel.agent.api.annotation.Agent;
import com.embabel.agent.api.common.OperationContext;
@Agent(description = "Answers a documentation question by drafting and then reviewing the answer")
public class DocsAnswerAgent {
public record Question(String text) { }
public record Draft(String text) { }
public record Answer(String text) { }
@Action(description = "Write a first answer")
public Draft draft(Question question, OperationContext context) {
return context.ai().withDefaultLlm()
.createObject("Answer briefly: " + question.text(), Draft.class);
}
@AchievesGoal(description = "A reviewed answer is ready")
@Action(description = "Review the draft and keep only what the docs support")
public Answer review(Draft draft, OperationContext context) {
String text = context.ai().withDefaultLlm()
.generateText("Tighten this answer and remove unsupported claims: " + draft.text());
return new Answer(text);
}
}
Source: Embabel Agent Framework guide 1.5.2.
The types carry the plan. Given a Question, the only action that can run is draft, which produces a Draft; review needs a Draft and produces an Answer, the goal. Nothing in the code
calls review after draft. Running the agent means asking the platform for the goal’s type:
package com.example.docsassistant;
import com.embabel.agent.api.invocation.AgentInvocation;
import com.embabel.agent.core.AgentPlatform;
import org.springframework.stereotype.Service;
@Service
public class EmbabelRunner {
private final AgentPlatform agentPlatform;
public EmbabelRunner(AgentPlatform agentPlatform) {
this.agentPlatform = agentPlatform;
}
public String ask(String text) {
AgentInvocation<DocsAnswerAgent.Answer> invocation =
AgentInvocation.create(this.agentPlatform, DocsAnswerAgent.Answer.class);
return invocation.invoke(new DocsAnswerAgent.Question(text)).text();
}
}
Source: Embabel Agent Framework guide 1.5.2.
Both classes compile against embabel-agent-api 1.5.2 (context.ai().withDefaultLlm() is the way to reach a model; createObject returns a typed object and generateText a string). They were not run against a model
or the full platform, because that needs an Embabel starter and a configured model. The model choice belongs to Embabel, through its own model configuration (embabel.models.default-llm per the guide), and not to
the Spring AI ChatModel bean of the rest of the application. The dependency for a local model is the platform starter plus a model starter:
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter</artifactId>
<version>1.5.2</version>
</dependency>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-ollama</artifactId>
<version>1.5.2</version>
</dependency>
Source: Embabel Agent Framework guide 1.5.2.
Other starters in the same group are -openai, -anthropic, -gemini, -mistral-ai, -deepseek, -lmstudio, -dockermodels, -shell (an interactive shell for development), -mcpserver and -a2a. Embabel
can also serve its agents as MCP tools, which connects it to MCP Server.
Workflows in ChatClient |
spring-ai-agent-utils |
Embabel | |
|---|---|---|---|
Who decides the steps |
Your code |
The model, through tools |
A planner, from typed actions and a goal |
Predictability |
High |
Low; depends on the model |
High for the sequence; the content of each step is still a model’s output |
Best for |
Known processes with model calls inside |
Open-ended tasks that need files, search, delegation |
Business processes with several possible routes to the same outcome |
Cost of being wrong |
A step fails |
A loop of tool calls |
A missing action; no route to the goal |
Maturity |
Part of Spring AI |
Community, 0.12.0 |
Independent framework, 1.5.2 |
Keep agents bounded
Every pattern on this page multiplies model calls, so every one needs a budget:
-
Rounds. The evaluator loop has
MAX_ROUNDS; any loop you write needs one. -
Tool calls. Spring AI caps a turn at 150 tool calls in total and 40 per tool by default (since 2.0.1). Lower them for DocsAssistant, for example
spring.ai.tools.limits.max-total-tool-calls=20andspring.ai.tools.limits.max-calls-per-tool-default=5. See Tool Calling. -
Tokens. Cap the output with
maxTokensin the options and watch the token counters described on Observability. A subagent has its own context, so its tokens are extra, not shared. -
Approval. Any tool that changes something outside the application goes through an approval step.
Testing an agent means testing the parts you own: the tools, the plan limits and the guardrails, with a scripted model, and separately running a small set of end-to-end tasks against a real model and scoring the outcome, as on Evaluation and Agent Evaluation.
Where older material differs
| Older text | Spring AI 2.0.1 and the libraries above |
|---|---|
Embabel 0.1.x or 0.3.0 in the README and in book chapters |
The 1.5 line: |
Blog: |
|
A tool loop inside every |
Removed; |
Agent memory as |
|