Files
Claude fff9f52116 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
2026-09-23 17:03:41 +00:00

4.2 KiB

tool-calling

@Tool methods, ToolCallingAdvisor (the advisor-layer replacement for Spring AI 1.x's per-ChatModel tool-calling loop), returnDirect, ToolContext, and ToolSearchToolCallingAdvisor for progressive disclosure across a 230-tool synthetic library -- with no live model anywhere in the test suite. Every test drives the real advisor classes against a hand-written ScriptedChatModel that queues real ChatResponse objects, so what gets asserted is the advisor's real behaviour, not a description of it.

Companion code for Tool Calling in Spring AI 2.0 on ankurm.com.

Versions

Component Version
Spring Boot 4.1.1
Spring AI 2.0.1
spring-ai-tool-search-advisor / spring-ai-tool-search-tool 2.0.1 (separately versioned artifacts, not covered by spring-ai-bom -- see the article's "going deeper" note)
Java 25 (LTS)

Quickstart

./scripts/run-all.sh

Runs the full test suite. No API key, no network access, no Docker -- every test builds a ChatClient around ScriptedChatModel (a queue of pre-programmed ChatResponses) instead of a real LLM. Output lands in output/.

What's here

File What it does
tools/WeatherTools.java The baseline: an ordinary @Tool method, no special attributes.
tools/ServerStatusTools.java @Tool(returnDirect = true) -- the tool's own return value skips the second model round trip entirely.
tools/UserContextTools.java A ToolContext-typed parameter -- excluded from the model-facing JSON schema, still delivered at call time.
config/LargeToolLibrary.java Generates 230 synthetic ToolCallbacks across six fake business domains via FunctionToolCallback.builder(...), so the search-advisor section has something realistic to search.
config/ChatClientFactory.java The exact ChatClient construction shared by production wiring (ChatClientConfig) and every test -- including the ToolCallingChatOptions base-options fix documented in its Javadoc, found by disassembling ToolCallingAdvisor.adviseCall.
config/ChatClientConfig.java Production Spring @Bean wiring around a real (autoconfigured) ChatModel.
support/ScriptedChatModel.java A hand-written ChatModel (only call(Prompt) is abstract -- confirmed with javap) that returns a queued script of responses instead of calling a real API.

Output files

File What it captures
output/01-plain-tool-call-round-trip.txt Two model calls for one tool question: the tool-call request, the tool result fed back, the final answer.
output/02-return-direct-skips-second-round.txt returnDirect = true: exactly one model call, the tool's own JSON returned unparaphrased.
output/03-tool-context-hidden-from-schema.txt my_account's real JSON schema (no userId property anywhere in it) next to the real answer, built from a userId the caller supplied out-of-band.
output/04-tool-search-progressive-disclosure.txt Tool counts actually offered to the model, read straight off ToolCallingChatOptions.getToolCallbacks(): 1 before searching, 6 after.
output/05-tool-description-footprint.txt Measured character-count comparison between the full 230-tool definition text and toolSearchTool's definition alone.
output/06-tool-library-size.txt The exact size and per-domain breakdown of the synthetic library, asserted by a test rather than hand-counted.

Requirements

Nothing beyond the JDK and Maven. spring-ai-starter-model-openai is on the classpath for the production ChatClientConfig bean to compile and (optionally) run against a real key via OPENAI_API_KEY, but the test suite never constructs it.