Tool Calling Advisor & Tool Search
|
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 (verified 2026-09-29). In Spring AI 2.0 the tool loop is no
longer hidden inside each ChatModel: it is an advisor, the ToolCallingAdvisor, that ChatClient adds to every call.
That makes the loop something you can order against other advisors, replace, observe, limit or drive by hand. This page
covers that advisor, the ways to change how it behaves, and ToolSearchToolCallingAdvisor, the replacement that keeps
the request small when DocsAssistant has dozens of tools. It builds on the two tools defined in
Tool Calling, searchDocs and openIssue, and on
Advisors. The classes on it were compiled against the 2.0.1 jars, and the loop, the manual
loop, the limits, memory inside the loop and the tool-search prompt were run against a stub ChatModel, since no model
server was available.
The ToolCallingAdvisor
ToolCallingAdvisor lives in org.springframework.ai.chat.client.advisor. It implements CallAdvisor,
StreamAdvisor and the marker interface ToolAdvisor, and it is a recursive advisor: on each iteration it re-enters the
part of the chain after it. One iteration sends the prompt to the model, and if the response carries tool calls it
hands them to the ToolCallingManager, appends the results and goes round again; when the response has no tool calls, that
response is the result. Token usage is accumulated over all iterations, so the usage you read from the final response
covers the whole turn. For streaming, tool-call chunks are filtered out of the stream the caller receives.
ChatClient registers one for every call, even when no tools are configured, so that tools added by another advisor
at run time still work. Three facts describe its placement:
-
its default order is
ToolCallingAdvisor.DEFAULT_ORDER,Ordered.HIGHEST_PRECEDENCE + 300, which puts it inside the default memory advisor (+ 200) and outside almost everything else; the propertyspring.ai.chat.client.tool-calling.advisor-orderchanges it; -
a chain may contain exactly one
ToolAdvisor. If you register your own, the default is not added; if you register two, building the chain fails with "At most one ToolAdvisor is allowed in the advisor chain"; -
auto-registration can be switched off for every call with
spring.ai.chat.client.tool-calling.enabled=false, or for a single call withAdvisorParams.toolCallingAdvisorAutoRegister(false). Then tools are still sent to the model, but tool calls in the response are returned instead of run.
| Option | Effect | Default |
|---|---|---|
|
The manager that resolves and runs tool calls; carries the call limits and the exception processor |
Built by |
|
Decides whether a response triggers another iteration |
|
|
Position in the chain; decides which advisors run inside and outside the loop |
|
|
Whether the advisor keeps the full turn history itself between iterations |
|
|
Shortcut for |
Most applications never build one. You build it when you need a non-default checker, an order or a manager, and you subclass it when you need to change the loop.
Hooks for a custom loop
The base class calls protected hooks at fixed points, in a call flavour and a stream flavour: doInitializeLoop
once before the first iteration, doBeforeCall before each iteration, doAfterCall after each model response, and
doFinalizeLoop once at the end, plus doGetNextInstructionsForToolCall, which chooses the messages sent in the next
iteration. ToolSearchToolCallingAdvisor, described below, is a subclass that uses exactly these hooks. To plug a custom
subclass into Spring Boot, register a ToolCallingAdvisor.Builder<?> bean from an auto-configuration that runs before
ChatClientAutoConfiguration; the default builder bean is conditional on there being none. Typical reasons to subclass
are an approval gate before destructive tools, a token or call budget per session, or resolving tools from a dynamic
source.
Often you do not need a subclass, only to watch the loop. An ordinary advisor with an order greater than
ToolCallingAdvisor.DEFAULT_ORDER sits inside the loop and sees every iteration, including the tool responses in the
prompt it receives. This one logs the names of the tools the model asks for, and only the names, because the arguments
are user content that may hold personal data.
package com.example.docsassistant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import reactor.core.publisher.Flux;
/** Runs inside the tool loop, so it sees every model round, and logs which tools were requested. */
class ToolAuditAdvisor implements CallAdvisor, StreamAdvisor {
private static final Logger log = LoggerFactory.getLogger(ToolAuditAdvisor.class);
@Override
public String getName() {
return "ToolAuditAdvisor";
}
@Override
public int getOrder() {
// larger than ToolCallingAdvisor.DEFAULT_ORDER: after the loop in the request direction, so inside it
return ToolCallingAdvisor.DEFAULT_ORDER + 100;
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
ChatClientResponse response = chain.nextCall(request);
audit(response.chatResponse());
return response;
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
return chain.nextStream(request).doOnNext(chunk -> audit(chunk.chatResponse()));
}
private void audit(ChatResponse response) {
if (response == null || !response.hasToolCalls()) {
return;
}
for (var generation : response.getResults()) {
for (AssistantMessage.ToolCall call : generation.getOutput().getToolCalls()) {
// log the name only: the arguments are user content and may hold personal data
log.info("model requested tool {}", call.name());
}
}
}
}
Source: Spring AI: ToolCallingAdvisor.
Memory and the loop
Where the memory advisor sits relative to the loop decides what is remembered. The default puts memory outside, and that is the safe choice.
| Outside (default) | Inside | |
|---|---|---|
Order of the memory advisor |
|
Greater than |
What is stored |
The final user message and the final assistant message of the turn |
The user message, every assistant tool-call message, every tool response, and the answer |
Repositories that can do it |
Every repository |
Only those that can store tool messages: in-memory, Redis and Neo4j |
Cost |
Nothing extra |
Later turns replay all tool traffic to the model |
Inside the loop the model can see on the next turn which tools were already tried and what they returned, at the price of
prompt size. JDBC, Cassandra and MongoDB repositories drop tool-call and tool-response messages when saving (JDBC logs a
warning when it does), so they must stay outside the loop. To avoid duplicate writes the advisor’s own history must be
off when memory is inside. The auto-registered advisor detects a MemoryAdvisor inside the loop and does that by itself.
You call disableInternalConversationHistory() only when you build the ToolCallingAdvisor by hand. The hand-built
advisor below also requires the finish reason to say "tool calls", and it compares case-insensitively: in 2.0.1
OpenAiChatModel reports the enum name TOOL_CALLS, and other providers report other values, some of them stop, so check
what yours sends before you add such a condition.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionEligibilityChecker;
class MemoryInsideLoop {
ChatClient build(ChatClient.Builder builder) {
// only a repository that can store tool messages is safe inside the loop
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(40)
.build();
MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(memory)
.order(ToolCallingAdvisor.DEFAULT_ORDER + 100) // greater than the loop: inside it
.build();
return builder.defaultAdvisors(memoryAdvisor).build(); // the auto-registered loop adapts by itself
}
ToolCallingAdvisor handBuiltLoop(ToolCallingManager toolCallingManager) {
// a stricter rule for "the model asked for tools": also require the provider's finish reason;
// OpenAI reports TOOL_CALLS, other providers use other values, so check what yours sends
ToolExecutionEligibilityChecker strict = response -> response != null
&& response.hasToolCalls()
&& "tool_calls".equalsIgnoreCase(response.getResult().getMetadata().getFinishReason());
return ToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager) // the auto-configured bean: limits, error policy, observations
.toolExecutionEligibilityChecker(strict)
.disableInternalConversationHistory() // needed only when you build the advisor yourself
.build();
}
}
Source: Spring AI: ToolCallingAdvisor.
With an in-memory repository and a stub model, one tool round left four stored messages for the conversation: the user
message, an assistant message that carried the tool call (its text empty), the tool response and the final answer. With the
default order only the user message and the answer were stored. The community project spring-ai-session is
announced to persist all message types on any backend and to replace ChatMemory in Spring AI 2.1. The repositories are
compared on Chat Memory Repositories and the memory model on
Chat Memory.
Driving the loop by hand
Sometimes each iteration must be yours: to require an approval before a write tool, to forward progress to a browser,
or to stop on an outside signal. Turn the advisor off for the call with
AdvisorParams.toolCallingAdvisorAutoRegister(false), look at the response yourself, run the calls with the
ToolCallingManager, and call the model again with the returned history. The class below gates every round on an
Approver, which in DocsAssistant would show the pending openIssue draft to a maintainer.
package com.example.docsassistant;
import java.util.List;
import org.springframework.ai.chat.client.AdvisorParams;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
/** Drives the tool loop by hand so that every write tool needs a human "yes" before it runs. */
class ApprovalGatedToolLoop {
interface Approver {
boolean approve(List<AssistantMessage.ToolCall> requestedCalls);
}
private final ChatClient chatClient;
private final ToolCallingManager toolCallingManager;
private final Approver approver;
ApprovalGatedToolLoop(ChatClient chatClient, ToolCallingManager toolCallingManager, Approver approver) {
this.chatClient = chatClient;
this.toolCallingManager = toolCallingManager; // the auto-configured bean, not a bare builder().build()
this.approver = approver;
}
String ask(String question, Object... toolObjects) {
ToolCallback[] tools = ToolCallbacks.from(toolObjects);
ToolCallingChatOptions.Builder<?> options = ToolCallingChatOptions.builder().toolCallbacks(tools);
Prompt prompt = new Prompt(List.of(new UserMessage(question)), options.build());
ChatClientResponse response = call(prompt, options);
while (response.chatResponse() != null && response.chatResponse().hasToolCalls()) {
List<AssistantMessage.ToolCall> requested = response.chatResponse().getResult().getOutput().getToolCalls();
if (!this.approver.approve(requested)) {
return "Cancelled: the requested action was not approved.";
}
ToolExecutionResult result = this.toolCallingManager.executeToolCalls(prompt, response.chatResponse());
prompt = new Prompt(result.conversationHistory(), options.build());
response = call(prompt, options);
}
return response.chatResponse().getResult().getOutput().getText();
}
private ChatClientResponse call(Prompt prompt, ToolCallingChatOptions.Builder<?> options) {
return this.chatClient.prompt()
.messages(prompt.getInstructions())
.options(options)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false)) // no automatic loop for this call
.call()
.chatClientResponse();
}
}
Against a stub model that asked for one tool, an approver that said yes produced two model calls and the final answer, and
one that said no stopped after the first call and never ran the tool. In 2.0.1 ChatClient.options(…) takes the options
builder, so the code passes ToolCallingChatOptions.builder() without build(), and builds a separate Prompt for the
manager. Driving the loop yourself bypasses the advisor’s usage accumulation and the single-ToolAdvisor guard, so prefer a
custom advisor inside the loop, or an approval hook in a subclass, whenever that is enough. For streaming, aggregate each
iteration’s chunks with ChatClientMessageAggregator to detect tool calls while forwarding the raw chunks to the
client; see Streaming & Reactive. Source:
Spring AI: ToolCallingAdvisor.
Limits, resolution and the fallback
A model that keeps calling tools is a cost and a stability risk, so the manager caps the calls in one turn. A turn starts at the last user message, so history replayed by a memory advisor does not count against the new turn.
| Property | Meaning | Default |
|---|---|---|
|
Calls to one tool per turn; |
40 |
|
Per-tool override, a map keyed by tool name |
none |
|
Tool names exempt from the per-tool limit |
none |
|
Calls across all tools per turn; |
150 |
|
|
|
The same limits are available in code on ToolCallingManager.builder(). A hand-built advisor or loop that is given no
manager falls back to a bare ToolCallingManager.builder().build(), which ignores these properties, the
ToolExecutionExceptionProcessor bean from Tool Calling and the observations. That is
why every hand-built example on this page injects the auto-configured ToolCallingManager bean. What happens on a breach depends on the behaviour. With THROW, the advisor catches the
ToolCallLimitExceededException and returns the breach as the final response: a single generation whose finish reason is
ToolCallLimitExceededException.FINISH_REASON (toolCallLimitExceeded), with the results of the calls that did succeed kept
in the response metadata. With RETURN_ERROR_RESPONSE the manager skips the call and returns an error tool result, so the
model is told the limit was reached and the conversation continues.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.model.tool.ToolCallLimitBehavior;
import org.springframework.ai.model.tool.ToolCallLimitExceededException;
import org.springframework.ai.model.tool.ToolCallingManager;
class ToolLimitsExample {
ToolCallingManager limitedManager() {
return ToolCallingManager.builder()
.maxCallsPerTool(20) // default for every tool, per turn
.maxCallsPerTool("searchDocs", 8) // tighter for one tool
.excludeToolFromLimit("pageUrl") // cheap and idempotent
.maxTotalToolCalls(60) // all tools together, per turn
.onLimitExceeded(ToolCallLimitBehavior.RETURN_ERROR_RESPONSE)
.build();
}
boolean hitTheLimit(ChatClientResponse response) {
// with the default THROW behaviour the advisor turns the breach into the final answer
return response.chatResponse() != null
&& ToolCallLimitExceededException.FINISH_REASON
.equals(response.chatResponse().getResult().getMetadata().getFinishReason());
}
}
Source: Spring AI: Tool Calling.
Both behaviours were run against a stub model that asked for a tool forever, with a total limit of two. THROW ended after three
model calls with the text "Total tool call limit (2) exceeded for this turn" and the finish reason above; RETURN_ERROR_RESPONSE
kept the loop going until the stub itself stopped, which shows why it should be paired with a model that reacts to
the error. Pick THROW when a runaway loop should end the request.
Name-based resolution
Tools are normally attached with tools(…) and defaultTools(…). The ToolCallbackResolver is the separate mechanism
that resolves a tool by name. Spring AI configures a StaticToolCallbackResolver with every ToolCallback bean and
the output of every ToolCallbackProvider bean, except MCP providers, which are left out so that startup does not
call every MCP server. You can replace it with a bean of your own, for example one backed by a database. By default a
tool that the resolver knows but the request did not attach is not run when the model names it; the property
spring.ai.tools.resolution.fallback.enabled=true (default false) allows that. Leave it off. Enabling it makes every tool the resolver
exposes executable whenever the model names it, including the destructive ones a request deliberately left out, such as
openIssue for a non-maintainer. The same effect is available on the manager builder as resolutionFallbackEnabled(true).
ToolSearchToolCallingAdvisor for large tool sets
The default advisor sends every tool definition on every request. With a handful of tools that is fine. With thirty or more,
and above all with several MCP servers aggregated into one client, the definitions take a large part of the context
window, cost tokens on every round, and make the model choose worse among similar names. ToolSearchToolCallingAdvisor
applies progressive tool disclosure: the tools are indexed once per session, the model first sees only one built-in tool,
toolSearchTool, and it searches by a natural-language query; only the definitions found are added to the next round.
The saving can be estimated. With registered tools of about tokens per definition and a fixed part (system prompt, history, question), a request costs
while with tool search, when tools have been discovered and the built-in tool with its instructions costs tokens,
so a request is cheaper when . With the illustrative values , and that is more than about seven tools, but each search is an extra model round trip that adds latency and its own tokens, so the practical threshold is higher. The reference suggests tool search from about ten tools, or when definitions exceed roughly 10,000 tokens per request, and staying with the default advisor for small, frequently used tool sets. The Spring team reports 34 to 64 percent token reductions on OpenAI, Anthropic and Gemini in its benchmark post; measure your own tool set rather than trusting a general figure.
Setup
The starter spring-ai-starter-tool-search-advisor brings the advisor, the auto-configuration and Lucene. The library
spring-ai-tool-search-advisor is the advisor alone, and the index implementations live in spring-ai-tool-search-tool.
With the starter, one property replaces the default advisor, with no change to the ChatClient code:
spring.ai.chat.client.tool-search-advisor.enabled=true
spring.ai.chat.client.tool-search-advisor.tool-index-type=lucene
spring.ai.chat.client.tool-search-advisor.max-results=5
spring.ai.chat.client.tool-search-advisor.eviction.ttl=30m
Source: Spring AI: Tool Search Tool.
| Property | Meaning | Default |
|---|---|---|
|
Replace the default |
|
|
|
|
|
Tool references returned per search; |
|
|
Custom instructions on how to use |
built-in text |
|
Keep tools discovered in earlier searches ( |
|
|
Advisor-context key holding the session id |
|
|
Position in the chain |
|
|
Sessions kept by the LRU strategy |
|
|
Idle time after which a session’s index is dropped; switches to LRU plus TTL |
none |
|
Minimum score of a Lucene hit |
|
| Index | Search | Best for |
|---|---|---|
|
Regular expressions over tool names |
Strict naming such as |
|
Keyword search with Apache Lucene |
Known vocabulary, fast, no embedding model |
|
Embedding similarity through a |
Fuzzy, natural-language queries |
A ToolIndex bean of your own always wins over the auto-configured one. The class below wires the same thing by hand, keeps
sessions for 30 minutes or 200 sessions, and shows the two places where a session id matters. Only the read-only tools are
defaults; issueTools is added per request after the maintainer check, as on Tool
Calling, so the write tool is indexed, and can be discovered, only in maintainers' sessions.
package com.example.docsassistant;
import java.time.Duration;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.tool.toolsearch.ToolIndex;
import org.springframework.ai.tool.toolsearch.eviction.CompositeEvictionStrategy;
import org.springframework.ai.tool.toolsearch.eviction.LruEvictionStrategy;
import org.springframework.ai.tool.toolsearch.eviction.TtlEvictionStrategy;
import org.springframework.ai.tool.toolsearch.index.lucene.LuceneToolIndex;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class ToolSearchConfig {
@Bean
ToolIndex toolIndex() {
return new LuceneToolIndex(0.3f); // keyword search, no embedding model; hits below 0.3 are dropped
}
@Bean
ToolSearchToolCallingAdvisor toolSearchAdvisor(ToolIndex toolIndex, ToolCallingManager toolCallingManager) {
return ToolSearchToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager) // keeps spring.ai.tools.limits.*, the error policy and observations
.toolIndex(toolIndex)
.maxResults(5)
.evictionStrategy(new CompositeEvictionStrategy(
new TtlEvictionStrategy(Duration.ofMinutes(30)),
new LruEvictionStrategy(200)))
.build();
}
@Bean
ChatClient searchingChatClient(ChatClient.Builder builder, ToolSearchToolCallingAdvisor advisor,
DocsTools docsTools, PageLinkTools pageLinkTools) {
// only read-only tools as defaults; they are indexed, not sent: the model first sees only the tool-search tool
return builder.defaultTools(docsTools, pageLinkTools)
.defaultAdvisors(advisor)
.build();
}
String ask(ChatClient client, IssueTools issueTools, boolean maintainer, String conversationId, String question) {
ChatClient.ChatClientRequestSpec request = client.prompt()
.user(question)
// the session id that scopes the tool index; the memory advisor reads the same key
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, conversationId));
if (maintainer) {
request = request.tools(issueTools); // the write tool is indexed only in maintainers' sessions
}
return request.call().content();
}
void onLogout(ToolSearchToolCallingAdvisor advisor, String conversationId) {
advisor.evictSession(conversationId); // free the index now instead of waiting for eviction
}
}
Source: Spring AI: Tool Search Tool.
Sessions, a security boundary
The index is scoped by a session id that the caller must supply on every request. By default the advisor reads it from
the advisor context under ChatMemory.CONVERSATION_ID, so an application that already passes a conversation id to a memory
advisor gets tool-search scoping for free. A request without it fails: the run above ended with an
IllegalArgumentException saying the context must contain a non-null value for chat_memory_conversation_id. Derive the id
from the authenticated user as described on Chat Memory, never from client input alone,
because the id decides which tools a request can discover. The default eviction is an LRU cap of 1,000 sessions; call
evictSession(id) on logout. The other strategies are NeverEvictStrategy, AlwaysEvictStrategy (re-index every request, for tests),
TtlEvictionStrategy and CompositeEvictionStrategy.
In the stub run the first prompt carried exactly one tool, toolSearchTool, and a model that named a registered tool it
had never discovered got an IllegalStateException ("No ToolCallback found for tool name"). That is the intended
behaviour: an undiscovered tool cannot be run. It also means a permission-filtered tool set is enforced by what is indexed,
which is worth a test for every role. Sources:
Spring AI: Tool Search Tool and
Spring AI: Tool Calling.
What 2.0 removed or renamed
Older code and books rely on the internal, per-model loop. These are the changes that touch the loop.
| In 1.x | In 2.0 |
|---|---|
Each |
Removed. |
|
Removed. Disable the advisor, or drive the loop yourself, as shown above |
|
Replaced by |
|
Removed. Declare a |
|
Renamed |
Memory advisor default order |
|
No call limits |
|
Tool span |
|
The full migration list is on Migrating from 1.x to 2.x, and the sibling frameworks handle the same loop differently: see Agent Loop & Architectures.