Advancing Tool Support - Part 1

* Defined new APIs consolidating the “tool” naming as opposed to the current “function”, aligning with the industry and solving the confusion between “function tool” and “Java Function”: ToolCallback and ToolCallingChatOptions. They extend the current ones to ensure backward compatibility, but FunctionCallback and FunctionCallingOptions can be considered deprecated.
* Enhanced support for methods as tools, introducing support for declarative Tool-annotated methods via MethodToolCallback and MethodToolCallbackProvider (deprecating the existing MethodInvokingFunctionCallback).
* Improved tool execution logic with granular support for returning the result directly to the client and exception handling.
* Improved JSON Schema generation and parsing logic, consolidating the usage of the victools/jsonschema-generator library and dropping the non-maintained Jackson JSON Schema Module. This makes it possible to use tools with input lists/arrays, which the latter library was not supporting.
* Extended ChatClient API with new methods tools() and toolCallbacks(). The existing functions() methods can be considered deprecated.

Relates to gh-2049

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-01-16 18:52:40 +01:00
committed by Christian Tzolov
parent 4a90a46925
commit 3c13b435c1
48 changed files with 4521 additions and 14 deletions

View File

@@ -0,0 +1,39 @@
/*
* 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.aot;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
/**
* Registers runtime hints for the tool calling APIs.
*
* @author Thomas Vitale
*/
public class ToolRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
var mcs = MemberCategory.values();
hints.reflection().registerType(DefaultToolCallResultConverter.class, mcs);
}
}

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.
@@ -215,6 +215,12 @@ public interface ChatClient {
<T extends ChatOptions> ChatClientRequestSpec options(T options);
ChatClientRequestSpec tools(String... toolNames);
ChatClientRequestSpec tools(Object... toolObjects);
ChatClientRequestSpec toolCallbacks(FunctionCallback... toolCallbacks);
/**
* @deprecated use {@link #functions(FunctionCallback...)} instead.
*/
@@ -293,6 +299,12 @@ public interface ChatClient {
Builder defaultSystem(Consumer<PromptSystemSpec> systemSpecConsumer);
Builder defaultTools(String... toolNames);
Builder defaultTools(Object... toolObjects);
Builder defaultToolCallbacks(FunctionCallback... toolCallbacks);
/**
* @deprecated use {@link #defaultFunctions(FunctionCallback...)} instead.
*/

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.
@@ -32,6 +32,7 @@ import java.util.function.Consumer;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
import org.springframework.ai.tool.ToolCallbacks;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
@@ -782,10 +783,9 @@ public class DefaultChatClient implements ChatClient {
builder.defaultOptions(this.chatOptions);
}
// workaround to set the missing fields.
builder.defaultRequest.getMessages().addAll(this.messages);
builder.defaultRequest.getFunctionCallbacks().addAll(this.functionCallbacks);
builder.defaultRequest.getToolContext().putAll(this.toolContext);
builder.addMessages(this.messages);
builder.addToolCallbacks(this.functionCallbacks);
builder.addToolContext(this.toolContext);
return builder;
}
@@ -836,6 +836,30 @@ public class DefaultChatClient implements ChatClient {
return this;
}
@Override
public ChatClientRequestSpec tools(String... toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
Assert.noNullElements(toolNames, "toolNames cannot contain null elements");
this.functionNames.addAll(List.of(toolNames));
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;
}
@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 <I, O> ChatClientRequestSpec function(String name, String description,
java.util.function.Function<I, O> function) {
@@ -888,10 +912,7 @@ public class DefaultChatClient implements ChatClient {
}
public ChatClientRequestSpec functions(String... functionBeanNames) {
Assert.notNull(functionBeanNames, "functionBeanNames cannot be null");
Assert.noNullElements(functionBeanNames, "functionBeanNames cannot contain null elements");
this.functionNames.addAll(List.of(functionBeanNames));
return this;
return tools(functionBeanNames);
}
public ChatClientRequestSpec functions(FunctionCallback... functionCallbacks) {

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.
@@ -30,10 +30,12 @@ import org.springframework.ai.chat.client.ChatClient.PromptUserSpec;
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
import org.springframework.ai.chat.messages.Message;
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;
@@ -147,6 +149,24 @@ public class DefaultChatClientBuilder implements Builder {
return this;
}
@Override
public Builder defaultTools(String... toolNames) {
this.defaultRequest.functions(toolNames);
return this;
}
@Override
public Builder defaultTools(Object... toolObjects) {
this.defaultRequest.functions(ToolCallbacks.from(toolObjects));
return this;
}
@Override
public Builder defaultToolCallbacks(FunctionCallback... toolCallbacks) {
this.defaultRequest.functions(toolCallbacks);
return this;
}
public <I, O> Builder defaultFunction(String name, String description, java.util.function.Function<I, O> function) {
this.defaultRequest.function(name, description, function);
return this;
@@ -173,4 +193,17 @@ public class DefaultChatClientBuilder implements Builder {
return this;
}
void addMessages(List<Message> messages) {
this.defaultRequest.messages(messages);
}
void addToolCallbacks(List<FunctionCallback> toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
this.defaultRequest.toolCallbacks(toolCallbacks.toArray(FunctionCallback[]::new));
}
void addToolContext(Map<String, Object> toolContext) {
this.defaultRequest.toolContext(toolContext);
}
}

View File

@@ -0,0 +1,458 @@
/*
* 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.prompt.ChatOptions;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Default implementation of {@link ToolCallingChatOptions}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
private List<ToolCallback> toolCallbacks = new ArrayList<>();
private Set<String> tools = new HashSet<>();
private Map<String, Object> toolContext = new HashMap<>();
@Nullable
private Boolean toolCallReturnDirect;
@Nullable
private String model;
@Nullable
private Double frequencyPenalty;
@Nullable
private Integer maxTokens;
@Nullable
private Double presencePenalty;
@Nullable
private List<String> stopSequences;
@Nullable
private Double temperature;
@Nullable
private Integer topK;
@Nullable
private Double topP;
@Override
public List<ToolCallback> getToolCallbacks() {
return List.copyOf(this.toolCallbacks);
}
@Override
public void setToolCallbacks(List<ToolCallback> toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
this.toolCallbacks = new ArrayList<>(toolCallbacks);
}
@Override
public void setToolCallbacks(ToolCallback... toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
setToolCallbacks(List.of(toolCallbacks));
}
@Override
public Set<String> getTools() {
return Set.copyOf(this.tools);
}
@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);
}
@Override
public void setTools(String... tools) {
Assert.notNull(tools, "tools cannot be null");
setTools(Set.of(tools));
}
@Override
public Map<String, Object> getToolContext() {
return Map.copyOf(this.toolContext);
}
@Override
public void setToolContext(Map<String, Object> toolContext) {
Assert.notNull(toolContext, "toolContext cannot be null");
Assert.noNullElements(toolContext.keySet(), "toolContext cannot contain null keys");
this.toolContext = new HashMap<>(toolContext);
}
@Override
@Nullable
public Boolean getToolCallReturnDirect() {
return this.toolCallReturnDirect;
}
@Override
public void setToolCallReturnDirect(@Nullable Boolean toolCallReturnDirect) {
this.toolCallReturnDirect = toolCallReturnDirect;
}
@Override
public List<FunctionCallback> getFunctionCallbacks() {
return getToolCallbacks().stream().map(FunctionCallback.class::cast).toList();
}
@Override
public void setFunctionCallbacks(List<FunctionCallback> functionCallbacks) {
throw new UnsupportedOperationException("Not supported. Call setToolCallbacks instead.");
}
@Override
public Set<String> getFunctions() {
return getTools();
}
@Override
public void setFunctions(Set<String> functions) {
setTools(functions);
}
@Override
@Nullable
public Boolean getProxyToolCalls() {
return getToolCallReturnDirect();
}
@Override
public void setProxyToolCalls(@Nullable Boolean proxyToolCalls) {
setToolCallReturnDirect(proxyToolCalls != null && proxyToolCalls);
}
@Override
@Nullable
public String getModel() {
return this.model;
}
public void setModel(@Nullable String model) {
this.model = model;
}
@Override
@Nullable
public Double getFrequencyPenalty() {
return this.frequencyPenalty;
}
public void setFrequencyPenalty(@Nullable Double frequencyPenalty) {
this.frequencyPenalty = frequencyPenalty;
}
@Override
@Nullable
public Integer getMaxTokens() {
return this.maxTokens;
}
public void setMaxTokens(@Nullable Integer maxTokens) {
this.maxTokens = maxTokens;
}
@Override
@Nullable
public Double getPresencePenalty() {
return this.presencePenalty;
}
public void setPresencePenalty(@Nullable Double presencePenalty) {
this.presencePenalty = presencePenalty;
}
@Override
@Nullable
public List<String> getStopSequences() {
return this.stopSequences;
}
public void setStopSequences(@Nullable List<String> stopSequences) {
this.stopSequences = stopSequences;
}
@Override
@Nullable
public Double getTemperature() {
return this.temperature;
}
public void setTemperature(@Nullable Double temperature) {
this.temperature = temperature;
}
@Override
@Nullable
public Integer getTopK() {
return this.topK;
}
public void setTopK(@Nullable Integer topK) {
this.topK = topK;
}
@Override
@Nullable
public Double getTopP() {
return this.topP;
}
public void setTopP(@Nullable Double topP) {
this.topP = topP;
}
@Override
@SuppressWarnings("unchecked")
public <T extends ChatOptions> T copy() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setToolCallbacks(getToolCallbacks());
options.setTools(getTools());
options.setToolContext(getToolContext());
options.setToolCallReturnDirect(getToolCallReturnDirect());
options.setModel(getModel());
options.setFrequencyPenalty(getFrequencyPenalty());
options.setMaxTokens(getMaxTokens());
options.setPresencePenalty(getPresencePenalty());
options.setStopSequences(getStopSequences());
options.setTemperature(getTemperature());
options.setTopK(getTopK());
options.setTopP(getTopP());
return (T) options;
}
/**
* Merge the given {@link ChatOptions} into this instance.
*/
public ToolCallingChatOptions merge(ChatOptions options) {
ToolCallingChatOptions.Builder builder = ToolCallingChatOptions.builder();
builder.model(StringUtils.hasText(options.getModel()) ? options.getModel() : this.getModel());
builder.frequencyPenalty(
options.getFrequencyPenalty() != null ? options.getFrequencyPenalty() : this.getFrequencyPenalty());
builder.maxTokens(options.getMaxTokens() != null ? options.getMaxTokens() : this.getMaxTokens());
builder.presencePenalty(
options.getPresencePenalty() != null ? options.getPresencePenalty() : this.getPresencePenalty());
builder.stopSequences(options.getStopSequences() != null ? new ArrayList<>(options.getStopSequences())
: this.getStopSequences());
builder.temperature(options.getTemperature() != null ? options.getTemperature() : this.getTemperature());
builder.topK(options.getTopK() != null ? options.getTopK() : this.getTopK());
builder.topP(options.getTopP() != null ? options.getTopP() : this.getTopP());
if (options instanceof ToolCallingChatOptions toolOptions) {
List<ToolCallback> toolCallbacks = new ArrayList<>(this.toolCallbacks);
if (!CollectionUtils.isEmpty(toolOptions.getToolCallbacks())) {
toolCallbacks.addAll(toolOptions.getToolCallbacks());
}
builder.toolCallbacks(toolCallbacks);
Set<String> tools = new HashSet<>(this.tools);
if (!CollectionUtils.isEmpty(toolOptions.getTools())) {
tools.addAll(toolOptions.getTools());
}
builder.tools(tools);
Map<String, Object> toolContext = new HashMap<>(this.toolContext);
if (!CollectionUtils.isEmpty(toolOptions.getToolContext())) {
toolContext.putAll(toolOptions.getToolContext());
}
builder.toolContext(toolContext);
builder.toolCallReturnDirect(toolOptions.getToolCallReturnDirect() != null
? toolOptions.getToolCallReturnDirect() : this.getToolCallReturnDirect());
}
else {
builder.toolCallbacks(this.toolCallbacks);
builder.tools(this.tools);
builder.toolContext(this.toolContext);
builder.toolCallReturnDirect(this.toolCallReturnDirect);
}
return builder.build();
}
public static Builder builder() {
return new Builder();
}
/**
* Default implementation of {@link ToolCallingChatOptions.Builder}.
*/
public static class Builder implements ToolCallingChatOptions.Builder {
private final DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
@Override
public ToolCallingChatOptions.Builder toolCallbacks(List<ToolCallback> toolCallbacks) {
this.options.setToolCallbacks(toolCallbacks);
return this;
}
@Override
public ToolCallingChatOptions.Builder toolCallbacks(ToolCallback... toolCallbacks) {
this.options.setToolCallbacks(toolCallbacks);
return this;
}
@Override
public ToolCallingChatOptions.Builder tools(Set<String> toolNames) {
this.options.setTools(toolNames);
return this;
}
@Override
public ToolCallingChatOptions.Builder tools(String... toolNames) {
this.options.setTools(toolNames);
return this;
}
@Override
public ToolCallingChatOptions.Builder toolContext(Map<String, Object> context) {
this.options.setToolContext(context);
return this;
}
@Override
public ToolCallingChatOptions.Builder toolContext(String key, Object value) {
Assert.hasText(key, "key cannot be null");
Assert.notNull(value, "value cannot be null");
Map<String, Object> updatedToolContext = new HashMap<>(this.options.getToolContext());
updatedToolContext.put(key, value);
this.options.setToolContext(updatedToolContext);
return this;
}
@Override
public ToolCallingChatOptions.Builder toolCallReturnDirect(@Nullable Boolean toolCallReturnDirect) {
this.options.setToolCallReturnDirect(toolCallReturnDirect);
return this;
}
@Override
@Deprecated // Use toolCallbacks() instead
public ToolCallingChatOptions.Builder functionCallbacks(List<FunctionCallback> functionCallbacks) {
Assert.notNull(functionCallbacks, "functionCallbacks cannot be null");
return toolCallbacks(functionCallbacks.stream().map(ToolCallback.class::cast).toList());
}
@Override
@Deprecated // Use toolCallbacks() instead
public ToolCallingChatOptions.Builder functionCallbacks(FunctionCallback... functionCallbacks) {
Assert.notNull(functionCallbacks, "functionCallbacks cannot be null");
return functionCallbacks(List.of(functionCallbacks));
}
@Override
@Deprecated // Use tools() instead
public ToolCallingChatOptions.Builder functions(Set<String> functions) {
return tools(functions);
}
@Override
@Deprecated // Use tools() instead
public ToolCallingChatOptions.Builder function(String function) {
return tools(function);
}
@Override
@Deprecated // Use toolCallReturnDirect() instead
public ToolCallingChatOptions.Builder proxyToolCalls(@Nullable Boolean proxyToolCalls) {
return toolCallReturnDirect(proxyToolCalls != null && proxyToolCalls);
}
@Override
public ToolCallingChatOptions.Builder model(@Nullable String model) {
this.options.setModel(model);
return this;
}
@Override
public ToolCallingChatOptions.Builder frequencyPenalty(@Nullable Double frequencyPenalty) {
this.options.setFrequencyPenalty(frequencyPenalty);
return this;
}
@Override
public ToolCallingChatOptions.Builder maxTokens(@Nullable Integer maxTokens) {
this.options.setMaxTokens(maxTokens);
return this;
}
@Override
public ToolCallingChatOptions.Builder presencePenalty(@Nullable Double presencePenalty) {
this.options.setPresencePenalty(presencePenalty);
return this;
}
@Override
public ToolCallingChatOptions.Builder stopSequences(@Nullable List<String> stopSequences) {
this.options.setStopSequences(stopSequences);
return this;
}
@Override
public ToolCallingChatOptions.Builder temperature(@Nullable Double temperature) {
this.options.setTemperature(temperature);
return this;
}
@Override
public ToolCallingChatOptions.Builder topK(@Nullable Integer topK) {
this.options.setTopK(topK);
return this;
}
@Override
public ToolCallingChatOptions.Builder topP(@Nullable Double topP) {
this.options.setTopP(topP);
return this;
}
@Override
public ToolCallingChatOptions build() {
return this.options;
}
}
}

View File

@@ -0,0 +1,181 @@
/*
* 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.model.function.FunctionCallback;
import org.springframework.ai.model.function.FunctionCallingOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.lang.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A set of options that can be used to configure the interaction with a chat model,
* including tool calling.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolCallingChatOptions extends FunctionCallingOptions {
/**
* ToolCallbacks to be registered with the ChatModel.
*/
List<ToolCallback> getToolCallbacks();
/**
* Set the ToolCallbacks to be registered with the ChatModel.
*/
void setToolCallbacks(List<ToolCallback> toolCallbacks);
/**
* Set the ToolCallbacks to be registered with the ChatModel.
*/
void setToolCallbacks(ToolCallback... toolCallbacks);
/**
* Names of the tools to register with the ChatModel.
*/
Set<String> getTools();
/**
* Set the names of the tools to register with the ChatModel.
*/
void setTools(Set<String> tools);
/**
* Set the names of the tools to register with the ChatModel.
*/
void setTools(String... tools);
/**
* Whether the result of each tool call should be returned directly or passed back to
* the model. It can be overridden for each {@link ToolCallback} instance via
* {@link ToolMetadata#returnDirect()}.
*/
@Nullable
Boolean getToolCallReturnDirect();
/**
* Set whether the result of each tool call should be returned directly or passed back
* to the model. It can be overridden for each {@link ToolCallback} instance via
* {@link ToolMetadata#returnDirect()}.
*/
void setToolCallReturnDirect(@Nullable Boolean toolCallReturnDirect);
/**
* A builder to create a new {@link ToolCallingChatOptions} instance.
*/
static Builder builder() {
return new DefaultToolCallingChatOptions.Builder();
}
/**
* A builder to create a {@link ToolCallingChatOptions} instance.
*/
interface Builder extends FunctionCallingOptions.Builder {
/**
* ToolCallbacks to be registered with the ChatModel.
*/
Builder toolCallbacks(List<ToolCallback> functionCallbacks);
/**
* ToolCallbacks to be registered with the ChatModel.
*/
Builder toolCallbacks(ToolCallback... functionCallbacks);
/**
* Names of the tools to register with the ChatModel.
*/
Builder tools(Set<String> toolNames);
/**
* Names of the tools to register with the ChatModel.
*/
Builder tools(String... toolNames);
/**
* Whether the result of each tool call should be returned directly or passed back
* to the model. It can be overridden for each {@link ToolCallback} instance via
* {@link ToolMetadata#returnDirect()}.
*/
Builder toolCallReturnDirect(@Nullable Boolean toolCallReturnDirect);
// FunctionCallingOptions.Builder methods
@Override
Builder toolContext(Map<String, Object> context);
@Override
Builder toolContext(String key, Object value);
@Override
@Deprecated // Use toolCallbacks() instead
Builder functionCallbacks(List<FunctionCallback> functionCallbacks);
@Override
@Deprecated // Use toolCallbacks() instead
Builder functionCallbacks(FunctionCallback... functionCallbacks);
@Override
@Deprecated // Use tools() instead
Builder functions(Set<String> functions);
@Override
@Deprecated // Use tools() instead
Builder function(String function);
@Override
@Deprecated // Use toolCallReturnDirect() instead
Builder proxyToolCalls(@Nullable Boolean proxyToolCalls);
// ChatOptions.Builder methods
@Override
Builder model(@Nullable String model);
@Override
Builder frequencyPenalty(@Nullable Double frequencyPenalty);
@Override
Builder maxTokens(@Nullable Integer maxTokens);
@Override
Builder presencePenalty(@Nullable Double presencePenalty);
@Override
Builder stopSequences(@Nullable List<String> stopSequences);
@Override
Builder temperature(@Nullable Double temperature);
@Override
Builder topK(@Nullable Integer topK);
@Override
Builder topP(@Nullable Double topP);
@Override
ToolCallingChatOptions build();
}
}

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.model.tool;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,58 @@
/*
* 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;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
/**
* Represents a tool whose execution can be triggered by an AI model.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolCallback extends FunctionCallback {
/**
* Definition used by the AI model to determine when and how to call the tool.
*/
ToolDefinition getToolDefinition();
/**
* Metadata providing additional information on how to handle the tool.
*/
default ToolMetadata getToolMetadata() {
return ToolMetadata.builder().build();
}
@Override
default String getName() {
return getToolDefinition().name();
}
@Override
default String getDescription() {
return getToolDefinition().description();
}
@Override
default String getInputTypeSchema() {
return getToolDefinition().inputTypeSchema();
}
}

View File

@@ -0,0 +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.tool;
/**
* Provides {@link ToolCallback} instances for tools defined in different sources.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolCallbackProvider {
ToolCallback[] getToolCallbacks();
}

View File

@@ -0,0 +1,36 @@
/*
* 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;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
/**
* Provides {@link ToolCallback} instances for tools defined in different sources.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class ToolCallbacks {
private ToolCallbacks() {
}
public static ToolCallback[] from(Object... sources) {
return MethodToolCallbackProvider.builder().toolObjects(sources).build().getToolCallbacks();
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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 org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
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 method as a tool in Spring AI.
*
* @author Thomas Vitale
* @since 1.0.0
*/
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Tool {
/**
* The name of the tool. If not provided, the method name will be used.
*/
String name() default "";
/**
* The description of the tool. If not provided, the method name will be used.
*/
String description() default "";
/**
* Whether the tool result should be returned directly or passed back to the model.
*/
boolean returnDirect() default false;
/**
* The class to use to convert the tool call result to a String.
*/
Class<? extends ToolCallResultConverter> resultConverter() default DefaultToolCallResultConverter.class;
}

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.tool.annotation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,71 @@
/*
* 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.definition;
import org.springframework.util.Assert;
/**
* Default implementation of {@link ToolDefinition}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public record DefaultToolDefinition(String name, String description, String inputTypeSchema) implements ToolDefinition {
public DefaultToolDefinition {
Assert.hasText(name, "name cannot be null or empty");
Assert.hasText(description, "description cannot be null or empty");
Assert.hasText(inputTypeSchema, "inputTypeSchema cannot be null or empty");
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String name;
private String description;
private String inputTypeSchema;
private Builder() {
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder description(String description) {
this.description = description;
return this;
}
public Builder inputTypeSchema(String inputTypeSchema) {
this.inputTypeSchema = inputTypeSchema;
return this;
}
public DefaultToolDefinition build() {
return new DefaultToolDefinition(name, description, inputTypeSchema);
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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.definition;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import java.lang.reflect.Method;
/**
* Definition used by the AI model to determine when and how to call the tool.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolDefinition {
/**
* The tool name. Unique within the tool set provided to a model.
*/
String name();
/**
* The tool description, used by the AI model to determine what the tool does.
*/
String description();
/**
* The JSON Schema of the parameters used to call the tool.
*/
String inputTypeSchema();
/**
* Create a default {@link ToolDefinition} builder.
*/
static DefaultToolDefinition.Builder builder() {
return DefaultToolDefinition.builder();
}
/**
* Create a default {@link ToolDefinition} instance from a {@link Method}.
*/
static ToolDefinition from(Method method) {
return DefaultToolDefinition.builder()
.name(ToolUtils.getToolName(method))
.description(ToolUtils.getToolDescription(method))
.inputTypeSchema(JsonSchemaGenerator.generateForMethodInput(method))
.build();
}
}

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.tool.definition;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,42 @@
/*
* 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.execution;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* A default implementation of {@link ToolCallResultConverter}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class DefaultToolCallResultConverter implements ToolCallResultConverter {
@Override
public String apply(@Nullable Object result, Class<?> returnType) {
Assert.notNull(returnType, "returnType cannot be null");
if (returnType == Void.TYPE) {
return "Done";
}
else {
return JsonParser.toJson(result);
}
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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.execution;
import org.springframework.lang.Nullable;
import java.util.function.BiFunction;
/**
* A functional interface to convert tool call results to a String that can be sent back
* to the AI model.
*
* @author Thomas Vitale
* @since 1.0.0
*/
@FunctionalInterface
public interface ToolCallResultConverter extends BiFunction<Object, Class<?>, String> {
/**
* Given an Object returned by a tool, convert it to a String compatible with the
* given class type.
*/
String apply(@Nullable Object result, Class<?> returnType);
}

View File

@@ -0,0 +1,40 @@
/*
* 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.execution;
import org.springframework.ai.tool.definition.ToolDefinition;
/**
* An exception thrown when a tool execution fails.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class ToolExecutionException extends RuntimeException {
private final ToolDefinition toolDefinition;
public ToolExecutionException(ToolDefinition toolDefinition, Throwable cause) {
super(cause.getMessage(), cause);
this.toolDefinition = toolDefinition;
}
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
}

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.tool.execution;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,49 @@
/*
* 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.metadata;
/**
* Default implementation of {@link ToolMetadata}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public record DefaultToolMetadata(boolean returnDirect) implements ToolMetadata {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private boolean returnDirect = false;
private Builder() {
}
public Builder returnDirect(boolean returnDirect) {
this.returnDirect = returnDirect;
return this;
}
public DefaultToolMetadata build() {
return new DefaultToolMetadata(returnDirect);
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.metadata;
import org.springframework.ai.tool.util.ToolUtils;
import java.lang.reflect.Method;
/**
* Metadata about a tool specification and execution.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolMetadata {
/**
* Whether the tool result should be returned directly or passed back to the model.
*/
default boolean returnDirect() {
return false;
}
/**
* Create a default {@link ToolMetadata} builder.
*/
static DefaultToolMetadata.Builder builder() {
return DefaultToolMetadata.builder();
}
/**
* Create a default {@link ToolMetadata} instance from a {@link Method}.
*/
static ToolMetadata from(Method method) {
return DefaultToolMetadata.builder().returnDirect(ToolUtils.getToolReturnDirect(method)).build();
}
}

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.tool.metadata;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,224 @@
/*
* 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.method;
import com.fasterxml.jackson.core.type.TypeReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Map;
import java.util.stream.Stream;
/**
* A {@link ToolCallback} implementation to invoke methods as tools.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class MethodToolCallback implements ToolCallback {
private static final Logger logger = LoggerFactory.getLogger(MethodToolCallback.class);
private static final ToolCallResultConverter DEFAULT_RESULT_CONVERTER = new DefaultToolCallResultConverter();
private final ToolDefinition toolDefinition;
private final ToolMetadata toolMetadata;
private final Method toolMethod;
private final Object toolObject;
private final ToolCallResultConverter toolCallResultConverter;
public MethodToolCallback(ToolDefinition toolDefinition, ToolMetadata toolMetadata, Method toolMethod,
Object toolObject, @Nullable ToolCallResultConverter toolCallResultConverter) {
Assert.notNull(toolDefinition, "toolDefinition cannot be null");
Assert.notNull(toolMetadata, "toolMetadata cannot be null");
Assert.notNull(toolMethod, "toolMethod cannot be null");
Assert.notNull(toolObject, "toolObject cannot be null");
this.toolDefinition = toolDefinition;
this.toolMetadata = toolMetadata;
this.toolMethod = toolMethod;
this.toolObject = toolObject;
this.toolCallResultConverter = toolCallResultConverter != null ? toolCallResultConverter
: DEFAULT_RESULT_CONVERTER;
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public ToolMetadata getToolMetadata() {
return toolMetadata;
}
@Override
public String call(String toolInput) {
return call(toolInput, null);
}
@Override
public String call(String toolInput, @Nullable ToolContext toolContext) {
Assert.hasText(toolInput, "toolInput cannot be null or empty");
logger.debug("Starting execution of tool: {}", toolDefinition.name());
validateToolContextSupport(toolContext);
Map<String, Object> toolArguments = extractToolArguments(toolInput);
Object[] methodArguments = buildMethodArguments(toolArguments, toolContext);
Object result = callMethod(methodArguments);
logger.debug("Successful execution of tool: {}", toolDefinition.name());
Class<?> returnType = toolMethod.getReturnType();
return toolCallResultConverter.apply(result, returnType);
}
private void validateToolContextSupport(@Nullable ToolContext toolContext) {
var isToolContextRequired = toolContext != null && !CollectionUtils.isEmpty(toolContext.getContext());
var isToolContextAcceptedByMethod = Stream.of(toolMethod.getParameterTypes())
.anyMatch(type -> ClassUtils.isAssignable(type, ToolContext.class));
if (isToolContextRequired && !isToolContextAcceptedByMethod) {
throw new IllegalArgumentException("ToolContext is not supported by the method as an argument");
}
}
private Map<String, Object> extractToolArguments(String toolInput) {
return JsonParser.fromJson(toolInput, new TypeReference<>() {
});
}
// Based on the implementation in MethodInvokingFunctionCallback.
private Object[] buildMethodArguments(Map<String, Object> toolInputArguments, @Nullable ToolContext toolContext) {
return Stream.of(toolMethod.getParameters()).map(parameter -> {
if (parameter.getType().isAssignableFrom(ToolContext.class)) {
return toolContext;
}
Object rawArgument = toolInputArguments.get(parameter.getName());
return buildTypedArgument(rawArgument, parameter.getType());
}).toArray();
}
@Nullable
private Object buildTypedArgument(@Nullable Object value, Class<?> type) {
if (value == null) {
return null;
}
return JsonParser.toTypedObject(value, type);
}
@Nullable
private Object callMethod(Object[] methodArguments) {
if (isObjectNotPublic() || isMethodNotPublic()) {
toolMethod.setAccessible(true);
}
Object result;
try {
result = toolMethod.invoke(toolObject, methodArguments);
}
catch (IllegalAccessException ex) {
throw new IllegalStateException("Could not access method: " + ex.getMessage(), ex);
}
catch (InvocationTargetException ex) {
throw new ToolExecutionException(toolDefinition, ex.getCause());
}
return result;
}
private boolean isObjectNotPublic() {
return !Modifier.isPublic(toolObject.getClass().getModifiers());
}
private boolean isMethodNotPublic() {
return !Modifier.isPublic(toolMethod.getModifiers());
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private ToolDefinition toolDefinition;
private ToolMetadata toolMetadata;
private Method toolMethod;
private Object toolObject;
private ToolCallResultConverter toolCallResultConverter;
private Builder() {
}
public Builder toolDefinition(ToolDefinition toolDefinition) {
this.toolDefinition = toolDefinition;
return this;
}
public Builder toolMetadata(ToolMetadata toolMetadata) {
this.toolMetadata = toolMetadata;
return this;
}
public Builder toolMethod(Method toolMethod) {
this.toolMethod = toolMethod;
return this;
}
public Builder toolObject(Object toolObject) {
this.toolObject = toolObject;
return this;
}
public Builder toolCallResultConverter(ToolCallResultConverter toolCallResultConverter) {
this.toolCallResultConverter = toolCallResultConverter;
return this;
}
public MethodToolCallback build() {
return new MethodToolCallback(toolDefinition, toolMetadata, toolMethod, toolObject,
toolCallResultConverter);
}
}
}

View File

@@ -0,0 +1,126 @@
/*
* 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.method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* A {@link ToolCallbackProvider} that builds {@link ToolCallback} instances from
* {@link Tool}-annotated methods.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class MethodToolCallbackProvider implements ToolCallbackProvider {
private static final Logger logger = LoggerFactory.getLogger(MethodToolCallbackProvider.class);
private final List<Object> toolObjects;
private MethodToolCallbackProvider(List<Object> toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
this.toolObjects = toolObjects;
}
@Override
public ToolCallback[] getToolCallbacks() {
var toolCallbacks = toolObjects.stream()
.map(toolObject -> Stream.of(ReflectionUtils.getDeclaredMethods(toolObject.getClass()))
.filter(toolMethod -> toolMethod.isAnnotationPresent(Tool.class))
.filter(toolMethod -> !isFunctionalType(toolMethod))
.map(toolMethod -> MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(toolObject)
.toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
.build())
.toArray(ToolCallback[]::new))
.flatMap(Stream::of)
.toArray(ToolCallback[]::new);
validateToolCallbacks(toolCallbacks);
return toolCallbacks;
}
private boolean isFunctionalType(Method toolMethod) {
var isFunction = ClassUtils.isAssignable(toolMethod.getReturnType(), Function.class)
|| ClassUtils.isAssignable(toolMethod.getReturnType(), Supplier.class)
|| ClassUtils.isAssignable(toolMethod.getReturnType(), Consumer.class);
if (isFunction) {
logger.warn("Method {} is annotated with @Tool but returns a functional type. "
+ "This is not supported and the method will be ignored.", toolMethod.getName());
}
return isFunction;
}
private void validateToolCallbacks(ToolCallback[] toolCallbacks) {
List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
if (!duplicateToolNames.isEmpty()) {
throw new IllegalStateException("Multiple tools with the same name (%s) found in sources: %s".formatted(
String.join(", ", duplicateToolNames),
toolObjects.stream().map(o -> o.getClass().getName()).collect(Collectors.joining(", "))));
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private List<Object> toolObjects;
private Builder() {
}
public Builder toolObjects(Object... toolObjects) {
Assert.notNull(toolObjects, "toolObjects cannot be null");
this.toolObjects = Arrays.asList(toolObjects);
return this;
}
public MethodToolCallbackProvider build() {
return new MethodToolCallbackProvider(toolObjects);
}
}
}

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.tool.method;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

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.tool;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,87 @@
/*
* 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.util;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.util.ParsingUtils;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Miscellaneous tool utility methods. Mainly for internal use within the framework.
*
* @author Thomas Vitale
*/
public final class ToolUtils {
private ToolUtils() {
}
public static String getToolName(Method method) {
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return method.getName();
}
return StringUtils.hasText(tool.name()) ? tool.name() : method.getName();
}
public static String getToolDescription(Method method) {
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return ParsingUtils.reConcatenateCamelCase(method.getName(), " ");
}
return StringUtils.hasText(tool.description()) ? tool.description() : method.getName();
}
public static boolean getToolReturnDirect(Method method) {
var tool = method.getAnnotation(Tool.class);
return tool != null && tool.returnDirect();
}
public static ToolCallResultConverter getToolCallResultConverter(Method method) {
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return new DefaultToolCallResultConverter();
}
var type = tool.resultConverter();
try {
return type.getDeclaredConstructor().newInstance();
}
catch (Exception e) {
throw new IllegalArgumentException("Failed to instantiate ToolCallResultConverter: " + type, e);
}
}
public static List<String> getDuplicateToolNames(FunctionCallback... functionCallbacks) {
return Stream.of(functionCallbacks)
.collect(Collectors.groupingBy(FunctionCallback::getName, Collectors.counting()))
.entrySet()
.stream()
.filter(entry -> entry.getValue() > 1)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
}

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.tool.util;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,138 @@
/*
* 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.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.springframework.ai.util.JacksonUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
/**
* Utilities to perform parsing operations between JSON and Java.
*/
public final class JsonParser {
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.addModules(JacksonUtils.instantiateAvailableModules())
.build();
private JsonParser() {
}
/**
* Returns a Jackson {@link ObjectMapper} instance tailored for JSON-parsing
* operations for tool calling and structured output.
*/
public static ObjectMapper getObjectMapper() {
return OBJECT_MAPPER;
}
/**
* Converts a JSON string to a Java object.
*/
public static <T> T fromJson(String json, Class<T> type) {
Assert.notNull(json, "json cannot be null");
Assert.notNull(type, "type cannot be null");
try {
return OBJECT_MAPPER.readValue(json, type);
}
catch (JsonProcessingException ex) {
throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getName()), ex);
}
}
/**
* Converts a JSON string to a Java object.
*/
public static <T> T fromJson(String json, TypeReference<T> type) {
Assert.notNull(json, "json cannot be null");
Assert.notNull(type, "type cannot be null");
try {
return OBJECT_MAPPER.readValue(json, type);
}
catch (JsonProcessingException ex) {
throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getType().getTypeName()),
ex);
}
}
/**
* Converts a Java object to a JSON string.
*/
public static String toJson(@Nullable Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
}
catch (JsonProcessingException ex) {
throw new IllegalStateException("Conversion from Object to JSON failed", ex);
}
}
/**
* Convert a Java Object to a typed Object. Based on the implementation in
* MethodInvokingFunctionCallback.
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public static Object toTypedObject(Object value, Class<?> type) {
Assert.notNull(value, "value cannot be null");
Assert.notNull(type, "type cannot be null");
var javaType = ClassUtils.resolvePrimitiveIfNecessary(type);
if (javaType == String.class) {
return value.toString();
}
else if (javaType == Byte.class) {
return Byte.parseByte(value.toString());
}
else if (javaType == Integer.class) {
return Integer.parseInt(value.toString());
}
else if (javaType == Short.class) {
return Short.parseShort(value.toString());
}
else if (javaType == Long.class) {
return Long.parseLong(value.toString());
}
else if (javaType == Double.class) {
return Double.parseDouble(value.toString());
}
else if (javaType == Float.class) {
return Float.parseFloat(value.toString());
}
else if (javaType == Boolean.class) {
return Boolean.parseBoolean(value.toString());
}
else if (javaType.isEnum()) {
return Enum.valueOf((Class<Enum>) javaType, value.toString());
}
String json = JsonParser.toJson(value);
return JsonParser.fromJson(json, javaType);
}
}

View File

@@ -0,0 +1,189 @@
/*
* 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 (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,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;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -1,3 +1,4 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.ai.aot.SpringAiCoreRuntimeHints,\
org.springframework.ai.aot.KnuddelsRuntimeHints
org.springframework.ai.aot.KnuddelsRuntimeHints,\
org.springframework.ai.aot.ToolRuntimeHints

View File

@@ -0,0 +1,39 @@
/*
* 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.aot;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.aot.hint.RuntimeHints;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection;
/**
* Unit tests for {@link ToolRuntimeHints}.
*/
class ToolRuntimeHintsTests {
@Test
void registerHints() {
RuntimeHints runtimeHints = new RuntimeHints();
ToolRuntimeHints toolRuntimeHints = new ToolRuntimeHints();
toolRuntimeHints.registerHints(runtimeHints, null);
assertThat(runtimeHints).matches(reflection().onType(DefaultToolCallResultConverter.class));
}
}

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.
@@ -31,6 +31,7 @@ import java.util.function.Function;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.tool.ToolCallback;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
@@ -1350,6 +1351,43 @@ class DefaultChatClientTests {
assertThat(defaultSpec.getChatOptions()).isEqualTo(options);
}
@Test
void whenToolNamesElementIsNullThenThrow() {
ChatClient chatClient = new DefaultChatClientBuilder(mock(ChatModel.class)).build();
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
assertThatThrownBy(() -> spec.tools("myTool", null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolNames cannot contain null elements");
}
@Test
void whenToolNamesThenReturn() {
ChatClient chatClient = new DefaultChatClientBuilder(mock(ChatModel.class)).build();
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
String toolName = "myTool";
spec = spec.tools(toolName);
DefaultChatClient.DefaultChatClientRequestSpec defaultSpec = (DefaultChatClient.DefaultChatClientRequestSpec) spec;
assertThat(defaultSpec.getFunctionNames()).contains(toolName);
}
@Test
void whenToolCallbacksElementIsNullThenThrow() {
ChatClient chatClient = new DefaultChatClientBuilder(mock(ChatModel.class)).build();
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
assertThatThrownBy(() -> spec.toolCallbacks(mock(ToolCallback.class), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolCallbacks cannot contain null elements");
}
@Test
void whenToolCallbacksThenReturn() {
ChatClient chatClient = new DefaultChatClientBuilder(mock(ChatModel.class)).build();
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
ToolCallback toolCallback = mock(ToolCallback.class);
spec = spec.toolCallbacks(toolCallback);
DefaultChatClient.DefaultChatClientRequestSpec defaultSpec = (DefaultChatClient.DefaultChatClientRequestSpec) spec;
assertThat(defaultSpec.getFunctionCallbacks()).contains(toolCallback);
}
// FunctionCallback.builder().description("description").function(null,input->"hello").inputType(String.class).build()
@Test
@@ -1480,7 +1518,7 @@ class DefaultChatClientTests {
ChatClient chatClient = new DefaultChatClientBuilder(mock(ChatModel.class)).build();
ChatClient.ChatClientRequestSpec spec = chatClient.prompt();
assertThatThrownBy(() -> spec.functions("myFunction", null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("functionBeanNames cannot contain null elements");
.hasMessage("toolNames cannot contain null elements");
}
@Test

View File

@@ -0,0 +1,280 @@
/*
* 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.prompt.ChatOptions;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallback;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link DefaultToolCallingChatOptions}.
*/
class DefaultToolCallingChatOptionsTests {
@Test
void setToolCallbacksShouldStoreToolCallbacks() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
ToolCallback callback1 = mock(ToolCallback.class);
ToolCallback callback2 = mock(ToolCallback.class);
List<ToolCallback> callbacks = List.of(callback1, callback2);
options.setToolCallbacks(callbacks);
assertThat(options.getToolCallbacks()).hasSize(2).containsExactlyElementsOf(callbacks);
}
@Test
void setToolCallbacksWithVarargsShouldStoreToolCallbacks() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
ToolCallback callback1 = mock(ToolCallback.class);
ToolCallback callback2 = mock(ToolCallback.class);
options.setToolCallbacks(callback1, callback2);
assertThat(options.getToolCallbacks()).hasSize(2).containsExactly(callback1, callback2);
}
@Test
void setToolCallbacksShouldRejectNullList() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThatThrownBy(() -> options.setToolCallbacks((List<ToolCallback>) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolCallbacks cannot be null");
}
@Test
void setToolsShouldStoreTools() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = Set.of("tool1", "tool2");
options.setTools(tools);
assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrderElementsOf(tools);
}
@Test
void setToolsWithVarargsShouldStoreTools() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setTools("tool1", "tool2");
assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2");
}
@Test
void setToolsShouldRejectNullSet() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThatThrownBy(() -> options.setTools((Set<String>) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot be null");
}
@Test
void setToolsShouldRejectNullElements() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = new HashSet<>();
tools.add(null);
assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot contain null elements");
}
@Test
void setToolsShouldRejectEmptyElements() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Set<String> tools = new HashSet<>();
tools.add("");
assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("tools cannot contain empty elements");
}
@Test
void setToolContextShouldStoreContext() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
Map<String, Object> context = Map.of("key1", "value1", "key2", 42);
options.setToolContext(context);
assertThat(options.getToolContext()).hasSize(2).containsAllEntriesOf(context);
}
@Test
void setToolContextShouldRejectNullMap() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThatThrownBy(() -> options.setToolContext(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolContext cannot be null");
}
@Test
void copyShouldCreateNewInstanceWithSameValues() {
DefaultToolCallingChatOptions original = new DefaultToolCallingChatOptions();
ToolCallback callback = mock(ToolCallback.class);
original.setToolCallbacks(List.of(callback));
original.setTools(Set.of("tool1"));
original.setToolContext(Map.of("key", "value"));
original.setToolCallReturnDirect(true);
original.setModel("gpt-4");
original.setTemperature(0.7);
DefaultToolCallingChatOptions copy = original.copy();
assertThat(copy).isNotSameAs(original).satisfies(c -> {
assertThat(c.getToolCallbacks()).isEqualTo(original.getToolCallbacks());
assertThat(c.getTools()).isEqualTo(original.getTools());
assertThat(c.getToolContext()).isEqualTo(original.getToolContext());
assertThat(c.getToolCallReturnDirect()).isEqualTo(original.getToolCallReturnDirect());
assertThat(c.getModel()).isEqualTo(original.getModel());
assertThat(c.getTemperature()).isEqualTo(original.getTemperature());
});
}
@Test
void gettersShouldReturnImmutableCollections() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
ToolCallback callback = mock(ToolCallback.class);
options.setToolCallbacks(List.of(callback));
options.setTools(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.getToolContext().put("key2", "value2"))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void mergeShouldCombineWithNonToolCallingChatOptions() {
DefaultToolCallingChatOptions original = new DefaultToolCallingChatOptions();
original.setToolCallbacks(List.of(mock(ToolCallback.class)));
original.setTools(Set.of("tool1"));
original.setModel("gpt-3.5");
ChatOptions toMerge = ChatOptions.builder().model("gpt-4").build();
ToolCallingChatOptions merged = original.merge(toMerge);
assertThat(merged.getToolCallbacks()).hasSize(1);
assertThat(merged.getTools()).containsExactly("tool1");
assertThat(merged.getModel()).isEqualTo("gpt-4");
}
@Test
void mergeShouldCombineOptionsCorrectly() {
DefaultToolCallingChatOptions original = new DefaultToolCallingChatOptions();
original.setToolCallbacks(List.of(mock(ToolCallback.class)));
original.setTools(Set.of("tool1"));
original.setToolContext(Map.of("key1", "value1"));
original.setModel("gpt-3.5");
DefaultToolCallingChatOptions toMerge = new DefaultToolCallingChatOptions();
toMerge.setToolCallbacks(List.of(mock(ToolCallback.class)));
toMerge.setTools(Set.of("tool2"));
toMerge.setToolContext(Map.of("key2", "value2"));
toMerge.setTemperature(0.8);
ToolCallingChatOptions merged = original.merge(toMerge);
assertThat(merged.getToolCallbacks()).hasSize(2);
assertThat(merged.getTools()).containsExactlyInAnyOrder("tool1", "tool2");
assertThat(merged.getToolContext()).containsEntry("key1", "value1").containsEntry("key2", "value2");
assertThat(merged.getModel()).isEqualTo("gpt-3.5");
assertThat(merged.getTemperature()).isEqualTo(0.8);
}
@Test
void builderShouldCreateOptionsWithAllProperties() {
ToolCallback callback = mock(ToolCallback.class);
Map<String, Object> context = Map.of("key", "value");
ToolCallingChatOptions options = DefaultToolCallingChatOptions.builder()
.toolCallbacks(List.of(callback))
.tools(Set.of("tool1"))
.toolContext(context)
.toolCallReturnDirect(true)
.model("gpt-4")
.temperature(0.7)
.maxTokens(100)
.frequencyPenalty(0.5)
.presencePenalty(0.3)
.stopSequences(List.of("stop"))
.topK(3)
.topP(0.9)
.build();
assertThat(options).satisfies(o -> {
assertThat(o.getToolCallbacks()).containsExactly(callback);
assertThat(o.getTools()).containsExactly("tool1");
assertThat(o.getToolContext()).isEqualTo(context);
assertThat(o.getToolCallReturnDirect()).isTrue();
assertThat(o.getModel()).isEqualTo("gpt-4");
assertThat(o.getTemperature()).isEqualTo(0.7);
assertThat(o.getMaxTokens()).isEqualTo(100);
assertThat(o.getFrequencyPenalty()).isEqualTo(0.5);
assertThat(o.getPresencePenalty()).isEqualTo(0.3);
assertThat(o.getStopSequences()).containsExactly("stop");
assertThat(o.getTopK()).isEqualTo(3);
assertThat(o.getTopP()).isEqualTo(0.9);
});
}
@Test
void builderShouldSupportToolContextAddition() {
ToolCallingChatOptions options = DefaultToolCallingChatOptions.builder()
.toolContext("key1", "value1")
.toolContext("key2", "value2")
.build();
assertThat(options.getToolContext()).containsEntry("key1", "value1").containsEntry("key2", "value2");
}
@Test
void deprecatedMethodsShouldWorkCorrectly() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
FunctionCallback callback = mock(FunctionCallback.class);
assertThatThrownBy(() -> options.setFunctionCallbacks(List.of(callback)))
.isInstanceOf(UnsupportedOperationException.class)
.hasMessage("Not supported. Call setToolCallbacks instead.");
options.setTools(Set.of("tool1"));
assertThat(options.getFunctions()).containsExactly("tool1");
options.setFunctions(Set.of("function1"));
assertThat(options.getTools()).containsExactly("function1");
options.setToolCallReturnDirect(true);
assertThat(options.getProxyToolCalls()).isTrue();
options.setProxyToolCalls(true);
assertThat(options.getToolCallReturnDirect()).isTrue();
}
}

View File

@@ -0,0 +1,42 @@
package org.springframework.ai.tool;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.definition.ToolDefinition;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ToolCallback}.
*
* @author Thomas Vitale
*/
class ToolCallbackTests {
@Test
void shouldOnlyImplementRequiredMethods() {
var testToolCallback = new TestToolCallback("test");
assertThat(testToolCallback.getToolDefinition()).isNotNull();
assertThat(testToolCallback.getToolMetadata()).isNotNull();
}
static class TestToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public TestToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).description(name).inputTypeSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
return "";
}
}
}

View File

@@ -0,0 +1,65 @@
package org.springframework.ai.tool.definition;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DefaultToolDefinition}.
*
* @author Thomas Vitale
*/
class DefaultToolDefinitionTests {
@Test
void shouldCreateDefaultToolDefinition() {
var toolDefinition = new DefaultToolDefinition("name", "description", "{}");
assertThat(toolDefinition.name()).isEqualTo("name");
assertThat(toolDefinition.description()).isEqualTo("description");
assertThat(toolDefinition.inputTypeSchema()).isEqualTo("{}");
}
@Test
void shouldThrowExceptionWhenNameIsNull() {
assertThatThrownBy(() -> new DefaultToolDefinition(null, "description", "{}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("name cannot be null or empty");
}
@Test
void shouldThrowExceptionWhenNameIsEmpty() {
assertThatThrownBy(() -> new DefaultToolDefinition("", "description", "{}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("name cannot be null or empty");
}
@Test
void shouldThrowExceptionWhenDescriptionIsNull() {
assertThatThrownBy(() -> new DefaultToolDefinition("name", null, "{}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("description cannot be null or empty");
}
@Test
void shouldThrowExceptionWhenDescriptionIsEmpty() {
assertThatThrownBy(() -> new DefaultToolDefinition("name", "", "{}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("description cannot be null or empty");
}
@Test
void shouldThrowExceptionWhenInputTypeSchemaIsNull() {
assertThatThrownBy(() -> new DefaultToolDefinition("name", "description", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("inputTypeSchema cannot be null or empty");
}
@Test
void shouldThrowExceptionWhenInputTypeSchemaIsEmpty() {
assertThatThrownBy(() -> new DefaultToolDefinition("name", "description", ""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("inputTypeSchema cannot be null or empty");
}
}

View File

@@ -0,0 +1,58 @@
package org.springframework.ai.tool.definition;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ToolDefinition}.
*
* @author Thomas Vitale
*/
class ToolDefinitionTests {
@Test
void shouldCreateDefaultToolDefinitionBuilder() {
var toolDefinition = ToolDefinition.builder()
.name("name")
.description("description")
.inputTypeSchema("{}")
.build();
assertThat(toolDefinition.name()).isEqualTo("name");
assertThat(toolDefinition.description()).isEqualTo("description");
assertThat(toolDefinition.inputTypeSchema()).isEqualTo("{}");
}
@Test
void shouldCreateToolDefinitionFromMethod() {
var toolDefinition = ToolDefinition.from(Tools.class.getDeclaredMethods()[0]);
assertThat(toolDefinition.name()).isEqualTo("mySuperTool");
assertThat(toolDefinition.description()).isEqualTo("Test description");
assertThat(toolDefinition.inputTypeSchema()).isEqualToIgnoringWhitespace("""
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"input" : {
"type" : "string"
}
},
"required" : [ "input" ],
"additionalProperties" : false
}
""");
}
static class Tools {
@Tool(description = "Test description")
public List<String> mySuperTool(String input) {
return List.of(input);
}
}
}

View File

@@ -0,0 +1,96 @@
package org.springframework.ai.tool.execution;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DefaultToolCallResultConverter}.
*
* @author Thomas Vitale
*/
class DefaultToolCallResultConverterTests {
private final DefaultToolCallResultConverter converter = new DefaultToolCallResultConverter();
@Test
void convertWithNullReturnTypeShouldThrowException() {
assertThatThrownBy(() -> converter.apply(null, null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("returnType cannot be null");
}
@Test
void convertVoidReturnTypeShouldReturnDone() {
String result = converter.apply(null, void.class);
assertThat(result).isEqualTo("Done");
}
@Test
void convertStringReturnTypeShouldReturnJson() {
String result = converter.apply("test", String.class);
assertThat(result).isEqualTo("\"test\"");
}
@Test
void convertNullReturnValueShouldReturnNullJson() {
String result = converter.apply(null, String.class);
assertThat(result).isEqualTo("null");
}
@Test
void convertObjectReturnTypeShouldReturnJson() {
TestObject testObject = new TestObject("test", 42);
String result = converter.apply(testObject, TestObject.class);
assertThat(result).containsIgnoringWhitespaces("""
"name": "test"
""").containsIgnoringWhitespaces("""
"value": 42
""");
}
@Test
void convertCollectionReturnTypeShouldReturnJson() {
List<String> testList = List.of("one", "two", "three");
String result = converter.apply(testList, List.class);
assertThat(result).isEqualTo("""
["one","two","three"]
""".trim());
}
@Test
void convertMapReturnTypeShouldReturnJson() {
Map<String, Integer> testMap = Map.of("one", 1, "two", 2);
String result = converter.apply(testMap, Map.class);
assertThat(result).containsIgnoringWhitespaces("""
"one": 1
""").containsIgnoringWhitespaces("""
"two": 2
""");
}
static class TestObject {
private final String name;
private final int value;
TestObject(String name, int value) {
this.name = name;
this.value = value;
}
public String getName() {
return name;
}
public int getValue() {
return value;
}
}
}

View File

@@ -0,0 +1,36 @@
package org.springframework.ai.tool.execution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.definition.ToolDefinition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link ToolExecutionException}.
*
* @author Thomas Vitale
*/
class ToolExecutionExceptionTests {
@Test
void constructorShouldSetCauseAndMessage() {
String errorMessage = "Test error message";
RuntimeException cause = new RuntimeException(errorMessage);
ToolExecutionException exception = new ToolExecutionException(mock(ToolDefinition.class), cause);
assertThat(exception.getCause()).isEqualTo(cause);
assertThat(exception.getMessage()).isEqualTo(errorMessage);
}
@Test
void getToolDefinitionShouldReturnToolDefinition() {
RuntimeException cause = new RuntimeException("Test error");
ToolDefinition toolDefinition = mock(ToolDefinition.class);
ToolExecutionException exception = new ToolExecutionException(toolDefinition, cause);
assertThat(exception.getToolDefinition()).isEqualTo(toolDefinition);
}
}

View File

@@ -0,0 +1,26 @@
package org.springframework.ai.tool.metadata;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link DefaultToolMetadata}.
*
* @author Thomas Vitale
*/
class DefaultToolMetadataTests {
@Test
void shouldCreateDefaultToolMetadataWithDefaultValues() {
var toolMetadata = DefaultToolMetadata.builder().build();
assertThat(toolMetadata.returnDirect()).isFalse();
}
@Test
void shouldCreateDefaultToolMetadataWithGivenValues() {
var toolMetadata = DefaultToolMetadata.builder().returnDirect(true).build();
assertThat(toolMetadata.returnDirect()).isTrue();
}
}

View File

@@ -0,0 +1,38 @@
package org.springframework.ai.tool.metadata;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ToolMetadata}.
*
* @author Thomas Vitale
*/
class ToolMetadataTests {
@Test
void shouldCreateDefaultToolMetadataBuilder() {
var toolMetadata = ToolMetadata.builder().build();
assertThat(toolMetadata.returnDirect()).isFalse();
}
@Test
void shouldCreateToolMetadataFromMethod() {
var toolMetadata = ToolMetadata.from(Tools.class.getDeclaredMethods()[0]);
assertThat(toolMetadata.returnDirect()).isTrue();
}
static class Tools {
@Tool(description = "Test description", returnDirect = true)
public List<String> mySuperTool(String input) {
return List.of(input);
}
}
}

View File

@@ -0,0 +1,254 @@
package org.springframework.ai.tool.method;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link MethodToolCallbackProvider}.
*
* @author Thomas Vitale
*/
class MethodToolCallbackProviderTests {
@Nested
class BuilderValidationTests {
@Test
void shouldRejectNullToolObjects() {
assertThatThrownBy(() -> MethodToolCallbackProvider.builder().toolObjects((Object[]) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolObjects cannot be null");
}
@Test
void shouldRejectNullToolObjectElements() {
assertThatThrownBy(() -> MethodToolCallbackProvider.builder().toolObjects(new Tools(), null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolObjects cannot contain null elements");
}
@Test
void shouldAcceptEmptyToolObjects() {
var provider = MethodToolCallbackProvider.builder().toolObjects().build();
assertThat(provider.getToolCallbacks()).isEmpty();
}
}
@Test
void shouldProvideToolCallbacksFromObject() {
Tools tools = new Tools();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(tools).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
assertThat(callbacks).hasSize(2);
var callback1 = Stream.of(callbacks).filter(c -> c.getName().equals("testMethod")).findFirst();
assertThat(callback1).isPresent();
assertThat(callback1.get().getName()).isEqualTo("testMethod");
assertThat(callback1.get().getDescription()).isEqualTo("Test description");
var callback2 = Stream.of(callbacks).filter(c -> c.getName().equals("testStaticMethod")).findFirst();
assertThat(callback2).isPresent();
assertThat(callback2.get().getName()).isEqualTo("testStaticMethod");
assertThat(callback2.get().getDescription()).isEqualTo("Test description");
}
@Test
void shouldProvideToolCallbacksFromMultipleObjects() {
Tools tools1 = new Tools();
ToolsExtra tools2 = new ToolsExtra();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(tools1, tools2).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
assertThat(callbacks).hasSize(4); // 2 from Tools + 2 from ToolsExtra
assertThat(Stream.of(callbacks).map(ToolCallback::getName)).containsExactlyInAnyOrder("testMethod",
"testStaticMethod", "extraMethod1", "extraMethod2");
}
@Test
void shouldEnsureUniqueToolNames() {
ToolsWithDuplicates testComponent = new ToolsWithDuplicates();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(testComponent).build();
assertThatThrownBy(provider::getToolCallbacks).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Multiple tools with the same name (testMethod) found in sources: "
+ testComponent.getClass().getName());
}
@Test
void shouldHandleToolMethodsWithDifferentVisibility() {
ToolsWithVisibility tools = new ToolsWithVisibility();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(tools).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
assertThat(callbacks).hasSize(3);
assertThat(Stream.of(callbacks).map(ToolCallback::getName)).containsExactlyInAnyOrder("publicMethod",
"protectedMethod", "privateMethod");
}
@Test
void shouldHandleToolMethodsWithDifferentParameters() {
ToolsWithParameters tools = new ToolsWithParameters();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(tools).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
assertThat(callbacks).hasSize(3);
assertThat(Stream.of(callbacks).map(ToolCallback::getName)).containsExactlyInAnyOrder("noParams", "oneParam",
"multipleParams");
}
@Test
void shouldHandleToolMethodsWithDifferentReturnTypes() {
ToolsWithReturnTypes tools = new ToolsWithReturnTypes();
MethodToolCallbackProvider provider = MethodToolCallbackProvider.builder().toolObjects(tools).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
assertThat(callbacks).hasSize(4);
assertThat(Stream.of(callbacks).map(ToolCallback::getName)).containsExactlyInAnyOrder("voidMethod",
"primitiveMethod", "objectMethod", "collectionMethod");
}
static class Tools {
@Tool(description = "Test description")
static List<String> testStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
List<String> testMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
Function<String, Integer> testFunction(String input) {
// This method should be ignored as it's a functional type
return String::length;
}
@Tool(description = "Test description")
Consumer<String> testConsumer(String input) {
// This method should be ignored as it's a functional type
return System.out::println;
}
@Tool(description = "Test description")
Supplier<String> testSupplier() {
// This method should be ignored as it's a functional type
return () -> "test";
}
void nonToolMethod() {
// This method should be ignored as it doesn't have @Tool annotation
}
}
static class ToolsExtra {
@Tool(description = "Extra method 1")
String extraMethod1() {
return "extra1";
}
@Tool(description = "Extra method 2")
String extraMethod2() {
return "extra2";
}
}
static class ToolsWithDuplicates {
@Tool(name = "testMethod", description = "Test description")
List<String> testMethod1(String input) {
return List.of(input);
}
@Tool(name = "testMethod", description = "Test description")
List<String> testMethod2(String input) {
return List.of(input);
}
}
static class ToolsWithVisibility {
@Tool(description = "Public method")
public String publicMethod() {
return "public";
}
@Tool(description = "Protected method")
protected String protectedMethod() {
return "protected";
}
@Tool(description = "Private method")
private String privateMethod() {
return "private";
}
}
static class ToolsWithParameters {
@Tool(description = "No parameters")
String noParams() {
return "no params";
}
@Tool(description = "One parameter")
String oneParam(String param) {
return param;
}
@Tool(description = "Multiple parameters")
String multipleParams(String param1, int param2, boolean param3) {
return param1 + param2 + param3;
}
}
static class ToolsWithReturnTypes {
@Tool(description = "Void method")
void voidMethod() {
}
@Tool(description = "Primitive method")
int primitiveMethod() {
return 42;
}
@Tool(description = "Object method")
String objectMethod() {
return "object";
}
@Tool(description = "Collection method")
List<String> collectionMethod() {
return List.of("collection");
}
}
}

View File

@@ -0,0 +1,309 @@
package org.springframework.ai.tool.method;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link MethodToolCallback}.
*
* @author Thomas Vitale
*/
class MethodToolCallbackTests {
@ParameterizedTest
@ValueSource(strings = { "publicStaticMethod", "privateStaticMethod", "packageStaticMethod", "publicMethod",
"privateMethod", "packageMethod" })
void shouldCallToolFromPublicClass(String methodName) {
validateAssertions(methodName, new PublicTools());
}
@ParameterizedTest
@ValueSource(strings = { "publicStaticMethod", "privateStaticMethod", "packageStaticMethod", "publicMethod",
"privateMethod", "packageMethod" })
void shouldCallToolFromPrivateClass(String methodName) {
validateAssertions(methodName, new PrivateTools());
}
@ParameterizedTest
@ValueSource(strings = { "publicStaticMethod", "privateStaticMethod", "packageStaticMethod", "publicMethod",
"privateMethod", "packageMethod" })
void shouldCallToolFromPackageClass(String methodName) {
validateAssertions(methodName, new PackageTools());
}
@Test
void shouldHandleToolContextWhenSupported() {
Method toolMethod = getMethod("methodWithToolContext", ToolContextTools.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(new ToolContextTools())
.build();
ToolContext toolContext = new ToolContext(Map.of("key", "value"));
String result = callback.call("""
{
"input": "test"
}
""", toolContext);
assertThat(result).contains("value");
}
@Test
void shouldThrowExceptionWhenToolContextNotSupported() {
Method toolMethod = getMethod("publicMethod", PublicTools.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(new PublicTools())
.build();
ToolContext toolContext = new ToolContext(Map.of("key", "value"));
assertThatThrownBy(() -> callback.call("""
{
"input": "test"
}
""", toolContext)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ToolContext is not supported");
}
@Test
void shouldHandleComplexArguments() {
Method toolMethod = getMethod("complexArgumentMethod", ComplexTools.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(new ComplexTools())
.build();
String result = callback.call("""
{
"stringArg": "test",
"intArg": 42,
"listArg": ["a", "b", "c"],
"optionalArg": null
}
""");
assertThat(JsonParser.fromJson(result, new TypeReference<Map<String, Object>>() {
})).containsEntry("stringValue", "test").containsEntry("intValue", 42).containsEntry("listSize", 3);
}
@Test
void shouldHandleCustomResultConverter() {
Method toolMethod = getMethod("publicMethod", PublicTools.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(new PublicTools())
.toolCallResultConverter((result, type) -> "Converted: " + result)
.build();
String result = callback.call("""
{
"input": "test"
}
""");
assertThat(result).startsWith("Converted:");
}
@Test
void shouldThrowExceptionWhenToolExecutionFails() {
Method toolMethod = getMethod("errorMethod", ErrorTools.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(new ErrorTools())
.build();
assertThatThrownBy(() -> callback.call("""
{
"input": "test"
}
""")).isInstanceOf(ToolExecutionException.class).hasMessageContaining("Test error");
}
private static void validateAssertions(String methodName, Object toolObject) {
Method toolMethod = getMethod(methodName, toolObject.getClass());
assertThat(toolMethod).isNotNull();
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinition.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(toolObject)
.build();
String result = callback.call("""
{
"input": "Wingardium Leviosa"
}
""");
assertThat(JsonParser.fromJson(result, new TypeReference<List<String>>() {
})).contains("Wingardium Leviosa");
}
private static Method getMethod(String name, Class<?> toolsClass) {
return Arrays.stream(ReflectionUtils.getDeclaredMethods(toolsClass))
.filter(m -> m.getName().equals(name))
.findFirst()
.orElseThrow();
}
static public class PublicTools {
@Tool(description = "Test description")
public static List<String> publicStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private static List<String> privateStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
static List<String> packageStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
public List<String> publicMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private List<String> privateMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
List<String> packageMethod(String input) {
return List.of(input);
}
}
static private class PrivateTools {
@Tool(description = "Test description")
public static List<String> publicStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private static List<String> privateStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
static List<String> packageStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
public List<String> publicMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private List<String> privateMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
List<String> packageMethod(String input) {
return List.of(input);
}
}
static class PackageTools {
@Tool(description = "Test description")
public static List<String> publicStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private static List<String> privateStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
static List<String> packageStaticMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
public List<String> publicMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
private List<String> privateMethod(String input) {
return List.of(input);
}
@Tool(description = "Test description")
List<String> packageMethod(String input) {
return List.of(input);
}
}
static class ToolContextTools {
@Tool(description = "Test description")
public String methodWithToolContext(String input, ToolContext toolContext) {
return input + ": " + toolContext.getContext().get("key");
}
}
static class ComplexTools {
@Tool(description = "Test description")
public Map<String, Object> complexArgumentMethod(String stringArg, int intArg, List<String> listArg,
String optionalArg) {
return Map.of("stringValue", stringArg, "intValue", intArg, "listSize", listArg.size(), "optionalProvided",
optionalArg != null);
}
}
static class ErrorTools {
@Tool(description = "Test description")
public String errorMethod(String input) {
throw new IllegalArgumentException("Test error");
}
}
}

View File

@@ -0,0 +1,197 @@
package org.springframework.ai.tool.utils;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.tool.util.ToolUtils;
import java.lang.reflect.Method;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ToolUtils}.
*
* @author Thomas Vitale
*/
class ToolUtilsTests {
@Test
void shouldDetectDuplicateToolNames() {
ToolCallback callback1 = new TestToolCallback("tool_a");
ToolCallback callback2 = new TestToolCallback("tool_a");
ToolCallback callback3 = new TestToolCallback("tool_b");
List<String> duplicates = ToolUtils.getDuplicateToolNames(callback1, callback2, callback3);
assertThat(duplicates).isNotEmpty();
assertThat(duplicates).contains("tool_a");
}
@Test
void shouldNotDetectDuplicateToolNames() {
ToolCallback callback1 = new TestToolCallback("tool_a");
ToolCallback callback2 = new TestToolCallback("tool_b");
ToolCallback callback3 = new TestToolCallback("tool_c");
List<String> duplicates = ToolUtils.getDuplicateToolNames(callback1, callback2, callback3);
assertThat(duplicates).isEmpty();
}
@Test
void shouldGetToolNameFromAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithCustomName");
assertThat(ToolUtils.getToolName(method)).isEqualTo("customName");
}
@Test
void shouldGetMethodNameWhenNoCustomNameInAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithoutCustomName");
assertThat(ToolUtils.getToolName(method)).isEqualTo("toolWithoutCustomName");
}
@Test
void shouldGetMethodNameWhenNoAnnotation() throws Exception {
Method method = TestTools.class.getMethod("methodWithoutAnnotation");
assertThat(ToolUtils.getToolName(method)).isEqualTo("methodWithoutAnnotation");
}
@Test
void shouldGetToolDescriptionFromAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithCustomDescription");
assertThat(ToolUtils.getToolDescription(method)).isEqualTo("Custom description");
}
@Test
void shouldGetMethodNameWhenNoCustomDescriptionInAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithoutCustomDescription");
assertThat(ToolUtils.getToolDescription(method)).isEqualTo("toolWithoutCustomDescription");
}
@Test
void shouldGetFormattedMethodNameWhenNoAnnotation() throws Exception {
Method method = TestTools.class.getMethod("camelCaseMethodWithoutAnnotation");
assertThat(ToolUtils.getToolDescription(method)).isEqualTo("camel case method without annotation");
}
@Test
void shouldGetToolReturnDirectFromAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithReturnDirect");
assertThat(ToolUtils.getToolReturnDirect(method)).isTrue();
}
@Test
void shouldGetDefaultReturnDirectWhenNoAnnotation() throws Exception {
Method method = TestTools.class.getMethod("methodWithoutAnnotation");
assertThat(ToolUtils.getToolReturnDirect(method)).isFalse();
}
@Test
void shouldGetToolCallResultConverterFromAnnotation() throws Exception {
Method method = TestTools.class.getMethod("toolWithCustomConverter");
ToolCallResultConverter converter = ToolUtils.getToolCallResultConverter(method);
assertThat(converter).isInstanceOf(CustomToolCallResultConverter.class);
}
@Test
void shouldGetDefaultConverterWhenNoAnnotation() throws Exception {
Method method = TestTools.class.getMethod("methodWithoutAnnotation");
ToolCallResultConverter converter = ToolUtils.getToolCallResultConverter(method);
assertThat(converter).isInstanceOf(DefaultToolCallResultConverter.class);
}
@Test
void shouldThrowExceptionWhenConverterCannotBeInstantiated() throws Exception {
Method method = TestTools.class.getMethod("toolWithInvalidConverter");
assertThatThrownBy(() -> ToolUtils.getToolCallResultConverter(method))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Failed to instantiate ToolCallResultConverter");
}
static class TestToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public TestToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).description(name).inputTypeSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String functionInput) {
return "";
}
}
static class TestTools {
@Tool(name = "customName")
public void toolWithCustomName() {
}
@Tool
public void toolWithoutCustomName() {
}
@Tool(description = "Custom description")
public void toolWithCustomDescription() {
}
@Tool
public void toolWithoutCustomDescription() {
}
@Tool(returnDirect = true)
public void toolWithReturnDirect() {
}
@Tool(resultConverter = CustomToolCallResultConverter.class)
public void toolWithCustomConverter() {
}
@Tool(resultConverter = InvalidToolCallResultConverter.class)
public void toolWithInvalidConverter() {
}
public void methodWithoutAnnotation() {
}
public void camelCaseMethodWithoutAnnotation() {
}
}
public static class CustomToolCallResultConverter implements ToolCallResultConverter {
@Override
public String apply(Object result, Class<?> returnType) {
return returnType.getName();
}
}
// No-public class with no-public constructor
static class InvalidToolCallResultConverter implements ToolCallResultConverter {
private InvalidToolCallResultConverter() {
}
@Override
public String apply(Object result, Class<?> returnType) {
return returnType.getName();
}
}
}

View File

@@ -0,0 +1,221 @@
package org.springframework.ai.util.json;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for the {@link JsonParser} class.
*
* @author Thomas Vitale
*/
class JsonParserTests {
@Test
void shouldGetObjectMapper() {
var objectMapper = JsonParser.getObjectMapper();
assertThat(objectMapper).isNotNull();
}
@Test
void shouldThrowExceptionWhenJsonIsNull() {
assertThatThrownBy(() -> JsonParser.fromJson(null, TestRecord.class))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("json cannot be null");
}
@Test
void shouldThrowExceptionWhenClassIsNull() {
assertThatThrownBy(() -> JsonParser.fromJson("{}", (Class<?>) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("type cannot be null");
}
@Test
void shouldThrowExceptionWhenTypeIsNull() {
assertThatThrownBy(() -> JsonParser.fromJson("{}", (TypeReference<?>) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("type cannot be null");
}
@Test
void fromJsonToObject() {
var json = """
{
"name" : "John",
"age" : 30
}
""";
var object = JsonParser.fromJson(json, TestRecord.class);
assertThat(object).isNotNull();
assertThat(object.name).isEqualTo("John");
assertThat(object.age).isEqualTo(30);
}
@Test
void fromJsonToObjectWithMissingProperty() {
var json = """
{
"name": "John"
}
""";
var object = JsonParser.fromJson(json, TestRecord.class);
assertThat(object).isNotNull();
assertThat(object.name).isEqualTo("John");
assertThat(object.age).isNull();
}
@Test
void fromJsonToObjectWithNullProperty() {
var json = """
{
"name": "John",
"age": null
}
""";
var object = JsonParser.fromJson(json, TestRecord.class);
assertThat(object).isNotNull();
assertThat(object.name).isEqualTo("John");
assertThat(object.age).isNull();
}
@Test
void fromJsonToObjectWithOtherNullProperty() {
var json = """
{
"name": null,
"age": 21
}
""";
var object = JsonParser.fromJson(json, TestRecord.class);
assertThat(object).isNotNull();
assertThat(object.name).isNull();
assertThat(object.age).isEqualTo(21);
}
@Test
void fromJsonToObjectWithUnknownProperty() {
var json = """
{
"name": "James",
"surname": "Bond"
}
""";
var object = JsonParser.fromJson(json, TestRecord.class);
assertThat(object).isNotNull();
assertThat(object.name).isEqualTo("James");
}
@Test
void fromObjectToJson() {
var object = new TestRecord("John", 30);
var json = JsonParser.toJson(object);
assertThat(json).isEqualToIgnoringWhitespace("""
{
"name" : "John",
"age" : 30
}
""");
}
@Test
void fromObjectToJsonWithNullValues() {
var object = new TestRecord("John", null);
var json = JsonParser.toJson(object);
assertThat(json).isEqualToIgnoringWhitespace("""
{
"name" : "John",
"age" : null
}
""");
}
@Test
void fromNullObjectToJson() {
var json = JsonParser.toJson(null);
assertThat(json).isEqualToIgnoringWhitespace("null");
}
@Test
void fromObjectToString() {
var value = JsonParser.toTypedObject("John", String.class);
assertThat(value).isOfAnyClassIn(String.class);
assertThat(value).isEqualTo("John");
}
@Test
void fromObjectToByte() {
var value = JsonParser.toTypedObject("1", Byte.class);
assertThat(value).isOfAnyClassIn(Byte.class);
assertThat(value).isEqualTo((byte) 1);
}
@Test
void fromObjectToInteger() {
var value = JsonParser.toTypedObject("1", Integer.class);
assertThat(value).isOfAnyClassIn(Integer.class);
assertThat(value).isEqualTo(1);
}
@Test
void fromObjectToShort() {
var value = JsonParser.toTypedObject("1", Short.class);
assertThat(value).isOfAnyClassIn(Short.class);
assertThat(value).isEqualTo((short) 1);
}
@Test
void fromObjectToLong() {
var value = JsonParser.toTypedObject("1", Long.class);
assertThat(value).isOfAnyClassIn(Long.class);
assertThat(value).isEqualTo(1L);
}
@Test
void fromObjectToDouble() {
var value = JsonParser.toTypedObject("1.0", Double.class);
assertThat(value).isOfAnyClassIn(Double.class);
assertThat(value).isEqualTo(1.0);
}
@Test
void fromObjectToFloat() {
var value = JsonParser.toTypedObject("1.0", Float.class);
assertThat(value).isOfAnyClassIn(Float.class);
assertThat(value).isEqualTo(1.0f);
}
@Test
void fromObjectToBoolean() {
var value = JsonParser.toTypedObject("true", Boolean.class);
assertThat(value).isOfAnyClassIn(Boolean.class);
assertThat(value).isEqualTo(true);
}
@Test
void fromObjectToEnum() {
var value = JsonParser.toTypedObject("VALUE", TestEnum.class);
assertThat(value).isOfAnyClassIn(TestEnum.class);
assertThat(value).isEqualTo(TestEnum.VALUE);
}
@Test
void fromObjectToRecord() {
var record = new TestRecord("John", 30);
var value = JsonParser.toTypedObject(record, TestRecord.class);
assertThat(value).isOfAnyClassIn(TestRecord.class);
assertThat(value).isEqualTo(new TestRecord("John", 30));
}
record TestRecord(String name, Integer age) {
}
enum TestEnum {
VALUE
}
}

View File

@@ -0,0 +1,368 @@
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 org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.Month;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link JsonSchemaGenerator}.
*
* @author Thomas Vitale
*/
class JsonSchemaGeneratorTests {
@Test
void generateSchemaForMethodWithSimpleParameters() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer",
"format" : "int32"
}
},
"required": [
"name",
"age"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithJsonPropertyAnnotations() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("annotatedMethod", String.class, String.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method,
JsonSchemaGenerator.SchemaOption.RESPECT_JSON_PROPERTY_REQUIRED);
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 generateSchemaForMethodWithAdditionalPropertiesAllowed() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method,
JsonSchemaGenerator.SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT);
JsonNode jsonNode = JsonParser.getObjectMapper().readTree(schema);
assertThat(jsonNode.has("additionalProperties")).isFalse();
}
@Test
void generateSchemaForMethodWithUpperCaseTypes() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method,
JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "OBJECT",
"properties": {
"name": {
"type": "STRING"
},
"age": {
"type": "INTEGER",
"format" : "int32"
}
},
"required": [
"name",
"age"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithComplexParameters() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("complexMethod", List.class, TestData.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "string"
}
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format" : "int32"
},
"name": {
"type": "string"
}
}
}
},
"required": [
"items",
"data"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForMethodWithTimeParameters() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("timeMethod", Duration.class, LocalDateTime.class,
Instant.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
String expectedJsonSchema = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"duration": {
"type": "string",
"format" : "duration"
},
"localDateTime": {
"type": "string",
"format": "date-time"
},
"instant": {
"type": "string",
"format": "date-time"
}
},
"required": [
"duration",
"localDateTime",
"instant"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForSimpleType() {
String schema = JsonSchemaGenerator.generateForType(Person.class);
String expectedJsonSchema = """
{
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "integer",
"format" : "int32"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForTypeWithAdditionalPropertiesAllowed() throws JsonProcessingException {
String schema = JsonSchemaGenerator.generateForType(Person.class,
JsonSchemaGenerator.SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT);
JsonNode jsonNode = JsonParser.getObjectMapper().readTree(schema);
assertThat(jsonNode.has("additionalProperties")).isFalse();
}
@Test
void generateSchemaForTypeWithUpperCaseValues() {
String schema = JsonSchemaGenerator.generateForType(Person.class,
JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES);
String expectedJsonSchema = """
{
"type": "OBJECT",
"properties": {
"email": {
"type": "STRING"
},
"id": {
"type": "INTEGER",
"format" : "int32"
},
"name": {
"type": "STRING"
}
},
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForRecord() {
String schema = JsonSchemaGenerator.generateForType(TestData.class);
String expectedJsonSchema = """
{
"type": "object",
"properties": {
"id": {
"type": "integer",
"format" : "int32"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void generateSchemaForEnum() {
String schema = JsonSchemaGenerator.generateForType(Month.class);
String expectedJsonSchema = """
{
"type": "string",
"enum": [
"JANUARY",
"FEBRUARY",
"MARCH",
"APRIL",
"MAY",
"JUNE",
"JULY",
"AUGUST",
"SEPTEMBER",
"OCTOBER",
"NOVEMBER",
"DECEMBER"
],
"additionalProperties": false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}
@Test
void throwExceptionWhenTypeIsNull() {
assertThatThrownBy(() -> JsonSchemaGenerator.generateForType(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("type cannot be null");
}
static class TestMethods {
public void simpleMethod(String name, int age) {
}
public void annotatedMethod(String username, @JsonProperty(required = true) String password) {
}
public void complexMethod(List<String> items, TestData data) {
}
public void timeMethod(Duration duration, LocalDateTime localDateTime, Instant instant) {
}
}
record TestData(int id, String name) {
}
static class Person {
private int id;
private String name;
private String email;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
}

View File

@@ -0,0 +1,193 @@
/*
* 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.integration.tests.tool;
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.chat.client.ChatClient;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.method.MethodToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link MethodToolCallback}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
public class MethodToolCallbackTests {
@Autowired
OpenAiChatModel openAiChatModel;
Tools tools = new Tools(new BookService());
@Test
void chatMethodNoArgs() {
var content = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user("Welcome the user to the library")
.tools(tools)
.call()
.content();
assertThat(content).isNotEmpty();
}
@Test
void chatMethodVoid() {
var content = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user("Welcome %s to the library".formatted("James Bond"))
.tools(tools)
.call()
.content();
assertThat(content).isNotEmpty();
}
@Test
void chatMethodSingle() {
var content = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user("What books written by %s are available in the library?".formatted("J.R.R. Tolkien"))
.tools(tools)
.call()
.content();
assertThat(content).isNotEmpty()
.contains("The Hobbit")
.contains("The Lord of The Rings")
.contains("The Silmarillion");
}
@Test
void chatMethodList() {
var content = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user("What authors wrote the books %s and %s available in the library?".formatted("The Hobbit", "Narnia"))
.tools(tools)
.call()
.content();
assertThat(content).isNotEmpty().contains("J.R.R. Tolkien").contains("C.S. Lewis");
}
@Test
void chatMethodCallback() {
var content = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user("What authors wrote the books %s and %s available in the library?".formatted("The Hobbit", "Narnia"))
.toolCallbacks(ToolCallbacks.from(tools))
.call()
.content();
assertThat(content).isNotEmpty().contains("J.R.R. Tolkien").contains("C.S. Lewis");
}
@Test
void chatMethodCallbackDefault() {
var content = ChatClient.builder(this.openAiChatModel)
.defaultTools(tools)
.build()
.prompt()
.user("How many books written by %s are available in the library?".formatted("J.R.R. Tolkien"))
.call()
.content();
assertThat(content).isNotEmpty().containsAnyOf("three", "3");
}
static class Tools {
private static final Logger logger = LoggerFactory.getLogger(Tools.class);
private final BookService bookService;
Tools(BookService bookService) {
this.bookService = bookService;
}
@Tool(description = "Welcome users to the library")
void welcome() {
logger.info("Welcoming users to the library");
}
@Tool(description = "Welcome a specific user to the library")
void welcomeUser(String user) {
logger.info("Welcoming {} to the library", user);
}
@Tool(description = "Get the list of books written by the given author available in the library")
List<Book> booksByAuthor(String author) {
logger.info("Getting books by author: {}", author);
return bookService.getBooksByAuthor(new Author(author));
}
@Tool(description = "Get the list of authors who wrote the given books available in the library")
List<Author> authorsByBooks(List<String> books) {
logger.info("Getting authors by books: {}", String.join(", ", books));
return bookService.getAuthorsByBook(books.stream().map(b -> new Book(b, "")).toList());
}
}
public record Author(String name) {
}
public record Book(String title, String author) {
}
static class BookService {
private static final Map<Integer, Book> books = new ConcurrentHashMap<>();
static {
books.put(1, new Book("His Dark Materials", "Philip Pullman"));
books.put(2, new Book("Narnia", "C.S. Lewis"));
books.put(3, new Book("The Hobbit", "J.R.R. Tolkien"));
books.put(4, new Book("The Lord of The Rings", "J.R.R. Tolkien"));
books.put(5, new Book("The Silmarillion", "J.R.R. Tolkien"));
}
public List<Book> getBooksByAuthor(Author author) {
return books.values().stream().filter(book -> author.name().equals(book.author())).toList();
}
public List<Author> getAuthorsByBook(List<Book> booksToSearch) {
return books.values()
.stream()
.filter(book -> booksToSearch.stream().anyMatch(b -> b.title().equals(book.title())))
.map(book -> new Author(book.author()))
.toList();
}
}
}