Add tool-calling module: @Tool, ToolCallingAdvisor, returnDirect, ToolContext, and Tool Search Advisor

Every test drives the real Spring AI advisor classes (ToolCallingAdvisor,
ToolSearchToolCallingAdvisor) against a hand-written ScriptedChatModel that queues
real ChatResponse objects instead of calling a live LLM -- confirmed viable because
ChatModel has exactly one abstract method, call(Prompt) (checked with javap).

Covers:
- The plain call/execute/recall loop (WeatherTools, an ordinary @Tool method)
- @Tool(returnDirect = true) skipping the second model round trip entirely
  (ServerStatusTools)
- ToolContext: excluded from the model-facing JSON schema (verified against the
  real generated schema), still delivered to the tool from caller-supplied data
  (UserContextTools)
- A 230-tool synthetic library across six fake domains, generated via
  FunctionToolCallback.builder(...) (LargeToolLibrary)
- ToolSearchToolCallingAdvisor + RegexToolIndex: one tool ("toolSearchTool")
  offered on the first call instead of 230, with real tool-count and
  character-footprint measurements taken off the actual outgoing prompts

Findings recorded in the module's Javadoc rather than silently worked around:
- ToolCallingAdvisor only engages when the request's Prompt carries
  ToolCallingChatOptions, built from ChatModel.getOptions().mutate() (not
  getDefaultOptions(), a separate default method the request-building path never
  calls) -- confirmed by disassembling ToolCallingAdvisor.adviseCall and
  DefaultChatClientUtils
- The Tool Search Advisor's own tool is named "toolSearchTool" (camelCase), not
  "tool_search_tool" -- confirmed via @Tool(name=...) in the decompiled class
- Its session ID comes from ChatClientRequest.context() (AdvisorSpec.param), not
  from ChatClient.toolContext(Map) -- confirmed by disassembling
  ToolSearchToolCallingAdvisor.initializeSession
- RegexToolIndex matches on verb/noun substrings, not semantic relevance -- a
  real captured search for "look up an invoice" returned 5 lookup_-named tools
  across three unrelated domains alongside the one actually wanted

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
This commit is contained in:
Claude
2026-09-23 17:03:41 +00:00
parent d73620e305
commit fff9f52116
33 changed files with 1256 additions and 0 deletions
@@ -0,0 +1,12 @@
package com.ankurm.toolcalling;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ToolCallingApplication {
public static void main(String[] args) {
SpringApplication.run(ToolCallingApplication.class, args);
}
}
@@ -0,0 +1,39 @@
package com.ankurm.toolcalling.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.toolsearch.index.regex.RegexToolIndex;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Production wiring for the two {@link ChatClient} beans this module demonstrates. Both share the
* same autoconfigured {@link ChatModel} (OpenAI, from {@code spring-ai-starter-model-openai}) and
* the same synthetic tool library from {@link LargeToolLibrary} -- the only thing that differs is
* the advisor, which is the whole point of the comparison. See {@link ChatClientFactory} for the
* shared construction logic, and the test sources for the same factory methods driven by
* {@code ScriptedChatModel} instead of a live model.
*/
@Configuration
public class ChatClientConfig {
@Bean
public RegexToolIndex regexToolIndex() {
return new RegexToolIndex();
}
@Bean("plainToolCallingChatClient")
public ChatClient plainToolCallingChatClient(ChatModel chatModel) {
List<ToolCallback> library = LargeToolLibrary.buildAll();
return ChatClientFactory.plainToolCallingClient(chatModel, library);
}
@Bean("toolSearchChatClient")
public ChatClient toolSearchChatClient(ChatModel chatModel, RegexToolIndex regexToolIndex) {
List<ToolCallback> library = LargeToolLibrary.buildAll();
return ChatClientFactory.toolSearchClient(chatModel, library, regexToolIndex, 5);
}
}
@@ -0,0 +1,86 @@
package com.ankurm.toolcalling.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.toolsearch.ToolIndex;
import org.springframework.ai.tool.toolsearch.ToolSearchTool;
import java.util.List;
/**
* Builds the two {@link ChatClient} configurations this module compares, from the same underlying
* {@link ChatModel} and the same tool library. Shared between {@link ChatClientConfig} (real Spring
* beans, wired with a real {@code OpenAiChatModel}) and the test sources (wired with
* {@code ScriptedChatModel}) so the exact construction under test is the exact construction that
* runs in production -- nothing about advisor wiring is reimplemented for the tests.
*/
public final class ChatClientFactory {
private ChatClientFactory() {
}
/**
* The Spring AI 1.x-shaped baseline: every tool callback registered directly on the
* {@link ChatClient}, and a plain {@link ToolCallingAdvisor} driving the call/execute/recall
* loop. Every tool's full name, description and JSON schema goes into the model's context on
* every single call -- fine for a handful of tools, expensive once the library reaches
* hundreds.
*/
public static ChatClient plainToolCallingClient(ChatModel chatModel, List<ToolCallback> toolLibrary) {
ToolCallingAdvisor advisor = ToolCallingAdvisor.builder().build();
return ChatClient.builder(chatModel)
.defaultOptions(baseToolCallingOptions(chatModel))
.defaultToolCallbacks(toolLibrary)
.defaultAdvisors(advisor)
.build();
}
/**
* The progressive-disclosure alternative: the tool library is still registered with the
* client (so it can actually be called once found), but {@link ToolSearchToolCallingAdvisor}
* indexes it into a {@link ToolIndex} and gives the model exactly one tool up front --
* {@code toolSearchTool} -- plus a system-message suffix explaining how to use it. The model
* searches for the handful of tools relevant to the current request instead of reading
* descriptions for all of them on every call.
*/
public static ChatClient toolSearchClient(ChatModel chatModel, List<ToolCallback> toolLibrary,
ToolIndex toolIndex, Integer maxResults) {
ToolSearchToolCallingAdvisor advisor = ToolSearchToolCallingAdvisor.builder()
.toolIndex(toolIndex)
.maxResults(maxResults)
.sessionIdKeyName(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY)
.systemMessageSuffix("Use the toolSearchTool to find the specific tool you need before calling "
+ "it. Do not guess a tool name that was not returned by a search.")
.build();
return ChatClient.builder(chatModel)
.defaultOptions(baseToolCallingOptions(chatModel))
.defaultToolCallbacks(toolLibrary)
.defaultAdvisors(advisor)
.build();
}
/**
* {@code ToolCallingAdvisor} only engages its call/execute/recall loop when the request's
* {@link org.springframework.ai.chat.prompt.Prompt Prompt} carries {@link ToolCallingChatOptions}
* -- confirmed by disassembling {@code ToolCallingAdvisor.adviseCall}, whose very first check is
* an {@code instanceof ToolCallingChatOptions} on {@code chatClientRequest.prompt().getOptions()}
* that falls straight through to {@code chain.nextCall(request)} (no tool execution at all) when
* it is anything else. {@code DefaultChatClientUtils} builds that options object from
* {@code chatModel.getOptions().mutate()} -- not {@code getDefaultOptions()}, a separate default
* method the request-building path never calls -- merged with whatever this builder sets here.
* Real providers (OpenAI, Anthropic) return their own {@code ToolCallingChatOptions}
* implementation from {@code getOptions()}; this mutates a copy of that so provider-specific
* settings survive, or falls back to an empty builder for a model (like
* {@code ScriptedChatModel}) whose own {@code getOptions()} already does the same thing.
*/
private static ToolCallingChatOptions.Builder<?> baseToolCallingOptions(ChatModel chatModel) {
if (chatModel.getOptions() instanceof ToolCallingChatOptions existing) {
return existing.mutate();
}
return ToolCallingChatOptions.builder();
}
}
@@ -0,0 +1,15 @@
package com.ankurm.toolcalling.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class ClockConfig {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}
@@ -0,0 +1,110 @@
package com.ankurm.toolcalling.config;
import com.ankurm.toolcalling.domain.SyntheticToolRequest;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* Generates a synthetic library of tool callbacks spread across several fake business domains, so
* the Tool Search Advisor section has something realistic to search over. A real system this size
* -- a platform team's internal tool catalog, say -- would have these as separate
* {@code @Service} classes; they are generated here so the repository does not need 130 near-
* identical Java files to make the point.
*
* <p>Every tool has the same {@link SyntheticToolRequest} input shape and the same trivial
* behaviour (echo a deterministic string). What varies, deliberately, is the tool name and
* description -- the only two things {@link org.springframework.ai.tool.toolsearch.index.regex
* RegexToolIndex} has to search against. See {@code ToolLibrarySizeTest} for the exact count this
* produces, captured to {@code output/06-tool-library-size.txt}.
*/
public final class LargeToolLibrary {
/** domain, verb, noun -- combined into "<domain>_<verb>_<noun>" tool names. */
private static final String[] DOMAINS = {
"hr", "finance", "devops", "crm", "logistics", "security"
};
private static final String[] VERBS = {
"lookup", "list", "create", "update", "archive", "search", "export", "summarize"
};
private static final String[][] NOUNS_BY_DOMAIN = {
// hr
{ "employee_record", "open_requisition", "pto_balance", "org_chart_node", "onboarding_task" },
// finance
{ "invoice", "purchase_order", "expense_report", "budget_line", "vendor_contract" },
// devops
{ "deployment", "incident", "build_pipeline", "feature_flag", "service_health_check" },
// crm
{ "lead", "opportunity", "support_ticket", "account_note", "renewal_forecast" },
// logistics
{ "shipment", "warehouse_slot", "carrier_rate", "inventory_count", "delivery_route" },
};
private LargeToolLibrary() {
}
/**
* Builds every synthetic tool in the library. Not every domain crosses every verb -- the
* generator skips a few combinations to land at a round, documented count rather than an
* arbitrary one; the exact number is asserted by {@code ToolLibrarySizeTest}, not hand-counted
* here.
*/
public static List<ToolCallback> buildAll() {
List<ToolCallback> callbacks = new ArrayList<>();
for (int d = 0; d < DOMAINS.length; d++) {
String domain = DOMAINS[d];
String[] nouns = NOUNS_BY_DOMAIN[d % NOUNS_BY_DOMAIN.length];
for (String noun : nouns) {
for (String verb : VERBS) {
// Skip a couple of combinations per noun so not every noun exposes every verb --
// "archive_pto_balance" and "create_service_health_check" do not make business
// sense, and a library where every tool crosses every verb would be unrealistically
// uniform for the search index to tell apart.
if (skip(verb, noun)) {
continue;
}
callbacks.add(buildOne(domain, verb, noun));
}
}
}
return callbacks;
}
private static boolean skip(String verb, String noun) {
boolean lifecycleNoun = noun.endsWith("_balance") || noun.endsWith("_check") || noun.endsWith("_node");
return lifecycleNoun && (verb.equals("archive") || verb.equals("create"));
}
private static ToolCallback buildOne(String domain, String verb, String noun) {
String name = domain + "_" + verb + "_" + noun;
String description = describe(domain, verb, noun);
Function<SyntheticToolRequest, String> function = request -> {
String id = (request != null && request.id() != null) ? request.id() : "unspecified";
return verb + " " + noun.replace('_', ' ') + " [" + domain + "] for id=" + id + ": ok";
};
return FunctionToolCallback.builder(name, function)
.description(description)
.inputType(SyntheticToolRequest.class)
.build();
}
private static String describe(String domain, String verb, String noun) {
String nounPhrase = noun.replace('_', ' ');
return switch (verb) {
case "lookup" -> "Look up a single " + nounPhrase + " by ID in the " + domain + " system.";
case "list" -> "List " + nounPhrase + " records in the " + domain + " system, optionally filtered.";
case "create" -> "Create a new " + nounPhrase + " in the " + domain + " system.";
case "update" -> "Update an existing " + nounPhrase + " in the " + domain + " system.";
case "archive" -> "Archive a " + nounPhrase + " in the " + domain + " system so it no longer appears in active lists.";
case "search" -> "Full-text search across " + nounPhrase + " records in the " + domain + " system.";
case "export" -> "Export " + nounPhrase + " records from the " + domain + " system as a downloadable report.";
case "summarize" -> "Summarize the current state of " + nounPhrase + " records in the " + domain + " system.";
default -> "Operate on " + nounPhrase + " in the " + domain + " system.";
};
}
}
@@ -0,0 +1,7 @@
package com.ankurm.toolcalling.domain;
/**
* See {@code com.ankurm.toolcalling.tools.UserContextTools#myAccount}.
*/
public record AccountSummary(String userId, String tier, double accountBalanceUsd) {
}
@@ -0,0 +1,11 @@
package com.ankurm.toolcalling.domain;
import java.time.Instant;
/**
* Returned as-is to the caller by a {@code returnDirect = true} tool -- the model never sees this
* record. See {@code com.ankurm.toolcalling.tools.ServerStatusTools#serverStatus} and the
* "what returnDirect actually skips" section of the post.
*/
public record ServerStatus(String service, String status, int activeConnections, Instant checkedAt) {
}
@@ -0,0 +1,11 @@
package com.ankurm.toolcalling.domain;
/**
* The single input type shared by every tool in {@code LargeToolLibrary}. Real tools in a system
* this size would each have their own request shape; this repository uses one shared shape on
* purpose, so the only thing that varies from tool to tool is the name and description text the
* search index has to tell apart -- which is exactly what {@code ToolSearchToolCallingAdvisor} is
* being asked to do well.
*/
public record SyntheticToolRequest(String id) {
}
@@ -0,0 +1,10 @@
package com.ankurm.toolcalling.domain;
/**
* The shape a {@code @Tool} method returns when the framework should serialize the result back
* into the model's tool-response message. There is nothing MCP- or Spring-AI-specific about this
* record -- it is deserialized to JSON by the same Jackson machinery that serializes any other
* return type. See {@code com.ankurm.toolcalling.tools.WeatherTools#currentWeather}.
*/
public record WeatherReport(String city, double temperatureCelsius, String conditions, int humidityPercent) {
}
@@ -0,0 +1,48 @@
package com.ankurm.toolcalling.tools;
import com.ankurm.toolcalling.domain.ServerStatus;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
/**
* {@code returnDirect = true} means the tool's raw return value goes straight back to whatever
* called the {@code ChatClient} -- it is never sent to the model as a tool-result message for a
* second inference pass. Useful for tools whose output is already the final answer (a status
* dashboard, a generated file, a confirmation payload) where a second model round-trip would only
* add latency and a chance for the model to paraphrase (or misstate) a number that was already
* correct. See {@code ScriptedChatModel} in the test sources for how the test proves the model
* really is skipped -- it queues only one response and the test still gets the tool's own object
* back.
*/
@Service
public class ServerStatusTools {
private static final Map<String, Integer> ACTIVE_CONNECTIONS = Map.of(
"orders-api", 214,
"inventory-api", 58,
"notifications-api", 3);
private final Clock clock;
public ServerStatusTools(Clock clock) {
this.clock = clock;
}
@Tool(name = "server_status", description = "Get the live status of one of this company's internal services "
+ "(orders-api, inventory-api, notifications-api). The result is returned directly to the "
+ "caller without a further model round-trip.", returnDirect = true)
public ServerStatus serverStatus(
@ToolParam(description = "The service name, e.g. orders-api", required = true) String serviceName) {
Integer connections = ACTIVE_CONNECTIONS.get(serviceName);
if (connections == null) {
throw new IllegalArgumentException("Unknown service: " + serviceName
+ ". Known services: " + ACTIVE_CONNECTIONS.keySet());
}
return new ServerStatus(serviceName, "UP", connections, Instant.now(this.clock));
}
}
@@ -0,0 +1,49 @@
package com.ankurm.toolcalling.tools;
import com.ankurm.toolcalling.domain.AccountSummary;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* {@link ToolContext} is how the calling code hands a tool data that the model must never see or
* choose -- the currently authenticated user, a tenant ID, a feature flag -- without inventing a
* fake tool parameter and hoping the model fills it in correctly (or, worse, lets the model supply
* its own "userId" string). A {@code @Tool} method that declares a parameter of type
* {@link ToolContext} receives whatever map the caller passed to
* {@code ChatClient#toolContext(Map)}; Spring AI recognizes the parameter by its type, not its
* name or position, and never exposes it to the model's function-calling schema.
*
* <p>This class deliberately has no repository or security layer behind it -- {@code myAccount}
* trusts the {@code userId} key in the {@link ToolContext} outright. In a real deployment that key
* would be populated from the authenticated principal on the server side (the same
* {@code SecurityContextHolder} pattern used in the {@code mcp-secure} module), never from
* anything a client sends.
*/
@Service
public class UserContextTools {
static final String USER_ID_KEY = "userId";
private static final Map<String, AccountSummary> ACCOUNTS = Map.of(
"alice", new AccountSummary("alice", "GOLD", 4820.50),
"bob", new AccountSummary("bob", "STANDARD", 112.75));
@Tool(name = "my_account", description = "Get the current user's own account summary: tier and balance. "
+ "Takes no arguments -- the user is identified from the caller's context, not from anything "
+ "the model supplies.")
public AccountSummary myAccount(ToolContext toolContext) {
Object userId = (toolContext != null) ? toolContext.getContext().get(USER_ID_KEY) : null;
if (userId == null) {
throw new IllegalStateException("No " + USER_ID_KEY + " in ToolContext -- the caller must set one "
+ "with ChatClient.toolContext(Map.of(\"" + USER_ID_KEY + "\", ...)) before this tool can run.");
}
AccountSummary account = ACCOUNTS.get(userId.toString());
if (account == null) {
throw new IllegalArgumentException("No account for userId: " + userId);
}
return account;
}
}
@@ -0,0 +1,35 @@
package com.ankurm.toolcalling.tools;
import com.ankurm.toolcalling.domain.WeatherReport;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* The plain, ordinary case: a {@code @Tool} method with no special attributes. The model calls
* it, gets JSON back, and decides what to say about it. This is the baseline every other tool in
* this repository is contrasted against -- {@code ServerStatusTools} for {@code returnDirect},
* {@code UserContextTools} for {@link org.springframework.ai.chat.model.ToolContext}.
*/
@Service
public class WeatherTools {
private static final Map<String, WeatherReport> FIXTURE_DATA = Map.of(
"Boston", new WeatherReport("Boston", 14.5, "Overcast", 71),
"Austin", new WeatherReport("Austin", 29.0, "Clear", 44),
"Seattle", new WeatherReport("Seattle", 12.0, "Light rain", 88));
@Tool(name = "current_weather", description = "Get the current weather conditions for a named city. "
+ "Returns temperature in Celsius, a short conditions description, and relative humidity.")
public WeatherReport currentWeather(
@ToolParam(description = "The city name, e.g. Boston", required = true) String city) {
WeatherReport report = FIXTURE_DATA.get(city);
if (report == null) {
throw new IllegalArgumentException("No weather fixture for city: " + city
+ ". Known cities: " + FIXTURE_DATA.keySet());
}
return report;
}
}
@@ -0,0 +1,11 @@
spring:
application:
name: tool-calling
ai:
openai:
# No real key is needed to run this module's test suite -- the tests never construct the
# autoconfigured OpenAiChatModel bean, they build a ChatClient directly around
# ScriptedChatModel. This placeholder only exists so the application context *could* start
# standalone (e.g. via `mvn spring-boot:run`) with a real key supplied as an environment
# variable.
api-key: ${OPENAI_API_KEY:demo-key-not-used-by-tests}