Advancing Tool Support - Part 3

* Introduced ToolCallingManager to manage the tool calling activities for resolving and executing tools. A default implementation is provided. It can be used to handle explicit tool execution on the client-side, superseding the previous FunctionCallingHelper class. It’s ready to be instrumented via Micrometer, and support exception handling when tool calls fail.
* Introduced ToolCallExceptionConverter to handle exceptions in tool calling, and provided a default implementation propagating the error message to the chat morel.
* Introduced ToolCallbackResolver to resolve ToolCallback instances. A default implementation is provided (DelegatingToolCallbackResolver), capable of delegating the resolution to a series of resolvers, including static resolution (StaticToolCallbackResolver) and dynamic resolution from the Spring context (SpringBeanToolCallbackResolver).
* Improved configuration in ToolCallingChatOptions to enable/disable the tool execution within a ChatModel (superseding the previous proxyToolCalls option).
* Added unit and integration tests to cover all the new use cases and existing functionality which was not covered by autotests (tool resolution from Spring context).
* Deprecated FunctionCallbackResolver, AbstractToolCallSupport, and FunctionCallingHelper.

Relates to gh-2049

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-01-27 07:38:57 +01:00
committed by Christian Tzolov
parent b03a6c9ef8
commit 8057b499bd
46 changed files with 2124 additions and 287 deletions

View File

@@ -32,6 +32,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.model.function.FunctionCallbackResolver;
import org.springframework.ai.model.function.FunctionCallingOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
@@ -44,7 +45,9 @@ import org.springframework.util.CollectionUtils;
* @author Thomas Vitale
* @author Jihoon Kim
* @since 1.0.0
* @deprecated Use {@link ToolCallingManager} instead.
*/
@Deprecated
public abstract class AbstractToolCallSupport {
protected static final boolean IS_RUNTIME_CALL = true;

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.
@@ -33,6 +33,7 @@ import org.springframework.util.CollectionUtils;
* @author Soby Chacko
* @author John Blum
* @author Alexandros Pappas
* @author Thomas Vitale
*/
public class ChatResponse implements ModelResponse<Generation> {
@@ -100,6 +101,16 @@ public class ChatResponse implements ModelResponse<Generation> {
return this.chatResponseMetadata;
}
/**
* Whether the model has requested the execution of a tool.
*/
public boolean hasToolCalls() {
if (CollectionUtils.isEmpty(generations)) {
return false;
}
return generations.stream().anyMatch(generation -> generation.getOutput().hasToolCalls());
}
@Override
public String toString() {
return "ChatResponse [metadata=" + this.chatResponseMetadata + ", generations=" + this.generations + "]";

View File

@@ -28,6 +28,8 @@ import kotlin.jvm.functions.Function2;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.model.function.FunctionCallback.SchemaType;
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
import org.springframework.ai.tool.resolution.TypeResolverHelper;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
@@ -55,7 +57,9 @@ import org.springframework.util.StringUtils;
* @author Christian Tzolov
* @author Christopher Smith
* @author Sebastien Deleuze
* @deprecated Use {@link SpringBeanToolCallbackResolver} instead.
*/
@Deprecated
public class DefaultFunctionCallbackResolver implements ApplicationContextAware, FunctionCallbackResolver {
private GenericApplicationContext applicationContext;

View File

@@ -16,6 +16,7 @@
package org.springframework.ai.model.function;
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
import org.springframework.lang.NonNull;
/**
@@ -23,7 +24,9 @@ import org.springframework.lang.NonNull;
*
* @author Christian Tzolov
* @since 1.0.0
* @deprecated Use {@link ToolCallbackResolver} instead.
*/
@Deprecated
public interface FunctionCallbackResolver {
/**

View File

@@ -23,6 +23,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import org.springframework.ai.model.tool.ToolCallingManager;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -40,7 +41,10 @@ import org.springframework.util.CollectionUtils;
* Helper class that reuses the {@link AbstractToolCallSupport} to implement the function
* call handling logic on the client side. Used when the withProxyToolCalls(true) option
* is enabled.
*
* @deprecated Use {@link ToolCallingManager} instead.
*/
@Deprecated
public class FunctionCallingHelper extends AbstractToolCallSupport {
public FunctionCallingHelper() {

View File

@@ -18,13 +18,11 @@ 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.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -39,14 +37,14 @@ import java.util.Set;
*/
public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
private List<ToolCallback> toolCallbacks = new ArrayList<>();
private List<FunctionCallback> toolCallbacks = new ArrayList<>();
private Set<String> tools = new HashSet<>();
private Map<String, Object> toolContext = new HashMap<>();
@Nullable
private Boolean toolCallReturnDirect;
private Boolean internalToolExecutionEnabled;
@Nullable
private String model;
@@ -73,23 +71,17 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
private Double topP;
@Override
public List<ToolCallback> getToolCallbacks() {
public List<FunctionCallback> getToolCallbacks() {
return List.copyOf(this.toolCallbacks);
}
@Override
public void setToolCallbacks(List<ToolCallback> toolCallbacks) {
public void setToolCallbacks(List<FunctionCallback> 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);
@@ -103,12 +95,6 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
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);
@@ -123,23 +109,23 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
@Override
@Nullable
public Boolean getToolCallReturnDirect() {
return this.toolCallReturnDirect;
public Boolean isInternalToolExecutionEnabled() {
return this.internalToolExecutionEnabled;
}
@Override
public void setToolCallReturnDirect(@Nullable Boolean toolCallReturnDirect) {
this.toolCallReturnDirect = toolCallReturnDirect;
public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) {
this.internalToolExecutionEnabled = internalToolExecutionEnabled;
}
@Override
public List<FunctionCallback> getFunctionCallbacks() {
return getToolCallbacks().stream().map(FunctionCallback.class::cast).toList();
return getToolCallbacks();
}
@Override
public void setFunctionCallbacks(List<FunctionCallback> functionCallbacks) {
throw new UnsupportedOperationException("Not supported. Call setToolCallbacks instead.");
setToolCallbacks(functionCallbacks);
}
@Override
@@ -155,12 +141,12 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
@Override
@Nullable
public Boolean getProxyToolCalls() {
return getToolCallReturnDirect();
return isInternalToolExecutionEnabled() != null ? !isInternalToolExecutionEnabled() : null;
}
@Override
public void setProxyToolCalls(@Nullable Boolean proxyToolCalls) {
setToolCallReturnDirect(proxyToolCalls != null && proxyToolCalls);
setInternalToolExecutionEnabled(proxyToolCalls == null || !proxyToolCalls);
}
@Override
@@ -250,7 +236,7 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
options.setToolCallbacks(getToolCallbacks());
options.setTools(getTools());
options.setToolContext(getToolContext());
options.setToolCallReturnDirect(getToolCallReturnDirect());
options.setInternalToolExecutionEnabled(isInternalToolExecutionEnabled());
options.setModel(getModel());
options.setFrequencyPenalty(getFrequencyPenalty());
options.setMaxTokens(getMaxTokens());
@@ -262,55 +248,6 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
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();
}
@@ -323,14 +260,15 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
private final DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
@Override
public ToolCallingChatOptions.Builder toolCallbacks(List<ToolCallback> toolCallbacks) {
public ToolCallingChatOptions.Builder toolCallbacks(List<FunctionCallback> toolCallbacks) {
this.options.setToolCallbacks(toolCallbacks);
return this;
}
@Override
public ToolCallingChatOptions.Builder toolCallbacks(ToolCallback... toolCallbacks) {
this.options.setToolCallbacks(toolCallbacks);
public ToolCallingChatOptions.Builder toolCallbacks(FunctionCallback... toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
this.options.setToolCallbacks(Arrays.asList(toolCallbacks));
return this;
}
@@ -342,7 +280,8 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
@Override
public ToolCallingChatOptions.Builder tools(String... toolNames) {
this.options.setTools(toolNames);
Assert.notNull(toolNames, "toolNames cannot be null");
this.options.setTools(Set.of(toolNames));
return this;
}
@@ -363,16 +302,16 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
}
@Override
public ToolCallingChatOptions.Builder toolCallReturnDirect(@Nullable Boolean toolCallReturnDirect) {
this.options.setToolCallReturnDirect(toolCallReturnDirect);
public ToolCallingChatOptions.Builder internalToolExecutionEnabled(
@Nullable Boolean internalToolExecutionEnabled) {
this.options.setInternalToolExecutionEnabled(internalToolExecutionEnabled);
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());
return toolCallbacks(functionCallbacks);
}
@Override
@@ -395,9 +334,9 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions {
}
@Override
@Deprecated // Use toolCallReturnDirect() instead
@Deprecated // Use internalToolExecutionEnabled() instead
public ToolCallingChatOptions.Builder proxyToolCalls(@Nullable Boolean proxyToolCalls) {
return toolCallReturnDirect(proxyToolCalls != null && proxyToolCalls);
return internalToolExecutionEnabled(proxyToolCalls == null || !proxyToolCalls);
}
@Override

View File

@@ -0,0 +1,249 @@
/*
* 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 io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.chat.prompt.Prompt;
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.definition.ToolDefinition;
import org.springframework.ai.tool.execution.DefaultToolCallExceptionConverter;
import org.springframework.ai.tool.execution.ToolCallExceptionConverter;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Default implementation of {@link ToolCallingManager}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class DefaultToolCallingManager implements ToolCallingManager {
// @formatter:off
private static final ObservationRegistry DEFAULT_OBSERVATION_REGISTRY
= ObservationRegistry.NOOP;
private static final ToolCallbackResolver DEFAULT_TOOL_CALLBACK_RESOLVER
= new DelegatingToolCallbackResolver(List.of());
private static final ToolCallExceptionConverter DEFAULT_TOOL_CALL_EXCEPTION_CONVERTER
= DefaultToolCallExceptionConverter.builder().build();
// @formatter:on
private final ObservationRegistry observationRegistry;
private final ToolCallbackResolver toolCallbackResolver;
private final ToolCallExceptionConverter toolCallExceptionConverter;
public DefaultToolCallingManager(ObservationRegistry observationRegistry, ToolCallbackResolver toolCallbackResolver,
ToolCallExceptionConverter toolCallExceptionConverter) {
Assert.notNull(observationRegistry, "observationRegistry cannot be null");
Assert.notNull(toolCallbackResolver, "toolCallbackResolver cannot be null");
Assert.notNull(toolCallExceptionConverter, "toolCallExceptionConverter cannot be null");
this.observationRegistry = observationRegistry;
this.toolCallbackResolver = toolCallbackResolver;
this.toolCallExceptionConverter = toolCallExceptionConverter;
}
@Override
public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
Assert.notNull(chatOptions, "chatOptions cannot be null");
List<FunctionCallback> toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks());
for (String toolName : chatOptions.getTools()) {
ToolCallback toolCallback = toolCallbackResolver.resolve(toolName);
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}
toolCallbacks.add(toolCallback);
}
return toolCallbacks.stream().map(functionCallback -> {
if (functionCallback instanceof ToolCallback toolCallback) {
return toolCallback.getToolDefinition();
}
else {
return ToolDefinition.builder()
.name(functionCallback.getName())
.description(functionCallback.getDescription())
.inputSchema(functionCallback.getInputTypeSchema())
.build();
}
}).toList();
}
@Override
public List<Message> executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
Assert.notNull(prompt, "prompt cannot be null");
Assert.notNull(chatResponse, "chatResponse cannot be null");
Optional<Generation> toolCallGeneration = chatResponse.getResults()
.stream()
.filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
.findFirst();
if (toolCallGeneration.isEmpty()) {
throw new IllegalStateException("No tool call requested by the chat model");
}
AssistantMessage assistantMessage = toolCallGeneration.get().getOutput();
ToolContext toolContext = buildToolContext(prompt, assistantMessage);
ToolResponseMessage toolMessageResponse = executeToolCall(prompt, assistantMessage, toolContext);
return buildConversationHistoryAfterToolExecution(prompt.getInstructions(), assistantMessage,
toolMessageResponse);
}
private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) {
Map<String, Object> toolContextMap = Map.of();
if (prompt.getOptions() instanceof FunctionCallingOptions functionOptions
&& !CollectionUtils.isEmpty(functionOptions.getToolContext())) {
toolContextMap = new HashMap<>(functionOptions.getToolContext());
List<Message> messageHistory = new ArrayList<>(prompt.copy().getInstructions());
messageHistory.add(new AssistantMessage(assistantMessage.getText(), assistantMessage.getMetadata(),
assistantMessage.getToolCalls()));
toolContextMap.put(ToolContext.TOOL_CALL_HISTORY,
buildConversationHistoryBeforeToolExecution(prompt, assistantMessage));
}
return new ToolContext(toolContextMap);
}
private static List<Message> buildConversationHistoryBeforeToolExecution(Prompt prompt,
AssistantMessage assistantMessage) {
List<Message> messageHistory = new ArrayList<>(prompt.copy().getInstructions());
messageHistory.add(new AssistantMessage(assistantMessage.getText(), assistantMessage.getMetadata(),
assistantMessage.getToolCalls()));
return messageHistory;
}
/**
* Execute the tool call and return the response message. To ensure backward
* compatibility, both {@link ToolCallback} and {@link FunctionCallback} are
* supported.
*/
private ToolResponseMessage executeToolCall(Prompt prompt, AssistantMessage assistantMessage,
ToolContext toolContext) {
List<FunctionCallback> toolCallbacks = List.of();
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) {
toolCallbacks = toolCallingChatOptions.getToolCallbacks();
}
else if (prompt.getOptions() instanceof FunctionCallingOptions functionOptions) {
toolCallbacks = functionOptions.getFunctionCallbacks();
}
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
String toolName = toolCall.name();
String toolInputArguments = toolCall.arguments();
FunctionCallback toolCallback = toolCallbacks.stream()
.filter(tool -> toolName.equals(tool.getName()))
.findFirst()
.orElse(toolCallbackResolver.resolve(toolName));
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}
String toolResult;
try {
toolResult = toolCallback.call(toolInputArguments, toolContext);
}
catch (ToolExecutionException ex) {
toolResult = toolCallExceptionConverter.convert(ex);
}
toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, toolResult));
}
return new ToolResponseMessage(toolResponses, Map.of());
}
private List<Message> buildConversationHistoryAfterToolExecution(List<Message> previousMessages,
AssistantMessage assistantMessage, ToolResponseMessage toolResponseMessage) {
List<Message> messages = new ArrayList<>(previousMessages);
messages.add(assistantMessage);
messages.add(toolResponseMessage);
return messages;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private ObservationRegistry observationRegistry = DEFAULT_OBSERVATION_REGISTRY;
private ToolCallbackResolver toolCallbackResolver = DEFAULT_TOOL_CALLBACK_RESOLVER;
private ToolCallExceptionConverter toolCallExceptionConverter = DEFAULT_TOOL_CALL_EXCEPTION_CONVERTER;
private Builder() {
}
public Builder observationRegistry(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
return this;
}
public Builder toolCallbackResolver(ToolCallbackResolver toolCallbackResolver) {
this.toolCallbackResolver = toolCallbackResolver;
return this;
}
public Builder toolCallExceptionConverter(ToolCallExceptionConverter toolCallExceptionConverter) {
this.toolCallExceptionConverter = toolCallExceptionConverter;
return this;
}
public DefaultToolCallingManager build() {
return new DefaultToolCallingManager(observationRegistry, toolCallbackResolver, toolCallExceptionConverter);
}
}
}

View File

@@ -16,11 +16,12 @@
package org.springframework.ai.model.tool;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
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 org.springframework.util.Assert;
import java.util.List;
import java.util.Map;
@@ -35,20 +36,17 @@ import java.util.Set;
*/
public interface ToolCallingChatOptions extends FunctionCallingOptions {
boolean DEFAULT_TOOL_EXECUTION_ENABLED = true;
/**
* ToolCallbacks to be registered with the ChatModel.
*/
List<ToolCallback> getToolCallbacks();
List<FunctionCallback> 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);
void setToolCallbacks(List<FunctionCallback> toolCallbacks);
/**
* Names of the tools to register with the ChatModel.
@@ -58,27 +56,20 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
/**
* Set the names of the tools to register with the ChatModel.
*/
void setTools(Set<String> tools);
void setTools(Set<String> toolNames);
/**
* 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()}.
* Whether the {@link ChatModel} is responsible for executing the tools requested by
* the model or if the tools should be executed directly by the caller.
*/
@Nullable
Boolean getToolCallReturnDirect();
Boolean isInternalToolExecutionEnabled();
/**
* 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()}.
* Set whether the {@link ChatModel} is responsible for executing the tools requested
* by the model or if the tools should be executed directly by the caller.
*/
void setToolCallReturnDirect(@Nullable Boolean toolCallReturnDirect);
void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled);
/**
* A builder to create a new {@link ToolCallingChatOptions} instance.
@@ -95,12 +86,12 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
/**
* ToolCallbacks to be registered with the ChatModel.
*/
Builder toolCallbacks(List<ToolCallback> functionCallbacks);
Builder toolCallbacks(List<FunctionCallback> functionCallbacks);
/**
* ToolCallbacks to be registered with the ChatModel.
*/
Builder toolCallbacks(ToolCallback... functionCallbacks);
Builder toolCallbacks(FunctionCallback... functionCallbacks);
/**
* Names of the tools to register with the ChatModel.
@@ -113,11 +104,10 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
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()}.
* Whether the {@link ChatModel} is responsible for executing the tools requested
* by the model or if the tools should be executed directly by the caller.
*/
Builder toolCallReturnDirect(@Nullable Boolean toolCallReturnDirect);
Builder internalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled);
// FunctionCallingOptions.Builder methods
@@ -144,7 +134,7 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
Builder function(String function);
@Override
@Deprecated // Use toolCallReturnDirect() instead
@Deprecated // Use internalToolExecutionEnabled() instead
Builder proxyToolCalls(@Nullable Boolean proxyToolCalls);
// ChatOptions.Builder methods
@@ -178,4 +168,21 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
}
static boolean isInternalToolExecutionEnabled(ChatOptions chatOptions) {
Assert.notNull(chatOptions, "chatOptions cannot be null");
boolean internalToolExecutionEnabled;
if (chatOptions instanceof ToolCallingChatOptions toolCallingChatOptions
&& toolCallingChatOptions.isInternalToolExecutionEnabled() != null) {
internalToolExecutionEnabled = Boolean.TRUE.equals(toolCallingChatOptions.isInternalToolExecutionEnabled());
}
else if (chatOptions instanceof FunctionCallingOptions functionCallingOptions
&& functionCallingOptions.getProxyToolCalls() != null) {
internalToolExecutionEnabled = Boolean.TRUE.equals(!functionCallingOptions.getProxyToolCalls());
}
else {
internalToolExecutionEnabled = DEFAULT_TOOL_EXECUTION_ENABLED;
}
return internalToolExecutionEnabled;
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.List;
/**
* Service responsible for managing the tool calling process for a chat model.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolCallingManager {
/**
* Resolve the tool definitions from the model's tool calling options.
*/
List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions);
/**
* Execute the tool calls requested by the model.
*/
List<Message> executeToolCalls(Prompt prompt, ChatResponse chatResponse);
/**
* Create a default {@link ToolCallingManager} builder.
*/
static DefaultToolCallingManager.Builder builder() {
return DefaultToolCallingManager.builder();
}
}

View File

@@ -64,9 +64,9 @@ public record DefaultToolDefinition(String name, String description, String inpu
return this;
}
public DefaultToolDefinition build() {
public ToolDefinition build() {
if (!StringUtils.hasText(description)) {
description = ToolUtils.getToolDescriptionFromName(description);
description = ToolUtils.getToolDescriptionFromName(name);
}
return new DefaultToolDefinition(name, description, inputSchema);
}

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.execution;
import org.springframework.util.Assert;
/**
* Default implementation of {@link ToolCallExceptionConverter}.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class DefaultToolCallExceptionConverter implements ToolCallExceptionConverter {
private static final boolean DEFAULT_ALWAYS_THROW = false;
private final boolean alwaysThrow;
public DefaultToolCallExceptionConverter(boolean alwaysThrow) {
this.alwaysThrow = alwaysThrow;
}
@Override
public String convert(ToolExecutionException exception) {
Assert.notNull(exception, "exception cannot be null");
if (alwaysThrow) {
throw exception;
}
return exception.getMessage();
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private boolean alwaysThrow = DEFAULT_ALWAYS_THROW;
public Builder alwaysThrow(boolean alwaysThrow) {
this.alwaysThrow = alwaysThrow;
return this;
}
public DefaultToolCallExceptionConverter build() {
return new DefaultToolCallExceptionConverter(alwaysThrow);
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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;
/**
* A functional interface to convert a tool call exception to a String that can be sent
* back to the AI model.
*
* @author Thomas Vitale
* @since 1.0.0
*/
@FunctionalInterface
public interface ToolCallExceptionConverter {
/**
* Convert an exception thrown by a tool to a String that can be sent back to the AI
* model.
*/
String convert(ToolExecutionException exception);
}

View File

@@ -40,7 +40,7 @@ public record DefaultToolMetadata(boolean returnDirect) implements ToolMetadata
return this;
}
public DefaultToolMetadata build() {
public ToolMetadata build() {
return new DefaultToolMetadata(returnDirect);
}

View File

@@ -0,0 +1,54 @@
/*
* 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.resolution;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.List;
/**
* A {@link ToolCallbackResolver} that delegates to a list of {@link ToolCallbackResolver}
* instances.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class DelegatingToolCallbackResolver implements ToolCallbackResolver {
private final List<ToolCallbackResolver> toolCallbackResolvers;
public DelegatingToolCallbackResolver(List<ToolCallbackResolver> toolCallbackResolvers) {
Assert.notNull(toolCallbackResolvers, "toolCallbackResolvers cannot be null");
Assert.noNullElements(toolCallbackResolvers, "toolCallbackResolvers cannot contain null elements");
this.toolCallbackResolvers = toolCallbackResolvers;
}
@Override
@Nullable
public ToolCallback resolve(String toolName) {
for (ToolCallbackResolver toolCallbackResolver : toolCallbackResolvers) {
ToolCallback toolCallback = toolCallbackResolver.resolve(toolName);
if (toolCallback != null) {
return toolCallback;
}
}
return null;
}
}

View File

@@ -0,0 +1,240 @@
/*
* 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.resolution;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.ai.util.json.SchemaType;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Description;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.KotlinDetector;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ResolvableType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* A Spring {@link ApplicationContext}-based implementation that provides a way to
* retrieve a bean from the Spring context and wrap it into a {@link ToolCallback}.
*
* @author Christian Tzolov
* @author Christopher Smith
* @author Sebastien Deleuze
* @author Thomas Vitale
* @since 1.0.0
*/
public class SpringBeanToolCallbackResolver implements ToolCallbackResolver {
private static final Map<String, ToolCallback> toolCallbacksCache = new HashMap<>();
private static final SchemaType DEFAULT_SCHEMA_TYPE = SchemaType.JSON_SCHEMA;
private final GenericApplicationContext applicationContext;
private final SchemaType schemaType;
public SpringBeanToolCallbackResolver(GenericApplicationContext applicationContext,
@Nullable SchemaType schemaType) {
Assert.notNull(applicationContext, "applicationContext cannot be null");
this.applicationContext = applicationContext;
this.schemaType = schemaType != null ? schemaType : DEFAULT_SCHEMA_TYPE;
}
@Override
public ToolCallback resolve(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
ToolCallback resolvedToolCallback = toolCallbacksCache.get(toolName);
if (resolvedToolCallback != null) {
return resolvedToolCallback;
}
ResolvableType toolType = TypeResolverHelper.resolveBeanType(applicationContext, toolName);
ResolvableType toolInputType = (ResolvableType.forType(Supplier.class).isAssignableFrom(toolType))
? ResolvableType.forType(Void.class) : TypeResolverHelper.getFunctionArgumentType(toolType, 0);
String toolDescription = resolveToolDescription(toolName, toolInputType.toClass());
Object bean = applicationContext.getBean(toolName);
resolvedToolCallback = buildToolCallback(toolName, toolType, toolInputType, toolDescription, bean);
toolCallbacksCache.put(toolName, resolvedToolCallback);
return resolvedToolCallback;
}
public SchemaType getSchemaType() {
return schemaType;
}
private String resolveToolDescription(String toolName, Class<?> toolInputType) {
Description descriptionAnnotation = applicationContext.findAnnotationOnBean(toolName, Description.class);
if (descriptionAnnotation != null && StringUtils.hasText(descriptionAnnotation.value())) {
return descriptionAnnotation.value();
}
JsonClassDescription jsonClassDescriptionAnnotation = toolInputType.getAnnotation(JsonClassDescription.class);
if (jsonClassDescriptionAnnotation != null && StringUtils.hasText(jsonClassDescriptionAnnotation.value())) {
return jsonClassDescriptionAnnotation.value();
}
return ToolUtils.getToolDescriptionFromName(toolName);
}
private ToolCallback buildToolCallback(String toolName, ResolvableType toolType, ResolvableType toolInputType,
String toolDescription, Object bean) {
if (KotlinDetector.isKotlinPresent()) {
if (KotlinDelegate.isKotlinFunction(toolType.toClass())) {
return FunctionToolCallback.builder(toolName, KotlinDelegate.wrapKotlinFunction(bean))
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
if (KotlinDelegate.isKotlinBiFunction(toolType.toClass())) {
return FunctionToolCallback.builder(toolName, KotlinDelegate.wrapKotlinBiFunction(bean))
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
if (KotlinDelegate.isKotlinSupplier(toolType.toClass())) {
return FunctionToolCallback.builder(toolName, KotlinDelegate.wrapKotlinSupplier(bean))
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
}
if (bean instanceof Function<?, ?> function) {
return FunctionToolCallback.builder(toolName, function)
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
if (bean instanceof BiFunction<?, ?, ?>) {
return FunctionToolCallback.builder(toolName, (BiFunction<?, ToolContext, ?>) bean)
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
if (bean instanceof Supplier<?> supplier) {
return FunctionToolCallback.builder(toolName, supplier)
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
if (bean instanceof Consumer<?> consumer) {
return FunctionToolCallback.builder(toolName, consumer)
.description(toolDescription)
.inputSchema(generateSchema(toolInputType))
.inputType(ParameterizedTypeReference.forType(toolInputType.getType()))
.build();
}
throw new IllegalStateException(
"Unsupported bean type. Support types: Function, BiFunction, Supplier, Consumer.");
}
private String generateSchema(ResolvableType toolInputType) {
if (schemaType == SchemaType.OPEN_API_SCHEMA) {
return JsonSchemaGenerator.generateForType(toolInputType.getType(),
JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES);
}
return JsonSchemaGenerator.generateForType(toolInputType.getType());
}
private static final class KotlinDelegate {
public static boolean isKotlinSupplier(Class<?> clazz) {
return Function0.class.isAssignableFrom(clazz);
}
@SuppressWarnings("unchecked")
public static Supplier<?> wrapKotlinSupplier(Object bean) {
return () -> ((Function0<Object>) bean).invoke();
}
public static boolean isKotlinFunction(Class<?> clazz) {
return Function1.class.isAssignableFrom(clazz);
}
@SuppressWarnings("unchecked")
public static Function<?, ?> wrapKotlinFunction(Object bean) {
return t -> ((Function1<Object, Object>) bean).invoke(t);
}
public static boolean isKotlinBiFunction(Class<?> clazz) {
return Function2.class.isAssignableFrom(clazz);
}
@SuppressWarnings("unchecked")
public static BiFunction<?, ToolContext, ?> wrapKotlinBiFunction(Object bean) {
return (t, u) -> ((Function2<Object, ToolContext, Object>) bean).invoke(t, u);
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private GenericApplicationContext applicationContext;
private SchemaType schemaType;
public Builder applicationContext(GenericApplicationContext applicationContext) {
this.applicationContext = applicationContext;
return this;
}
public Builder schemaType(SchemaType schemaType) {
this.schemaType = schemaType;
return this;
}
public SpringBeanToolCallbackResolver build() {
return new SpringBeanToolCallbackResolver(applicationContext, schemaType);
}
}
}

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.resolution;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.Assert;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A {@link ToolCallbackResolver} that resolves tool callbacks from a static registry.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class StaticToolCallbackResolver implements ToolCallbackResolver {
private final Map<String, ToolCallback> toolCallbacks = new HashMap<>();
public StaticToolCallbackResolver(List<ToolCallback> toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
toolCallbacks
.forEach(toolCallback -> this.toolCallbacks.put(toolCallback.getToolDefinition().name(), toolCallback));
}
@Override
public ToolCallback resolve(String toolName) {
return toolCallbacks.get(toolName);
}
}

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.resolution;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
/**
* A resolver for {@link ToolCallback} instances.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ToolCallbackResolver {
/**
* Resolve the {@link ToolCallback} for the given tool name.
*/
@Nullable
ToolCallback resolve(String toolName);
}

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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.function;
package org.springframework.ai.tool.resolution;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
@@ -45,7 +45,7 @@ import org.springframework.util.ReflectionUtils;
* @author Christian Tzolov
* @author Sebastien Dekeuze
*/
public abstract class TypeResolverHelper {
public final class TypeResolverHelper {
/**
* Returns the input class of a given Consumer 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.resolution;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,37 @@
/*
* 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;
/**
* The type of schema to generate for a given Java type.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public enum SchemaType {
/**
* JSON schema.
*/
JSON_SCHEMA,
/**
* Open API schema.
*/
OPEN_API_SCHEMA;
}

View File

@@ -0,0 +1,51 @@
/*
* 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.chat.model;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ChatResponse}.
*
* @author Thomas Vitale
*/
class ChatResponseTests {
@Test
void whenToolCallsArePresentThenReturnTrue() {
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"))))))
.build();
assertThat(chatResponse.hasToolCalls()).isTrue();
}
@Test
void whenNoToolCallsArePresentThenReturnFalse() {
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("Result"))))
.build();
assertThat(chatResponse.hasToolCalls()).isFalse();
}
}

View File

@@ -17,7 +17,6 @@
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;
@@ -32,6 +31,8 @@ import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link DefaultToolCallingChatOptions}.
*
* @author Thomas Vitale
*/
class DefaultToolCallingChatOptionsTests {
@@ -40,7 +41,7 @@ class DefaultToolCallingChatOptionsTests {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
ToolCallback callback1 = mock(ToolCallback.class);
ToolCallback callback2 = mock(ToolCallback.class);
List<ToolCallback> callbacks = List.of(callback1, callback2);
List<FunctionCallback> callbacks = List.of(callback1, callback2);
options.setToolCallbacks(callbacks);
@@ -53,7 +54,7 @@ class DefaultToolCallingChatOptionsTests {
ToolCallback callback1 = mock(ToolCallback.class);
ToolCallback callback2 = mock(ToolCallback.class);
options.setToolCallbacks(callback1, callback2);
options.setToolCallbacks(List.of(callback1, callback2));
assertThat(options.getToolCallbacks()).hasSize(2).containsExactly(callback1, callback2);
}
@@ -62,8 +63,7 @@ class DefaultToolCallingChatOptionsTests {
void setToolCallbacksShouldRejectNullList() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThatThrownBy(() -> options.setToolCallbacks((List<ToolCallback>) null))
.isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> options.setToolCallbacks(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolCallbacks cannot be null");
}
@@ -81,7 +81,7 @@ class DefaultToolCallingChatOptionsTests {
void setToolsWithVarargsShouldStoreTools() {
DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setTools("tool1", "tool2");
options.setTools(Set.of("tool1", "tool2"));
assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2");
}
@@ -139,7 +139,7 @@ class DefaultToolCallingChatOptionsTests {
original.setToolCallbacks(List.of(callback));
original.setTools(Set.of("tool1"));
original.setToolContext(Map.of("key", "value"));
original.setToolCallReturnDirect(true);
original.setInternalToolExecutionEnabled(true);
original.setModel("gpt-4");
original.setTemperature(0.7);
@@ -149,7 +149,7 @@ class DefaultToolCallingChatOptionsTests {
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.isInternalToolExecutionEnabled()).isEqualTo(original.isInternalToolExecutionEnabled());
assertThat(c.getModel()).isEqualTo(original.getModel());
assertThat(c.getTemperature()).isEqualTo(original.getTemperature());
});
@@ -170,45 +170,6 @@ class DefaultToolCallingChatOptionsTests {
.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);
@@ -218,7 +179,7 @@ class DefaultToolCallingChatOptionsTests {
.toolCallbacks(List.of(callback))
.tools(Set.of("tool1"))
.toolContext(context)
.toolCallReturnDirect(true)
.internalToolExecutionEnabled(true)
.model("gpt-4")
.temperature(0.7)
.maxTokens(100)
@@ -233,7 +194,7 @@ class DefaultToolCallingChatOptionsTests {
assertThat(o.getToolCallbacks()).containsExactly(callback);
assertThat(o.getTools()).containsExactly("tool1");
assertThat(o.getToolContext()).isEqualTo(context);
assertThat(o.getToolCallReturnDirect()).isTrue();
assertThat(o.isInternalToolExecutionEnabled()).isTrue();
assertThat(o.getModel()).isEqualTo("gpt-4");
assertThat(o.getTemperature()).isEqualTo(0.7);
assertThat(o.getMaxTokens()).isEqualTo(100);
@@ -258,11 +219,11 @@ class DefaultToolCallingChatOptionsTests {
@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.");
FunctionCallback callback1 = mock(FunctionCallback.class);
ToolCallback callback2 = mock(ToolCallback.class);
options.setFunctionCallbacks(List.of(callback1, callback2));
assertThat(options.getFunctionCallbacks()).hasSize(2);
options.setTools(Set.of("tool1"));
assertThat(options.getFunctions()).containsExactly("tool1");
@@ -270,11 +231,11 @@ class DefaultToolCallingChatOptionsTests {
options.setFunctions(Set.of("function1"));
assertThat(options.getTools()).containsExactly("function1");
options.setToolCallReturnDirect(true);
assertThat(options.getProxyToolCalls()).isTrue();
options.setInternalToolExecutionEnabled(true);
assertThat(options.getProxyToolCalls()).isFalse();
options.setProxyToolCalls(true);
assertThat(options.getToolCallReturnDirect()).isTrue();
assertThat(options.isInternalToolExecutionEnabled()).isFalse();
}
}

View File

@@ -0,0 +1,259 @@
/*
* 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 io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolCallExceptionConverter;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
import java.util.List;
import java.util.Map;
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 DefaultToolCallingManager}.
*
* @author Thomas Vitale
*/
class DefaultToolCallingManagerTests {
// BUILD
@Test
void whenDefaultArgumentsThenReturn() {
DefaultToolCallingManager defaultToolExecutor = DefaultToolCallingManager.builder().build();
assertThat(defaultToolExecutor).isNotNull();
}
@Test
void whenObservationRegistryIsNullThenThrow() {
assertThatThrownBy(() -> DefaultToolCallingManager.builder()
.observationRegistry(null)
.toolCallbackResolver(mock(ToolCallbackResolver.class))
.toolCallExceptionConverter(mock(ToolCallExceptionConverter.class))
.build()).isInstanceOf(IllegalArgumentException.class).hasMessage("observationRegistry cannot be null");
}
@Test
void whenToolCallbackResolverIsNullThenThrow() {
assertThatThrownBy(() -> DefaultToolCallingManager.builder()
.observationRegistry(mock(ObservationRegistry.class))
.toolCallbackResolver(null)
.toolCallExceptionConverter(mock(ToolCallExceptionConverter.class))
.build()).isInstanceOf(IllegalArgumentException.class).hasMessage("toolCallbackResolver cannot be null");
}
@Test
void whenToolCallExceptionConverterIsNullThenThrow() {
assertThatThrownBy(() -> DefaultToolCallingManager.builder()
.observationRegistry(mock(ObservationRegistry.class))
.toolCallbackResolver(mock(ToolCallbackResolver.class))
.toolCallExceptionConverter(null)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("toolCallExceptionConverter cannot be null");
}
// RESOLVE TOOL DEFINITIONS
@Test
void whenChatOptionsIsNullThenThrow() {
DefaultToolCallingManager defaultToolExecutor = DefaultToolCallingManager.builder().build();
assertThatThrownBy(() -> defaultToolExecutor.resolveToolDefinitions(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatOptions cannot be null");
}
@Test
void whenToolCallbackExistsThenResolve() {
ToolCallback toolCallback = new TestToolCallback("toolA");
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
List<ToolDefinition> toolDefinitions = toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build());
assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition());
}
@Test
void whenToolCallbackDoesNotExistThenThrow() {
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of());
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
assertThatThrownBy(() -> toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolB").build()))
.isInstanceOf(IllegalStateException.class)
.hasMessage("No ToolCallback found for tool name: toolB");
}
// EXECUTE TOOL CALLS
@Test
void whenPromptIsNullThenThrow() {
DefaultToolCallingManager defaultToolExecutor = DefaultToolCallingManager.builder().build();
assertThatThrownBy(() -> defaultToolExecutor.executeToolCalls(null, mock(ChatResponse.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("prompt cannot be null");
}
@Test
void whenChatResponseIsNullThenThrow() {
DefaultToolCallingManager defaultToolExecutor = DefaultToolCallingManager.builder().build();
assertThatThrownBy(() -> defaultToolExecutor.executeToolCalls(mock(Prompt.class), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatResponse cannot be null");
}
@Test
void whenNoToolCallInChatResponseThenThrow() {
DefaultToolCallingManager defaultToolExecutor = DefaultToolCallingManager.builder().build();
assertThatThrownBy(() -> defaultToolExecutor.executeToolCalls(mock(Prompt.class),
ChatResponse.builder().generations(List.of()).build()))
.isInstanceOf(IllegalStateException.class)
.hasMessage("No tool call requested by the chat model");
}
@Test
void whenSingleToolCallInChatResponseThenExecute() {
ToolCallback toolCallback = new TestToolCallback("toolA");
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
}
@Test
void whenMultipleToolCallsInChatResponseThenExecute() {
ToolCallback toolCallbackA = new TestToolCallback("toolA");
ToolCallback toolCallbackB = new TestToolCallback("toolB");
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(
List.of(toolCallbackA, toolCallbackB));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"),
new AssistantMessage.ToolCall("toolB", "function", "toolB", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"),
new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
}
@Test
void whenToolCallWithExceptionThenReturnError() {
ToolCallback toolCallback = new FailingToolCallback("toolC");
ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback));
ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolC", "function", "toolC", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
}
static class TestToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public TestToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
return "Mission accomplished!";
}
}
static class FailingToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public FailingToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
throw new ToolExecutionException(toolDefinition, new IllegalStateException("You failed this city!"));
}
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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.model.function.FunctionCallingOptions;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
/**
* Unit tests for {@link ToolCallingChatOptions}.
*
* @author Thomas Vitale
*/
class ToolCallingChatOptionsTests {
@Test
void whenToolCallingChatOptionsAndExecutionEnabledTrue() {
ToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setInternalToolExecutionEnabled(true);
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isTrue();
}
@Test
void whenToolCallingChatOptionsAndExecutionEnabledFalse() {
ToolCallingChatOptions options = new DefaultToolCallingChatOptions();
options.setInternalToolExecutionEnabled(false);
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isFalse();
}
@Test
void whenToolCallingChatOptionsAndExecutionEnabledDefault() {
ToolCallingChatOptions options = new DefaultToolCallingChatOptions();
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isTrue();
}
@Test
void whenFunctionCallingOptionsAndExecutionEnabledTrue() {
FunctionCallingOptions options = FunctionCallingOptions.builder().build();
options.setProxyToolCalls(false);
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isTrue();
}
@Test
void whenFunctionCallingOptionsAndExecutionEnabledFalse() {
FunctionCallingOptions options = FunctionCallingOptions.builder().build();
options.setProxyToolCalls(true);
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isFalse();
}
@Test
void whenFunctionCallingOptionsAndExecutionEnabledDefault() {
FunctionCallingOptions options = FunctionCallingOptions.builder().build();
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isTrue();
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.tool.execution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DefaultToolCallExceptionConverter}.
*
* @author Thomas Vitale
*/
class DefaultToolCallExceptionConverterTests {
@Test
void whenDefaultThenReturnMessage() {
ToolCallExceptionConverter converter = DefaultToolCallExceptionConverter.builder().build();
ToolExecutionException exception = new ToolExecutionException(generateTestDefinition(),
new RuntimeException("Test"));
assertThat(converter.convert(exception)).isEqualTo("Test");
}
@Test
void whenNotAlwaysThrowThenReturnMessage() {
ToolCallExceptionConverter converter = DefaultToolCallExceptionConverter.builder().alwaysThrow(false).build();
ToolExecutionException exception = new ToolExecutionException(generateTestDefinition(),
new RuntimeException("Test"));
assertThat(converter.convert(exception)).isEqualTo("Test");
}
@Test
void whenAlwaysThrowThenThrow() {
ToolCallExceptionConverter converter = DefaultToolCallExceptionConverter.builder().alwaysThrow(true).build();
ToolExecutionException exception = new ToolExecutionException(generateTestDefinition(),
new RuntimeException("Test"));
assertThatThrownBy(() -> converter.convert(exception)).isInstanceOf(ToolExecutionException.class);
}
private ToolDefinition generateTestDefinition() {
return DefaultToolDefinition.builder().name("test").inputSchema("{}").build();
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.resolution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link DelegatingToolCallbackResolver}.
*
* @author Thomas Vitale
*/
class DelegatingToolCallbackResolverTests {
@Test
void whenToolCallbackResolversAreNullThenThrowException() {
assertThatThrownBy(() -> new DelegatingToolCallbackResolver(null)).isInstanceOf(IllegalArgumentException.class);
}
@Test
void whenToolCallbackResolversContainNullElementsThenThrowException() {
var toolCallbackResolvers = new ArrayList<ToolCallbackResolver>();
toolCallbackResolvers.add(null);
assertThatThrownBy(() -> new DelegatingToolCallbackResolver(toolCallbackResolvers))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void whenToolCallbacksAreProvidedThenResolveToolCallback() {
ToolCallback toolCallback = mock(ToolCallback.class);
when(toolCallback.getToolDefinition())
.thenReturn(ToolDefinition.builder().name("myTool").inputSchema("{}").build());
StaticToolCallbackResolver staticToolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback));
DelegatingToolCallbackResolver delegatingToolCallbackResolver = new DelegatingToolCallbackResolver(
List.of(staticToolCallbackResolver));
assertThat(delegatingToolCallbackResolver.resolve("myTool")).isEqualTo(toolCallback);
}
}

View File

@@ -0,0 +1,175 @@
/*
* 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.resolution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.util.json.SchemaType;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;
import org.springframework.context.support.GenericApplicationContext;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link SpringBeanToolCallbackResolver}.
*
* @author Thomas Vitale
*/
class SpringBeanToolCallbackResolverTests {
@Test
void whenApplicationContextIsNullThenThrow() {
assertThatThrownBy(() -> new SpringBeanToolCallbackResolver(null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("applicationContext cannot be null");
assertThatThrownBy(() -> SpringBeanToolCallbackResolver.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("applicationContext cannot be null");
}
@Test
void whenSchemaTypeIsNullThenUseDefault() {
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(new GenericApplicationContext(),
null);
assertThat(resolver.getSchemaType()).isEqualTo(SchemaType.JSON_SCHEMA);
SpringBeanToolCallbackResolver resolver2 = SpringBeanToolCallbackResolver.builder()
.applicationContext(new GenericApplicationContext())
.build();
assertThat(resolver2.getSchemaType()).isEqualTo(SchemaType.JSON_SCHEMA);
}
@Test
void whenSchemaTypeIsNotNullThenUseIt() {
SchemaType schemaType = SchemaType.OPEN_API_SCHEMA;
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(new GenericApplicationContext(),
schemaType);
assertThat(resolver.getSchemaType()).isEqualTo(schemaType);
SpringBeanToolCallbackResolver resolver2 = SpringBeanToolCallbackResolver.builder()
.applicationContext(new GenericApplicationContext())
.schemaType(schemaType)
.build();
assertThat(resolver2.getSchemaType()).isEqualTo(schemaType);
}
@Test
void whenRequiredArgumentsAreProvidedThenCreateInstance() {
GenericApplicationContext applicationContext = new GenericApplicationContext();
SchemaType schemaType = SchemaType.OPEN_API_SCHEMA;
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(applicationContext, schemaType);
assertThat(resolver).isNotNull();
SpringBeanToolCallbackResolver resolver2 = SpringBeanToolCallbackResolver.builder()
.applicationContext(applicationContext)
.schemaType(schemaType)
.build();
assertThat(resolver2).isNotNull();
}
@Test
void whenToolCallbackWithVoidConsumerIsResolvedThenReturnIt() {
GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(Functions.class);
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(applicationContext,
SchemaType.JSON_SCHEMA);
ToolCallback resolvedToolCallback = resolver.resolve(Functions.WELCOME_TOOL_NAME);
assertThat(resolvedToolCallback).isNotNull();
assertThat(resolvedToolCallback.getToolDefinition().name()).isEqualTo(Functions.WELCOME_TOOL_NAME);
assertThat(resolvedToolCallback.getToolDefinition().description())
.isEqualTo(Functions.WELCOME_TOOL_DESCRIPTION);
}
@Test
void whenToolCallbackWithConsumerIsResolvedThenReturnIt() {
GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(Functions.class);
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(applicationContext,
SchemaType.JSON_SCHEMA);
ToolCallback resolvedToolCallback = resolver.resolve(Functions.WELCOME_USER_TOOL_NAME);
assertThat(resolvedToolCallback).isNotNull();
assertThat(resolvedToolCallback.getToolDefinition().name()).isEqualTo(Functions.WELCOME_USER_TOOL_NAME);
assertThat(resolvedToolCallback.getToolDefinition().description())
.isEqualTo(Functions.WELCOME_USER_TOOL_DESCRIPTION);
}
@Test
void whenToolCallbackWithFunctionIsResolvedThenReturnIt() {
GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(Functions.class);
SpringBeanToolCallbackResolver resolver = new SpringBeanToolCallbackResolver(applicationContext,
SchemaType.JSON_SCHEMA);
ToolCallback resolvedToolCallback = resolver.resolve(Functions.BOOKS_BY_AUTHOR_TOOL_NAME);
assertThat(resolvedToolCallback).isNotNull();
assertThat(resolvedToolCallback.getToolDefinition().name()).isEqualTo(Functions.BOOKS_BY_AUTHOR_TOOL_NAME);
assertThat(resolvedToolCallback.getToolDefinition().description())
.isEqualTo(Functions.BOOKS_BY_AUTHOR_TOOL_DESCRIPTION);
}
@Configuration(proxyBeanMethods = false)
static class Functions {
public static final String BOOKS_BY_AUTHOR_TOOL_NAME = "booksByAuthor";
public static final String BOOKS_BY_AUTHOR_TOOL_DESCRIPTION = "Get the list of books written by the given author available in the library";
public static final String WELCOME_TOOL_NAME = "welcome";
public static final String WELCOME_TOOL_DESCRIPTION = "Welcome users to the library";
public static final String WELCOME_USER_TOOL_NAME = "welcomeUser";
public static final String WELCOME_USER_TOOL_DESCRIPTION = "Welcome a specific user to the library";
@Bean(WELCOME_TOOL_NAME)
@Description(WELCOME_TOOL_DESCRIPTION)
Consumer<Void> welcome() {
return (input) -> {
};
}
@Bean(WELCOME_USER_TOOL_NAME)
@Description(WELCOME_USER_TOOL_DESCRIPTION)
Consumer<User> welcomeUser() {
return user -> {
};
}
@Bean(BOOKS_BY_AUTHOR_TOOL_NAME)
@Description(BOOKS_BY_AUTHOR_TOOL_DESCRIPTION)
Function<Author, List<Book>> booksByAuthor() {
return author -> List.of(new Book("Book 1", author.name()), new Book("Book 2", author.name()));
}
public record User(String name) {
}
public record Author(String name) {
}
public record Book(String title, String author) {
}
}
}

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.
@@ -14,12 +14,12 @@
* limitations under the License.
*/
package org.springframework.ai.model.function;
package org.springframework.ai.tool.resolution;
import java.util.function.Function;
import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherRequest;
import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherResponse;
import org.springframework.ai.tool.resolution.TypeResolverHelperIT.WeatherRequest;
import org.springframework.ai.tool.resolution.TypeResolverHelperIT.WeatherResponse;
/**
* @author Christian Tzolov

View File

@@ -0,0 +1,66 @@
/*
* 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.resolution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link StaticToolCallbackResolver}.
*
* @author Thomas Vitale
*/
class StaticToolCallbackResolverTests {
@Test
void whenToolCallbacksAreNullThenThrowException() {
assertThatThrownBy(() -> new StaticToolCallbackResolver(null)).isInstanceOf(IllegalArgumentException.class);
}
@Test
void whenToolCallbacksContainNullElementsThenThrowException() {
var toolCallbacks = new ArrayList<ToolCallback>();
toolCallbacks.add(null);
assertThatThrownBy(() -> new StaticToolCallbackResolver(toolCallbacks))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void whenToolCallbacksAreEmptyThenReturn() {
StaticToolCallbackResolver resolver = new StaticToolCallbackResolver(List.of());
assertThat(resolver).isNotNull();
}
@Test
void whenToolCallbacksAreProvidedThenResolveToolCallback() {
ToolCallback toolCallback = mock(ToolCallback.class);
when(toolCallback.getToolDefinition())
.thenReturn(ToolDefinition.builder().name("myTool").inputSchema("{}").build());
StaticToolCallbackResolver resolver = new StaticToolCallbackResolver(List.of(toolCallback));
assertThat(resolver.resolve("myTool")).isEqualTo(toolCallback);
}
}

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.
@@ -14,14 +14,13 @@
* limitations under the License.
*/
package org.springframework.ai.model.function;
package org.springframework.ai.tool.resolution;
import java.util.function.Consumer;
import java.util.function.Function;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
@@ -71,8 +70,8 @@ public class TypeResolverHelperIT {
}
@Configuration
@ComponentScan({ "org.springframework.ai.model.function.config",
"org.springframework.ai.model.function.component" })
@ComponentScan({ "org.springframework.ai.tool.resolution.config",
"org.springframework.ai.tool.resolution.component" })
public static class TypeResolverHelperConfiguration {
@Bean

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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.function;
package org.springframework.ai.tool.resolution;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -25,9 +25,8 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.junit.jupiter.api.Test;
import org.springframework.ai.model.function.TypeResolverHelperTests.MockWeatherService.Request;
import org.springframework.ai.model.function.TypeResolverHelperTests.MockWeatherService.Response;
import org.springframework.ai.tool.resolution.TypeResolverHelperTests.MockWeatherService.Request;
import org.springframework.ai.tool.resolution.TypeResolverHelperTests.MockWeatherService.Response;
import static org.assertj.core.api.Assertions.assertThat;

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.
@@ -14,12 +14,12 @@
* limitations under the License.
*/
package org.springframework.ai.model.function.component;
package org.springframework.ai.tool.resolution.component;
import java.util.function.Function;
import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherRequest;
import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherResponse;
import org.springframework.ai.tool.resolution.TypeResolverHelperIT.WeatherRequest;
import org.springframework.ai.tool.resolution.TypeResolverHelperIT.WeatherResponse;
import org.springframework.stereotype.Component;
/**

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.
@@ -14,9 +14,9 @@
* limitations under the License.
*/
package org.springframework.ai.model.function.config;
package org.springframework.ai.tool.resolution.config;
import org.springframework.ai.model.function.StandaloneWeatherFunction;
import org.springframework.ai.tool.resolution.StandaloneWeatherFunction;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,8 +0,0 @@
package org.springframework.ai.model.function
class StandaloneWeatherKotlinFunction : Function1<WeatherRequest, WeatherResponse> {
override fun invoke(weatherRequest: WeatherRequest): WeatherResponse {
return WeatherResponse(42.0f)
}
}

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.
@@ -14,12 +14,14 @@
* limitations under the License.
*/
package org.springframework.ai.model.function
package org.springframework.ai.tool.resolution
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.ai.model.function.FunctionCallback
import org.springframework.ai.model.function.inputType
class FunctionCallbackExtensionsTests {

View File

@@ -0,0 +1,114 @@
/*
* 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.resolution
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.ai.util.json.SchemaType
import org.springframework.context.annotation.AnnotationConfigApplicationContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Description
import org.springframework.context.support.GenericApplicationContext
import java.util.function.Consumer
import java.util.function.Function
/**
* Unit tests for {@link SpringBeanToolCallbackResolver}.
*
* @author Thomas Vitale
*/
class SpringBeanToolCallbackResolverKotlinTests {
@Test
fun whenToolCallbackWithVoidConsumerIsResolvedThenReturnIt() {
val applicationContext: GenericApplicationContext = AnnotationConfigApplicationContext(Functions::class.java)
val resolver = SpringBeanToolCallbackResolver(applicationContext, SchemaType.JSON_SCHEMA)
val resolvedToolCallback = resolver.resolve(Functions.WELCOME_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback).isNotNull()
Assertions.assertThat(resolvedToolCallback.toolDefinition.name()).isEqualTo(Functions.WELCOME_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback.toolDefinition.description())
.isEqualTo(Functions.WELCOME_TOOL_DESCRIPTION)
}
@Test
fun whenToolCallbackWithConsumerIsResolvedThenReturnIt() {
val applicationContext: GenericApplicationContext = AnnotationConfigApplicationContext(Functions::class.java)
val resolver = SpringBeanToolCallbackResolver(applicationContext, SchemaType.JSON_SCHEMA)
val resolvedToolCallback = resolver.resolve(Functions.WELCOME_USER_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback).isNotNull()
Assertions.assertThat(resolvedToolCallback.toolDefinition.name()).isEqualTo(Functions.WELCOME_USER_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback.toolDefinition.description())
.isEqualTo(Functions.WELCOME_USER_TOOL_DESCRIPTION)
}
@Test
fun whenToolCallbackWithFunctionIsResolvedThenReturnIt() {
val applicationContext: GenericApplicationContext = AnnotationConfigApplicationContext(Functions::class.java)
val resolver = SpringBeanToolCallbackResolver(applicationContext, SchemaType.JSON_SCHEMA)
val resolvedToolCallback = resolver.resolve(Functions.BOOKS_BY_AUTHOR_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback).isNotNull()
Assertions.assertThat(resolvedToolCallback.toolDefinition.name()).isEqualTo(Functions.BOOKS_BY_AUTHOR_TOOL_NAME)
Assertions.assertThat(resolvedToolCallback.toolDefinition.description())
.isEqualTo(Functions.BOOKS_BY_AUTHOR_TOOL_DESCRIPTION)
}
@Configuration(proxyBeanMethods = false)
open class Functions {
@Bean(WELCOME_TOOL_NAME)
@Description(WELCOME_TOOL_DESCRIPTION)
open fun welcome(): Consumer<Void> {
return Consumer { input: Void? -> }
}
@Bean(WELCOME_USER_TOOL_NAME)
@Description(WELCOME_USER_TOOL_DESCRIPTION)
open fun welcomeUser(): Consumer<User> {
return Consumer { user: User? -> }
}
@Bean(BOOKS_BY_AUTHOR_TOOL_NAME)
@Description(BOOKS_BY_AUTHOR_TOOL_DESCRIPTION)
open fun booksByAuthor(): Function<Author, List<Book>> {
return Function { author: Author ->
java.util.List.of(
Book("Book 1", author.name),
Book("Book 2", author.name)
)
}
}
data class User(val name: String)
data class Author(val name: String)
data class Book(val title: String, val author: String)
companion object {
const val BOOKS_BY_AUTHOR_TOOL_NAME: String = "booksByAuthor"
const val BOOKS_BY_AUTHOR_TOOL_DESCRIPTION: String = "Get the list of books written by the given author available in the library"
const val WELCOME_TOOL_NAME: String = "welcome"
const val WELCOME_TOOL_DESCRIPTION: String = "Welcome users to the library"
const val WELCOME_USER_TOOL_NAME: String = "welcomeUser"
const val WELCOME_USER_TOOL_DESCRIPTION: String = "Welcome a specific user to the library"
}
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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.resolution
class StandaloneWeatherKotlinFunction : Function1<WeatherRequest, WeatherResponse> {
override fun invoke(weatherRequest: WeatherRequest): WeatherResponse {
return WeatherResponse(42.0f)
}
}

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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.function
package org.springframework.ai.tool.resolution
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.params.ParameterizedTest
@@ -53,7 +53,7 @@ class TypeResolverHelperKotlinIT {
}
@Configuration
@ComponentScan("org.springframework.ai.model.function.kotlinconfig")
@ComponentScan("org.springframework.ai.tool.resolution.kotlinconfig")
open class TypeResolverHelperConfiguration {
@Bean

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.
@@ -14,9 +14,9 @@
* limitations under the License.
*/
package org.springframework.ai.model.function.kotlinconfig
package org.springframework.ai.tool.resolution.kotlinconfig
import org.springframework.ai.model.function.StandaloneWeatherKotlinFunction
import org.springframework.ai.tool.resolution.StandaloneWeatherKotlinFunction
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

View File

@@ -52,7 +52,7 @@ class RewriteQueryTransformerIT {
assertThat(transformedQuery).isNotNull();
System.out.println(transformedQuery);
assertThat(transformedQuery.text()).containsIgnoringCase("Large Language Model");
assertThat(transformedQuery.text()).containsIgnoringCase("model");
}
}

View File

@@ -22,6 +22,9 @@ 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.integration.tests.tool.domain.Author;
import org.springframework.ai.integration.tests.tool.domain.Book;
import org.springframework.ai.integration.tests.tool.domain.BookService;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
@@ -32,8 +35,6 @@ import org.springframework.context.annotation.Description;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -232,44 +233,9 @@ public class FunctionToolCallbackTests {
public record User(String name) {
}
public record Author(String name) {
}
public record Authors(List<Author> authors) {
}
public record Book(String title, String author) {
}
public record Books(List<Book> books) {
}
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();
}
}
// @formatter:on
}

View File

@@ -25,6 +25,9 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.integration.tests.tool.domain.Author;
import org.springframework.ai.integration.tests.tool.domain.Book;
import org.springframework.ai.integration.tests.tool.domain.BookService;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
@@ -158,36 +161,4 @@ public class MethodToolCallbackTests {
}
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();
}
}
}

View File

@@ -0,0 +1,158 @@
/*
* 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.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.integration.tests.tool.domain.Author;
import org.springframework.ai.integration.tests.tool.domain.Book;
import org.springframework.ai.integration.tests.tool.domain.BookService;
import org.springframework.ai.model.function.FunctionCallingOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import reactor.core.publisher.Flux;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link ToolCallingManager}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
public class ToolCallingManagerTests {
private final Tools tools = new Tools();
private final ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
@Autowired
OpenAiChatModel openAiChatModel;
@Test
void explicitToolCallingExecutionWithNewOptions() {
ChatOptions chatOptions = ToolCallingChatOptions.builder()
.toolCallbacks(ToolCallbacks.from(tools))
.internalToolExecutionEnabled(false)
.build();
Prompt prompt = new Prompt(
new UserMessage("What books written by %s are available in the library?".formatted("J.R.R. Tolkien")),
chatOptions);
runExplicitToolCallingExecutionWithOptions(chatOptions, prompt);
}
@Test
void explicitToolCallingExecutionWithLegacyOptions() {
ChatOptions chatOptions = FunctionCallingOptions.builder()
.functionCallbacks(ToolCallbacks.from(tools))
.proxyToolCalls(true)
.build();
Prompt prompt = new Prompt(
new UserMessage("What books written by %s are available in the library?".formatted("J.R.R. Tolkien")),
chatOptions);
runExplicitToolCallingExecutionWithOptions(chatOptions, prompt);
}
@Test
void explicitToolCallingExecutionWithNewOptionsStream() {
ChatOptions chatOptions = ToolCallingChatOptions.builder()
.toolCallbacks(ToolCallbacks.from(tools))
.internalToolExecutionEnabled(false)
.build();
Prompt prompt = new Prompt(new UserMessage("What books written by %s, %s, and %s are available in the library?"
.formatted("J.R.R. Tolkien", "Philip Pullman", "C.S. Lewis")), chatOptions);
runExplicitToolCallingExecutionWithOptionsStream(chatOptions, prompt);
}
private void runExplicitToolCallingExecutionWithOptions(ChatOptions chatOptions, Prompt prompt) {
ChatResponse chatResponse = openAiChatModel.call(prompt);
assertThat(chatResponse).isNotNull();
assertThat(chatResponse.hasToolCalls()).isTrue();
List<Message> messages = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(messages).isNotEmpty();
assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue();
Prompt secondPrompt = new Prompt(messages, chatOptions);
ChatResponse secondChatResponse = openAiChatModel.call(secondPrompt);
assertThat(secondChatResponse).isNotNull();
assertThat(secondChatResponse.getResult().getOutput().getText()).isNotEmpty()
.contains("The Hobbit")
.contains("The Lord of The Rings")
.contains("The Silmarillion");
}
private void runExplicitToolCallingExecutionWithOptionsStream(ChatOptions chatOptions, Prompt prompt) {
ChatResponse chatResponse = openAiChatModel.stream(prompt).flatMap(response -> {
if (response.hasToolCalls()) {
List<Message> messages = toolCallingManager.executeToolCalls(prompt, response);
assertThat(messages).isNotEmpty();
assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue();
Prompt secondPrompt = new Prompt(messages, chatOptions);
// return openAiChatModel.stream(secondPrompt);
return Flux.just(openAiChatModel.call(secondPrompt));
}
return Flux.just(response);
}).blockLast();
assertThat(chatResponse).isNotNull();
assertThat(chatResponse.getResult().getOutput().getText()).isNotEmpty()
.contains("His Dark Materials")
.contains("Narnia")
.contains("The Hobbit")
.contains("The Lord of The Rings")
.contains("The Silmarillion");
}
static class Tools {
private static final Logger logger = LoggerFactory.getLogger(Tools.class);
private final BookService bookService = new BookService();
@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));
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* 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.domain;
/**
* @author Thomas Vitale
*/
public record Author(String name) {
}

View File

@@ -0,0 +1,23 @@
/*
* 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.domain;
/**
* @author Thomas Vitale
*/
public record Book(String title, String author) {
}

View File

@@ -0,0 +1,50 @@
/*
* 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.domain;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author Thomas Vitale
*/
public 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();
}
}