Advancing Tool Support - Part 4

* Adopted new tool calling logic in OllamaChatModel, while maintaining full API backward compatibility thanks to the LegacyToolCallingManager.
* Improved efficiency and robustness of merging options in prompts for Ollama.
* Update Ollama Autoconfiguration to use the new ToolCallingManager.
* Improved troubleshooting for new tool calling APIs and finalised changes for full backward compatibility.
* Updated Ollama Testcontainers dependency to 0.5.7.

Relates to gh-2049

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-01-29 23:26:32 +01:00
committed by Christian Tzolov
parent 76ab91fab8
commit b902ca2afb
31 changed files with 1269 additions and 151 deletions

View File

@@ -290,8 +290,7 @@ public interface ChatClient {
Builder defaultFunctions(String... functionNames);
/**
* @deprecated in favor of {@link #defaultTools(FunctionCallback...)} or
* {@link #defaultToolCallbacks(FunctionCallback...)}
* @deprecated in favor of {@link #defaultTools(Object...)}
*/
@Deprecated
Builder defaultFunctions(FunctionCallback... functionCallbacks);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -64,10 +64,12 @@ public abstract class AbstractToolCallSupport {
*/
protected final FunctionCallbackResolver functionCallbackResolver;
@Deprecated
protected AbstractToolCallSupport(FunctionCallbackResolver functionCallbackResolver) {
this(functionCallbackResolver, FunctionCallingOptions.builder().build(), List.of());
}
@Deprecated
protected AbstractToolCallSupport(FunctionCallbackResolver functionCallbackResolver,
FunctionCallingOptions functionCallingOptions, List<FunctionCallback> toolFunctionCallbacks) {
@@ -97,6 +99,7 @@ public abstract class AbstractToolCallSupport {
return toolFunctionCallbacksCopy;
}
@Deprecated
public Map<String, FunctionCallback> getFunctionCallbackRegister() {
return this.functionCallbackRegister;
}
@@ -107,6 +110,7 @@ public abstract class AbstractToolCallSupport {
* @param runtimeFunctionOptions FunctionCallingOptions to handle.
* @return Set of function names to call.
*/
@Deprecated
protected Set<String> runtimeFunctionCallbackConfigurations(FunctionCallingOptions runtimeFunctionOptions) {
Set<String> enabledFunctionsToCall = new HashSet<>();
@@ -133,6 +137,7 @@ public abstract class AbstractToolCallSupport {
return enabledFunctionsToCall;
}
@Deprecated
protected List<Message> handleToolCalls(Prompt prompt, ChatResponse response) {
Optional<Generation> toolCallGeneration = response.getResults()
.stream()
@@ -165,6 +170,7 @@ public abstract class AbstractToolCallSupport {
return toolConversationHistory;
}
@Deprecated
protected List<Message> buildToolCallConversation(List<Message> previousMessages, AssistantMessage assistantMessage,
ToolResponseMessage toolResponseMessage) {
List<Message> messages = new ArrayList<>(previousMessages);
@@ -179,6 +185,7 @@ public abstract class AbstractToolCallSupport {
* @param functionNames Name of function callbacks to retrieve.
* @return list of resolved FunctionCallbacks.
*/
@Deprecated
protected List<FunctionCallback> resolveFunctionCallbacks(Set<String> functionNames) {
List<FunctionCallback> retrievedFunctionCallbacks = new ArrayList<>();
@@ -208,6 +215,7 @@ public abstract class AbstractToolCallSupport {
return retrievedFunctionCallbacks;
}
@Deprecated
protected ToolResponseMessage executeFunctions(AssistantMessage assistantMessage, ToolContext toolContext) {
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
@@ -230,6 +238,7 @@ public abstract class AbstractToolCallSupport {
return new ToolResponseMessage(toolResponses, Map.of());
}
@Deprecated
protected boolean isToolCall(ChatResponse chatResponse, Set<String> toolCallFinishReasons) {
Assert.isTrue(!CollectionUtils.isEmpty(toolCallFinishReasons), "Tool call finish reasons cannot be empty!");
@@ -252,6 +261,7 @@ public abstract class AbstractToolCallSupport {
* @param toolCallFinishReasons the tool call finish reasons to check.
* @return true if the generation is a tool call, false otherwise.
*/
@Deprecated
protected boolean isToolCall(Generation generation, Set<String> toolCallFinishReasons) {
var finishReason = (generation.getMetadata().getFinishReason() != null)
? generation.getMetadata().getFinishReason() : "";
@@ -271,6 +281,7 @@ public abstract class AbstractToolCallSupport {
* @param defaultOptions the default tool call options to check.
* @return true if the proxyToolCalls is enabled, false otherwise.
*/
@Deprecated
protected boolean isProxyToolCalls(Prompt prompt, FunctionCallingOptions defaultOptions) {
if (prompt.getOptions() instanceof FunctionCallingOptions functionCallOptions
&& functionCallOptions.getProxyToolCalls() != null) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -71,7 +71,8 @@ public class ToolContext {
/**
* Returns the tool call history from the context map.
* @return The tool call history.
* @return The tool call history. TODO: review whether we still need this or
* ToolCallingManager solves the original issue
*/
@SuppressWarnings("unchecked")
public List<Message> getToolCallHistory() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,6 +29,7 @@ import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.model.ModelRequest;
import org.springframework.lang.Nullable;
/**
* The Prompt class represents a prompt used in AI model requests. A prompt consists of
@@ -36,11 +37,13 @@ import org.springframework.ai.model.ModelRequest;
*
* @author Mark Pollack
* @author luocongqiu
* @author Thomas Vitale
*/
public class Prompt implements ModelRequest<List<Message>> {
private final List<Message> messages;
@Nullable
private ChatOptions chatOptions;
public Prompt(String contents) {
@@ -81,6 +84,7 @@ public class Prompt implements ModelRequest<List<Message>> {
}
@Override
@Nullable
public ChatOptions getOptions() {
return this.chatOptions;
}

View File

@@ -17,6 +17,8 @@
package org.springframework.ai.model.tool;
import io.micrometer.observation.ObservationRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
@@ -50,6 +52,8 @@ import java.util.Optional;
*/
public class DefaultToolCallingManager implements ToolCallingManager {
private static final Logger logger = LoggerFactory.getLogger(DefaultToolCallingManager.class);
// @formatter:off
private static final ObservationRegistry DEFAULT_OBSERVATION_REGISTRY
@@ -86,7 +90,7 @@ public class DefaultToolCallingManager implements ToolCallingManager {
List<FunctionCallback> toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks());
for (String toolName : chatOptions.getTools()) {
ToolCallback toolCallback = toolCallbackResolver.resolve(toolName);
FunctionCallback toolCallback = toolCallbackResolver.resolve(toolName);
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}
@@ -176,13 +180,15 @@ public class DefaultToolCallingManager implements ToolCallingManager {
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
logger.debug("Executing tool call: {}", toolCall.name());
String toolName = toolCall.name();
String toolInputArguments = toolCall.arguments();
FunctionCallback toolCallback = toolCallbacks.stream()
.filter(tool -> toolName.equals(tool.getName()))
.findFirst()
.orElse(toolCallbackResolver.resolve(toolName));
.orElseGet(() -> toolCallbackResolver.resolve(toolName));
if (toolCallback == null) {
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);

View File

@@ -0,0 +1,241 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.AbstractToolCallSupport;
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.FunctionCallbackResolver;
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.lang.Nullable;
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;
/**
* Implementation of {@link ToolCallingManager} supporting the migration from
* {@link AbstractToolCallSupport} to {@link ToolCallingManager} and ensuring AI
* compatibility for all the ChatModel implementations.
*
* @author Thomas Vitale
* @since 1.0.0
* @deprecated Only to help moving away from {@link AbstractToolCallSupport}. It will be
* removed in the next milestone.
*/
@Deprecated
public class LegacyToolCallingManager implements ToolCallingManager {
private final FunctionCallbackResolver functionCallbackResolver;
private final Map<String, FunctionCallback> functionCallbacks = new HashMap<>();
private final ToolCallExceptionConverter toolCallExceptionConverter = DefaultToolCallExceptionConverter.builder()
.build();
public LegacyToolCallingManager(@Nullable FunctionCallbackResolver functionCallbackResolver,
List<FunctionCallback> functionCallbacks) {
Assert.notNull(functionCallbacks, "functionCallbacks cannot be null");
Assert.noNullElements(functionCallbacks.toArray(), "functionCallbacks cannot contain null elements");
this.functionCallbackResolver = functionCallbackResolver;
functionCallbacks.forEach(toolCallback -> this.functionCallbacks.put(toolCallback.getName(), toolCallback));
}
@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()) {
FunctionCallback toolCallback = resolveFunctionCallback(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();
}
@Nullable
private FunctionCallback resolveFunctionCallback(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
if (functionCallbacks.get(toolName) != null) {
return functionCallbacks.get(toolName);
}
return functionCallbackResolver != null ? functionCallbackResolver.resolve(toolName) : null;
}
@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()
.orElseGet(() -> resolveFunctionCallback(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 FunctionCallbackResolver functionCallbackResolver;
private List<FunctionCallback> functionCallbacks = new ArrayList<>();
private Builder() {
}
public Builder functionCallbackResolver(FunctionCallbackResolver functionCallbackResolver) {
this.functionCallbackResolver = functionCallbackResolver;
return this;
}
public Builder functionCallbacks(List<FunctionCallback> functionCallbacks) {
this.functionCallbacks = functionCallbacks;
return this;
}
public LegacyToolCallingManager build() {
return new LegacyToolCallingManager(functionCallbackResolver, functionCallbacks);
}
}
}

View File

@@ -23,6 +23,8 @@ import org.springframework.ai.model.function.FunctionCallingOptions;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -185,4 +187,21 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions {
return internalToolExecutionEnabled;
}
static Set<String> mergeToolNames(Set<String> runtimeToolNames, Set<String> defaultToolNames) {
Assert.notNull(runtimeToolNames, "runtimeToolNames cannot be null");
Assert.notNull(defaultToolNames, "defaultToolNames cannot be null");
var mergedToolNames = new HashSet<>(runtimeToolNames);
mergedToolNames.addAll(defaultToolNames);
return mergedToolNames;
}
static List<FunctionCallback> mergeToolCallbacks(List<FunctionCallback> runtimeToolCallbacks,
List<FunctionCallback> defaultToolCallbacks) {
Assert.notNull(runtimeToolCallbacks, "runtimeToolCallbacks cannot be null");
Assert.notNull(defaultToolCallbacks, "defaultToolCallbacks cannot be null");
var mergedToolCallbacks = new ArrayList<>(runtimeToolCallbacks);
mergedToolCallbacks.addAll(defaultToolCallbacks);
return mergedToolCallbacks;
}
}

View File

@@ -18,6 +18,7 @@ package org.springframework.ai.tool.definition;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.ai.util.json.JsonSchemaGenerator;
import org.springframework.util.Assert;
import java.lang.reflect.Method;
@@ -55,6 +56,7 @@ public interface ToolDefinition {
* Create a default {@link ToolDefinition} builder from a {@link Method}.
*/
static DefaultToolDefinition.Builder builder(Method method) {
Assert.notNull(method, "method cannot be null");
return DefaultToolDefinition.builder()
.name(ToolUtils.getToolName(method))
.description(ToolUtils.getToolDescription(method))

View File

@@ -16,6 +16,8 @@
package org.springframework.ai.tool.execution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;
/**
@@ -26,6 +28,8 @@ import org.springframework.util.Assert;
*/
public class DefaultToolCallExceptionConverter implements ToolCallExceptionConverter {
private final static Logger logger = LoggerFactory.getLogger(DefaultToolCallExceptionConverter.class);
private static final boolean DEFAULT_ALWAYS_THROW = false;
private final boolean alwaysThrow;
@@ -40,6 +44,8 @@ public class DefaultToolCallExceptionConverter implements ToolCallExceptionConve
if (alwaysThrow) {
throw exception;
}
logger.debug("Exception thrown by tool: {}. Message: {}", exception.getToolDefinition().name(),
exception.getMessage());
return exception.getMessage();
}

View File

@@ -41,6 +41,7 @@ public final class DefaultToolCallResultConverter implements ToolCallResultConve
return "Done";
}
else {
logger.debug("Converting tool result to JSON.");
return JsonParser.toJson(result);
}
}

View File

@@ -105,6 +105,11 @@ public class FunctionToolCallback<I, O> implements ToolCallback {
return toolCallResultConverter.apply(response, null);
}
@Override
public String toString() {
return "FunctionToolCallback{" + "toolDefinition=" + toolDefinition + ", toolMetadata=" + toolMetadata + '}';
}
/**
* Build a {@link FunctionToolCallback} from a {@link BiFunction}.
*/

View File

@@ -17,6 +17,7 @@
package org.springframework.ai.tool.metadata;
import org.springframework.ai.tool.util.ToolUtils;
import org.springframework.util.Assert;
import java.lang.reflect.Method;
@@ -46,6 +47,7 @@ public interface ToolMetadata {
* Create a default {@link ToolMetadata} instance from a {@link Method}.
*/
static ToolMetadata from(Method method) {
Assert.notNull(method, "method cannot be null");
return DefaultToolMetadata.builder().returnDirect(ToolUtils.getToolReturnDirect(method)).build();
}

View File

@@ -60,6 +60,7 @@ public class MethodToolCallback implements ToolCallback {
private final Method toolMethod;
@Nullable
private final Object toolObject;
private final ToolCallResultConverter toolCallResultConverter;
@@ -174,6 +175,11 @@ public class MethodToolCallback implements ToolCallback {
return !Modifier.isPublic(toolMethod.getModifiers());
}
@Override
public String toString() {
return "MethodToolCallback{" + "toolDefinition=" + toolDefinition + ", toolMetadata=" + toolMetadata + '}';
}
public static Builder builder() {
return new Builder();
}

View File

@@ -16,7 +16,7 @@
package org.springframework.ai.tool.resolution;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -41,9 +41,11 @@ public class DelegatingToolCallbackResolver implements ToolCallbackResolver {
@Override
@Nullable
public ToolCallback resolve(String toolName) {
public FunctionCallback resolve(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
for (ToolCallbackResolver toolCallbackResolver : toolCallbackResolvers) {
ToolCallback toolCallback = toolCallbackResolver.resolve(toolName);
FunctionCallback toolCallback = toolCallbackResolver.resolve(toolName);
if (toolCallback != null) {
return toolCallback;
}

View File

@@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonClassDescription;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
@@ -55,6 +57,8 @@ import java.util.function.Supplier;
*/
public class SpringBeanToolCallbackResolver implements ToolCallbackResolver {
private static final Logger logger = LoggerFactory.getLogger(SpringBeanToolCallbackResolver.class);
private static final Map<String, ToolCallback> toolCallbacksCache = new HashMap<>();
private static final SchemaType DEFAULT_SCHEMA_TYPE = SchemaType.JSON_SCHEMA;
@@ -75,6 +79,8 @@ public class SpringBeanToolCallbackResolver implements ToolCallbackResolver {
public ToolCallback resolve(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
logger.debug("ToolCallback resolution attempt from Spring application context");
ToolCallback resolvedToolCallback = toolCallbacksCache.get(toolName);
if (resolvedToolCallback != null) {

View File

@@ -16,6 +16,9 @@
package org.springframework.ai.tool.resolution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.Assert;
@@ -31,18 +34,26 @@ import java.util.Map;
*/
public class StaticToolCallbackResolver implements ToolCallbackResolver {
private final Map<String, ToolCallback> toolCallbacks = new HashMap<>();
private static final Logger logger = LoggerFactory.getLogger(StaticToolCallbackResolver.class);
public StaticToolCallbackResolver(List<ToolCallback> toolCallbacks) {
private final Map<String, FunctionCallback> toolCallbacks = new HashMap<>();
public StaticToolCallbackResolver(List<FunctionCallback> 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));
toolCallbacks.forEach(callback -> {
if (callback instanceof ToolCallback toolCallback) {
this.toolCallbacks.put(toolCallback.getToolDefinition().name(), toolCallback);
}
this.toolCallbacks.put(callback.getName(), callback);
});
}
@Override
public ToolCallback resolve(String toolName) {
public FunctionCallback resolve(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
logger.debug("ToolCallback resolution attempt from static registry");
return toolCallbacks.get(toolName);
}

View File

@@ -16,6 +16,7 @@
package org.springframework.ai.tool.resolution;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
@@ -28,9 +29,9 @@ import org.springframework.lang.Nullable;
public interface ToolCallbackResolver {
/**
* Resolve the {@link ToolCallback} for the given tool name.
* Resolve the {@link FunctionCallback} for the given tool name.
*/
@Nullable
ToolCallback resolve(String toolName);
FunctionCallback resolve(String toolName);
}

View File

@@ -42,6 +42,7 @@ public final class ToolUtils {
}
public static String getToolName(Method method) {
Assert.notNull(method, "method cannot be null");
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return method.getName();
@@ -49,12 +50,13 @@ public final class ToolUtils {
return StringUtils.hasText(tool.name()) ? tool.name() : method.getName();
}
public static String getToolDescriptionFromName(@Nullable String toolName) {
public static String getToolDescriptionFromName(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
return ParsingUtils.reConcatenateCamelCase(toolName, " ");
}
public static String getToolDescription(Method method) {
Assert.notNull(method, "method cannot be null");
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return ParsingUtils.reConcatenateCamelCase(method.getName(), " ");
@@ -63,11 +65,13 @@ public final class ToolUtils {
}
public static boolean getToolReturnDirect(Method method) {
Assert.notNull(method, "method cannot be null");
var tool = method.getAnnotation(Tool.class);
return tool != null && tool.returnDirect();
}
public static ToolCallResultConverter getToolCallResultConverter(Method method) {
Assert.notNull(method, "method cannot be null");
var tool = method.getAnnotation(Tool.class);
if (tool == null) {
return new DefaultToolCallResultConverter();
@@ -81,8 +85,9 @@ public final class ToolUtils {
}
}
public static List<String> getDuplicateToolNames(FunctionCallback... functionCallbacks) {
return Stream.of(functionCallbacks)
public static List<String> getDuplicateToolNames(FunctionCallback... toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
return Stream.of(toolCallbacks)
.collect(Collectors.groupingBy(FunctionCallback::getName, Collectors.counting()))
.entrySet()
.stream()

View File

@@ -0,0 +1,211 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.tool;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.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.ToolExecutionException;
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 LegacyToolCallingManager}.
*
* @author Thomas Vitale
*/
class LegacyToolCallingManagerTests {
// RESOLVE TOOL DEFINITIONS
@Test
void whenChatOptionsIsNullThenThrow() {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().build();
assertThatThrownBy(() -> toolCallingManager.resolveToolDefinitions(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatOptions cannot be null");
}
@Test
void whenToolCallbackExistsThenResolve() {
ToolCallback toolCallback = new TestToolCallback("toolA");
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder()
.functionCallbacks(List.of(toolCallback))
.build();
List<ToolDefinition> toolDefinitions = toolCallingManager
.resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build());
assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition());
}
@Test
void whenToolCallbackDoesNotExistThenThrow() {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().functionCallbacks(List.of()).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() {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().build();
assertThatThrownBy(() -> toolCallingManager.executeToolCalls(null, mock(ChatResponse.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("prompt cannot be null");
}
@Test
void whenChatResponseIsNullThenThrow() {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().build();
assertThatThrownBy(() -> toolCallingManager.executeToolCalls(mock(Prompt.class), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatResponse cannot be null");
}
@Test
void whenNoToolCallInChatResponseThenThrow() {
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().build();
assertThatThrownBy(() -> toolCallingManager.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 LegacyToolCallingManagerTests.TestToolCallback("toolA");
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder()
.functionCallbacks(List.of(toolCallback))
.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 LegacyToolCallingManagerTests.TestToolCallback("toolA");
ToolCallback toolCallbackB = new LegacyToolCallingManagerTests.TestToolCallback("toolB");
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder()
.functionCallbacks(List.of(toolCallbackA, toolCallbackB))
.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 LegacyToolCallingManagerTests.FailingToolCallback("toolC");
ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder()
.functionCallbacks(List.of(toolCallback))
.build();
Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build());
ChatResponse chatResponse = ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage("", Map.of(),
List.of(new AssistantMessage.ToolCall("toolC", "function", "toolC", "{}"))))))
.build();
ToolResponseMessage expectedToolResponse = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!")));
List<Message> toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse);
assertThat(toolCallHistory).contains(expectedToolResponse);
}
static class TestToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public TestToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
return "Mission accomplished!";
}
}
static class FailingToolCallback implements ToolCallback {
private final ToolDefinition toolDefinition;
public FailingToolCallback(String name) {
this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build();
}
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
throw new ToolExecutionException(toolDefinition, new IllegalStateException("You failed this city!"));
}
}
}

View File

@@ -16,9 +16,15 @@
package org.springframework.ai.model.tool;
import org.junit.jupiter.api.Test;
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 static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ToolCallingChatOptions}.
@@ -67,4 +73,92 @@ class ToolCallingChatOptionsTests {
assertThat(ToolCallingChatOptions.isInternalToolExecutionEnabled(options)).isTrue();
}
@Test
void whenMergeRuntimeAndDefaultToolNames() {
Set<String> runtimeToolNames = Set.of("toolA");
Set<String> defaultToolNames = Set.of("toolB");
Set<String> mergedToolNames = ToolCallingChatOptions.mergeToolNames(runtimeToolNames, defaultToolNames);
assertThat(mergedToolNames).containsExactlyInAnyOrder("toolA", "toolB");
}
@Test
void whenMergeRuntimeAndEmptyDefaultToolNames() {
Set<String> runtimeToolNames = Set.of("toolA");
Set<String> defaultToolNames = Set.of();
Set<String> mergedToolNames = ToolCallingChatOptions.mergeToolNames(runtimeToolNames, defaultToolNames);
assertThat(mergedToolNames).containsExactlyInAnyOrder("toolA");
}
@Test
void whenMergeEmptyRuntimeAndDefaultToolNames() {
Set<String> runtimeToolNames = Set.of();
Set<String> defaultToolNames = Set.of("toolB");
Set<String> mergedToolNames = ToolCallingChatOptions.mergeToolNames(runtimeToolNames, defaultToolNames);
assertThat(mergedToolNames).containsExactlyInAnyOrder("toolB");
}
@Test
void whenMergeEmptyRuntimeAndEmptyDefaultToolNames() {
Set<String> runtimeToolNames = Set.of();
Set<String> defaultToolNames = Set.of();
Set<String> mergedToolNames = ToolCallingChatOptions.mergeToolNames(runtimeToolNames, defaultToolNames);
assertThat(mergedToolNames).containsExactlyInAnyOrder();
}
@Test
void whenMergeRuntimeAndDefaultToolCallbacks() {
List<FunctionCallback> runtimeToolCallbacks = List.of(new TestToolCallback("toolA"));
List<FunctionCallback> defaultToolCallbacks = List.of(new TestToolCallback("toolB"));
List<FunctionCallback> mergedToolCallbacks = ToolCallingChatOptions.mergeToolCallbacks(runtimeToolCallbacks,
defaultToolCallbacks);
assertThat(mergedToolCallbacks).hasSize(2);
}
@Test
void whenMergeRuntimeAndEmptyDefaultToolCallbacks() {
List<FunctionCallback> runtimeToolCallbacks = List.of(new TestToolCallback("toolA"));
List<FunctionCallback> defaultToolCallbacks = List.of();
List<FunctionCallback> mergedToolCallbacks = ToolCallingChatOptions.mergeToolCallbacks(runtimeToolCallbacks,
defaultToolCallbacks);
assertThat(mergedToolCallbacks).hasSize(1);
}
@Test
void whenMergeEmptyRuntimeAndDefaultToolCallbacks() {
List<FunctionCallback> runtimeToolCallbacks = List.of();
List<FunctionCallback> defaultToolCallbacks = List.of(new TestToolCallback("toolB"));
List<FunctionCallback> mergedToolCallbacks = ToolCallingChatOptions.mergeToolCallbacks(runtimeToolCallbacks,
defaultToolCallbacks);
assertThat(mergedToolCallbacks).hasSize(1);
}
@Test
void whenMergeEmptyRuntimeAndEmptyDefaultToolCallbacks() {
List<FunctionCallback> runtimeToolCallbacks = List.of();
List<FunctionCallback> defaultToolCallbacks = List.of();
List<FunctionCallback> mergedToolCallbacks = ToolCallingChatOptions.mergeToolCallbacks(runtimeToolCallbacks,
defaultToolCallbacks);
assertThat(mergedToolCallbacks).hasSize(0);
}
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!";
}
}
}

View File

@@ -17,6 +17,7 @@
package org.springframework.ai.tool.resolution;
import org.junit.jupiter.api.Test;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -42,7 +43,7 @@ class StaticToolCallbackResolverTests {
@Test
void whenToolCallbacksContainNullElementsThenThrowException() {
var toolCallbacks = new ArrayList<ToolCallback>();
var toolCallbacks = new ArrayList<FunctionCallback>();
toolCallbacks.add(null);
assertThatThrownBy(() -> new StaticToolCallbackResolver(toolCallbacks))
.isInstanceOf(IllegalArgumentException.class);