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:
committed by
Christian Tzolov
parent
b03a6c9ef8
commit
8057b499bd
@@ -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;
|
||||
|
||||
@@ -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 + "]";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public record DefaultToolMetadata(boolean returnDirect) implements ToolMetadata
|
||||
return this;
|
||||
}
|
||||
|
||||
public DefaultToolMetadata build() {
|
||||
public ToolMetadata build() {
|
||||
return new DefaultToolMetadata(returnDirect);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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!"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user