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 property spring.ai.chat.client.tool-calling.advisor-order changes 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 with AdvisorParams.toolCallingAdvisorAutoRegister(false). Then tools are still sent to the model, but tool calls in the response are returned instead of run.

Table 1. Options of ToolCallingAdvisor.builder()
Option Effect Default

toolCallingManager(…​)

The manager that resolves and runs tool calls; carries the call limits and the exception processor

Built by ChatClient from its ObservationRegistry

toolExecutionEligibilityChecker(…​)

Decides whether a response triggers another iteration

response != null && response.hasToolCalls()

advisorOrder(int)

Position in the chain; decides which advisors run inside and outside the loop

HIGHEST_PRECEDENCE + 300

conversationHistoryEnabled(boolean)

Whether the advisor keeps the full turn history itself between iterations

true

disableInternalConversationHistory()

Shortcut for conversationHistoryEnabled(false)

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.

ToolAuditAdvisor.java: an advisor inside the tool loop
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());
            }
        }
    }
}

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.

Table 2. Memory outside or inside the tool loop
Outside (default) Inside

Order of the memory advisor

HIGHEST_PRECEDENCE + 200

Greater than ToolCallingAdvisor.DEFAULT_ORDER, for example + 400

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.

MemoryInsideLoop.java: memory inside the loop, and a hand-built advisor with a stricter checker
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();
    }
}

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.

ApprovalGatedToolLoop.java: a loop that asks before it acts
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.

Table 3. Tool call limits, property form
Property Meaning Default

spring.ai.tools.limits.max-calls-per-tool-default

Calls to one tool per turn; -1 disables

40

spring.ai.tools.limits.max-calls-per-tool

Per-tool override, a map keyed by tool name

none

spring.ai.tools.limits.excluded-tools

Tool names exempt from the per-tool limit

none

spring.ai.tools.limits.max-total-tool-calls

Calls across all tools per turn; -1 disables

150

spring.ai.tools.limits.on-limit-exceeded

THROW or RETURN_ERROR_RESPONSE

THROW

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.

ToolLimitsExample.java: limits in code and how to detect a breach
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());
    }
}

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.

sequenceDiagram participant M as Model participant A as ToolSearchToolCallingAdvisor participant I as ToolIndex participant G as ToolCallingManager A->>I: index all registered tools for the session A->>M: request with only toolSearchTool M-->>A: toolSearchTool query about issues A->>I: search query I-->>A: openIssue definition A->>M: history plus the discovered definition M-->>A: call openIssue with arguments A->>G: executeToolCalls G-->>A: tool result A->>M: history plus tool result M-->>A: final answer
Figure 1. Progressive tool disclosure with toolSearchTool

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:

application.properties: switch tool search on
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
Table 4. Properties under spring.ai.chat.client.tool-search-advisor
Property Meaning Default

enabled

Replace the default ToolCallingAdvisor

false

tool-index-type

regex, lucene or vector

regex

max-results

Tool references returned per search; null lets the model decide

null

system-message-suffix

Custom instructions on how to use toolSearchTool

built-in text

reference-tool-name-accumulation

Keep tools discovered in earlier searches (true) or only the latest turn’s

true

session-id-key-name

Advisor-context key holding the session id

chat_memory_conversation_id

advisor-order

Position in the chain

HIGHEST_PRECEDENCE + 300

eviction.lru-max-sessions

Sessions kept by the LRU strategy

1000

eviction.ttl

Idle time after which a session’s index is dropped; switches to LRU plus TTL

none

lucene.min-score-threshold

Minimum score of a Lucene hit

0.25

Table 5. The three index strategies
Index Search Best for

RegexToolIndex

Regular expressions over tool names

Strict naming such as get_*_data; no dependencies; the default

LuceneToolIndex

Keyword search with Apache Lucene

Known vocabulary, fast, no embedding model

VectorToolIndex

Embedding similarity through a VectorStore bean

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.

ToolSearchConfig.java: tool search wired by hand
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
    }
}

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.

Table 6. Tool-loop changes from 1.x to 2.0
In 1.x In 2.0

Each ChatModel ran the tool loop internally

Removed. ChatModel.call returns the model’s tool-call request; ChatClient runs the loop through ToolCallingAdvisor

internalToolExecutionEnabled, and the property spring.ai.<provider>.chat.internal-tool-execution-enabled

Removed. Disable the advisor, or drive the loop yourself, as shown above

ToolExecutionEligibilityPredicate

Replaced by ToolExecutionEligibilityChecker on the advisor

SpringBeanToolCallbackResolver and toolNames(), tools resolved from bare Function beans

Removed. Declare a ToolCallback bean (for example with FunctionToolCallback) and attach it

ToolCallAdvisor

Renamed ToolCallingAdvisor; AdvisorParams.toolCallAdvisorAutoRegister remains as the older name of the switch

Memory advisor default order HIGHEST_PRECEDENCE + 1000

HIGHEST_PRECEDENCE + 200, outside the tool loop

No call limits

spring.ai.tools.limits.*, and the resolution fallback disabled by default

Tool span tool_call <name>

execute_tool <name>, see Observability

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.