Advancing Tool Support - Part 5

* Introduced new ToolParam annotation for defining a description for tool parameters and marking them as (non)required.
* Improved the JSON Schema generation for tools, solving inconsistencies between methods and functions, and ensuring a predictable outcome.
* Added support for returning tool results directly to the user instead of passing them back to the model. Introduced new ToolExecutionResult API to propagate this information.
* Consolidated naming of tool-related options in ToolCallingChatOptions.
* Fixed varargs issue in ChatClient when passing ToolCallback[].
* Introduced new documentation for the tool calling capabilities in Spring AI, and deprecated the old one.
* Bumped jsonschema dependency to 4.37.0.

Relates to gh-2049

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-02-02 22:04:59 +01:00
committed by Christian Tzolov
parent 854e5458e4
commit 560baaae5a
43 changed files with 1748 additions and 384 deletions

View File

@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.ai.model.tool.LegacyToolCallingManager;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.util.json.JsonParser;
import reactor.core.publisher.Flux;
@@ -271,10 +272,19 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode
if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response != null
&& response.hasToolCalls()) {
var toolCallConversation = this.toolCallingManager.executeToolCalls(prompt, response);
// Recursively call the call method with the tool call message
// conversation that contains the call responses.
return this.internalCall(new Prompt(toolCallConversation, prompt.getOptions()), response);
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the client.
return ChatResponse.builder()
.from(response)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build();
}
else {
// Send the tool execution result back to the model.
return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()),
response);
}
}
return response;
@@ -335,10 +345,17 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode
// @formatter:off
Flux<ChatResponse> chatResponseFlux = chatResponse.flatMap(response -> {
if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response.hasToolCalls()) {
var toolCallConversation = this.toolCallingManager.executeToolCalls(prompt, response);
// Recursively call the stream method with the tool call message
// conversation that contains the call responses.
return this.internalStream(new Prompt(toolCallConversation, prompt.getOptions()), response);
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the client.
return Flux.just(ChatResponse.builder().from(response)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build());
} else {
// Send the tool execution result back to the model.
return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()),
response);
}
}
else {
return Flux.just(response);
@@ -379,13 +396,13 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode
// Merge tool names and tool callbacks explicitly since they are ignored by
// Jackson, used by ModelOptionsUtils.
if (runtimeOptions != null) {
requestOptions.setTools(
ToolCallingChatOptions.mergeToolNames(runtimeOptions.getTools(), this.defaultOptions.getTools()));
requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(),
this.defaultOptions.getToolNames()));
requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(),
this.defaultOptions.getToolCallbacks()));
}
else {
requestOptions.setTools(this.defaultOptions.getTools());
requestOptions.setToolNames(this.defaultOptions.getToolNames());
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
}

View File

@@ -383,7 +383,7 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions {
.mirostatEta(fromOptions.getMirostatEta())
.penalizeNewline(fromOptions.getPenalizeNewline())
.stop(fromOptions.getStop())
.tools(fromOptions.getTools())
.toolNames(fromOptions.getToolNames())
.internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled())
.toolCallbacks(fromOptions.getToolCallbacks())
.toolContext(fromOptions.getToolContext()).build();
@@ -700,13 +700,13 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions {
@Override
@JsonIgnore
public Set<String> getTools() {
public Set<String> getToolNames() {
return this.toolNames;
}
@Override
@JsonIgnore
public void setTools(Set<String> toolNames) {
public void setToolNames(Set<String> toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
Assert.noNullElements(toolNames, "toolNames cannot contain null elements");
toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements"));
@@ -744,14 +744,14 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions {
@Deprecated
@JsonIgnore
public Set<String> getFunctions() {
return this.getTools();
return this.getToolNames();
}
@Override
@Deprecated
@JsonIgnore
public void setFunctions(Set<String> functions) {
this.setTools(functions);
this.setToolNames(functions);
}
@Override
@@ -1028,12 +1028,12 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions {
return this;
}
public Builder tools(Set<String> toolNames) {
this.options.setTools(toolNames);
public Builder toolNames(Set<String> toolNames) {
this.options.setToolNames(toolNames);
return this;
}
public Builder tools(String... toolNames) {
public Builder toolNames(String... toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
this.options.toolNames.addAll(Set.of(toolNames));
return this;
@@ -1051,12 +1051,12 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions {
@Deprecated
public Builder functions(Set<String> functions) {
return tools(functions);
return toolNames(functions);
}
@Deprecated
public Builder function(String functionName) {
return tools(functionName);
return toolNames(functionName);
}
@Deprecated

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -36,7 +36,7 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions;
import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -189,7 +189,7 @@
<com.google.cloud.version>26.48.0</com.google.cloud.version>
<qdrant.version>1.9.1</qdrant.version>
<ibm.sdk.version>9.20.0</ibm.sdk.version>
<jsonschema.version>4.35.0</jsonschema.version>
<jsonschema.version>4.37.0</jsonschema.version>
<swagger-annotations.version>2.2.25</swagger-annotations.version>
<spring-cloud-bindings.version>2.0.3</spring-cloud-bindings.version>

View File

@@ -216,9 +216,9 @@ public interface ChatClient {
ChatClientRequestSpec tools(String... toolNames);
ChatClientRequestSpec tools(Object... toolObjects);
ChatClientRequestSpec tools(FunctionCallback... toolCallbacks);
// ChatClientRequestSpec toolCallbacks(FunctionCallback... toolCallbacks);
ChatClientRequestSpec tools(Object... toolObjects);
@Deprecated
<I, O> ChatClientRequestSpec functions(FunctionCallback... functionCallbacks);
@@ -281,6 +281,8 @@ public interface ChatClient {
Builder defaultTools(String... toolNames);
Builder defaultTools(FunctionCallback... toolCallbacks);
Builder defaultTools(Object... toolObjects);
/**

View File

@@ -846,41 +846,27 @@ public class DefaultChatClient implements ChatClient {
}
@Override
public ChatClientRequestSpec tools(Object... toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
List<FunctionCallback> functionCallbacks = new ArrayList<>();
List<Object> nonFunctinCallbacks = new ArrayList<>();
for (Object toolObject : toolObjects) {
if (toolObject instanceof FunctionCallback) {
functionCallbacks.add((FunctionCallback) toolObject);
}
else {
nonFunctinCallbacks.add(toolObject);
}
}
this.functionCallbacks.addAll(functionCallbacks);
this.functionCallbacks.addAll(Arrays
.asList(ToolCallbacks.from(nonFunctinCallbacks.toArray(new Object[nonFunctinCallbacks.size()]))));
public ChatClientRequestSpec tools(FunctionCallback... toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
this.functionCallbacks.addAll(List.of(toolCallbacks));
return this;
}
// @Override
// public ChatClientRequestSpec toolCallbacks(FunctionCallback... toolCallbacks) {
// Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
// Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null
// elements");
// this.functionCallbacks.addAll(Arrays.asList(toolCallbacks));
// return this;
// }
@Override
public ChatClientRequestSpec tools(Object... toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
this.functionCallbacks.addAll(Arrays.asList(ToolCallbacks.from(toolObjects)));
return this;
}
@Deprecated
@Deprecated // Use tools()
public ChatClientRequestSpec functions(String... functionBeanNames) {
return tools(functionBeanNames);
}
@Deprecated
@Deprecated // Use tools()
public ChatClientRequestSpec functions(FunctionCallback... functionCallbacks) {
Assert.notNull(functionCallbacks, "functionCallbacks cannot be null");
Assert.noNullElements(functionCallbacks, "functionCallbacks cannot contain null elements");

View File

@@ -35,7 +35,6 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -151,7 +150,13 @@ public class DefaultChatClientBuilder implements Builder {
@Override
public Builder defaultTools(String... toolNames) {
this.defaultRequest.functions(toolNames);
this.defaultRequest.tools(toolNames);
return this;
}
@Override
public Builder defaultTools(FunctionCallback... toolCallbacks) {
this.defaultRequest.tools(toolCallbacks);
return this;
}
@@ -161,12 +166,14 @@ public class DefaultChatClientBuilder implements Builder {
return this;
}
@Deprecated // Use defaultTools()
public <I, O> Builder defaultFunction(String name, String description, java.util.function.Function<I, O> function) {
this.defaultRequest
.functions(FunctionCallback.builder().function(name, function).description(description).build());
return this;
}
@Deprecated // Use defaultTools()
public <I, O> Builder defaultFunction(String name, String description,
java.util.function.BiFunction<I, ToolContext, O> biFunction) {
this.defaultRequest
@@ -174,11 +181,13 @@ public class DefaultChatClientBuilder implements Builder {
return this;
}
@Deprecated // Use defaultTools()
public Builder defaultFunctions(String... functionNames) {
this.defaultRequest.functions(functionNames);
return this;
}
@Deprecated // Use defaultTools()
public Builder defaultFunctions(FunctionCallback... functionCallbacks) {
this.defaultRequest.functions(functionCallbacks);
return this;

View File

@@ -39,7 +39,7 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
private List<FunctionCallback> toolCallbacks = new ArrayList<>();
private Set<String> tools = new HashSet<>();
private Set<String> toolNames = new HashSet<>();
private Map<String, Object> toolContext = new HashMap<>();
@@ -83,16 +83,16 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
}
@Override
public Set<String> getTools() {
return Set.copyOf(this.tools);
public Set<String> getToolNames() {
return Set.copyOf(this.toolNames);
}
@Override
public void setTools(Set<String> tools) {
Assert.notNull(tools, "tools cannot be null");
Assert.noNullElements(tools, "tools cannot contain null elements");
tools.forEach(tool -> Assert.hasText(tool, "tools cannot contain empty elements"));
this.tools = new HashSet<>(tools);
public void setToolNames(Set<String> toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
Assert.noNullElements(toolNames, "toolNames cannot contain null elements");
toolNames.forEach(toolName -> Assert.hasText(toolName, "toolNames cannot contain empty elements"));
this.toolNames = new HashSet<>(toolNames);
}
@Override
@@ -130,12 +130,12 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
@Override
public Set<String> getFunctions() {
return getTools();
return getToolNames();
}
@Override
public void setFunctions(Set<String> functions) {
setTools(functions);
setToolNames(functions);
}
@Override
@@ -234,7 +234,7 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
public <T extends ChatOptions> T copy() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setToolCallbacks(getToolCallbacks());
options.setTools(getTools());
options.setToolNames(getToolNames());
options.setToolContext(getToolContext());
options.setInternalToolExecutionEnabled(isInternalToolExecutionEnabled());
options.setModel(getModel());
@@ -273,15 +273,15 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
}
@Override
public ToolCallingChatOptions.Builder tools(Set<String> toolNames) {
this.options.setTools(toolNames);
public ToolCallingChatOptions.Builder toolNames(Set<String> toolNames) {
this.options.setToolNames(toolNames);
return this;
}
@Override
public ToolCallingChatOptions.Builder tools(String... toolNames) {
public ToolCallingChatOptions.Builder toolNames(String... toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
this.options.setTools(Set.of(toolNames));
this.options.setToolNames(Set.of(toolNames));
return this;
}
@@ -322,15 +322,15 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
}
@Override
@Deprecated // Use tools() instead
@Deprecated // Use toolNames() instead
public ToolCallingChatOptions.Builder functions(Set<String> functions) {
return tools(functions);
return toolNames(functions);
}
@Override
@Deprecated // Use tools() instead
@Deprecated // Use toolNames() instead
public ToolCallingChatOptions.Builder function(String function) {
return tools(function);
return toolNames(function);
}
@Override

View File

@@ -89,7 +89,7 @@ public class DefaultToolCallingManager implements ToolCallingManager {
Assert.notNull(chatOptions, "chatOptions cannot be null");
List<FunctionCallback> toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks());
for (String toolName : chatOptions.getTools()) {
for (String toolName : chatOptions.getToolNames()) {
FunctionCallback toolCallback = toolCallbackResolver.resolve(toolName);
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
@@ -112,7 +112,7 @@ public class DefaultToolCallingManager implements ToolCallingManager {
}
@Override
public List<Message> executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
Assert.notNull(prompt, "prompt cannot be null");
Assert.notNull(chatResponse, "chatResponse cannot be null");
@@ -129,10 +129,16 @@ public class DefaultToolCallingManager implements ToolCallingManager {
ToolContext toolContext = buildToolContext(prompt, assistantMessage);
ToolResponseMessage toolMessageResponse = executeToolCall(prompt, assistantMessage, toolContext);
InternalToolExecutionResult internalToolExecutionResult = executeToolCall(prompt, assistantMessage,
toolContext);
return buildConversationHistoryAfterToolExecution(prompt.getInstructions(), assistantMessage,
toolMessageResponse);
List<Message> conversationHistory = buildConversationHistoryAfterToolExecution(prompt.getInstructions(),
assistantMessage, internalToolExecutionResult.toolResponseMessage());
return ToolExecutionResult.builder()
.conversationHistory(conversationHistory)
.returnDirect(internalToolExecutionResult.returnDirect())
.build();
}
private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) {
@@ -166,7 +172,7 @@ public class DefaultToolCallingManager implements ToolCallingManager {
* compatibility, both {@link ToolCallback} and {@link FunctionCallback} are
* supported.
*/
private ToolResponseMessage executeToolCall(Prompt prompt, AssistantMessage assistantMessage,
private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMessage assistantMessage,
ToolContext toolContext) {
List<FunctionCallback> toolCallbacks = List.of();
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) {
@@ -178,6 +184,8 @@ public class DefaultToolCallingManager implements ToolCallingManager {
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
Boolean returnDirect = null;
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
logger.debug("Executing tool call: {}", toolCall.name());
@@ -194,6 +202,13 @@ public class DefaultToolCallingManager implements ToolCallingManager {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}
if (returnDirect == null && toolCallback instanceof ToolCallback callback) {
returnDirect = callback.getToolMetadata().returnDirect();
}
else if (toolCallback instanceof ToolCallback callback) {
returnDirect = returnDirect && callback.getToolMetadata().returnDirect();
}
String toolResult;
try {
toolResult = toolCallback.call(toolInputArguments, toolContext);
@@ -205,7 +220,7 @@ public class DefaultToolCallingManager implements ToolCallingManager {
toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, toolResult));
}
return new ToolResponseMessage(toolResponses, Map.of());
return new InternalToolExecutionResult(new ToolResponseMessage(toolResponses, Map.of()), returnDirect);
}
private List<Message> buildConversationHistoryAfterToolExecution(List<Message> previousMessages,
@@ -216,6 +231,9 @@ public class DefaultToolCallingManager implements ToolCallingManager {
return messages;
}
private record InternalToolExecutionResult(ToolResponseMessage toolResponseMessage, boolean returnDirect) {
}
public static Builder builder() {
return new Builder();
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.springframework.ai.chat.messages.Message;
import org.springframework.util.Assert;
import java.util.List;
/**
* Default implementation of {@link ToolExecutionResult}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public record DefaultToolExecutionResult(List<Message> conversationHistory,
boolean returnDirect) implements ToolExecutionResult {
public DefaultToolExecutionResult {
Assert.notNull(conversationHistory, "conversationHistory cannot be null");
Assert.noNullElements(conversationHistory, "conversationHistory cannot contain null elements");
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private List<Message> conversationHistory = List.of();
private boolean returnDirect;
private Builder() {
}
public Builder conversationHistory(List<Message> conversationHistory) {
this.conversationHistory = conversationHistory;
return this;
}
public Builder returnDirect(boolean returnDirect) {
this.returnDirect = returnDirect;
return this;
}
public DefaultToolExecutionResult build() {
return new DefaultToolExecutionResult(conversationHistory, returnDirect);
}
}
}

View File

@@ -75,7 +75,7 @@ public class LegacyToolCallingManager implements ToolCallingManager {
Assert.notNull(chatOptions, "chatOptions cannot be null");
List<FunctionCallback> toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks());
for (String toolName : chatOptions.getTools()) {
for (String toolName : chatOptions.getToolNames()) {
FunctionCallback toolCallback = resolveFunctionCallback(toolName);
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
@@ -107,7 +107,7 @@ public class LegacyToolCallingManager implements ToolCallingManager {
}
@Override
public List<Message> executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
Assert.notNull(prompt, "prompt cannot be null");
Assert.notNull(chatResponse, "chatResponse cannot be null");
@@ -126,8 +126,10 @@ public class LegacyToolCallingManager implements ToolCallingManager {
ToolResponseMessage toolMessageResponse = executeToolCall(prompt, assistantMessage, toolContext);
return buildConversationHistoryAfterToolExecution(prompt.getInstructions(), assistantMessage,
toolMessageResponse);
List<Message> conversationHistory = buildConversationHistoryAfterToolExecution(prompt.getInstructions(),
assistantMessage, toolMessageResponse);
return ToolExecutionResult.builder().conversationHistory(conversationHistory).returnDirect(false).build();
}
private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) {

View File

@@ -53,12 +53,12 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
/**
* Names of the tools to register with the ChatModel.
*/
Set<String> getTools();
Set<String> getToolNames();
/**
* Set the names of the tools to register with the ChatModel.
*/
void setTools(Set<String> toolNames);
void setToolNames(Set<String> toolNames);
/**
* Whether the {@link ChatModel} is responsible for executing the tools requested by
@@ -98,12 +98,12 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
/**
* Names of the tools to register with the ChatModel.
*/
Builder tools(Set<String> toolNames);
Builder toolNames(Set<String> toolNames);
/**
* Names of the tools to register with the ChatModel.
*/
Builder tools(String... toolNames);
Builder toolNames(String... toolNames);
/**
* Whether the {@link ChatModel} is responsible for executing the tools requested

View File

@@ -16,7 +16,6 @@
package org.springframework.ai.model.tool;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -39,7 +38,7 @@ public interface ToolCallingManager {
/**
* Execute the tool calls requested by the model.
*/
List<Message> executeToolCalls(Prompt prompt, ChatResponse chatResponse);
ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse);
/**
* Create a default {@link ToolCallingManager} builder.

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
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.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.Generation;
import java.util.ArrayList;
import java.util.List;
/**
* The result of a tool execution.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolExecutionResult {
String FINISH_REASON = "returnDirect";
String METADATA_TOOL_ID = "toolId";
String METADATA_TOOL_NAME = "toolName";
/**
* The history of messages exchanged during the conversation, including the tool
* execution result.
*/
List<Message> conversationHistory();
/**
* Whether the tool execution result should be returned directly or passed back to the
* model.
*/
default boolean returnDirect() {
return false;
}
/**
* Create a default {@link ToolExecutionResult} builder.
*/
static DefaultToolExecutionResult.Builder builder() {
return DefaultToolExecutionResult.builder();
}
/**
* Build a list of {@link Generation} from the tool execution result, useful for
* sending the tool execution result to the client directly.
*/
static List<Generation> buildGenerations(ToolExecutionResult toolExecutionResult) {
List<Message> conversationHistory = toolExecutionResult.conversationHistory();
List<Generation> generations = new ArrayList<>();
if (conversationHistory
.get(conversationHistory.size() - 1) instanceof ToolResponseMessage toolResponseMessage) {
toolResponseMessage.getResponses().forEach(response -> {
AssistantMessage assistantMessage = new AssistantMessage(response.responseData());
Generation generation = new Generation(assistantMessage,
ChatGenerationMetadata.builder()
.metadata(METADATA_TOOL_ID, response.id())
.metadata(METADATA_TOOL_NAME, response.name())
.finishReason(FINISH_REASON)
.build());
generations.add(generation);
});
}
return generations;
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.tool.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a tool argument.
*
* @author Thomas Vitale
* @since 1.0.0
*/
@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolParam {
/**
* Whether the tool argument is required.
*/
boolean required() default true;
/**
* The description of the tool argument.
*/
String description() default "";
}

View File

@@ -17,7 +17,7 @@
package org.springframework.ai.tool.definition;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.util.Assert;
import java.lang.reflect.Method;

View File

@@ -33,7 +33,7 @@ import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

View File

@@ -26,8 +26,8 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.SchemaType;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.ai.util.json.schema.SchemaType;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Description;
import org.springframework.context.support.GenericApplicationContext;

View File

@@ -1,192 +0,0 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.util.json;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Option;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import org.springframework.util.Assert;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* Utilities to generate JSON Schemas from Java entities.
*/
public final class JsonSchemaGenerator {
private static final SchemaGenerator TYPE_SCHEMA_GENERATOR;
private static final SchemaGenerator SUBTYPE_SCHEMA_GENERATOR;
/*
* Initialize JSON Schema generators.
*/
static {
var schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12,
OptionPreset.PLAIN_JSON)
.with(new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED))
.with(new Swagger2Module())
.with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
.with(Option.PLAIN_DEFINITION_KEYS);
var typeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.without(Option.SCHEMA_VERSION_INDICATOR).build();
TYPE_SCHEMA_GENERATOR = new SchemaGenerator(typeSchemaGeneratorConfig);
var subtypeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.build();
SUBTYPE_SCHEMA_GENERATOR = new SchemaGenerator(subtypeSchemaGeneratorConfig);
}
private JsonSchemaGenerator() {
}
/**
* Generate a JSON Schema for a method's input parameters.
*/
public static String generateForMethodInput(Method method, SchemaOption... schemaOptions) {
ObjectNode schema = JsonParser.getObjectMapper().createObjectNode();
schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
schema.put("type", "object");
ObjectNode properties = schema.putObject("properties");
List<String> required = new ArrayList<>();
for (int i = 0; i < method.getParameterCount(); i++) {
var parameterName = method.getParameters()[i].getName();
var parameterType = method.getGenericParameterTypes()[i];
if (isMethodParameterRequired(method, i)) {
required.add(parameterName);
}
properties.set(parameterName, SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType));
}
var requiredArray = schema.putArray("required");
if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.RESPECT_JSON_PROPERTY_REQUIRED)) {
required.forEach(requiredArray::add);
}
else {
Stream.of(method.getParameters()).map(Parameter::getName).forEach(requiredArray::add);
}
if (Stream.of(schemaOptions)
.noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) {
schema.put("additionalProperties", false);
}
if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) {
convertTypeValuesToUpperCase(schema);
}
return schema.toPrettyString();
}
/**
* Generate a JSON Schema for a class type.
*/
public static String generateForType(Type type, SchemaOption... schemaOptions) {
Assert.notNull(type, "type cannot be null");
ObjectNode schema = TYPE_SCHEMA_GENERATOR.generateSchema(type);
if ((type == Void.class) && !schema.has("properties")) {
schema.putObject("properties");
}
if (Stream.of(schemaOptions)
.noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) {
schema.put("additionalProperties", false);
}
if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) {
convertTypeValuesToUpperCase(schema);
}
return schema.toPrettyString();
}
private static boolean isMethodParameterRequired(Method method, int index) {
var jsonPropertyAnnotation = method.getParameters()[index].getAnnotation(JsonProperty.class);
if (jsonPropertyAnnotation == null) {
return false;
}
return jsonPropertyAnnotation.required();
}
// Based on the method in ModelOptionsUtils.
private static void convertTypeValuesToUpperCase(ObjectNode node) {
if (node.isObject()) {
node.fields().forEachRemaining(entry -> {
JsonNode value = entry.getValue();
if (value.isObject()) {
convertTypeValuesToUpperCase((ObjectNode) value);
}
else if (value.isArray()) {
value.elements().forEachRemaining(element -> {
if (element.isObject() || element.isArray()) {
convertTypeValuesToUpperCase((ObjectNode) element);
}
});
}
else if (value.isTextual() && entry.getKey().equals("type")) {
String oldValue = node.get("type").asText();
node.put("type", oldValue.toUpperCase());
}
});
}
else if (node.isArray()) {
node.elements().forEachRemaining(element -> {
if (element.isObject() || element.isArray()) {
convertTypeValuesToUpperCase((ObjectNode) element);
}
});
}
}
/**
* Options for generating JSON Schemas.
*/
public enum SchemaOption {
/**
* Properties are only required if marked as such via the Jackson annotation
* "@JsonProperty(required = true)". Beware, that OpenAI requires all properties
* to be required.
*/
RESPECT_JSON_PROPERTY_REQUIRED,
/**
* Allow additional properties by default. Beware, that OpenAI requires additional
* properties NOT to be allowed.
*/
ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT,
/**
* Convert all "type" values to upper case. For example, it's require in OpenAPI
* 3.0 with Vertex AI.
*/
UPPER_CASE_TYPE_VALUES;
}
}

View File

@@ -0,0 +1,268 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.util.json.schema;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.Option;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* Utilities to generate JSON Schemas from Java types and method signatures. It's designed
* to work well in the context of tool calling and structured outputs, aiming at ensuring
* consistency and robustness across different model providers.
* <p>
* Metadata such as descriptions and required properties can be specified using one of the
* following supported annotations:
* <p>
* <ul>
* <li>{@code @ToolParam(required = ..., description = ...)}</li>
* <li>{@code @JsonProperty(required = ...)}</li>
* <li>{@code @Schema(required = ..., description = ...)}</li>
* <li>{@code @Nullable}</li>
* </ul>
* <p>
* If none of these annotations are present, the default behavior is to consider the
* property as required and not to include a description.
* <p>
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class JsonSchemaGenerator {
/**
* To ensure consistency and robustness across different model providers, all
* properties in the JSON Schema are considered required by default. This behavior can
* be overridden by setting the {@link ToolParam#required()},
* {@link JsonProperty#required()}, or {@link Schema#requiredMode()}} annotation.
*/
private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true;
private static final SchemaGenerator TYPE_SCHEMA_GENERATOR;
private static final SchemaGenerator SUBTYPE_SCHEMA_GENERATOR;
/*
* Initialize JSON Schema generators.
*/
static {
Module jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
Module openApiModule = new Swagger2Module();
Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule()
: new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
SchemaGeneratorConfigBuilder schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
.with(jacksonModule)
.with(openApiModule)
.with(springAiSchemaModule)
.with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
.with(Option.PLAIN_DEFINITION_KEYS);
SchemaGeneratorConfig typeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.build();
TYPE_SCHEMA_GENERATOR = new SchemaGenerator(typeSchemaGeneratorConfig);
SchemaGeneratorConfig subtypeSchemaGeneratorConfig = schemaGeneratorConfigBuilder
.without(Option.SCHEMA_VERSION_INDICATOR)
.build();
SUBTYPE_SCHEMA_GENERATOR = new SchemaGenerator(subtypeSchemaGeneratorConfig);
}
private JsonSchemaGenerator() {
}
/**
* Generate a JSON Schema for a method's input parameters.
*/
public static String generateForMethodInput(Method method, SchemaOption... schemaOptions) {
ObjectNode schema = JsonParser.getObjectMapper().createObjectNode();
schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
schema.put("type", "object");
ObjectNode properties = schema.putObject("properties");
List<String> required = new ArrayList<>();
for (int i = 0; i < method.getParameterCount(); i++) {
String parameterName = method.getParameters()[i].getName();
Type parameterType = method.getGenericParameterTypes()[i];
if (isMethodParameterRequired(method, i)) {
required.add(parameterName);
}
ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
String parameterDescription = getMethodParameterDescription(method, i);
if (StringUtils.hasText(parameterDescription)) {
parameterNode.put("description", parameterDescription);
}
properties.set(parameterName, parameterNode);
}
var requiredArray = schema.putArray("required");
required.forEach(requiredArray::add);
processSchemaOptions(schemaOptions, schema);
return schema.toPrettyString();
}
/**
* Generate a JSON Schema for a class type.
*/
public static String generateForType(Type type, SchemaOption... schemaOptions) {
Assert.notNull(type, "type cannot be null");
ObjectNode schema = TYPE_SCHEMA_GENERATOR.generateSchema(type);
if ((type == Void.class) && !schema.has("properties")) {
schema.putObject("properties");
}
processSchemaOptions(schemaOptions, schema);
return schema.toPrettyString();
}
private static void processSchemaOptions(SchemaOption[] schemaOptions, ObjectNode schema) {
if (Stream.of(schemaOptions)
.noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) {
schema.put("additionalProperties", false);
}
if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) {
convertTypeValuesToUpperCase(schema);
}
}
/**
* Determines whether a property is required based on the presence of a series of
* annotations.
* <p>
* - {@code @ToolParam(required = ...)} - {@code @JsonProperty(required = ...)} -
* {@code @Schema(required = ...)}
* <p>
* If none of these annotations are present, the default behavior is to consider the
* property as required.
*/
private static boolean isMethodParameterRequired(Method method, int index) {
Parameter parameter = method.getParameters()[index];
var toolParamAnnotation = parameter.getAnnotation(ToolParam.class);
if (toolParamAnnotation != null) {
return toolParamAnnotation.required();
}
var propertyAnnotation = parameter.getAnnotation(JsonProperty.class);
if (propertyAnnotation != null) {
return propertyAnnotation.required();
}
var schemaAnnotation = parameter.getAnnotation(Schema.class);
if (schemaAnnotation != null) {
return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
|| schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required();
}
var nullableAnnotation = parameter.getAnnotation(Nullable.class);
if (nullableAnnotation != null) {
return false;
}
return PROPERTY_REQUIRED_BY_DEFAULT;
}
@Nullable
private static String getMethodParameterDescription(Method method, int index) {
Parameter parameter = method.getParameters()[index];
var toolParamAnnotation = parameter.getAnnotation(ToolParam.class);
if (toolParamAnnotation != null && StringUtils.hasText(toolParamAnnotation.description())) {
return toolParamAnnotation.description();
}
var schemaAnnotation = parameter.getAnnotation(Schema.class);
if (schemaAnnotation != null && StringUtils.hasText(schemaAnnotation.description())) {
return schemaAnnotation.description();
}
return null;
}
// Based on the method in ModelOptionsUtils.
private static void convertTypeValuesToUpperCase(ObjectNode node) {
if (node.isObject()) {
node.fields().forEachRemaining(entry -> {
JsonNode value = entry.getValue();
if (value.isObject()) {
convertTypeValuesToUpperCase((ObjectNode) value);
}
else if (value.isArray()) {
value.elements().forEachRemaining(element -> {
if (element.isObject() || element.isArray()) {
convertTypeValuesToUpperCase((ObjectNode) element);
}
});
}
else if (value.isTextual() && entry.getKey().equals("type")) {
String oldValue = node.get("type").asText();
node.put("type", oldValue.toUpperCase());
}
});
}
else if (node.isArray()) {
node.elements().forEachRemaining(element -> {
if (element.isObject() || element.isArray()) {
convertTypeValuesToUpperCase((ObjectNode) element);
}
});
}
}
/**
* Options for generating JSON Schemas.
*/
public enum SchemaOption {
/**
* Allow an object to contain additional key/values not defined in the schema.
*/
ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT,
/**
* Convert all "type" values to upper case.
*/
UPPER_CASE_TYPE_VALUES;
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.util.json;
package org.springframework.ai.util.json.schema;
/**
* The type of schema to generate for a given Java type.

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.util.json.schema;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.MemberScope;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigPart;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
import java.util.stream.Stream;
/**
* JSON Schema Generator Module for Spring AI.
* <p>
* This module provides a set of customizations to the JSON Schema generator to support
* the Spring AI framework. It allows to extract descriptions from
* {@code @ToolParam(description = ...)} annotations and to determine whether a property
* is required based on the presence of a series of annotations.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class SpringAiSchemaModule implements Module {
private final boolean requiredByDefault;
public SpringAiSchemaModule(Option... options) {
this.requiredByDefault = Stream.of(options)
.noneMatch(option -> option == Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
}
@Override
public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
this.applyToConfigBuilder(builder.forFields());
}
private void applyToConfigBuilder(SchemaGeneratorConfigPart<FieldScope> configPart) {
configPart.withDescriptionResolver(this::resolveDescription);
configPart.withRequiredCheck(this::checkRequired);
}
/**
* Extract description from {@code @ToolParam(description = ...)} for the given field.
*/
@Nullable
private String resolveDescription(MemberScope<?, ?> member) {
var toolParamAnnotation = member.getAnnotationConsideringFieldAndGetter(ToolParam.class);
if (toolParamAnnotation != null && StringUtils.hasText(toolParamAnnotation.description())) {
return toolParamAnnotation.description();
}
return null;
}
/**
* Determines whether a property is required based on the presence of a series of
* annotations.
* <p>
* <ul>
* <li>{@code @ToolParam(required = ...)}</li>
* <li>{@code @JsonProperty(required = ...)}</li>
* <li>{@code @Schema(required = ...)}</li>
* <li>{@code @Nullable}</li>
* </ul>
* <p>
* If none of these annotations are present, the default behavior is to consider the
* property as required, unless the {@link Option#PROPERTY_REQUIRED_FALSE_BY_DEFAULT}
* option is set.
*/
private boolean checkRequired(MemberScope<?, ?> member) {
var toolParamAnnotation = member.getAnnotationConsideringFieldAndGetter(ToolParam.class);
if (toolParamAnnotation != null) {
return toolParamAnnotation.required();
}
var propertyAnnotation = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class);
if (propertyAnnotation != null) {
return propertyAnnotation.required();
}
var schemaAnnotation = member.getAnnotationConsideringFieldAndGetter(Schema.class);
if (schemaAnnotation != null) {
return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
|| schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required();
}
var nullableAnnotation = member.getAnnotationConsideringFieldAndGetter(Nullable.class);
if (nullableAnnotation != null) {
return false;
}
return this.requiredByDefault;
}
/**
* Options for customizing the behavior of the module.
*/
public enum Option {
/**
* Properties are only required if marked as such via one of the supported
* annotations.
*/
PROPERTY_REQUIRED_FALSE_BY_DEFAULT;
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.util.json.schema;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -1375,7 +1375,7 @@ class DefaultChatClientTests {
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
assertThatThrownBy(() -> spec.tools(mock(ToolCallback.class), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolObjects cannot contain null elements");
.hasMessage("toolCallbacks cannot contain null elements");
}
@Test

View File

@@ -68,50 +68,50 @@ class DefaultToolCallingChatOptionsTests {
}
@Test
void setToolsShouldStoreTools() {
void setToolNamesShouldStoreToolNames() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = Set.of("tool1", "tool2");
Set<String> toolNames = Set.of("tool1", "tool2");
options.setTools(tools);
options.setToolNames(toolNames);
assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrderElementsOf(tools);
assertThat(options.getToolNames()).hasSize(2).containsExactlyInAnyOrderElementsOf(toolNames);
}
@Test
void setToolsWithVarargsShouldStoreTools() {
void setToolNamesWithVarargsShouldStoreToolNames() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setTools(Set.of("tool1", "tool2"));
options.setToolNames(Set.of("tool1", "tool2"));
assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2");
assertThat(options.getToolNames()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2");
}
@Test
void setToolsShouldRejectNullSet() {
void setToolNamesShouldRejectNullSet() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThatThrownBy(() -> options.setTools((Set<String>) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot be null");
assertThatThrownBy(() -> options.setToolNames(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolNames cannot be null");
}
@Test
void setToolsShouldRejectNullElements() {
void setToolNamesShouldRejectNullElements() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = new HashSet<>();
tools.add(null);
Set<String> toolNames = new HashSet<>();
toolNames.add(null);
assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot contain null elements");
assertThatThrownBy(() -> options.setToolNames(toolNames)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolNames cannot contain null elements");
}
@Test
void setToolsShouldRejectEmptyElements() {
void setToolNamesShouldRejectEmptyElements() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = new HashSet<>();
tools.add("");
Set<String> toolNames = new HashSet<>();
toolNames.add("");
assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot contain empty elements");
assertThatThrownBy(() -> options.setToolNames(toolNames)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolNames cannot contain empty elements");
}
@Test
@@ -137,7 +137,7 @@ class DefaultToolCallingChatOptionsTests {
DefaultToolCallingChatOptions original = new DefaultToolCallingChatOptions();
ToolCallback callback = mock(ToolCallback.class);
original.setToolCallbacks(List.of(callback));
original.setTools(Set.of("tool1"));
original.setToolNames(Set.of("tool1"));
original.setToolContext(Map.of("key", "value"));
original.setInternalToolExecutionEnabled(true);
original.setModel("gpt-4");
@@ -147,7 +147,7 @@ class DefaultToolCallingChatOptionsTests {
assertThat(copy).isNotSameAs(original).satisfies(c -> {
assertThat(c.getToolCallbacks()).isEqualTo(original.getToolCallbacks());
assertThat(c.getTools()).isEqualTo(original.getTools());
assertThat(c.getToolNames()).isEqualTo(original.getToolNames());
assertThat(c.getToolContext()).isEqualTo(original.getToolContext());
assertThat(c.isInternalToolExecutionEnabled()).isEqualTo(original.isInternalToolExecutionEnabled());
assertThat(c.getModel()).isEqualTo(original.getModel());
@@ -160,12 +160,12 @@ class DefaultToolCallingChatOptionsTests {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
ToolCallback callback = mock(ToolCallback.class);
options.setToolCallbacks(List.of(callback));
options.setTools(Set.of("tool1"));
options.setToolNames(Set.of("tool1"));
options.setToolContext(Map.of("key", "value"));
assertThatThrownBy(() -> options.getToolCallbacks().add(mock(ToolCallback.class)))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> options.getTools().add("tool2")).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> options.getToolNames().add("tool2")).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> options.getToolContext().put("key2", "value2"))
.isInstanceOf(UnsupportedOperationException.class);
}
@@ -177,7 +177,7 @@ class DefaultToolCallingChatOptionsTests {
ToolCallingChatOptions options = DefaultToolCallingChatOptions.builder()
.toolCallbacks(List.of(callback))
.tools(Set.of("tool1"))
.toolNames(Set.of("tool1"))
.toolContext(context)
.internalToolExecutionEnabled(true)
.model("gpt-4")
@@ -192,7 +192,7 @@ class DefaultToolCallingChatOptionsTests {
assertThat(options).satisfies(o -> {
assertThat(o.getToolCallbacks()).containsExactly(callback);
assertThat(o.getTools()).containsExactly("tool1");
assertThat(o.getToolNames()).containsExactly("tool1");
assertThat(o.getToolContext()).isEqualTo(context);
assertThat(o.isInternalToolExecutionEnabled()).isTrue();
assertThat(o.getModel()).isEqualTo("gpt-4");
@@ -225,11 +225,11 @@ class DefaultToolCallingChatOptionsTests {
options.setFunctionCallbacks(List.of(callback1, callback2));
assertThat(options.getFunctionCallbacks()).hasSize(2);
options.setTools(Set.of("tool1"));
options.setToolNames(Set.of("tool1"));
assertThat(options.getFunctions()).containsExactly("tool1");
options.setFunctions(Set.of("function1"));
assertThat(options.getTools()).containsExactly("function1");
assertThat(options.getToolNames()).containsExactly("function1");
options.setInternalToolExecutionEnabled(true);
assertThat(options.getProxyToolCalls()).isFalse();

View File

@@ -19,7 +19,6 @@ package org.springframework.ai.model.tool;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
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.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
@@ -29,6 +28,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolCallExceptionConverter;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
@@ -101,7 +101,7 @@ class DefaultToolCallingManagerTests {
.build();
List<ToolDefinition> toolDefinitions = toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build());
.resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolA").build());
assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition());
}
@@ -114,7 +114,7 @@ class DefaultToolCallingManagerTests {
.build();
assertThatThrownBy(() -> toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolB").build()))
.resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolB").build()))
.isInstanceOf(IllegalStateException.class)
.hasMessage("No ToolCallback found for tool name: toolB");
}
@@ -163,9 +163,32 @@ class DefaultToolCallingManagerTests {
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
}
@Test
void whenSingleToolCallWithReturnDirectInChatResponseThenExecute() {
ToolCallback toolCallback = new TestToolCallback("toolA", true);
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!")));
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isTrue();
}
@Test
@@ -189,9 +212,63 @@ class DefaultToolCallingManagerTests {
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"),
new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
}
@Test
void whenMultipleToolCallsWithReturnDirectInChatResponseThenExecute() {
ToolCallback toolCallbackA = new TestToolCallback("toolA", true);
ToolCallback toolCallbackB = new TestToolCallback("toolB", true);
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(
List.of(toolCallbackA, toolCallbackB));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"),
new AssistantMessage.ToolCall("toolB", "function", "toolB", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"),
new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!")));
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isTrue();
}
@Test
void whenMultipleToolCallsWithMixedReturnDirectInChatResponseThenExecute() {
ToolCallback toolCallbackA = new TestToolCallback("toolA", true);
ToolCallback toolCallbackB = new TestToolCallback("toolB", false);
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(
List.of(toolCallbackA, toolCallbackB));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"),
new AssistantMessage.ToolCall("toolB", "function", "toolB", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"),
new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!")));
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isFalse();
}
@Test
@@ -211,17 +288,25 @@ class DefaultToolCallingManagerTests {
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
}
static class TestToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
private final ToolMetadata toolMetadata;
public TestToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
this.toolMetadata = ToolMetadata.builder().build();
}
public TestToolCallback(String name, boolean returnDirect) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
this.toolMetadata = ToolMetadata.builder().returnDirect(returnDirect).build();
}
@Override
@@ -229,6 +314,11 @@ class DefaultToolCallingManagerTests {
return toolDefinition;
}
@Override
public ToolMetadata getToolMetadata() {
return toolMetadata;
}
@Override
public String call(String toolInput) {
return "Mission accomplished!";

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import java.util.ArrayList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DefaultToolExecutionResult}.
*
* @author Thomas Vitale
*/
class DefaultToolExecutionResultTests {
@Test
void whenConversationHistoryIsNullThenThrow() {
assertThatThrownBy(() -> DefaultToolExecutionResult.builder().conversationHistory(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("conversationHistory cannot be null");
}
@Test
void whenConversationHistoryHasNullElementsThenThrow() {
var history = new ArrayList<Message>();
history.add(null);
assertThatThrownBy(() -> DefaultToolExecutionResult.builder().conversationHistory(history).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("conversationHistory cannot contain null elements");
}
@Test
void builder() {
var conversationHistory = new ArrayList<Message>();
var result = DefaultToolExecutionResult.builder()
.conversationHistory(conversationHistory)
.returnDirect(true)
.build();
assertThat(result.conversationHistory()).isEqualTo(conversationHistory);
assertThat(result.returnDirect()).isTrue();
}
}

View File

@@ -18,7 +18,6 @@ package org.springframework.ai.model.tool;
import org.junit.jupiter.api.Test;
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.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
@@ -60,7 +59,7 @@ class LegacyToolCallingManagerTests {
.build();
List<ToolDefinition> toolDefinitions = toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build());
.resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolA").build());
assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition());
}
@@ -70,7 +69,7 @@ class LegacyToolCallingManagerTests {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().functionCallbacks(List.of()).build();
assertThatThrownBy(() -> toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolB").build()))
.resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolB").build()))
.isInstanceOf(IllegalStateException.class)
.hasMessage("No ToolCallback found for tool name: toolB");
}
@@ -118,9 +117,10 @@ class LegacyToolCallingManagerTests {
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isFalse();
}
@Test
@@ -142,9 +142,10 @@ class LegacyToolCallingManagerTests {
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"),
new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isFalse();
}
@Test
@@ -163,9 +164,10 @@ class LegacyToolCallingManagerTests {
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse);
assertThat(toolExecutionResult.returnDirect()).isFalse();
}
static class TestToolCallback implements ToolCallback {

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ToolExecutionResult}.
*
* @author Thomas Vitale
*/
class ToolExecutionResultTests {
@Test
void whenSingleToolCallThenSingleGeneration() {
var toolExecutionResult = ToolExecutionResult.builder()
.conversationHistory(List.of(new AssistantMessage("Hello, how can I help you?"),
new UserMessage("I would like to know the weather in London"),
new AssistantMessage("Call the weather tool"),
new ToolResponseMessage(List.of(new ToolResponseMessage.ToolResponse("42", "weather",
"The weather in London is 20 degrees Celsius")))))
.build();
var generations = ToolExecutionResult.buildGenerations(toolExecutionResult);
assertThat(generations).hasSize(1);
assertThat(generations.get(0).getOutput().getText()).isEqualTo("The weather in London is 20 degrees Celsius");
assertThat((String) generations.get(0).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME))
.isEqualTo("weather");
assertThat(generations.get(0).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON);
}
@Test
void whenMultipleToolCallsThenMultipleGenerations() {
var toolExecutionResult = ToolExecutionResult.builder()
.conversationHistory(List.of(new AssistantMessage("Hello, how can I help you?"),
new UserMessage("I would like to know the weather in London"),
new AssistantMessage("Call the weather tool and the news tool"),
new ToolResponseMessage(List.of(
new ToolResponseMessage.ToolResponse("42", "weather",
"The weather in London is 20 degrees Celsius"),
new ToolResponseMessage.ToolResponse("21", "news",
"There is heavy traffic in the centre of London")))))
.build();
var generations = ToolExecutionResult.buildGenerations(toolExecutionResult);
assertThat(generations).hasSize(2);
assertThat(generations.get(0).getOutput().getText()).isEqualTo("The weather in London is 20 degrees Celsius");
assertThat((String) generations.get(0).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME))
.isEqualTo("weather");
assertThat(generations.get(0).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON);
assertThat(generations.get(1).getOutput().getText())
.isEqualTo("There is heavy traffic in the centre of London");
assertThat((String) generations.get(1).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME))
.isEqualTo("news");
assertThat(generations.get(1).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON);
}
}

View File

@@ -22,7 +22,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.core.ParameterizedTypeReference;
import java.util.List;

View File

@@ -18,7 +18,7 @@ package org.springframework.ai.tool.resolution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.util.json.SchemaType;
import org.springframework.ai.util.json.schema.SchemaType;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,9 +1,29 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.util.json;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import org.springframework.lang.Nullable;
import java.lang.reflect.Method;
import java.time.Duration;
@@ -22,6 +42,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*/
class JsonSchemaGeneratorTests {
// METHODS
@Test
void generateSchemaForMethodWithSimpleParameters() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class);
@@ -37,7 +59,7 @@ class JsonSchemaGeneratorTests {
},
"age": {
"type": "integer",
"format" : "int32"
"format": "int32"
}
},
"required": [
@@ -52,11 +74,121 @@ class JsonSchemaGeneratorTests {
}
@Test
void generateSchemaForMethodWithJsonPropertyAnnotations() throws Exception {
void generateSchemaForMethodWithToolParamAnnotations() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("annotatedMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method,
JsonSchemaGenerator.SchemaOption.RESPECT_JSON_PROPERTY_REQUIRED);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"username": {
"type": "string",
"description": "The username of the customer"
},
"password": {
"type": "string"
}
},
"required": [
"password"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWhenParameterRequiredByDefault() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("anotherAnnotatedMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"username",
"password"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithOpenApiSchemaAnnotations() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("openApiMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"username": {
"type": "string",
"description": "The username of the customer"
},
"password": {
"type": "string"
}
},
"required": [
"password"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithJacksonAnnotations() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("jacksonMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"password"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithNullableAnnotations() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("nullableMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -106,7 +238,7 @@ class JsonSchemaGeneratorTests {
},
"age": {
"type": "INTEGER",
"format" : "int32"
"format": "int32"
}
},
"required": [
@@ -122,7 +254,8 @@ class JsonSchemaGeneratorTests {
@Test
void generateSchemaForMethodWithComplexParameters() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("complexMethod", List.class, TestData.class);
Method method = TestMethods.class.getDeclaredMethod("complexMethod", List.class, TestData.class,
MoreTestData.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
@@ -142,18 +275,31 @@ class JsonSchemaGeneratorTests {
"properties": {
"id": {
"type": "integer",
"format" : "int32"
"format": "int32"
},
"name": {
"type": "string"
"type": "string",
"description": "The special name"
}
}
},
"required": [ "id", "name" ]
},
"moreData": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"description": "Even more special name"
}
},
"required": [ "id", "name" ]
}
},
"required": [
"items",
"data"
],
"required": [ "items", "data", "moreData" ],
"additionalProperties": false
}
""";
@@ -174,7 +320,7 @@ class JsonSchemaGeneratorTests {
"properties": {
"duration": {
"type": "string",
"format" : "duration"
"format": "duration"
},
"localDateTime": {
"type": "string",
@@ -197,11 +343,14 @@ class JsonSchemaGeneratorTests {
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
// TYPES
@Test
void generateSchemaForSimpleType() {
String schema = JsonSchemaGenerator.generateForType(Person.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
@@ -209,12 +358,13 @@ class JsonSchemaGeneratorTests {
},
"id": {
"type": "integer",
"format" : "int32"
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [ "email", "id", "name" ],
"additionalProperties": false
}
""";
@@ -231,12 +381,166 @@ class JsonSchemaGeneratorTests {
assertThat(jsonNode.has("additionalProperties")).isFalse();
}
@Test
void generateSchemaWhenParameterRequiredByDefault() {
String schema = JsonSchemaGenerator.generateForType(Person.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [
"email",
"id",
"name"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithToolArgAnnotation() {
String schema = JsonSchemaGenerator.generateForType(AnnotatedPerson.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The email of the person"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithOpenApiAnnotation() {
String schema = JsonSchemaGenerator.generateForType(OpenApiPerson.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The email of the person"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithJacksonAnnotation() {
String schema = JsonSchemaGenerator.generateForType(JacksonPerson.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithNullableAnnotation() {
String schema = JsonSchemaGenerator.generateForType(JacksonPerson.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithUpperCaseValues() {
String schema = JsonSchemaGenerator.generateForType(Person.class,
JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "OBJECT",
"properties": {
"email": {
@@ -244,12 +548,13 @@ class JsonSchemaGeneratorTests {
},
"id": {
"type": "INTEGER",
"format" : "int32"
"format": "int32"
},
"name": {
"type": "STRING"
}
},
"required": [ "email", "id", "name" ],
"additionalProperties": false
}
""";
@@ -262,16 +567,19 @@ class JsonSchemaGeneratorTests {
String schema = JsonSchemaGenerator.generateForType(TestData.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "integer",
"format" : "int32"
"format": "int32"
},
"name": {
"type": "string"
"type": "string",
"description": "The special name"
}
},
"required": [ "id", "name" ],
"additionalProperties": false
}
""";
@@ -284,6 +592,7 @@ class JsonSchemaGeneratorTests {
String schema = JsonSchemaGenerator.generateForType(Month.class);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"enum": [
"JANUARY",
@@ -317,10 +626,27 @@ class JsonSchemaGeneratorTests {
public void simpleMethod(String name, int age) {
}
public void annotatedMethod(String username, @JsonProperty(required = true) String password) {
public void annotatedMethod(
@ToolParam(required = false, description = "The username of the customer") String username,
@ToolParam(required = true) String password) {
}
public void complexMethod(List<String> items, TestData data) {
public void anotherAnnotatedMethod(String username, @ToolParam String password) {
}
public void openApiMethod(
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "The username of the customer") String username,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String password) {
}
public void jacksonMethod(@JsonProperty String username, @JsonProperty(required = true) String password) {
}
public void nullableMethod(@Nullable String username, String password) {
}
public void complexMethod(List<String> items, TestData data, MoreTestData moreData) {
}
public void timeMethod(Duration duration, LocalDateTime localDateTime, Instant instant) {
@@ -328,7 +654,27 @@ class JsonSchemaGeneratorTests {
}
record TestData(int id, String name) {
record TestData(int id, @ToolParam(description = "The special name") String name) {
}
record MoreTestData(int id, @Schema(description = "Even more special name") String name) {
}
record AnnotatedPerson(@ToolParam int id, @ToolParam String name,
@ToolParam(required = false, description = "The email of the person") String email) {
}
record JacksonPerson(@JsonProperty(required = true) int id, @JsonProperty(required = true) String name,
@JsonProperty String email) {
}
record OpenApiPerson(@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "The email of the person") String email) {
}
record NullablePerson(int id, String name, @Nullable String email) {
}
static class Person {

View File

@@ -18,7 +18,7 @@ package org.springframework.ai.tool.resolution
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.ai.util.json.SchemaType
import org.springframework.ai.util.json.schema.SchemaType
import org.springframework.context.annotation.AnnotationConfigApplicationContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -73,13 +73,13 @@ class SpringBeanToolCallbackResolverKotlinTests {
@Bean(WELCOME_TOOL_NAME)
@Description(WELCOME_TOOL_DESCRIPTION)
open fun welcome(): Consumer<Void> {
return Consumer { input: Void? -> }
return Consumer { _: Void? -> }
}
@Bean(WELCOME_USER_TOOL_NAME)
@Description(WELCOME_USER_TOOL_DESCRIPTION)
open fun welcomeUser(): Consumer<User> {
return Consumer { user: User? -> }
return Consumer { _: User? -> }
}
@Bean(BOOKS_BY_AUTHOR_TOOL_NAME)

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@@ -94,7 +94,8 @@
* xref:observability/index.adoc[]
* xref:api/prompt.adoc[]
* xref:api/structured-output-converter.adoc[Structured Output]
* xref:api/functions.adoc[Function Calling]
* xref:api/tools.adoc[Tool Calling]
* xref:api/functions.adoc[Function Calling (Deprecated)]
** xref:api/function-callback.adoc[FunctionCallback API]
* xref:api/multimodality.adoc[Multimodality]
* xref:api/etl-pipeline.adoc[]

View File

@@ -0,0 +1,318 @@
[[Tools]]
= Tool Calling
_Tool calling_ (also known as _function calling_) is a common pattern in AI applications allowing a model to interact with a set of APIs, or _tools_, augmenting its capabilities.
Tools are mainly used for:
* **Information Retrieval**. Tools in this category can be used to retrieve information from external sources, such as a database, a web service, a file system, or a web search engine. The goal is to augment the knowledge of the model, allowing it to answer questions that it would not be able to answer otherwise. As such, they can be used in Retrieval Augmented Generation (RAG) scenarios. For example, a tool can be used to retrieve the current weather for a given location, to retrieve the latest news articles, or to query a database for a specific record.
* **Taking Action**. Tools in this category can be used to take action in a software system, such as sending an email, creating a new record in a database, submitting a form, or triggering a workflow. The goal is to automate tasks that would otherwise require human intervention or explicit programming. For example, a tool can be used to book a flight for a customer interacting with a chatbot, to fill out a form on a web page, or to implement a Java class based on an automated test (TDD) in a code generation scenario.
Even though we typically refer to _tool calling_ as a model capability, it is actually up to the client application to provide the tool calling logic. The model can only request a tool call and provide the input arguments, whereas the application is responsible for executing the tool call from the input arguments and returning the result. The model never gets access to any of the APIs provided as tools, which is a critical security consideration.
Spring AI provides convenient APIs to define tools, resolve tool call requests from a model, and execute the tool calls. The following sections provide an overview of the tool calling capabilities in Spring AI.
== Quick Start
Let's see how to start using tool calling in Spring AI. We'll implement two simple tools: one for information retrieval and one for taking action. The information retrieval tool will be used to get the current date and time in the user's time zone. The action tool will be used to set an alarm for a specified time.
=== Information Retrieval
AI models don't have access to real-time information. Any question that assumes awareness of information such as the current date or weather forecast cannot be answered by the model. However, we can provide a tool that can retrieve this information, and let the model call this tool when access to real-time information is needed.
Let's implement a tool to get the current date and time in the user's time zone in a `DateTimeTools` class. The tool will take no argument. The `LocaleContextHolder` from Spring Framework can provide the user's time zone. The tool will be defined as a method annotated with `@Tool`. To help the model understand if and when to call this tool, we'll provide a detailed description of what the tools does.
[source,java]
----
import java.time.LocalDateTime;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.i18n.LocaleContextHolder;
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
}
----
Next, let's make the tool available to the model. In this example, we'll use the `ChatClient` to interact with the model. We'll provide the tool to the model by passing an instance of `DateTimeTools` via the `tools()` method. When the model needs to know the current date and time, it will request the tool to be called. Internally, the `ChatClient` will call the tool and return the result to the model, which will then use the tool call result to generate the final response to the original question.
[source,java]
----
ChatModel chatModel = ...
String response = ChatClient.create(chatModel)
.prompt("What day is tomorrow?")
.tools(new DateTimeTools())
.call()
.content();
System.out.println(response);
----
The output will be something like:
[source]
----
Tomorrow is 2015-10-21.
----
You can retry asking the same question again. This time, don't provide the tool to the model. The output will be something like:
[source]
----
I am an AI and do not have access to real-time information. Please provide the current date so I can accurately determine what day tomorrow will be.
----
Without the tool, the model doesn't know how to answer the question because it doesn't have the ability to determine the current date and time.
=== Taking Actions
AI models can be used to generate plans for accomplishing certain goals. For example, a model can generate a plan for booking a trip to Denmark. However, the model doesn't have the ability to execute the plan. That's where tools come in: they can be used to execute the plan that a model generates.
In the previous example, we used a tool to determine the current date and time. In this example, we'll define a second tool for setting an alarm at a specific time. The goal is to set an alarm for 10 minutes from now, so we need to provide both tools to the model to accomplish this task.
We'll add the new tool to the same `DateTimeTools` class as before. The new tool will take a single parameter, which is the time in ISO-8601 format. The tool will then print a message to the console indicating that the alarm has been set for the given time. Like before, the tool is defined as a method annotated with `@Tool`, which we also use to provide a detailed description to help the model understand when and how to use the tool.
[source,java]
----
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.context.i18n.LocaleContextHolder;
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
System.out.println("Alarm set for " + alarmTime);
}
}
----
Next, let's make both tools available to the model. We'll use the `ChatClient` to interact with the model. We'll provide the tools to the model by passing an instance of `DateTimeTools` via the `tools()` method. When we ask to set up an alarm 10 minutes from now, the model will first need to know the current date and time. Then, it will use the current date and time to calculate the alarm time. Finally, it will use the alarm time to set up the alarm. Internally, the `ChatClient` will handle any tool call request from the model and send back to it any tool call execution result, so that the model can generate the final response.
[source,java]
----
ChatModel chatModel = ...
String response = ChatClient.create(chatModel)
.prompt("Can you set an alarm 10 minutes from now?")
.tools(new DateTimeTools())
.call()
.content();
System.out.println(response);
----
In the application logs, you can check the alarm has been set at the correct time.
== Overview
Spring AI supports tool calling through a set of flexible abstractions that allow you to define, resolve, and execute tools in a consistent way. This section provides an overview of the main concepts and components of tool calling in Spring AI.
image::tools/tool-calling-01.jpg[The main sequence of actions for tool calling, width=700, align="center"]
1. When we want to make a tool available to the model, we include its definition in the chat request. Each tool definition comprises of a name, a description, and the schema of the input parameters.
2. When the model decides to call a tool, it sends a response with the tool name and the input parameters modeled after the defined schema.
3. The application is responsible for using the tool name to identify and execute the tool with the provided input parameters.
4. The result of the tool call is processed by the application.
5. The application sends the tool call result back to the model.
6. The model generates the final response using the tool call result as additional context.
Tools are the building blocks of tool calling and they are modeled by the `ToolCallback` interface. Spring AI provides built-in support for specifying `ToolCallback`s from methods and functions, but you can always define your own `ToolCallback` implementations to support more use cases.
`ChatModel` implementations transparently dispatch tool call requests to the corresponding `ToolCallback` implementations and will send the tool call results back to the model, which will ultimately generate the final response. They do so using the `ToolCallingManager` interface, which is responsible for managing the tool execution lifecycle.
Both `ChatClient` and `ChatModel` accept a list of `ToolCallback` objects to make the tools available to the model and the `ToolCallingManager` that will eventually execute them.
Besides passing the `ToolCallback` objects directly, you can also pass a list of tool names, that will be resolved using the `ToolCallbackResolver` interface.
The following sections will go into more details about all these concepts and APIs, including how to customize and extend them to support more use cases.
== Tool Specification
In Spring AI, tools are modeled via the `ToolCallback` interface, which provides a way to define the tool name, description, input schema, and the actual tool execution logic.
This section describes how to:
- build `ToolCallback`(s) from methods and functions;
- define the schema for the tool input parameters;
- provide additional context to tools;
- return the tool call result directly.
=== Methods as Tools
Spring AI provides built-in support for specifying tools (i.e. `ToolCallback`(s)) from methods, either declaratively using the `@Tool` annotation or programmatically using the low-level `MethodToolCallback` implementation.
==== Declarative Specification: `@Tool`
You can turn a method into a tool by annotating it with `@Tool`. The annotation allows you to provide a description for the tool, which can be used by the model to understand when and how to call the tool. If you don't provide a description, the method name will be used as the tool description.
However, it's strongly recommended to provide a detailed description because that's paramount for the model to understand the tool's purpose and how to use it. Failing in providing a good description can lead to the model not using the tool when it should or using it incorrectly.
[source,java]
----
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
}
----
The method can be either static or instance, and it can have any visibility (public, protected, package-private, or private). The class that contains the method can be either a top-level class or a nested class, and it can also have any visibility (as long as it's accessible where you're planning to instantiate it).
You can define any number of arguments for the method (including no argument) with any type (primitives, POJOs, enums, lists, arrays, maps, and so on). Similarly, the method can return any type, including `void`. If the method returns a value, the return type must be a serializable type, as the result will be serialized and sent back to the model.
NOTE: Some types are not supported. See: <<limitations>>.
===== Adding Tools to `ChatClient`
When using the declarative specification approach, there are a few options for adding tools to a `ChatClient`.
Such tools will only be available for the specific chat request they are added to.
* Pass the tool class instance directly to the `tools()` method.
[source,java]
----
ChatClient.create(chatModel)
.prompt("What day is tomorrow?")
.tools(new DateTimeTools())
.call()
.content();
----
* Generate `ToolCallback`(s) from the tool class instance and pass them to the `tools()` method.
[source,java]
----
ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools());
ChatClient.create(chatModel)
.prompt("What day is tomorrow?")
.tools(dateTimeTools)
.call()
.content();
----
===== Adding Default Tools to `ChatClient`
When using the declarative specification approach, you can add default tools to a `ChatClient` by adding them to the `ChatClient.Builder` used to instantiate it.
Such tools will be available for ALL the chat requests performed by ALL the `ChatClient` instances built from that specific `ChatClient.Builder`.
They are useful for tools that are commonly used across different chat requests, but they can also be dangerous if not used carefully, risking to make them available when they shouldn't.
* Pass the tool class instance directly to the `tools()` method.
[source,java]
----
ChatModel chatModel = ...
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(new DateTimeTools())
.build();
----
* Generate `ToolCallback`s from the tool class instance and pass them to the `tools()` method.
[source,java]
----
ChatModel chatModel = ...
ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools());
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(dateTimeTools)
.build();
----
===== Adding Tools to `ChatModel`
When using the declarative specification approach, there are a few options for adding tools to a `ChatModel`.
Such tools will only be available for the specific chat request they are added to.
* Generate `ToolCallback`(s) from the tool class instance and pass them to the `toolCallbacks()` method of `ToolCallingChatOptions`.
[source,java]
----
ChatModel chatModel = ...
ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools());
ChatOptions chatOptions = ToolCallingChatOptions.builder()
.toolCallbacks(dateTimeTools)
.build():
Prompt prompt = new Prompt("What day is tomorrow?", chatOptions);
chatModel.call(prompt);
----
==== Programmatic Specification: `MethodToolCallback`
===== Adding Tools to `ChatClient`
===== Adding Tools to `ChatModel`
==== Limitations
Methods using `Optional`, asynchronous (e.g. `CompletableFuture`, `Future`) or reactive types (e.g. `Flow`, `Mono`, `Flux`) as parameters or return types are not currently supported to be used as tools.
Furthermore, methods returning a functional type (e.g. `Function`, `Supplier`, `Consumer`) are not supported to be used as tools using this approach, but they are supported using the function-based approach described in the next section.
=== Functions as Tools
Spring AI provides built-in support for specifying tools from functions, either programmatically using the low-level `FunctionToolCallback` implementation or dynamic using the `ToolCallbackResolver` interface for resolution at run-time.
==== Programmatic Specification: `FunctionToolCallback`
===== Adding Tools to `ChatClient`
===== Adding Tools to `ChatModel`
==== Dynamic Specification: `@Bean`
===== Adding Tools to `ChatClient`
===== Adding Tools to `ChatModel`
==== Limitations
* Only POJOs.
* Only public.
* No primitives.
* No lists or arrays.
=== JSON Schema
==== Description
==== Required
=== Result Conversion
=== Exception Handling
=== Tool Context
=== Return Direct
== Tool Execution
=== Framework-Controlled Tool Execution
=== User-Controlled Tool Execution
== Tool Resolution
=== Resolution from Application Context
== Structured Outputs
== Observability
=== Logging

View File

@@ -22,9 +22,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.model.tool.ToolExecutionResult;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
@@ -102,12 +102,13 @@ public class ToolCallingManagerTests {
assertThat(chatResponse).isNotNull();
assertThat(chatResponse.hasToolCalls()).isTrue();
List<Message> messages = toolCallingManager.executeToolCalls(prompt, chatResponse);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(messages).isNotEmpty();
assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue();
assertThat(toolExecutionResult.conversationHistory()).isNotEmpty();
assertThat(toolExecutionResult.conversationHistory().stream().anyMatch(m -> m instanceof ToolResponseMessage))
.isTrue();
Prompt secondPrompt = new Prompt(messages, chatOptions);
Prompt secondPrompt = new Prompt(toolExecutionResult.conversationHistory(), chatOptions);
ChatResponse secondChatResponse = openAiChatModel.call(secondPrompt);
@@ -121,12 +122,14 @@ public class ToolCallingManagerTests {
private void runExplicitToolCallingExecutionWithOptionsStream(ChatOptions chatOptions, Prompt prompt) {
ChatResponse chatResponse = openAiChatModel.stream(prompt).flatMap(response -> {
if (response.hasToolCalls()) {
List<Message> messages = toolCallingManager.executeToolCalls(prompt, response);
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response);
assertThat(messages).isNotEmpty();
assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue();
assertThat(toolExecutionResult.conversationHistory()).isNotEmpty();
assertThat(toolExecutionResult.conversationHistory()
.stream()
.anyMatch(m -> m instanceof ToolResponseMessage)).isTrue();
Prompt secondPrompt = new Prompt(messages, chatOptions);
Prompt secondPrompt = new Prompt(toolExecutionResult.conversationHistory(), chatOptions);
// return openAiChatModel.stream(secondPrompt);
return Flux.just(openAiChatModel.call(secondPrompt));
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -96,7 +96,7 @@ class FunctionCallWithFunctionBeanIT {
"What's the weather like in San Francisco, Paris and in Tokyo? Use Multi-turn function calling.");
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage),
ToolCallingChatOptions.builder().tools("weatherFunction").build()));
ToolCallingChatOptions.builder().toolNames("weatherFunction").build()));
logger.info("Response: {}", response);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -64,14 +64,14 @@ class FunctionCallWithFunctionBeanIT {
"What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius.");
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage),
ToolCallingChatOptions.builder().tools("weatherFunction").build()));
ToolCallingChatOptions.builder().toolNames("weatherFunction").build()));
logger.info("Response: {}", response);
assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
response = chatModel.call(new Prompt(List.of(userMessage),
ToolCallingChatOptions.builder().tools("weatherFunction3").build()));
ToolCallingChatOptions.builder().toolNames("weatherFunction3").build()));
logger.info("Response: {}", response);
@@ -93,7 +93,7 @@ class FunctionCallWithFunctionBeanIT {
"What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius.");
Flux<ChatResponse> responses = chatModel.stream(new Prompt(List.of(userMessage),
ToolCallingChatOptions.builder().tools("weatherFunction").build()));
ToolCallingChatOptions.builder().toolNames("weatherFunction").build()));
String content = responses.collectList()
.block()

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -119,7 +119,7 @@ public class OllamaFunctionCallbackIT extends BaseOllamaIT {
UserMessage userMessage = new UserMessage(
"What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.");
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("WeatherInfo").build();
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("WeatherInfo").build();
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));

View File

@@ -103,7 +103,7 @@ public class OllamaFunctionToolBeanIT extends BaseOllamaIT {
"What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.");
ChatResponse response = chatModel
.call(new Prompt(List.of(userMessage), OllamaOptions.builder().tools("weatherInfo").build()));
.call(new Prompt(List.of(userMessage), OllamaOptions.builder().toolNames("weatherInfo").build()));
logger.info("Response: {}", response);
@@ -147,7 +147,7 @@ public class OllamaFunctionToolBeanIT extends BaseOllamaIT {
UserMessage userMessage = new UserMessage(
"What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.");
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("weatherInfo").build();
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("weatherInfo").build();
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -155,7 +155,9 @@ class FunctionCallbackWithPlainFunctionBeanIT {
UserMessage userMessage = new UserMessage(
"Please schedule a train from San Francisco to Los Angeles on 2023-12-25");
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("trainReservation").build();
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder()
.toolNames("trainReservation")
.build();
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));
@@ -264,7 +266,9 @@ class FunctionCallbackWithPlainFunctionBeanIT {
// Test weatherFunction
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("weatherFunction").build();
ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder()
.toolNames("weatherFunction")
.build();
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));