Module 4 — Tool Calling / Function Calling
1. Declaring Tools (@Tool, Function Beans) and the Request/Response Loop
The conceptual loop first, because the annotation syntax only makes sense once the round-trip is clear:
1. App sends a prompt + list of available tool definitions to the model
2. Model decides it needs information/action it doesn't have → emits a "tool call"
(tool name + arguments) instead of a final answer
3. Spring AI intercepts this, invokes the actual Java method matching that tool
4. The method's return value is sent back to the model as a new message
5. Model incorporates the tool result and either calls another tool or produces
the final answer
This loop can iterate multiple times in a single logical request — a model might call three tools in sequence before producing a final answer, and Spring AI’s ChatClient handles this iteration transparently by default; the application code doesn’t manually drive the loop unless it needs fine-grained control.
Declaring a tool via @Tool (the modern, annotation-based approach):
@Component
public class OrderTools {
@Autowired
private OrderRepository orderRepository;
@Tool(description = "Looks up the current status of an order by its order ID")
public String getOrderStatus(@ToolParam(description = "The order ID to look up") String orderId) {
return orderRepository.findById(orderId)
.map(order -> order.getStatus().toString())
.orElse("Order not found");
}
@Tool(description = "Cancels an order if it hasn't shipped yet")
public String cancelOrder(@ToolParam(description = "The order ID to cancel") String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
if (order.getStatus() == OrderStatus.SHIPPED) {
return "Cannot cancel — order has already shipped.";
}
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
return "Order cancelled successfully.";
}
}
String response = chatClient.prompt()
.user("Is order 4471 ready to ship, and if not, cancel it.")
.tools(new OrderTools()) // register the tool-bearing bean for this call
.call()
.content();
The description fields are not documentation — they’re the model’s only information about what the tool does and when to use it. This is a frequently underappreciated point: a vague @Tool(description = "gets order info") gives the model far less signal than a precise one, and tool-selection mistakes (calling the wrong tool, or not calling one when it should) are often a prompt-engineering problem at the description level, not a code bug.
Function-bean style (the older/alternative approach, still supported) — defining a tool as a Function/BiFunction bean rather than an annotated method, useful when tool logic needs to be assembled dynamically or come from a non-annotated source:
@Bean
@Description("Looks up current weather for a given city")
public Function<WeatherRequest, WeatherResponse> currentWeather() {
return request -> weatherService.getCurrentWeather(request.city());
}
Talking point: “@Tool is the right default for anything you control and can annotate directly — it’s the same readability argument as @RestController annotations versus manually registering handler mappings. The function-bean style earns its place when tools need to be constructed dynamically — for example, generating a set of tools at runtime from a database-defined configuration — which is a real pattern in multi-tenant systems where different tenants expose different toolsets to the same underlying agent.”
2. ToolCallingAdvisor — The Rename and Why It Matters
What changed: ToolCallAdvisor was renamed to ToolCallingAdvisor, with backward-compatible shims provided so existing code doesn’t break immediately — but new code and documentation now reference the new name.
Why this is worth knowing beyond “cosmetic rename”: it reflects the Advisors API’s broader maturation — tool calling is being formalized as one Advisor among several in the same composable middleware chain established in Module 1 (memory, RAG, logging, tool calling), rather than a bolted-on special case. The naming shift (ToolCall → ToolCalling) makes the advisor’s behavior the name, consistent with how other advisors are named by what they do (MessageChatMemoryAdvisor, QuestionAnswerAdvisor), rather than naming it after the artifact it produces.
Where this surfaces practically — advisor ordering, which is the part that actually causes bugs:
String response = chatClient.prompt()
.advisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(), // must run OUTSIDE tool calling
ToolCallingAdvisor.builder().build() // (conceptually — usually auto-applied
// when tools are registered)
)
.user("What's the status of my last order?")
.tools(new OrderTools())
.call()
.content();
This directly connects to a real, recent fix flagged in Module 1: memory advisors needed to be explicitly placed outside the tool-calling advisor by default, because if memory persistence ran inside the tool-calling loop, intermediate tool-call exchanges (which aren’t meaningful conversation turns from the user’s perspective) risked being persisted as if they were normal dialogue — polluting the stored conversation history with internal tool-call plumbing the user never actually said or saw directly.
Talking point — and this is a strong one to make explicitly, since it’s a direct callback to your Java/Spring track’s recurring theme: “This is genuinely the same architectural lesson as the AOP proxy self-invocation pitfall that came up repeatedly across the Java/Spring track — ordering and scope of cross-cutting middleware changes correctness, not just behavior style. A memory advisor positioned at the wrong point in the chain doesn’t throw an exception; it just silently persists the wrong thing, the same way a misplaced @Transactional boundary doesn’t fail loudly, it just silently doesn’t do what you assumed. Tool-calling and memory interacting correctly is an ordering problem, and ordering problems in middleware chains are exactly the class of bug that’s invisible until you specifically go looking for it.”
3. ToolContext and the Removal of Conversation History — Designing Tools Statelessly
What ToolContext is: a side-channel for passing additional, non-prompt data into a tool method at invocation time — things like the current authenticated user, a tenant ID, or request-scoped metadata that shouldn’t be part of the model-visible prompt but that the actual Java method execution needs.
@Tool(description = "Cancels an order if it hasn't shipped")
public String cancelOrder(@ToolParam String orderId, ToolContext toolContext) {
String currentUserId = (String) toolContext.getContext().get("userId");
// authorize against currentUserId before performing the cancellation
...
}
chatClient.prompt()
.user("Cancel order 4471")
.toolContext(Map.of("userId", currentUser.getId()))
.tools(new OrderTools())
.call()
.content();
The breaking change: in a 2.0 milestone, conversation history was removed from ToolContext — earlier versions apparently allowed a tool implementation to reach into the full conversation history via the context object. Removing this is a deliberate architectural correction, not just a cleanup.
Why this matters and is worth understanding deeply, not just adapting to: a tool that depends on reaching back into conversation history to do its job is implicitly stateful and implicitly coupled to the calling context in a way that breaks several things at once:
- Testability — a tool that only behaves correctly given a specific conversation history can’t be unit-tested as a pure function of its declared parameters; you’d need to fabricate an entire fake conversation to test simple logic.
- Composability — if a tool secretly depends on conversation state, it can’t be safely reused across different advisor chains, different conversation memory backends, or invoked directly (e.g., from a different agent or workflow) without carrying that hidden dependency along.
- Reasoning about tool calls — what a tool actually does should be fully described by its declared parameters and
@ToolParamdescriptions, since that’s also literally what the model sees. A tool with hidden behavior driven by context the model can’t see is a tool whose actual behavior diverges from what was advertised to the model deciding to call it.
The correct design pattern post-change: any information a tool genuinely needs should be either (a) an explicit @ToolParam the model is told about and can supply, or (b) passed explicitly via ToolContext as scoped, intentional metadata (auth identity, tenant ID) — never implicitly reached for via conversation history.
Talking point: “Removing conversation history from ToolContext is a forced move toward functional purity for tools — a tool’s behavior should be fully explained by what’s declared in its signature plus whatever’s explicitly passed through context, not by reaching backward into an entire conversation it wasn’t designed to parse. This is the same ‘no hidden global state’ discipline as designing a clean REST endpoint that doesn’t implicitly depend on session state nobody documented — and it’s a good design discipline even setting the framework’s specific removal aside, because stateless, narrowly-scoped tools are dramatically easier to test, reuse, and reason about than tools that secretly depend on everything that’s been said so far in the conversation.”
4. Client-Side vs. Server-Exposed Tools; Security Implications
Client-side tools — tools defined and executed within the same application that’s calling the model; the loop in section 1 happens entirely inside one JVM process. This is the default, most common pattern — what’s shown in every example above.
Server-exposed tools — exposing tools as a server for external consumers to call, most commonly via MCP (Module 8’s territory) — a different application, potentially a different team or organization’s agent, calls into tools your service exposes. The conceptual loop is the same, but the trust boundary is fundamentally different: client-side tools are invoked by a model you’re directly orchestrating; server-exposed tools are invoked by a model (and prompt) you may have zero visibility into or control over.
The security implications, which are genuinely serious and worth treating with real engineering rigor — letting a model call code is a new class of attack surface:
- Prompt injection driving unintended tool calls — if any tool input includes untrusted text (a user-submitted support ticket, a scraped web page, a document ingested via RAG), that text can contain instructions designed to manipulate the model into calling tools it shouldn’t, with attacker-chosen arguments. This is the AI-era analog of SQL injection or XSS — untrusted input being interpreted as instructions rather than pure data — and a model deciding “I should call
cancelOrderbecause the document told me to” is a structurally similar failure to a query executing injected SQL because input wasn’t parameterized. - Tool permissions must be scoped at the application layer, not assumed safe because “the model decided to.” A tool’s authorization checks (can this user cancel this order) must happen inside the tool implementation itself, using context explicitly passed in (e.g., the authenticated user from
ToolContext) — never trusting that the model only calls a tool when it’s “supposed to.” The model’s decision to call a tool is a suggestion arriving through an unreliable, manipulable channel; the actual authorization boundary belongs in ordinary application security code, exactly where it would for any other caller. - Irreversible or high-consequence tools need an explicit confirmation step, not direct execution — the same “side-effectful actions need explicit user confirmation” principle that governs any agent-like system. A
cancelOrderorsendEmailtool being directly callable without a human-in-the-loop checkpoint is a meaningfully higher-risk design than a read-onlygetOrderStatustool. - Server-exposed tools (MCP) need their own auth layer independent of the calling model’s behavior — MCP Security (flagged in the table of contents’ Module 8) exists precisely because exposing tools to external/third-party agents means the trust assumptions of “I wrote the prompt, I control what gets sent” no longer hold.
Talking point that ties this whole module together, and is a strong one to lead with in an interview about this topic: “The single biggest mental shift in tool calling is that the model is now a caller of your code, and callers should never be trusted by default — that’s just standard security practice, except the caller here is non-deterministic and can be manipulated by content it reads, not just by the developer who wrote the integration. I design every tool the same way I’d design a public API endpoint: explicit, narrow parameters; authorization enforced inside the method using identity passed through trusted context, never inferred from conversation content; and an explicit human-confirmation gate for anything irreversible. The ToolContext history removal we just covered is actually a small piece of this same philosophy — pushing toward tools that only act on data they were explicitly and deliberately given, not data they happened to have access to.”