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:
@@ -9,5 +9,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
|
|||||||
| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) |
|
| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) |
|
||||||
| [`mcp-client/`](mcp-client) | `ChatClient` calling tools from two real external MCP servers (filesystem, git) over stdio via `defaultToolCallbacks(ToolCallbackProvider...)`, contrasted with a local `@Tool` method, with every call logged through one Micrometer `ObservationHandler`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) |
|
| [`mcp-client/`](mcp-client) | `ChatClient` calling tools from two real external MCP servers (filesystem, git) over stdio via `defaultToolCallbacks(ToolCallbackProvider...)`, contrasted with a local `@Tool` method, with every call logged through one Micrometer `ObservationHandler`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) |
|
||||||
| [`mcp-secure/`](mcp-secure) | The mcp-server article's order-lookup tools behind a real OAuth2 resource server: JWT validation, one scope per tool via `@PreAuthorize`, unauthenticated tool discovery rejected outright, and every call audit-logged through MDC -- denials included. Spring Boot 4.1.1, Spring AI 2.0.1, Spring Security 7.1.1, Java 25. | [Securing an MCP Server with Spring Security 7](https://ankurm.com/spring-ai-2-0-mcp-server-security/) |
|
| [`mcp-secure/`](mcp-secure) | The mcp-server article's order-lookup tools behind a real OAuth2 resource server: JWT validation, one scope per tool via `@PreAuthorize`, unauthenticated tool discovery rejected outright, and every call audit-logged through MDC -- denials included. Spring Boot 4.1.1, Spring AI 2.0.1, Spring Security 7.1.1, Java 25. | [Securing an MCP Server with Spring Security 7](https://ankurm.com/spring-ai-2-0-mcp-server-security/) |
|
||||||
|
| [`tool-calling/`](tool-calling) | `@Tool` methods, `ToolCallingAdvisor` (the advisor-layer replacement for Spring AI 1.x's per-model tool loop), `returnDirect`, `ToolContext`, and `ToolSearchToolCallingAdvisor` for progressive disclosure across a 230-tool synthetic library -- every test driven by a hand-written `ScriptedChatModel`, no live model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Tool Calling in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-tool-calling/) |
|
||||||
|
|
||||||
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
|
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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](https://ankurm.com/spring-ai-2-0-tool-calling/)
|
||||||
|
on [ankurm.com](https://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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./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 `ChatResponse`s) instead of a
|
||||||
|
real LLM. Output lands in `output/`.
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
| File | What it does |
|
||||||
|
|---|---|
|
||||||
|
| [`tools/WeatherTools.java`](src/main/java/com/ankurm/toolcalling/tools/WeatherTools.java) | The baseline: an ordinary `@Tool` method, no special attributes. |
|
||||||
|
| [`tools/ServerStatusTools.java`](src/main/java/com/ankurm/toolcalling/tools/ServerStatusTools.java) | `@Tool(returnDirect = true)` -- the tool's own return value skips the second model round trip entirely. |
|
||||||
|
| [`tools/UserContextTools.java`](src/main/java/com/ankurm/toolcalling/tools/UserContextTools.java) | A `ToolContext`-typed parameter -- excluded from the model-facing JSON schema, still delivered at call time. |
|
||||||
|
| [`config/LargeToolLibrary.java`](src/main/java/com/ankurm/toolcalling/config/LargeToolLibrary.java) | Generates 230 synthetic `ToolCallback`s across six fake business domains via `FunctionToolCallback.builder(...)`, so the search-advisor section has something realistic to search. |
|
||||||
|
| [`config/ChatClientFactory.java`](src/main/java/com/ankurm/toolcalling/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`](src/main/java/com/ankurm/toolcalling/config/ChatClientConfig.java) | Production Spring `@Bean` wiring around a real (autoconfigured) `ChatModel`. |
|
||||||
|
| [`support/ScriptedChatModel.java`](src/test/java/com/ankurm/toolcalling/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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Plain ToolCallingAdvisor: two model calls for one weather question
|
||||||
|
|
||||||
|
model call count: 2
|
||||||
|
|
||||||
|
call 1 -- model requests a tool:
|
||||||
|
tool call: What's the weather in Boston?
|
||||||
|
|
||||||
|
call 2 -- advisor sends the tool result back to the model:
|
||||||
|
tool response: ToolResponse[id=call-1, name=current_weather, responseData={"city":"Boston","temperatureCelsius":14.5,"conditions":"Overcast","humidityPercent":71}]
|
||||||
|
|
||||||
|
final answer returned to the caller:
|
||||||
|
It's 14.5C and overcast in Boston right now, with 71% humidity.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# returnDirect = true: the model is called exactly once
|
||||||
|
|
||||||
|
model call count: 1
|
||||||
|
|
||||||
|
raw content returned to the caller (the tool's own JSON, unparaphrased):
|
||||||
|
{"service":"orders-api","status":"UP","activeConnections":214,"checkedAt":"2026-09-23T18:00:00Z"}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# ToolContext: excluded from the model-facing schema, still delivered to the tool
|
||||||
|
|
||||||
|
my_account input schema sent to the model:
|
||||||
|
{
|
||||||
|
"$schema" : "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : { },
|
||||||
|
"required" : [ ],
|
||||||
|
"additionalProperties" : false
|
||||||
|
}
|
||||||
|
|
||||||
|
model's tool call arguments (empty -- it was never told a userId parameter exists):
|
||||||
|
{}
|
||||||
|
|
||||||
|
final answer, using the real userId the CALLER supplied via ChatClient.toolContext():
|
||||||
|
You're on the GOLD tier with a balance of $4820.50.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ToolSearchToolCallingAdvisor: one tool offered up front instead of the whole library
|
||||||
|
|
||||||
|
tools registered on the ChatClient: 230
|
||||||
|
|
||||||
|
tools OFFERED to the model on call 1 (before any search): 1
|
||||||
|
[toolSearchTool]
|
||||||
|
|
||||||
|
tools OFFERED to the model on call 2 (after it searched for "look up an invoice"): 6
|
||||||
|
[crm_lookup_support_ticket, finance_lookup_invoice, hr_lookup_employee_record, hr_lookup_open_requisition, hr_lookup_pto_balance, toolSearchTool]
|
||||||
|
|
||||||
|
final answer: Invoice INV-1001 was found in the finance system.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Definition-text footprint: full library vs. one search-tool definition
|
||||||
|
|
||||||
|
tools in the library: 230
|
||||||
|
total characters of name + description + input schema, ALL 230 tools: 69958
|
||||||
|
characters of name + description + input schema, toolSearchTool ONLY: 463
|
||||||
|
reduction on the first call of a conversation: 99.3%
|
||||||
|
|
||||||
|
caveat: characters are not tokens, and this is the FIRST call only -- once the model
|
||||||
|
has searched, the tools it found are added back in for the rest of that conversation.
|
||||||
|
See ToolSearchProgressiveDisclosureTest / output/04 for what gets added back.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Synthetic tool library: size and per-domain breakdown
|
||||||
|
|
||||||
|
total tools: 230
|
||||||
|
|
||||||
|
crm 40 tools
|
||||||
|
devops 38 tools
|
||||||
|
finance 40 tools
|
||||||
|
hr 36 tools
|
||||||
|
logistics 40 tools
|
||||||
|
security 36 tools
|
||||||
|
|
||||||
|
sample tool definitions:
|
||||||
|
hr_lookup_employee_record Look up a single employee record by ID in the hr system.
|
||||||
|
hr_list_employee_record List employee record records in the hr system, optionally filtered.
|
||||||
|
hr_create_employee_record Create a new employee record in the hr system.
|
||||||
|
hr_update_employee_record Update an existing employee record in the hr system.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>tool-calling</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>tool-calling</name>
|
||||||
|
<description>Tool calling in Spring AI 2.0: @Tool methods, ToolCallingAdvisor, returnDirect, tool context, and the Tool Search Advisor for progressive disclosure across a large tool library.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
|
||||||
|
<spring-ai.version>2.0.1</spring-ai.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-bom</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Not managed by spring-ai-bom (a separately versioned, separately released module).
|
||||||
|
Pinned to the same 2.0.1 release train explicitly. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-tool-search-advisor</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-tool-search-tool</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file under output/ -- the test suite writes each one itself via the
|
||||||
|
# Transcript helper, overwriting it in place. No file in this module's output/ is hand-captured.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
rm -rf target
|
||||||
|
mvn -q -o test
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.ChatClientFactory;
|
||||||
|
import com.ankurm.toolcalling.support.ScriptedChatModel;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import com.ankurm.toolcalling.tools.WeatherTools;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.support.ToolCallbacks;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline round trip: the model asks for a tool, {@code ToolCallingAdvisor} executes it and
|
||||||
|
* calls the model a second time with the result, and only then does the caller get a final answer.
|
||||||
|
* See {@code com.ankurm.toolcalling.tools.WeatherTools} for the tool itself.
|
||||||
|
*/
|
||||||
|
class PlainToolCallingRoundTripTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void modelCallsToolThenGetsCalledAgainWithTheResult() {
|
||||||
|
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||||
|
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "current_weather",
|
||||||
|
"{\"city\":\"Boston\"}"))
|
||||||
|
.thenRespond("It's 14.5C and overcast in Boston right now, with 71% humidity.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChatClient client = ChatClientFactory.plainToolCallingClient(model,
|
||||||
|
List.of(ToolCallbacks.from(new WeatherTools())));
|
||||||
|
|
||||||
|
String answer = client.prompt().user("What's the weather in Boston?").call().content();
|
||||||
|
|
||||||
|
assertThat(model.callCount()).isEqualTo(2);
|
||||||
|
|
||||||
|
Prompt secondPrompt = model.capturedPrompts().get(1);
|
||||||
|
ToolResponseMessage toolResponse = secondPrompt.getInstructions().stream()
|
||||||
|
.filter(ToolResponseMessage.class::isInstance)
|
||||||
|
.map(ToolResponseMessage.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("second call did not include a ToolResponseMessage"));
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("01-plain-tool-call-round-trip.txt",
|
||||||
|
"Plain ToolCallingAdvisor: two model calls for one weather question")) {
|
||||||
|
t.line("model call count: %d", model.callCount());
|
||||||
|
t.blank();
|
||||||
|
t.line("call 1 -- model requests a tool:");
|
||||||
|
t.line(" tool call: %s", toolCallOf(model.capturedPrompts().get(0)));
|
||||||
|
t.blank();
|
||||||
|
t.line("call 2 -- advisor sends the tool result back to the model:");
|
||||||
|
t.line(" tool response: %s", toolResponse.getResponses().get(0));
|
||||||
|
t.blank();
|
||||||
|
t.line("final answer returned to the caller:");
|
||||||
|
t.line(" %s", answer);
|
||||||
|
|
||||||
|
assertThat(toolResponse.getResponses().get(0).name()).isEqualTo("current_weather");
|
||||||
|
assertThat(toolResponse.getResponses().get(0).responseData()).contains("Boston").contains("Overcast");
|
||||||
|
assertThat(answer).isEqualTo("It's 14.5C and overcast in Boston right now, with 71% humidity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toolCallOf(Prompt prompt) {
|
||||||
|
// First prompt has no assistant tool-call yet -- this reports the request itself, since the
|
||||||
|
// *response* to it (queued in the script) is what carries the tool call. Kept simple: log the
|
||||||
|
// user message that triggered it.
|
||||||
|
Message last = prompt.getInstructions().get(prompt.getInstructions().size() - 1);
|
||||||
|
return last.getText();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.ChatClientFactory;
|
||||||
|
import com.ankurm.toolcalling.support.ScriptedChatModel;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import com.ankurm.toolcalling.tools.ServerStatusTools;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.support.ToolCallbacks;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code @Tool(returnDirect = true)} means the advisor never makes the second model call at all --
|
||||||
|
* the tool's own return value becomes the answer. This test only queues one scripted response on
|
||||||
|
* purpose: if the advisor tried to call the model again, {@link ScriptedChatModel} would throw on
|
||||||
|
* the second call with no response left, and the test would fail loudly rather than silently
|
||||||
|
* passing on a lucky assertion. See {@code com.ankurm.toolcalling.tools.ServerStatusTools}.
|
||||||
|
*/
|
||||||
|
class ReturnDirectSkipsSecondRoundTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolResultIsReturnedWithoutAFollowUpModelCall() {
|
||||||
|
Clock fixedClock = Clock.fixed(Instant.parse("2026-09-23T18:00:00Z"), ZoneOffset.UTC);
|
||||||
|
|
||||||
|
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||||
|
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "server_status",
|
||||||
|
"{\"serviceName\":\"orders-api\"}"))
|
||||||
|
// Deliberately no second response queued -- returnDirect must mean the advisor never
|
||||||
|
// asks.
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChatClient client = ChatClientFactory.plainToolCallingClient(model,
|
||||||
|
List.of(ToolCallbacks.from(new ServerStatusTools(fixedClock))));
|
||||||
|
|
||||||
|
String answer = client.prompt().user("Is orders-api up?").call().content();
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("02-return-direct-skips-second-round.txt",
|
||||||
|
"returnDirect = true: the model is called exactly once")) {
|
||||||
|
t.line("model call count: %d", model.callCount());
|
||||||
|
t.blank();
|
||||||
|
t.line("raw content returned to the caller (the tool's own JSON, unparaphrased):");
|
||||||
|
t.line(" %s", answer);
|
||||||
|
|
||||||
|
assertThat(model.callCount())
|
||||||
|
.as("returnDirect should mean the advisor never calls the model a second time")
|
||||||
|
.isEqualTo(1);
|
||||||
|
assertThat(answer).contains("orders-api").contains("UP").contains("214");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.ChatClientFactory;
|
||||||
|
import com.ankurm.toolcalling.support.ScriptedChatModel;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import com.ankurm.toolcalling.tools.UserContextTools;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.support.ToolCallbacks;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two things worth proving separately about {@link org.springframework.ai.chat.model.ToolContext
|
||||||
|
* ToolContext}: first, that it never appears in the JSON schema the model sees (so the model
|
||||||
|
* cannot supply, guess, or overwrite it); second, that the tool still receives the real value at
|
||||||
|
* call time, sourced from {@code ChatClient.toolContext(Map)} on the request, not from the model's
|
||||||
|
* tool-call arguments. See {@code com.ankurm.toolcalling.tools.UserContextTools}.
|
||||||
|
*/
|
||||||
|
class ToolContextNotExposedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolContextParameterIsHiddenFromTheSchemaButStillDeliveredToTheTool() {
|
||||||
|
ToolCallback callback = ToolCallbacks.from(new UserContextTools())[0];
|
||||||
|
String schema = callback.getToolDefinition().inputSchema();
|
||||||
|
|
||||||
|
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||||
|
// The model calls my_account with an EMPTY arguments object -- it has no userId to
|
||||||
|
// supply, because the schema never told it one exists.
|
||||||
|
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "my_account", "{}"))
|
||||||
|
.thenRespond("You're on the GOLD tier with a balance of $4820.50.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChatClient client = ChatClientFactory.plainToolCallingClient(model, List.of(callback));
|
||||||
|
|
||||||
|
String answer = client.prompt()
|
||||||
|
.user("What's my account tier?")
|
||||||
|
.toolContext(Map.of("userId", "alice"))
|
||||||
|
.call()
|
||||||
|
.content();
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("03-tool-context-hidden-from-schema.txt",
|
||||||
|
"ToolContext: excluded from the model-facing schema, still delivered to the tool")) {
|
||||||
|
t.line("my_account input schema sent to the model:");
|
||||||
|
t.line(" %s", schema);
|
||||||
|
t.blank();
|
||||||
|
t.line("model's tool call arguments (empty -- it was never told a userId parameter exists):");
|
||||||
|
t.line(" {}");
|
||||||
|
t.blank();
|
||||||
|
t.line("final answer, using the real userId the CALLER supplied via ChatClient.toolContext():");
|
||||||
|
t.line(" %s", answer);
|
||||||
|
|
||||||
|
assertThat(schema).doesNotContain("userId").doesNotContain("ToolContext").doesNotContain("toolContext");
|
||||||
|
assertThat(answer).contains("GOLD").contains("4820.50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.LargeToolLibrary;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not a token count from a real model call -- no live model is involved anywhere in this module --
|
||||||
|
* but a real, measured character count of the tool name/description/JSON-schema text that would go
|
||||||
|
* into the model's context on every single call under the plain {@code ToolCallingAdvisor}
|
||||||
|
* (all 230 tools), versus what goes in under {@code ToolSearchToolCallingAdvisor} on the first call
|
||||||
|
* of a conversation (just {@code toolSearchTool}'s own definition). Characters are not tokens, but
|
||||||
|
* they are a real, reproducible, unit-testable proxy for the same claim the Spring blog post this
|
||||||
|
* feature originated from makes in tokens -- see the "going deeper" note in the article for why
|
||||||
|
* this test measures characters rather than pretending to know the model's tokenizer.
|
||||||
|
*/
|
||||||
|
class ToolDescriptionFootprintTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullLibraryDefinitionTextIsMuchLargerThanOneSearchToolDefinition() {
|
||||||
|
List<ToolCallback> library = LargeToolLibrary.buildAll();
|
||||||
|
|
||||||
|
int fullLibraryChars = library.stream().mapToInt(ToolDescriptionFootprintTest::definitionSize).sum();
|
||||||
|
|
||||||
|
// The tool-search client's first call offers exactly one definition: toolSearchTool itself.
|
||||||
|
// Its schema is small and fixed regardless of library size -- captured directly from the
|
||||||
|
// resolved artifact rather than hand-typed, so this number tracks the real dependency.
|
||||||
|
int searchToolOnlyChars = definitionSizeOf("toolSearchTool",
|
||||||
|
"Search for tools in the tool registry to discover capabilities for completing the "
|
||||||
|
+ "current task.\nUse this when you need functionality not provided by your currently "
|
||||||
|
+ "available tools.\nThe search queries against tool names, descriptions, and parameter "
|
||||||
|
+ "information to find the most relevant tools.\nReturns references to matching tools "
|
||||||
|
+ "which will be expanded into full definitions you can then invoke.\n",
|
||||||
|
"{\"query\":\"...\",\"maxResults\":5,\"categoryFilter\":\"...\"}");
|
||||||
|
|
||||||
|
double reduction = 100.0 * (1 - ((double) searchToolOnlyChars / fullLibraryChars));
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("05-tool-description-footprint.txt",
|
||||||
|
"Definition-text footprint: full library vs. one search-tool definition")) {
|
||||||
|
t.line("tools in the library: %d", library.size());
|
||||||
|
t.line("total characters of name + description + input schema, ALL %d tools: %d",
|
||||||
|
library.size(), fullLibraryChars);
|
||||||
|
t.line("characters of name + description + input schema, toolSearchTool ONLY: %d",
|
||||||
|
searchToolOnlyChars);
|
||||||
|
t.line("reduction on the first call of a conversation: %.1f%%", reduction);
|
||||||
|
t.blank();
|
||||||
|
t.line("caveat: characters are not tokens, and this is the FIRST call only -- once the model");
|
||||||
|
t.line("has searched, the tools it found are added back in for the rest of that conversation.");
|
||||||
|
t.line("See ToolSearchProgressiveDisclosureTest / output/04 for what gets added back.");
|
||||||
|
|
||||||
|
assertThat(fullLibraryChars).isGreaterThan(searchToolOnlyChars * 20);
|
||||||
|
assertThat(reduction).isGreaterThan(90.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int definitionSize(ToolCallback callback) {
|
||||||
|
ToolDefinition definition = callback.getToolDefinition();
|
||||||
|
return definitionSizeOf(definition.name(), definition.description(), definition.inputSchema());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int definitionSizeOf(String name, String description, String inputSchema) {
|
||||||
|
return name.length() + description.length() + inputSchema.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.LargeToolLibrary;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the size and shape of the synthetic tool library that {@code ToolSearchProgressiveDisclosureTest}
|
||||||
|
* runs the search advisor against -- this is the number that makes "one tool advertised instead of
|
||||||
|
* the whole library" a real comparison instead of a made-up one.
|
||||||
|
*/
|
||||||
|
class ToolLibrarySizeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void libraryHasTheDocumentedShapeAndSize() {
|
||||||
|
List<ToolCallback> library = LargeToolLibrary.buildAll();
|
||||||
|
|
||||||
|
Map<String, Long> countsByDomain = new TreeMap<>(library.stream()
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
cb -> cb.getToolDefinition().name().split("_", 2)[0],
|
||||||
|
Collectors.counting())));
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("06-tool-library-size.txt",
|
||||||
|
"Synthetic tool library: size and per-domain breakdown")) {
|
||||||
|
t.line("total tools: %d", library.size());
|
||||||
|
t.blank();
|
||||||
|
countsByDomain.forEach((domain, count) -> t.line("%-10s %d tools", domain, count));
|
||||||
|
t.blank();
|
||||||
|
t.line("sample tool definitions:");
|
||||||
|
library.stream().limit(4).forEach(cb -> t.line(" %-30s %s", cb.getToolDefinition().name(),
|
||||||
|
cb.getToolDefinition().description()));
|
||||||
|
|
||||||
|
assertThat(library.size()).isEqualTo(230);
|
||||||
|
assertThat(countsByDomain).hasSize(6);
|
||||||
|
assertThat(library.stream().map(cb -> cb.getToolDefinition().name()).distinct().count())
|
||||||
|
.isEqualTo(library.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.ankurm.toolcalling;
|
||||||
|
|
||||||
|
import com.ankurm.toolcalling.config.ChatClientFactory;
|
||||||
|
import com.ankurm.toolcalling.config.LargeToolLibrary;
|
||||||
|
import com.ankurm.toolcalling.support.ScriptedChatModel;
|
||||||
|
import com.ankurm.toolcalling.support.Transcript;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.toolsearch.ToolSearchTool;
|
||||||
|
import org.springframework.ai.tool.toolsearch.index.regex.RegexToolIndex;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static com.ankurm.toolcalling.support.TestSupport.toolNamesOfferedTo;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point of {@code ToolSearchToolCallingAdvisor}: with a 230-tool library registered on the
|
||||||
|
* client, the model is offered exactly one tool -- {@code tool_search_tool} -- on the first call,
|
||||||
|
* not all 230. Only after it searches does the specific tool it needs appear on the next call. See
|
||||||
|
* {@code com.ankurm.toolcalling.config.LargeToolLibrary} for where the 230 comes from and
|
||||||
|
* {@code ToolLibrarySizeTest} for the exact count.
|
||||||
|
*/
|
||||||
|
class ToolSearchProgressiveDisclosureTest {
|
||||||
|
|
||||||
|
private static final String SESSION_ID = "progressive-disclosure-test-session";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void modelIsOfferedOnlyTheSearchToolUntilItSearches() {
|
||||||
|
List<ToolCallback> library = LargeToolLibrary.buildAll();
|
||||||
|
|
||||||
|
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||||
|
// The tool's real, model-facing name is "toolSearchTool" -- @Tool(name = "toolSearchTool")
|
||||||
|
// on ToolSearchTool.toolSearchTool(...), confirmed by disassembling the class rather than
|
||||||
|
// guessing from the advisor's class name. The advisor's own sessionId parameter is not part
|
||||||
|
// of the schema either: it is read out of ToolContext, not a model-supplied argument.
|
||||||
|
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "toolSearchTool",
|
||||||
|
"{\"query\":\"look up an invoice\"}"))
|
||||||
|
.thenCallTools(new AssistantMessage.ToolCall("call-2", "function", "finance_lookup_invoice",
|
||||||
|
"{\"id\":\"INV-1001\"}"))
|
||||||
|
.thenRespond("Invoice INV-1001 was found in the finance system.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChatClient client = ChatClientFactory.toolSearchClient(model, library, new RegexToolIndex(), 5);
|
||||||
|
|
||||||
|
// The session id the advisor uses to key its ToolIndex lookups comes from the
|
||||||
|
// ChatClientRequest's advisor-param context (AdvisorSpec.param), not from
|
||||||
|
// ChatOptions.getToolContext() / ChatClient.toolContext() -- confirmed by disassembling
|
||||||
|
// ToolSearchToolCallingAdvisor.initializeSession, which reads
|
||||||
|
// ChatClientRequest.context() directly rather than the request's ToolCallingChatOptions.
|
||||||
|
String answer = client.prompt()
|
||||||
|
.user("Can you look up invoice INV-1001 for me?")
|
||||||
|
.advisors(a -> a.param(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY, SESSION_ID))
|
||||||
|
.call()
|
||||||
|
.content();
|
||||||
|
|
||||||
|
List<String> offeredOnFirstCall = toolNamesOfferedTo(model.capturedPrompts().get(0));
|
||||||
|
List<String> offeredAfterSearch = toolNamesOfferedTo(model.capturedPrompts().get(1));
|
||||||
|
|
||||||
|
try (Transcript t = new Transcript("04-tool-search-progressive-disclosure.txt",
|
||||||
|
"ToolSearchToolCallingAdvisor: one tool offered up front instead of the whole library")) {
|
||||||
|
t.line("tools registered on the ChatClient: %d", library.size());
|
||||||
|
t.blank();
|
||||||
|
t.line("tools OFFERED to the model on call 1 (before any search): %d", offeredOnFirstCall.size());
|
||||||
|
t.line(" %s", offeredOnFirstCall);
|
||||||
|
t.blank();
|
||||||
|
t.line("tools OFFERED to the model on call 2 (after it searched for \"look up an invoice\"): %d",
|
||||||
|
offeredAfterSearch.size());
|
||||||
|
t.line(" %s", offeredAfterSearch);
|
||||||
|
t.blank();
|
||||||
|
t.line("final answer: %s", answer);
|
||||||
|
|
||||||
|
assertThat(offeredOnFirstCall).containsExactly("toolSearchTool");
|
||||||
|
assertThat(offeredAfterSearch).contains("finance_lookup_invoice");
|
||||||
|
assertThat(offeredAfterSearch.size())
|
||||||
|
.as("progressive disclosure should still offer far fewer than the full %d-tool library",
|
||||||
|
library.size())
|
||||||
|
.isLessThan(10);
|
||||||
|
assertThat(answer).isEqualTo("Invoice INV-1001 was found in the finance system.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.ankurm.toolcalling.support;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.model.Generation;
|
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hand-written {@link ChatModel} that returns a pre-programmed queue of {@link ChatResponse}s,
|
||||||
|
* one per {@link #call(Prompt)} invocation, instead of calling a real LLM API. {@code ChatModel}
|
||||||
|
* has exactly one abstract method -- confirmed with {@code javap} against
|
||||||
|
* {@code spring-ai-model-2.0.1.jar}, everything else on the interface has a default implementation
|
||||||
|
* -- so this is the entire surface a deterministic test double needs to implement.
|
||||||
|
*
|
||||||
|
* <p>{@link org.springframework.ai.chat.client.advisor.ToolCallingAdvisor ToolCallingAdvisor} (and
|
||||||
|
* its subclass, {@code ToolSearchToolCallingAdvisor}) drive the tool-calling loop by calling the
|
||||||
|
* underlying {@link ChatModel} once per round: once to get the model's first response (which may
|
||||||
|
* contain tool calls), then once more per round of tool results fed back in, until a response
|
||||||
|
* comes back with no tool calls. Queuing responses here lets a test assert the exact shape of that
|
||||||
|
* loop -- how many rounds it took, what tool calls appeared, what the final answer was -- without
|
||||||
|
* an API key, network access, or the nondeterminism of an actual model.
|
||||||
|
*
|
||||||
|
* <p>Every {@link Prompt} the advisor sends is recorded in {@link #capturedPrompts()} so a test can
|
||||||
|
* also assert on what the advisor sent back on the next round -- in particular, that a
|
||||||
|
* {@code ToolResponseMessage} was appended after tool execution.
|
||||||
|
*/
|
||||||
|
public class ScriptedChatModel implements ChatModel {
|
||||||
|
|
||||||
|
private final Queue<ChatResponse> script;
|
||||||
|
private final List<Prompt> capturedPrompts = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
private ScriptedChatModel(Deque<ChatResponse> script) {
|
||||||
|
this.script = script;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatResponse call(Prompt prompt) {
|
||||||
|
this.capturedPrompts.add(prompt);
|
||||||
|
ChatResponse next = this.script.poll();
|
||||||
|
if (next == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"ScriptedChatModel ran out of queued responses after " + this.capturedPrompts.size()
|
||||||
|
+ " calls. Prompts so far: " + this.capturedPrompts);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Prompt> capturedPrompts() {
|
||||||
|
return List.copyOf(this.capturedPrompts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code DefaultChatClientUtils} builds every outgoing {@link Prompt}'s options from
|
||||||
|
* {@code chatModel.getOptions().mutate()} -- not {@code getDefaultOptions()}, which is a
|
||||||
|
* separate default method nobody in the request-building path actually calls. Confirmed by
|
||||||
|
* disassembling both: {@link ChatModel#getOptions()}'s default body is a bare
|
||||||
|
* {@code ChatOptions.builder().build()}, a plain {@link ChatOptions} that is not a
|
||||||
|
* {@link ToolCallingChatOptions}. Since {@code ToolCallingAdvisor.adviseCall} starts with an
|
||||||
|
* {@code instanceof ToolCallingChatOptions} check on that exact object and falls straight
|
||||||
|
* through to the underlying model with no tool loop at all when it fails, leaving this method's
|
||||||
|
* default in place silently turns every tool call in this repository into a no-op -- confirmed
|
||||||
|
* the hard way, by a first version of this class that overrode {@code getDefaultOptions()}
|
||||||
|
* instead and watched every test below get back an empty answer after exactly one model call.
|
||||||
|
* Real providers (OpenAI, Anthropic) return their own {@code ToolCallingChatOptions}
|
||||||
|
* implementation from {@code getOptions()} for the same reason.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChatOptions getOptions() {
|
||||||
|
return ToolCallingChatOptions.builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int callCount() {
|
||||||
|
return this.capturedPrompts.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
|
||||||
|
private final Deque<ChatResponse> script = new ArrayDeque<>();
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues a plain-text final answer with no tool calls -- ends the tool-calling loop. */
|
||||||
|
public Builder thenRespond(String text) {
|
||||||
|
this.script.add(new ChatResponse(List.of(new Generation(new AssistantMessage(text)))));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues an assistant turn that calls one or more tools, continuing the loop. */
|
||||||
|
public Builder thenCallTools(AssistantMessage.ToolCall... toolCalls) {
|
||||||
|
AssistantMessage message = AssistantMessage.builder()
|
||||||
|
.content("")
|
||||||
|
.toolCalls(List.of(toolCalls))
|
||||||
|
.build();
|
||||||
|
this.script.add(new ChatResponse(List.of(new Generation(message))));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScriptedChatModel build() {
|
||||||
|
return new ScriptedChatModel(new ArrayDeque<>(this.script));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ankurm.toolcalling.support;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small shared helpers for reading back what the advisor actually sent to the (scripted) model, so
|
||||||
|
* tests can assert on the wire shape of a request rather than trusting the advisor did the right
|
||||||
|
* thing.
|
||||||
|
*/
|
||||||
|
public final class TestSupport {
|
||||||
|
|
||||||
|
private TestSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of every tool the model was told about on a given call -- read from
|
||||||
|
* {@link ToolCallingChatOptions#getToolCallbacks()} on the {@link Prompt}'s options, not from
|
||||||
|
* anything the advisor logs. This is what makes the progressive-disclosure test a proof rather
|
||||||
|
* than an assertion about behaviour nobody can see: a plain {@code ToolCallingAdvisor} puts
|
||||||
|
* every registered tool here on every call, and {@code ToolSearchToolCallingAdvisor} puts only
|
||||||
|
* {@code tool_search_tool} here until the model has searched for something more specific.
|
||||||
|
*/
|
||||||
|
public static List<String> toolNamesOfferedTo(Prompt prompt) {
|
||||||
|
if (!(prompt.getOptions() instanceof ToolCallingChatOptions toolOptions)) {
|
||||||
|
throw new IllegalStateException("Prompt options are not ToolCallingChatOptions: " + prompt.getOptions());
|
||||||
|
}
|
||||||
|
return toolOptions.getToolCallbacks().stream()
|
||||||
|
.map(callback -> callback.getToolDefinition().name())
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ankurm.toolcalling.support;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a numbered transcript under {@code output/} and echoes it to the console. Every console
|
||||||
|
* block quoted in the article comes out of one of these files verbatim.
|
||||||
|
*/
|
||||||
|
public final class Transcript implements AutoCloseable {
|
||||||
|
|
||||||
|
private final Path path;
|
||||||
|
private final StringWriter buffer = new StringWriter();
|
||||||
|
private final PrintWriter out = new PrintWriter(buffer);
|
||||||
|
|
||||||
|
public Transcript(String fileName, String title) {
|
||||||
|
this.path = Path.of("output", fileName);
|
||||||
|
out.println("# " + title);
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transcript line(String format, Object... args) {
|
||||||
|
out.println(args.length == 0 ? format : String.format(format, args));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transcript blank() {
|
||||||
|
out.println();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
out.flush();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.writeString(path, buffer.toString());
|
||||||
|
}
|
||||||
|
catch (IOException e) {
|
||||||
|
throw new IllegalStateException("could not write " + path, e);
|
||||||
|
}
|
||||||
|
System.out.print(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user