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:
@@ -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}
|
||||
Reference in New Issue
Block a user