diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java index 12eef9752..f970249ab 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java @@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.model.tool.LegacyToolCallingManager; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionResult; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.util.json.JsonParser; import reactor.core.publisher.Flux; @@ -271,10 +272,19 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response != null && response.hasToolCalls()) { - var toolCallConversation = this.toolCallingManager.executeToolCalls(prompt, response); - // Recursively call the call method with the tool call message - // conversation that contains the call responses. - return this.internalCall(new Prompt(toolCallConversation, prompt.getOptions()), response); + var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); + if (toolExecutionResult.returnDirect()) { + // Return tool execution result directly to the client. + return ChatResponse.builder() + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); + } + else { + // Send the tool execution result back to the model. + return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } } return response; @@ -335,10 +345,17 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode // @formatter:off Flux chatResponseFlux = chatResponse.flatMap(response -> { if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response.hasToolCalls()) { - var toolCallConversation = this.toolCallingManager.executeToolCalls(prompt, response); - // Recursively call the stream method with the tool call message - // conversation that contains the call responses. - return this.internalStream(new Prompt(toolCallConversation, prompt.getOptions()), response); + var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); + if (toolExecutionResult.returnDirect()) { + // Return tool execution result directly to the client. + return Flux.just(ChatResponse.builder().from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build()); + } else { + // Send the tool execution result back to the model. + return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } } else { return Flux.just(response); @@ -379,13 +396,13 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode // Merge tool names and tool callbacks explicitly since they are ignored by // Jackson, used by ModelOptionsUtils. if (runtimeOptions != null) { - requestOptions.setTools( - ToolCallingChatOptions.mergeToolNames(runtimeOptions.getTools(), this.defaultOptions.getTools())); + requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(), + this.defaultOptions.getToolNames())); requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(), this.defaultOptions.getToolCallbacks())); } else { - requestOptions.setTools(this.defaultOptions.getTools()); + requestOptions.setToolNames(this.defaultOptions.getToolNames()); requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); } diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java index af36cd394..fc815202b 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java @@ -383,7 +383,7 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions { .mirostatEta(fromOptions.getMirostatEta()) .penalizeNewline(fromOptions.getPenalizeNewline()) .stop(fromOptions.getStop()) - .tools(fromOptions.getTools()) + .toolNames(fromOptions.getToolNames()) .internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled()) .toolCallbacks(fromOptions.getToolCallbacks()) .toolContext(fromOptions.getToolContext()).build(); @@ -700,13 +700,13 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions { @Override @JsonIgnore - public Set getTools() { + public Set getToolNames() { return this.toolNames; } @Override @JsonIgnore - public void setTools(Set toolNames) { + public void setToolNames(Set toolNames) { Assert.notNull(toolNames, "toolNames cannot be null"); Assert.noNullElements(toolNames, "toolNames cannot contain null elements"); toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements")); @@ -744,14 +744,14 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions { @Deprecated @JsonIgnore public Set getFunctions() { - return this.getTools(); + return this.getToolNames(); } @Override @Deprecated @JsonIgnore public void setFunctions(Set functions) { - this.setTools(functions); + this.setToolNames(functions); } @Override @@ -1028,12 +1028,12 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions { return this; } - public Builder tools(Set toolNames) { - this.options.setTools(toolNames); + public Builder toolNames(Set toolNames) { + this.options.setToolNames(toolNames); return this; } - public Builder tools(String... toolNames) { + public Builder toolNames(String... toolNames) { Assert.notNull(toolNames, "toolNames cannot be null"); this.options.toolNames.addAll(Set.of(toolNames)); return this; @@ -1051,12 +1051,12 @@ public class OllamaOptions implements ToolCallingChatOptions, EmbeddingOptions { @Deprecated public Builder functions(Set functions) { - return tools(functions); + return toolNames(functions); } @Deprecated public Builder function(String functionName) { - return tools(functionName); + return toolNames(functionName); } @Deprecated diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java index 8e286bf04..82beeb07f 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.tool.function.FunctionToolCallback; -import org.springframework.ai.util.json.JsonSchemaGenerator; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; import org.springframework.beans.factory.annotation.Autowired; diff --git a/pom.xml b/pom.xml index 591b49810..5f6beed1d 100644 --- a/pom.xml +++ b/pom.xml @@ -189,7 +189,7 @@ 26.48.0 1.9.1 9.20.0 - 4.35.0 + 4.37.0 2.2.25 2.0.3 diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java index 7fd359b10..fad1ff7c5 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -216,9 +216,9 @@ public interface ChatClient { ChatClientRequestSpec tools(String... toolNames); - ChatClientRequestSpec tools(Object... toolObjects); + ChatClientRequestSpec tools(FunctionCallback... toolCallbacks); - // ChatClientRequestSpec toolCallbacks(FunctionCallback... toolCallbacks); + ChatClientRequestSpec tools(Object... toolObjects); @Deprecated ChatClientRequestSpec functions(FunctionCallback... functionCallbacks); @@ -281,6 +281,8 @@ public interface ChatClient { Builder defaultTools(String... toolNames); + Builder defaultTools(FunctionCallback... toolCallbacks); + Builder defaultTools(Object... toolObjects); /** diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index c06452c5a..d6086ad91 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -846,41 +846,27 @@ public class DefaultChatClient implements ChatClient { } @Override - public ChatClientRequestSpec tools(Object... toolObjects) { - Assert.notNull(toolObjects, "toolObjects cannot be null"); - Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements"); - - List functionCallbacks = new ArrayList<>(); - List nonFunctinCallbacks = new ArrayList<>(); - for (Object toolObject : toolObjects) { - if (toolObject instanceof FunctionCallback) { - functionCallbacks.add((FunctionCallback) toolObject); - } - else { - nonFunctinCallbacks.add(toolObject); - } - } - this.functionCallbacks.addAll(functionCallbacks); - this.functionCallbacks.addAll(Arrays - .asList(ToolCallbacks.from(nonFunctinCallbacks.toArray(new Object[nonFunctinCallbacks.size()])))); + public ChatClientRequestSpec tools(FunctionCallback... toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.functionCallbacks.addAll(List.of(toolCallbacks)); return this; } - // @Override - // public ChatClientRequestSpec toolCallbacks(FunctionCallback... toolCallbacks) { - // Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); - // Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null - // elements"); - // this.functionCallbacks.addAll(Arrays.asList(toolCallbacks)); - // return this; - // } + @Override + public ChatClientRequestSpec tools(Object... toolObjects) { + Assert.notNull(toolObjects, "toolObjects cannot be null"); + Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements"); + this.functionCallbacks.addAll(Arrays.asList(ToolCallbacks.from(toolObjects))); + return this; + } - @Deprecated + @Deprecated // Use tools() public ChatClientRequestSpec functions(String... functionBeanNames) { return tools(functionBeanNames); } - @Deprecated + @Deprecated // Use tools() public ChatClientRequestSpec functions(FunctionCallback... functionCallbacks) { Assert.notNull(functionCallbacks, "functionCallbacks cannot be null"); Assert.noNullElements(functionCallbacks, "functionCallbacks cannot contain null elements"); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index d5a565990..676e8f224 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -35,7 +35,6 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.model.function.FunctionCallback; -import org.springframework.ai.tool.ToolCallbacks; import org.springframework.core.io.Resource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -151,7 +150,13 @@ public class DefaultChatClientBuilder implements Builder { @Override public Builder defaultTools(String... toolNames) { - this.defaultRequest.functions(toolNames); + this.defaultRequest.tools(toolNames); + return this; + } + + @Override + public Builder defaultTools(FunctionCallback... toolCallbacks) { + this.defaultRequest.tools(toolCallbacks); return this; } @@ -161,12 +166,14 @@ public class DefaultChatClientBuilder implements Builder { return this; } + @Deprecated // Use defaultTools() public Builder defaultFunction(String name, String description, java.util.function.Function function) { this.defaultRequest .functions(FunctionCallback.builder().function(name, function).description(description).build()); return this; } + @Deprecated // Use defaultTools() public Builder defaultFunction(String name, String description, java.util.function.BiFunction biFunction) { this.defaultRequest @@ -174,11 +181,13 @@ public class DefaultChatClientBuilder implements Builder { return this; } + @Deprecated // Use defaultTools() public Builder defaultFunctions(String... functionNames) { this.defaultRequest.functions(functionNames); return this; } + @Deprecated // Use defaultTools() public Builder defaultFunctions(FunctionCallback... functionCallbacks) { this.defaultRequest.functions(functionCallbacks); return this; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptions.java index 61b3d52ac..327435a58 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptions.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptions.java @@ -39,7 +39,7 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { private List toolCallbacks = new ArrayList<>(); - private Set tools = new HashSet<>(); + private Set toolNames = new HashSet<>(); private Map toolContext = new HashMap<>(); @@ -83,16 +83,16 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { } @Override - public Set getTools() { - return Set.copyOf(this.tools); + public Set getToolNames() { + return Set.copyOf(this.toolNames); } @Override - public void setTools(Set tools) { - Assert.notNull(tools, "tools cannot be null"); - Assert.noNullElements(tools, "tools cannot contain null elements"); - tools.forEach(tool -> Assert.hasText(tool, "tools cannot contain empty elements")); - this.tools = new HashSet<>(tools); + public void setToolNames(Set toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + Assert.noNullElements(toolNames, "toolNames cannot contain null elements"); + toolNames.forEach(toolName -> Assert.hasText(toolName, "toolNames cannot contain empty elements")); + this.toolNames = new HashSet<>(toolNames); } @Override @@ -130,12 +130,12 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { @Override public Set getFunctions() { - return getTools(); + return getToolNames(); } @Override public void setFunctions(Set functions) { - setTools(functions); + setToolNames(functions); } @Override @@ -234,7 +234,7 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { public T copy() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); options.setToolCallbacks(getToolCallbacks()); - options.setTools(getTools()); + options.setToolNames(getToolNames()); options.setToolContext(getToolContext()); options.setInternalToolExecutionEnabled(isInternalToolExecutionEnabled()); options.setModel(getModel()); @@ -273,15 +273,15 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { } @Override - public ToolCallingChatOptions.Builder tools(Set toolNames) { - this.options.setTools(toolNames); + public ToolCallingChatOptions.Builder toolNames(Set toolNames) { + this.options.setToolNames(toolNames); return this; } @Override - public ToolCallingChatOptions.Builder tools(String... toolNames) { + public ToolCallingChatOptions.Builder toolNames(String... toolNames) { Assert.notNull(toolNames, "toolNames cannot be null"); - this.options.setTools(Set.of(toolNames)); + this.options.setToolNames(Set.of(toolNames)); return this; } @@ -322,15 +322,15 @@ public class DefaultToolCallingChatOptions implements ToolCallingChatOptions { } @Override - @Deprecated // Use tools() instead + @Deprecated // Use toolNames() instead public ToolCallingChatOptions.Builder functions(Set functions) { - return tools(functions); + return toolNames(functions); } @Override - @Deprecated // Use tools() instead + @Deprecated // Use toolNames() instead public ToolCallingChatOptions.Builder function(String function) { - return tools(function); + return toolNames(function); } @Override diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java index a266f376f..56cdfc8f8 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java @@ -89,7 +89,7 @@ public class DefaultToolCallingManager implements ToolCallingManager { Assert.notNull(chatOptions, "chatOptions cannot be null"); List toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks()); - for (String toolName : chatOptions.getTools()) { + for (String toolName : chatOptions.getToolNames()) { FunctionCallback toolCallback = toolCallbackResolver.resolve(toolName); if (toolCallback == null) { throw new IllegalStateException("No ToolCallback found for tool name: " + toolName); @@ -112,7 +112,7 @@ public class DefaultToolCallingManager implements ToolCallingManager { } @Override - public List executeToolCalls(Prompt prompt, ChatResponse chatResponse) { + public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) { Assert.notNull(prompt, "prompt cannot be null"); Assert.notNull(chatResponse, "chatResponse cannot be null"); @@ -129,10 +129,16 @@ public class DefaultToolCallingManager implements ToolCallingManager { ToolContext toolContext = buildToolContext(prompt, assistantMessage); - ToolResponseMessage toolMessageResponse = executeToolCall(prompt, assistantMessage, toolContext); + InternalToolExecutionResult internalToolExecutionResult = executeToolCall(prompt, assistantMessage, + toolContext); - return buildConversationHistoryAfterToolExecution(prompt.getInstructions(), assistantMessage, - toolMessageResponse); + List conversationHistory = buildConversationHistoryAfterToolExecution(prompt.getInstructions(), + assistantMessage, internalToolExecutionResult.toolResponseMessage()); + + return ToolExecutionResult.builder() + .conversationHistory(conversationHistory) + .returnDirect(internalToolExecutionResult.returnDirect()) + .build(); } private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) { @@ -166,7 +172,7 @@ public class DefaultToolCallingManager implements ToolCallingManager { * compatibility, both {@link ToolCallback} and {@link FunctionCallback} are * supported. */ - private ToolResponseMessage executeToolCall(Prompt prompt, AssistantMessage assistantMessage, + private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMessage assistantMessage, ToolContext toolContext) { List toolCallbacks = List.of(); if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) { @@ -178,6 +184,8 @@ public class DefaultToolCallingManager implements ToolCallingManager { List toolResponses = new ArrayList<>(); + Boolean returnDirect = null; + for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) { logger.debug("Executing tool call: {}", toolCall.name()); @@ -194,6 +202,13 @@ public class DefaultToolCallingManager implements ToolCallingManager { throw new IllegalStateException("No ToolCallback found for tool name: " + toolName); } + if (returnDirect == null && toolCallback instanceof ToolCallback callback) { + returnDirect = callback.getToolMetadata().returnDirect(); + } + else if (toolCallback instanceof ToolCallback callback) { + returnDirect = returnDirect && callback.getToolMetadata().returnDirect(); + } + String toolResult; try { toolResult = toolCallback.call(toolInputArguments, toolContext); @@ -205,7 +220,7 @@ public class DefaultToolCallingManager implements ToolCallingManager { toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, toolResult)); } - return new ToolResponseMessage(toolResponses, Map.of()); + return new InternalToolExecutionResult(new ToolResponseMessage(toolResponses, Map.of()), returnDirect); } private List buildConversationHistoryAfterToolExecution(List previousMessages, @@ -216,6 +231,9 @@ public class DefaultToolCallingManager implements ToolCallingManager { return messages; } + private record InternalToolExecutionResult(ToolResponseMessage toolResponseMessage, boolean returnDirect) { + } + public static Builder builder() { return new Builder(); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolExecutionResult.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolExecutionResult.java new file mode 100644 index 000000000..5129fe40f --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/DefaultToolExecutionResult.java @@ -0,0 +1,67 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.tool; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * Default implementation of {@link ToolExecutionResult}. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public record DefaultToolExecutionResult(List conversationHistory, + boolean returnDirect) implements ToolExecutionResult { + + public DefaultToolExecutionResult { + Assert.notNull(conversationHistory, "conversationHistory cannot be null"); + Assert.noNullElements(conversationHistory, "conversationHistory cannot contain null elements"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List conversationHistory = List.of(); + + private boolean returnDirect; + + private Builder() { + } + + public Builder conversationHistory(List conversationHistory) { + this.conversationHistory = conversationHistory; + return this; + } + + public Builder returnDirect(boolean returnDirect) { + this.returnDirect = returnDirect; + return this; + } + + public DefaultToolExecutionResult build() { + return new DefaultToolExecutionResult(conversationHistory, returnDirect); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/LegacyToolCallingManager.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/LegacyToolCallingManager.java index ae12fb05d..3922c4476 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/LegacyToolCallingManager.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/LegacyToolCallingManager.java @@ -75,7 +75,7 @@ public class LegacyToolCallingManager implements ToolCallingManager { Assert.notNull(chatOptions, "chatOptions cannot be null"); List toolCallbacks = new ArrayList<>(chatOptions.getToolCallbacks()); - for (String toolName : chatOptions.getTools()) { + for (String toolName : chatOptions.getToolNames()) { FunctionCallback toolCallback = resolveFunctionCallback(toolName); if (toolCallback == null) { throw new IllegalStateException("No ToolCallback found for tool name: " + toolName); @@ -107,7 +107,7 @@ public class LegacyToolCallingManager implements ToolCallingManager { } @Override - public List executeToolCalls(Prompt prompt, ChatResponse chatResponse) { + public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) { Assert.notNull(prompt, "prompt cannot be null"); Assert.notNull(chatResponse, "chatResponse cannot be null"); @@ -126,8 +126,10 @@ public class LegacyToolCallingManager implements ToolCallingManager { ToolResponseMessage toolMessageResponse = executeToolCall(prompt, assistantMessage, toolContext); - return buildConversationHistoryAfterToolExecution(prompt.getInstructions(), assistantMessage, - toolMessageResponse); + List conversationHistory = buildConversationHistoryAfterToolExecution(prompt.getInstructions(), + assistantMessage, toolMessageResponse); + + return ToolExecutionResult.builder().conversationHistory(conversationHistory).returnDirect(false).build(); } private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java index 32aed320e..6be2cf37d 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java @@ -53,12 +53,12 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions { /** * Names of the tools to register with the ChatModel. */ - Set getTools(); + Set getToolNames(); /** * Set the names of the tools to register with the ChatModel. */ - void setTools(Set toolNames); + void setToolNames(Set toolNames); /** * Whether the {@link ChatModel} is responsible for executing the tools requested by @@ -98,12 +98,12 @@ public interface ToolCallingChatOptions extends FunctionCallingOptions { /** * Names of the tools to register with the ChatModel. */ - Builder tools(Set toolNames); + Builder toolNames(Set toolNames); /** * Names of the tools to register with the ChatModel. */ - Builder tools(String... toolNames); + Builder toolNames(String... toolNames); /** * Whether the {@link ChatModel} is responsible for executing the tools requested diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java index 63e7ba5cb..c93a38a21 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java @@ -16,7 +16,6 @@ package org.springframework.ai.model.tool; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.tool.definition.ToolDefinition; @@ -39,7 +38,7 @@ public interface ToolCallingManager { /** * Execute the tool calls requested by the model. */ - List executeToolCalls(Prompt prompt, ChatResponse chatResponse); + ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse); /** * Create a default {@link ToolCallingManager} builder. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolExecutionResult.java b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolExecutionResult.java new file mode 100644 index 000000000..41db567d4 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/tool/ToolExecutionResult.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.tool; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.Generation; + +import java.util.ArrayList; +import java.util.List; + +/** + * The result of a tool execution. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public interface ToolExecutionResult { + + String FINISH_REASON = "returnDirect"; + + String METADATA_TOOL_ID = "toolId"; + + String METADATA_TOOL_NAME = "toolName"; + + /** + * The history of messages exchanged during the conversation, including the tool + * execution result. + */ + List conversationHistory(); + + /** + * Whether the tool execution result should be returned directly or passed back to the + * model. + */ + default boolean returnDirect() { + return false; + } + + /** + * Create a default {@link ToolExecutionResult} builder. + */ + static DefaultToolExecutionResult.Builder builder() { + return DefaultToolExecutionResult.builder(); + } + + /** + * Build a list of {@link Generation} from the tool execution result, useful for + * sending the tool execution result to the client directly. + */ + static List buildGenerations(ToolExecutionResult toolExecutionResult) { + List conversationHistory = toolExecutionResult.conversationHistory(); + List generations = new ArrayList<>(); + if (conversationHistory + .get(conversationHistory.size() - 1) instanceof ToolResponseMessage toolResponseMessage) { + toolResponseMessage.getResponses().forEach(response -> { + AssistantMessage assistantMessage = new AssistantMessage(response.responseData()); + Generation generation = new Generation(assistantMessage, + ChatGenerationMetadata.builder() + .metadata(METADATA_TOOL_ID, response.id()) + .metadata(METADATA_TOOL_NAME, response.name()) + .finishReason(FINISH_REASON) + .build()); + generations.add(generation); + }); + } + return generations; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/tool/annotation/ToolParam.java b/spring-ai-core/src/main/java/org/springframework/ai/tool/annotation/ToolParam.java new file mode 100644 index 000000000..2414e4cae --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/tool/annotation/ToolParam.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a tool argument. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolParam { + + /** + * Whether the tool argument is required. + */ + boolean required() default true; + + /** + * The description of the tool argument. + */ + String description() default ""; + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java b/spring-ai-core/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java index 699c69acb..19dc01781 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java @@ -17,7 +17,7 @@ package org.springframework.ai.tool.definition; import org.springframework.ai.tool.util.ToolUtils; -import org.springframework.ai.util.json.JsonSchemaGenerator; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.util.Assert; import java.lang.reflect.Method; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java b/spring-ai-core/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java index 00a569b61..8fe476570 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java @@ -33,7 +33,7 @@ import org.springframework.ai.tool.execution.ToolCallResultConverter; import org.springframework.ai.tool.metadata.ToolMetadata; import org.springframework.ai.tool.util.ToolUtils; import org.springframework.ai.util.json.JsonParser; -import org.springframework.ai.util.json.JsonSchemaGenerator; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.Nullable; import org.springframework.util.Assert; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolver.java b/spring-ai-core/src/main/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolver.java index 5465ee836..69398dc63 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolver.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolver.java @@ -26,8 +26,8 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.ai.tool.util.ToolUtils; -import org.springframework.ai.util.json.JsonSchemaGenerator; -import org.springframework.ai.util.json.SchemaType; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; +import org.springframework.ai.util.json.schema.SchemaType; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Description; import org.springframework.context.support.GenericApplicationContext; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/json/JsonSchemaGenerator.java b/spring-ai-core/src/main/java/org/springframework/ai/util/json/JsonSchemaGenerator.java deleted file mode 100644 index 6e6bb0bbb..000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/util/json/JsonSchemaGenerator.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.util.json; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.github.victools.jsonschema.generator.Option; -import com.github.victools.jsonschema.generator.OptionPreset; -import com.github.victools.jsonschema.generator.SchemaGenerator; -import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; -import com.github.victools.jsonschema.generator.SchemaVersion; -import com.github.victools.jsonschema.module.jackson.JacksonModule; -import com.github.victools.jsonschema.module.jackson.JacksonOption; -import com.github.victools.jsonschema.module.swagger2.Swagger2Module; -import org.springframework.util.Assert; - -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; - -/** - * Utilities to generate JSON Schemas from Java entities. - */ -public final class JsonSchemaGenerator { - - private static final SchemaGenerator TYPE_SCHEMA_GENERATOR; - - private static final SchemaGenerator SUBTYPE_SCHEMA_GENERATOR; - - /* - * Initialize JSON Schema generators. - */ - static { - var schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, - OptionPreset.PLAIN_JSON) - .with(new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED)) - .with(new Swagger2Module()) - .with(Option.EXTRA_OPEN_API_FORMAT_VALUES) - .with(Option.PLAIN_DEFINITION_KEYS); - - var typeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.without(Option.SCHEMA_VERSION_INDICATOR).build(); - TYPE_SCHEMA_GENERATOR = new SchemaGenerator(typeSchemaGeneratorConfig); - - var subtypeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.build(); - SUBTYPE_SCHEMA_GENERATOR = new SchemaGenerator(subtypeSchemaGeneratorConfig); - } - - private JsonSchemaGenerator() { - } - - /** - * Generate a JSON Schema for a method's input parameters. - */ - public static String generateForMethodInput(Method method, SchemaOption... schemaOptions) { - ObjectNode schema = JsonParser.getObjectMapper().createObjectNode(); - schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier()); - schema.put("type", "object"); - - ObjectNode properties = schema.putObject("properties"); - List required = new ArrayList<>(); - - for (int i = 0; i < method.getParameterCount(); i++) { - var parameterName = method.getParameters()[i].getName(); - var parameterType = method.getGenericParameterTypes()[i]; - if (isMethodParameterRequired(method, i)) { - required.add(parameterName); - } - properties.set(parameterName, SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType)); - } - - var requiredArray = schema.putArray("required"); - if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.RESPECT_JSON_PROPERTY_REQUIRED)) { - required.forEach(requiredArray::add); - } - else { - Stream.of(method.getParameters()).map(Parameter::getName).forEach(requiredArray::add); - } - - if (Stream.of(schemaOptions) - .noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) { - schema.put("additionalProperties", false); - } - - if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) { - convertTypeValuesToUpperCase(schema); - } - - return schema.toPrettyString(); - } - - /** - * Generate a JSON Schema for a class type. - */ - public static String generateForType(Type type, SchemaOption... schemaOptions) { - Assert.notNull(type, "type cannot be null"); - ObjectNode schema = TYPE_SCHEMA_GENERATOR.generateSchema(type); - if ((type == Void.class) && !schema.has("properties")) { - schema.putObject("properties"); - } - if (Stream.of(schemaOptions) - .noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) { - schema.put("additionalProperties", false); - } - if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) { - convertTypeValuesToUpperCase(schema); - } - return schema.toPrettyString(); - } - - private static boolean isMethodParameterRequired(Method method, int index) { - var jsonPropertyAnnotation = method.getParameters()[index].getAnnotation(JsonProperty.class); - if (jsonPropertyAnnotation == null) { - return false; - } - return jsonPropertyAnnotation.required(); - } - - // Based on the method in ModelOptionsUtils. - private static void convertTypeValuesToUpperCase(ObjectNode node) { - if (node.isObject()) { - node.fields().forEachRemaining(entry -> { - JsonNode value = entry.getValue(); - if (value.isObject()) { - convertTypeValuesToUpperCase((ObjectNode) value); - } - else if (value.isArray()) { - value.elements().forEachRemaining(element -> { - if (element.isObject() || element.isArray()) { - convertTypeValuesToUpperCase((ObjectNode) element); - } - }); - } - else if (value.isTextual() && entry.getKey().equals("type")) { - String oldValue = node.get("type").asText(); - node.put("type", oldValue.toUpperCase()); - } - }); - } - else if (node.isArray()) { - node.elements().forEachRemaining(element -> { - if (element.isObject() || element.isArray()) { - convertTypeValuesToUpperCase((ObjectNode) element); - } - }); - } - } - - /** - * Options for generating JSON Schemas. - */ - public enum SchemaOption { - - /** - * Properties are only required if marked as such via the Jackson annotation - * "@JsonProperty(required = true)". Beware, that OpenAI requires all properties - * to be required. - */ - RESPECT_JSON_PROPERTY_REQUIRED, - - /** - * Allow additional properties by default. Beware, that OpenAI requires additional - * properties NOT to be allowed. - */ - ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT, - - /** - * Convert all "type" values to upper case. For example, it's require in OpenAPI - * 3.0 with Vertex AI. - */ - UPPER_CASE_TYPE_VALUES; - - } - -} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java new file mode 100644 index 000000000..e66227933 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java @@ -0,0 +1,268 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.util.json.schema; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.Option; +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfig; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import com.github.victools.jsonschema.module.jackson.JacksonModule; +import com.github.victools.jsonschema.module.jackson.JacksonOption; +import com.github.victools.jsonschema.module.swagger2.Swagger2Module; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.util.json.JsonParser; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Utilities to generate JSON Schemas from Java types and method signatures. It's designed + * to work well in the context of tool calling and structured outputs, aiming at ensuring + * consistency and robustness across different model providers. + *

+ * Metadata such as descriptions and required properties can be specified using one of the + * following supported annotations: + *

+ *

    + *
  • {@code @ToolParam(required = ..., description = ...)}
  • + *
  • {@code @JsonProperty(required = ...)}
  • + *
  • {@code @Schema(required = ..., description = ...)}
  • + *
  • {@code @Nullable}
  • + *
+ *

+ * If none of these annotations are present, the default behavior is to consider the + * property as required and not to include a description. + *

+ * + * @author Thomas Vitale + * @since 1.0.0 + */ +public final class JsonSchemaGenerator { + + /** + * To ensure consistency and robustness across different model providers, all + * properties in the JSON Schema are considered required by default. This behavior can + * be overridden by setting the {@link ToolParam#required()}, + * {@link JsonProperty#required()}, or {@link Schema#requiredMode()}} annotation. + */ + private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true; + + private static final SchemaGenerator TYPE_SCHEMA_GENERATOR; + + private static final SchemaGenerator SUBTYPE_SCHEMA_GENERATOR; + + /* + * Initialize JSON Schema generators. + */ + static { + Module jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED); + Module openApiModule = new Swagger2Module(); + Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule() + : new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT); + + SchemaGeneratorConfigBuilder schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder( + SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) + .with(jacksonModule) + .with(openApiModule) + .with(springAiSchemaModule) + .with(Option.EXTRA_OPEN_API_FORMAT_VALUES) + .with(Option.PLAIN_DEFINITION_KEYS); + + SchemaGeneratorConfig typeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.build(); + TYPE_SCHEMA_GENERATOR = new SchemaGenerator(typeSchemaGeneratorConfig); + + SchemaGeneratorConfig subtypeSchemaGeneratorConfig = schemaGeneratorConfigBuilder + .without(Option.SCHEMA_VERSION_INDICATOR) + .build(); + SUBTYPE_SCHEMA_GENERATOR = new SchemaGenerator(subtypeSchemaGeneratorConfig); + } + + private JsonSchemaGenerator() { + } + + /** + * Generate a JSON Schema for a method's input parameters. + */ + public static String generateForMethodInput(Method method, SchemaOption... schemaOptions) { + ObjectNode schema = JsonParser.getObjectMapper().createObjectNode(); + schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier()); + schema.put("type", "object"); + + ObjectNode properties = schema.putObject("properties"); + List required = new ArrayList<>(); + + for (int i = 0; i < method.getParameterCount(); i++) { + String parameterName = method.getParameters()[i].getName(); + Type parameterType = method.getGenericParameterTypes()[i]; + if (isMethodParameterRequired(method, i)) { + required.add(parameterName); + } + ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType); + String parameterDescription = getMethodParameterDescription(method, i); + if (StringUtils.hasText(parameterDescription)) { + parameterNode.put("description", parameterDescription); + } + properties.set(parameterName, parameterNode); + } + + var requiredArray = schema.putArray("required"); + required.forEach(requiredArray::add); + + processSchemaOptions(schemaOptions, schema); + + return schema.toPrettyString(); + } + + /** + * Generate a JSON Schema for a class type. + */ + public static String generateForType(Type type, SchemaOption... schemaOptions) { + Assert.notNull(type, "type cannot be null"); + ObjectNode schema = TYPE_SCHEMA_GENERATOR.generateSchema(type); + if ((type == Void.class) && !schema.has("properties")) { + schema.putObject("properties"); + } + processSchemaOptions(schemaOptions, schema); + return schema.toPrettyString(); + } + + private static void processSchemaOptions(SchemaOption[] schemaOptions, ObjectNode schema) { + if (Stream.of(schemaOptions) + .noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) { + schema.put("additionalProperties", false); + } + if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) { + convertTypeValuesToUpperCase(schema); + } + } + + /** + * Determines whether a property is required based on the presence of a series of + * annotations. + *

+ * - {@code @ToolParam(required = ...)} - {@code @JsonProperty(required = ...)} - + * {@code @Schema(required = ...)} + *

+ * If none of these annotations are present, the default behavior is to consider the + * property as required. + */ + private static boolean isMethodParameterRequired(Method method, int index) { + Parameter parameter = method.getParameters()[index]; + + var toolParamAnnotation = parameter.getAnnotation(ToolParam.class); + if (toolParamAnnotation != null) { + return toolParamAnnotation.required(); + } + + var propertyAnnotation = parameter.getAnnotation(JsonProperty.class); + if (propertyAnnotation != null) { + return propertyAnnotation.required(); + } + + var schemaAnnotation = parameter.getAnnotation(Schema.class); + if (schemaAnnotation != null) { + return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED + || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required(); + } + + var nullableAnnotation = parameter.getAnnotation(Nullable.class); + if (nullableAnnotation != null) { + return false; + } + + return PROPERTY_REQUIRED_BY_DEFAULT; + } + + @Nullable + private static String getMethodParameterDescription(Method method, int index) { + Parameter parameter = method.getParameters()[index]; + + var toolParamAnnotation = parameter.getAnnotation(ToolParam.class); + if (toolParamAnnotation != null && StringUtils.hasText(toolParamAnnotation.description())) { + return toolParamAnnotation.description(); + } + + var schemaAnnotation = parameter.getAnnotation(Schema.class); + if (schemaAnnotation != null && StringUtils.hasText(schemaAnnotation.description())) { + return schemaAnnotation.description(); + } + + return null; + } + + // Based on the method in ModelOptionsUtils. + private static void convertTypeValuesToUpperCase(ObjectNode node) { + if (node.isObject()) { + node.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value.isObject()) { + convertTypeValuesToUpperCase((ObjectNode) value); + } + else if (value.isArray()) { + value.elements().forEachRemaining(element -> { + if (element.isObject() || element.isArray()) { + convertTypeValuesToUpperCase((ObjectNode) element); + } + }); + } + else if (value.isTextual() && entry.getKey().equals("type")) { + String oldValue = node.get("type").asText(); + node.put("type", oldValue.toUpperCase()); + } + }); + } + else if (node.isArray()) { + node.elements().forEachRemaining(element -> { + if (element.isObject() || element.isArray()) { + convertTypeValuesToUpperCase((ObjectNode) element); + } + }); + } + } + + /** + * Options for generating JSON Schemas. + */ + public enum SchemaOption { + + /** + * Allow an object to contain additional key/values not defined in the schema. + */ + ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT, + + /** + * Convert all "type" values to upper case. + */ + UPPER_CASE_TYPE_VALUES; + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/json/SchemaType.java b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SchemaType.java similarity index 94% rename from spring-ai-core/src/main/java/org/springframework/ai/util/json/SchemaType.java rename to spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SchemaType.java index 9a9c988ff..29b4df278 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/util/json/SchemaType.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SchemaType.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.util.json; +package org.springframework.ai.util.json.schema; /** * The type of schema to generate for a given Java type. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java new file mode 100644 index 000000000..4ec861d7f --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java @@ -0,0 +1,127 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.util.json.schema; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.victools.jsonschema.generator.FieldScope; +import com.github.victools.jsonschema.generator.MemberScope; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigPart; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +import java.util.stream.Stream; + +/** + * JSON Schema Generator Module for Spring AI. + *

+ * This module provides a set of customizations to the JSON Schema generator to support + * the Spring AI framework. It allows to extract descriptions from + * {@code @ToolParam(description = ...)} annotations and to determine whether a property + * is required based on the presence of a series of annotations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public final class SpringAiSchemaModule implements Module { + + private final boolean requiredByDefault; + + public SpringAiSchemaModule(Option... options) { + this.requiredByDefault = Stream.of(options) + .noneMatch(option -> option == Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT); + } + + @Override + public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { + this.applyToConfigBuilder(builder.forFields()); + } + + private void applyToConfigBuilder(SchemaGeneratorConfigPart configPart) { + configPart.withDescriptionResolver(this::resolveDescription); + configPart.withRequiredCheck(this::checkRequired); + } + + /** + * Extract description from {@code @ToolParam(description = ...)} for the given field. + */ + @Nullable + private String resolveDescription(MemberScope member) { + var toolParamAnnotation = member.getAnnotationConsideringFieldAndGetter(ToolParam.class); + if (toolParamAnnotation != null && StringUtils.hasText(toolParamAnnotation.description())) { + return toolParamAnnotation.description(); + } + return null; + } + + /** + * Determines whether a property is required based on the presence of a series of + * annotations. + *

+ *

    + *
  • {@code @ToolParam(required = ...)}
  • + *
  • {@code @JsonProperty(required = ...)}
  • + *
  • {@code @Schema(required = ...)}
  • + *
  • {@code @Nullable}
  • + *
+ *

+ * If none of these annotations are present, the default behavior is to consider the + * property as required, unless the {@link Option#PROPERTY_REQUIRED_FALSE_BY_DEFAULT} + * option is set. + */ + private boolean checkRequired(MemberScope member) { + var toolParamAnnotation = member.getAnnotationConsideringFieldAndGetter(ToolParam.class); + if (toolParamAnnotation != null) { + return toolParamAnnotation.required(); + } + + var propertyAnnotation = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + if (propertyAnnotation != null) { + return propertyAnnotation.required(); + } + + var schemaAnnotation = member.getAnnotationConsideringFieldAndGetter(Schema.class); + if (schemaAnnotation != null) { + return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED + || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required(); + } + + var nullableAnnotation = member.getAnnotationConsideringFieldAndGetter(Nullable.class); + if (nullableAnnotation != null) { + return false; + } + + return this.requiredByDefault; + } + + /** + * Options for customizing the behavior of the module. + */ + public enum Option { + + /** + * Properties are only required if marked as such via one of the supported + * annotations. + */ + PROPERTY_REQUIRED_FALSE_BY_DEFAULT; + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/package-info.java new file mode 100644 index 000000000..410ac0727 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/util/json/schema/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.util.json.schema; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java index c29d87495..8570bd9ef 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java @@ -1375,7 +1375,7 @@ class DefaultChatClientTests { ChatClient.ChatClientRequestSpec spec = chatClient.prompt(); assertThatThrownBy(() -> spec.tools(mock(ToolCallback.class), null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("toolObjects cannot contain null elements"); + .hasMessage("toolCallbacks cannot contain null elements"); } @Test diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptionsTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptionsTests.java index 7f93db1e1..7c53cb7d1 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptionsTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptionsTests.java @@ -68,50 +68,50 @@ class DefaultToolCallingChatOptionsTests { } @Test - void setToolsShouldStoreTools() { + void setToolNamesShouldStoreToolNames() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); - Set tools = Set.of("tool1", "tool2"); + Set toolNames = Set.of("tool1", "tool2"); - options.setTools(tools); + options.setToolNames(toolNames); - assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrderElementsOf(tools); + assertThat(options.getToolNames()).hasSize(2).containsExactlyInAnyOrderElementsOf(toolNames); } @Test - void setToolsWithVarargsShouldStoreTools() { + void setToolNamesWithVarargsShouldStoreToolNames() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); - options.setTools(Set.of("tool1", "tool2")); + options.setToolNames(Set.of("tool1", "tool2")); - assertThat(options.getTools()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2"); + assertThat(options.getToolNames()).hasSize(2).containsExactlyInAnyOrder("tool1", "tool2"); } @Test - void setToolsShouldRejectNullSet() { + void setToolNamesShouldRejectNullSet() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); - assertThatThrownBy(() -> options.setTools((Set) null)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("tools cannot be null"); + assertThatThrownBy(() -> options.setToolNames(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("toolNames cannot be null"); } @Test - void setToolsShouldRejectNullElements() { + void setToolNamesShouldRejectNullElements() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); - Set tools = new HashSet<>(); - tools.add(null); + Set toolNames = new HashSet<>(); + toolNames.add(null); - assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("tools cannot contain null elements"); + assertThatThrownBy(() -> options.setToolNames(toolNames)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("toolNames cannot contain null elements"); } @Test - void setToolsShouldRejectEmptyElements() { + void setToolNamesShouldRejectEmptyElements() { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); - Set tools = new HashSet<>(); - tools.add(""); + Set toolNames = new HashSet<>(); + toolNames.add(""); - assertThatThrownBy(() -> options.setTools(tools)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("tools cannot contain empty elements"); + assertThatThrownBy(() -> options.setToolNames(toolNames)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("toolNames cannot contain empty elements"); } @Test @@ -137,7 +137,7 @@ class DefaultToolCallingChatOptionsTests { DefaultToolCallingChatOptions original = new DefaultToolCallingChatOptions(); ToolCallback callback = mock(ToolCallback.class); original.setToolCallbacks(List.of(callback)); - original.setTools(Set.of("tool1")); + original.setToolNames(Set.of("tool1")); original.setToolContext(Map.of("key", "value")); original.setInternalToolExecutionEnabled(true); original.setModel("gpt-4"); @@ -147,7 +147,7 @@ class DefaultToolCallingChatOptionsTests { assertThat(copy).isNotSameAs(original).satisfies(c -> { assertThat(c.getToolCallbacks()).isEqualTo(original.getToolCallbacks()); - assertThat(c.getTools()).isEqualTo(original.getTools()); + assertThat(c.getToolNames()).isEqualTo(original.getToolNames()); assertThat(c.getToolContext()).isEqualTo(original.getToolContext()); assertThat(c.isInternalToolExecutionEnabled()).isEqualTo(original.isInternalToolExecutionEnabled()); assertThat(c.getModel()).isEqualTo(original.getModel()); @@ -160,12 +160,12 @@ class DefaultToolCallingChatOptionsTests { DefaultToolCallingChatOptions options = new DefaultToolCallingChatOptions(); ToolCallback callback = mock(ToolCallback.class); options.setToolCallbacks(List.of(callback)); - options.setTools(Set.of("tool1")); + options.setToolNames(Set.of("tool1")); options.setToolContext(Map.of("key", "value")); assertThatThrownBy(() -> options.getToolCallbacks().add(mock(ToolCallback.class))) .isInstanceOf(UnsupportedOperationException.class); - assertThatThrownBy(() -> options.getTools().add("tool2")).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> options.getToolNames().add("tool2")).isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> options.getToolContext().put("key2", "value2")) .isInstanceOf(UnsupportedOperationException.class); } @@ -177,7 +177,7 @@ class DefaultToolCallingChatOptionsTests { ToolCallingChatOptions options = DefaultToolCallingChatOptions.builder() .toolCallbacks(List.of(callback)) - .tools(Set.of("tool1")) + .toolNames(Set.of("tool1")) .toolContext(context) .internalToolExecutionEnabled(true) .model("gpt-4") @@ -192,7 +192,7 @@ class DefaultToolCallingChatOptionsTests { assertThat(options).satisfies(o -> { assertThat(o.getToolCallbacks()).containsExactly(callback); - assertThat(o.getTools()).containsExactly("tool1"); + assertThat(o.getToolNames()).containsExactly("tool1"); assertThat(o.getToolContext()).isEqualTo(context); assertThat(o.isInternalToolExecutionEnabled()).isTrue(); assertThat(o.getModel()).isEqualTo("gpt-4"); @@ -225,11 +225,11 @@ class DefaultToolCallingChatOptionsTests { options.setFunctionCallbacks(List.of(callback1, callback2)); assertThat(options.getFunctionCallbacks()).hasSize(2); - options.setTools(Set.of("tool1")); + options.setToolNames(Set.of("tool1")); assertThat(options.getFunctions()).containsExactly("tool1"); options.setFunctions(Set.of("function1")); - assertThat(options.getTools()).containsExactly("function1"); + assertThat(options.getToolNames()).containsExactly("function1"); options.setInternalToolExecutionEnabled(true); assertThat(options.getProxyToolCalls()).isFalse(); diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTests.java index 5529b8f7b..f86ad1c19 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTests.java @@ -19,7 +19,6 @@ package org.springframework.ai.model.tool; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; @@ -29,6 +28,7 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.execution.ToolCallExceptionConverter; import org.springframework.ai.tool.execution.ToolExecutionException; +import org.springframework.ai.tool.metadata.ToolMetadata; import org.springframework.ai.tool.resolution.StaticToolCallbackResolver; import org.springframework.ai.tool.resolution.ToolCallbackResolver; @@ -101,7 +101,7 @@ class DefaultToolCallingManagerTests { .build(); List toolDefinitions = toolCallingManager - .resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build()); + .resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolA").build()); assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition()); } @@ -114,7 +114,7 @@ class DefaultToolCallingManagerTests { .build(); assertThatThrownBy(() -> toolCallingManager - .resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolB").build())) + .resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolB").build())) .isInstanceOf(IllegalStateException.class) .hasMessage("No ToolCallback found for tool name: toolB"); } @@ -163,9 +163,32 @@ class DefaultToolCallingManagerTests { ToolResponseMessage expectedToolResponse = new ToolResponseMessage( List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + } + + @Test + void whenSingleToolCallWithReturnDirectInChatResponseThenExecute() { + ToolCallback toolCallback = new TestToolCallback("toolA", true); + ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver(List.of(toolCallback)); + ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder() + .toolCallbackResolver(toolCallbackResolver) + .build(); + + Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build()); + ChatResponse chatResponse = ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage("", Map.of(), + List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}")))))) + .build(); + + ToolResponseMessage expectedToolResponse = new ToolResponseMessage( + List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"))); + + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); + + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isTrue(); } @Test @@ -189,9 +212,63 @@ class DefaultToolCallingManagerTests { List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"), new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + } + + @Test + void whenMultipleToolCallsWithReturnDirectInChatResponseThenExecute() { + ToolCallback toolCallbackA = new TestToolCallback("toolA", true); + ToolCallback toolCallbackB = new TestToolCallback("toolB", true); + ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver( + List.of(toolCallbackA, toolCallbackB)); + ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder() + .toolCallbackResolver(toolCallbackResolver) + .build(); + + Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build()); + ChatResponse chatResponse = ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage("", Map.of(), + List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"), + new AssistantMessage.ToolCall("toolB", "function", "toolB", "{}")))))) + .build(); + + ToolResponseMessage expectedToolResponse = new ToolResponseMessage( + List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"), + new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!"))); + + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); + + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isTrue(); + } + + @Test + void whenMultipleToolCallsWithMixedReturnDirectInChatResponseThenExecute() { + ToolCallback toolCallbackA = new TestToolCallback("toolA", true); + ToolCallback toolCallbackB = new TestToolCallback("toolB", false); + ToolCallbackResolver toolCallbackResolver = new StaticToolCallbackResolver( + List.of(toolCallbackA, toolCallbackB)); + ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder() + .toolCallbackResolver(toolCallbackResolver) + .build(); + + Prompt prompt = new Prompt(new UserMessage("Hello"), ToolCallingChatOptions.builder().build()); + ChatResponse chatResponse = ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage("", Map.of(), + List.of(new AssistantMessage.ToolCall("toolA", "function", "toolA", "{}"), + new AssistantMessage.ToolCall("toolB", "function", "toolB", "{}")))))) + .build(); + + ToolResponseMessage expectedToolResponse = new ToolResponseMessage( + List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"), + new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!"))); + + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); + + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isFalse(); } @Test @@ -211,17 +288,25 @@ class DefaultToolCallingManagerTests { ToolResponseMessage expectedToolResponse = new ToolResponseMessage( List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); } static class TestToolCallback implements ToolCallback { private final ToolDefinition toolDefinition; + private final ToolMetadata toolMetadata; + public TestToolCallback(String name) { this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build(); + this.toolMetadata = ToolMetadata.builder().build(); + } + + public TestToolCallback(String name, boolean returnDirect) { + this.toolDefinition = ToolDefinition.builder().name(name).inputSchema("{}").build(); + this.toolMetadata = ToolMetadata.builder().returnDirect(returnDirect).build(); } @Override @@ -229,6 +314,11 @@ class DefaultToolCallingManagerTests { return toolDefinition; } + @Override + public ToolMetadata getToolMetadata() { + return toolMetadata; + } + @Override public String call(String toolInput) { return "Mission accomplished!"; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolExecutionResultTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolExecutionResultTests.java new file mode 100644 index 000000000..9e3384500 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/DefaultToolExecutionResultTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.tool; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; + +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link DefaultToolExecutionResult}. + * + * @author Thomas Vitale + */ +class DefaultToolExecutionResultTests { + + @Test + void whenConversationHistoryIsNullThenThrow() { + assertThatThrownBy(() -> DefaultToolExecutionResult.builder().conversationHistory(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("conversationHistory cannot be null"); + } + + @Test + void whenConversationHistoryHasNullElementsThenThrow() { + var history = new ArrayList(); + history.add(null); + assertThatThrownBy(() -> DefaultToolExecutionResult.builder().conversationHistory(history).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("conversationHistory cannot contain null elements"); + } + + @Test + void builder() { + var conversationHistory = new ArrayList(); + var result = DefaultToolExecutionResult.builder() + .conversationHistory(conversationHistory) + .returnDirect(true) + .build(); + assertThat(result.conversationHistory()).isEqualTo(conversationHistory); + assertThat(result.returnDirect()).isTrue(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/LegacyToolCallingManagerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/LegacyToolCallingManagerTests.java index 7e6adffac..1d56f629d 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/LegacyToolCallingManagerTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/LegacyToolCallingManagerTests.java @@ -18,7 +18,6 @@ package org.springframework.ai.model.tool; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; @@ -60,7 +59,7 @@ class LegacyToolCallingManagerTests { .build(); List toolDefinitions = toolCallingManager - .resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolA").build()); + .resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolA").build()); assertThat(toolDefinitions).containsExactly(toolCallback.getToolDefinition()); } @@ -70,7 +69,7 @@ class LegacyToolCallingManagerTests { ToolCallingManager toolCallingManager = LegacyToolCallingManager.builder().functionCallbacks(List.of()).build(); assertThatThrownBy(() -> toolCallingManager - .resolveToolDefinitions(ToolCallingChatOptions.builder().tools("toolB").build())) + .resolveToolDefinitions(ToolCallingChatOptions.builder().toolNames("toolB").build())) .isInstanceOf(IllegalStateException.class) .hasMessage("No ToolCallback found for tool name: toolB"); } @@ -118,9 +117,10 @@ class LegacyToolCallingManagerTests { ToolResponseMessage expectedToolResponse = new ToolResponseMessage( List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isFalse(); } @Test @@ -142,9 +142,10 @@ class LegacyToolCallingManagerTests { List.of(new ToolResponseMessage.ToolResponse("toolA", "toolA", "Mission accomplished!"), new ToolResponseMessage.ToolResponse("toolB", "toolB", "Mission accomplished!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isFalse(); } @Test @@ -163,9 +164,10 @@ class LegacyToolCallingManagerTests { ToolResponseMessage expectedToolResponse = new ToolResponseMessage( List.of(new ToolResponseMessage.ToolResponse("toolC", "toolC", "You failed this city!"))); - List toolCallHistory = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(toolCallHistory).contains(expectedToolResponse); + assertThat(toolExecutionResult.conversationHistory()).contains(expectedToolResponse); + assertThat(toolExecutionResult.returnDirect()).isFalse(); } static class TestToolCallback implements ToolCallback { diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/tool/ToolExecutionResultTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/ToolExecutionResultTests.java new file mode 100644 index 000000000..3baf9d662 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/tool/ToolExecutionResultTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.tool; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ToolExecutionResult}. + * + * @author Thomas Vitale + */ +class ToolExecutionResultTests { + + @Test + void whenSingleToolCallThenSingleGeneration() { + var toolExecutionResult = ToolExecutionResult.builder() + .conversationHistory(List.of(new AssistantMessage("Hello, how can I help you?"), + new UserMessage("I would like to know the weather in London"), + new AssistantMessage("Call the weather tool"), + new ToolResponseMessage(List.of(new ToolResponseMessage.ToolResponse("42", "weather", + "The weather in London is 20 degrees Celsius"))))) + .build(); + + var generations = ToolExecutionResult.buildGenerations(toolExecutionResult); + + assertThat(generations).hasSize(1); + assertThat(generations.get(0).getOutput().getText()).isEqualTo("The weather in London is 20 degrees Celsius"); + assertThat((String) generations.get(0).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME)) + .isEqualTo("weather"); + assertThat(generations.get(0).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON); + } + + @Test + void whenMultipleToolCallsThenMultipleGenerations() { + var toolExecutionResult = ToolExecutionResult.builder() + .conversationHistory(List.of(new AssistantMessage("Hello, how can I help you?"), + new UserMessage("I would like to know the weather in London"), + new AssistantMessage("Call the weather tool and the news tool"), + new ToolResponseMessage(List.of( + new ToolResponseMessage.ToolResponse("42", "weather", + "The weather in London is 20 degrees Celsius"), + new ToolResponseMessage.ToolResponse("21", "news", + "There is heavy traffic in the centre of London"))))) + .build(); + + var generations = ToolExecutionResult.buildGenerations(toolExecutionResult); + + assertThat(generations).hasSize(2); + assertThat(generations.get(0).getOutput().getText()).isEqualTo("The weather in London is 20 degrees Celsius"); + assertThat((String) generations.get(0).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME)) + .isEqualTo("weather"); + assertThat(generations.get(0).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON); + + assertThat(generations.get(1).getOutput().getText()) + .isEqualTo("There is heavy traffic in the centre of London"); + assertThat((String) generations.get(1).getMetadata().get(ToolExecutionResult.METADATA_TOOL_NAME)) + .isEqualTo("news"); + assertThat(generations.get(1).getMetadata().getFinishReason()).isEqualTo(ToolExecutionResult.FINISH_REASON); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/tool/function/FunctionToolCallbackTests.java b/spring-ai-core/src/test/java/org/springframework/ai/tool/function/FunctionToolCallbackTests.java index caf346347..293ed1429 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/tool/function/FunctionToolCallbackTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/tool/function/FunctionToolCallbackTests.java @@ -22,7 +22,7 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.execution.ToolCallResultConverter; import org.springframework.ai.tool.metadata.ToolMetadata; -import org.springframework.ai.util.json.JsonSchemaGenerator; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.core.ParameterizedTypeReference; import java.util.List; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverTests.java b/spring-ai-core/src/test/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverTests.java index f6b5049bf..7f6df87dd 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverTests.java @@ -18,7 +18,7 @@ package org.springframework.ai.tool.resolution; import org.junit.jupiter.api.Test; import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.util.json.SchemaType; +import org.springframework.ai.util.json.schema.SchemaType; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java index 406d8cf35..243d81b14 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java @@ -1,9 +1,29 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.springframework.ai.util.json; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; +import org.springframework.lang.Nullable; import java.lang.reflect.Method; import java.time.Duration; @@ -22,6 +42,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; */ class JsonSchemaGeneratorTests { + // METHODS + @Test void generateSchemaForMethodWithSimpleParameters() throws Exception { Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class); @@ -37,7 +59,7 @@ class JsonSchemaGeneratorTests { }, "age": { "type": "integer", - "format" : "int32" + "format": "int32" } }, "required": [ @@ -52,11 +74,121 @@ class JsonSchemaGeneratorTests { } @Test - void generateSchemaForMethodWithJsonPropertyAnnotations() throws Exception { + void generateSchemaForMethodWithToolParamAnnotations() throws Exception { Method method = TestMethods.class.getDeclaredMethod("annotatedMethod", String.class, String.class); - String schema = JsonSchemaGenerator.generateForMethodInput(method, - JsonSchemaGenerator.SchemaOption.RESPECT_JSON_PROPERTY_REQUIRED); + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The username of the customer" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWhenParameterRequiredByDefault() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("anotherAnnotatedMethod", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithOpenApiSchemaAnnotations() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("openApiMethod", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The username of the customer" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithJacksonAnnotations() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("jacksonMethod", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); String expectedJsonSchema = """ { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -106,7 +238,7 @@ class JsonSchemaGeneratorTests { }, "age": { "type": "INTEGER", - "format" : "int32" + "format": "int32" } }, "required": [ @@ -122,7 +254,8 @@ class JsonSchemaGeneratorTests { @Test void generateSchemaForMethodWithComplexParameters() throws Exception { - Method method = TestMethods.class.getDeclaredMethod("complexMethod", List.class, TestData.class); + Method method = TestMethods.class.getDeclaredMethod("complexMethod", List.class, TestData.class, + MoreTestData.class); String schema = JsonSchemaGenerator.generateForMethodInput(method); @@ -142,18 +275,31 @@ class JsonSchemaGeneratorTests { "properties": { "id": { "type": "integer", - "format" : "int32" + "format": "int32" }, "name": { - "type": "string" + "type": "string", + "description": "The special name" } - } + }, + "required": [ "id", "name" ] + }, + "moreData": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "description": "Even more special name" + } + }, + "required": [ "id", "name" ] } }, - "required": [ - "items", - "data" - ], + "required": [ "items", "data", "moreData" ], "additionalProperties": false } """; @@ -174,7 +320,7 @@ class JsonSchemaGeneratorTests { "properties": { "duration": { "type": "string", - "format" : "duration" + "format": "duration" }, "localDateTime": { "type": "string", @@ -197,11 +343,14 @@ class JsonSchemaGeneratorTests { assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); } + // TYPES + @Test void generateSchemaForSimpleType() { String schema = JsonSchemaGenerator.generateForType(Person.class); String expectedJsonSchema = """ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "email": { @@ -209,12 +358,13 @@ class JsonSchemaGeneratorTests { }, "id": { "type": "integer", - "format" : "int32" + "format": "int32" }, "name": { "type": "string" } }, + "required": [ "email", "id", "name" ], "additionalProperties": false } """; @@ -231,12 +381,166 @@ class JsonSchemaGeneratorTests { assertThat(jsonNode.has("additionalProperties")).isFalse(); } + @Test + void generateSchemaWhenParameterRequiredByDefault() { + String schema = JsonSchemaGenerator.generateForType(Person.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email", + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithToolArgAnnotation() { + String schema = JsonSchemaGenerator.generateForType(AnnotatedPerson.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "The email of the person" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithOpenApiAnnotation() { + String schema = JsonSchemaGenerator.generateForType(OpenApiPerson.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "The email of the person" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithJacksonAnnotation() { + String schema = JsonSchemaGenerator.generateForType(JacksonPerson.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation() { + String schema = JsonSchemaGenerator.generateForType(JacksonPerson.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + @Test void generateSchemaForTypeWithUpperCaseValues() { String schema = JsonSchemaGenerator.generateForType(Person.class, JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES); String expectedJsonSchema = """ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "OBJECT", "properties": { "email": { @@ -244,12 +548,13 @@ class JsonSchemaGeneratorTests { }, "id": { "type": "INTEGER", - "format" : "int32" + "format": "int32" }, "name": { "type": "STRING" } }, + "required": [ "email", "id", "name" ], "additionalProperties": false } """; @@ -262,16 +567,19 @@ class JsonSchemaGeneratorTests { String schema = JsonSchemaGenerator.generateForType(TestData.class); String expectedJsonSchema = """ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "integer", - "format" : "int32" + "format": "int32" }, "name": { - "type": "string" + "type": "string", + "description": "The special name" } }, + "required": [ "id", "name" ], "additionalProperties": false } """; @@ -284,6 +592,7 @@ class JsonSchemaGeneratorTests { String schema = JsonSchemaGenerator.generateForType(Month.class); String expectedJsonSchema = """ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", "enum": [ "JANUARY", @@ -317,10 +626,27 @@ class JsonSchemaGeneratorTests { public void simpleMethod(String name, int age) { } - public void annotatedMethod(String username, @JsonProperty(required = true) String password) { + public void annotatedMethod( + @ToolParam(required = false, description = "The username of the customer") String username, + @ToolParam(required = true) String password) { } - public void complexMethod(List items, TestData data) { + public void anotherAnnotatedMethod(String username, @ToolParam String password) { + } + + public void openApiMethod( + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, + description = "The username of the customer") String username, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String password) { + } + + public void jacksonMethod(@JsonProperty String username, @JsonProperty(required = true) String password) { + } + + public void nullableMethod(@Nullable String username, String password) { + } + + public void complexMethod(List items, TestData data, MoreTestData moreData) { } public void timeMethod(Duration duration, LocalDateTime localDateTime, Instant instant) { @@ -328,7 +654,27 @@ class JsonSchemaGeneratorTests { } - record TestData(int id, String name) { + record TestData(int id, @ToolParam(description = "The special name") String name) { + } + + record MoreTestData(int id, @Schema(description = "Even more special name") String name) { + } + + record AnnotatedPerson(@ToolParam int id, @ToolParam String name, + @ToolParam(required = false, description = "The email of the person") String email) { + } + + record JacksonPerson(@JsonProperty(required = true) int id, @JsonProperty(required = true) String name, + @JsonProperty String email) { + } + + record OpenApiPerson(@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name, + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, + description = "The email of the person") String email) { + } + + record NullablePerson(int id, String name, @Nullable String email) { } static class Person { diff --git a/spring-ai-core/src/test/kotlin/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverKotlinTests.kt b/spring-ai-core/src/test/kotlin/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverKotlinTests.kt index c9088ea5e..039b332f0 100644 --- a/spring-ai-core/src/test/kotlin/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverKotlinTests.kt +++ b/spring-ai-core/src/test/kotlin/org/springframework/ai/tool/resolution/SpringBeanToolCallbackResolverKotlinTests.kt @@ -18,7 +18,7 @@ package org.springframework.ai.tool.resolution import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test -import org.springframework.ai.util.json.SchemaType +import org.springframework.ai.util.json.schema.SchemaType import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -73,13 +73,13 @@ class SpringBeanToolCallbackResolverKotlinTests { @Bean(WELCOME_TOOL_NAME) @Description(WELCOME_TOOL_DESCRIPTION) open fun welcome(): Consumer { - return Consumer { input: Void? -> } + return Consumer { _: Void? -> } } @Bean(WELCOME_USER_TOOL_NAME) @Description(WELCOME_USER_TOOL_DESCRIPTION) open fun welcomeUser(): Consumer { - return Consumer { user: User? -> } + return Consumer { _: User? -> } } @Bean(BOOKS_BY_AUTHOR_TOOL_NAME) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.jpg new file mode 100644 index 000000000..2c9b9a896 Binary files /dev/null and b/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.jpg differ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.png b/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.png new file mode 100644 index 000000000..e4d384b1e Binary files /dev/null and b/spring-ai-docs/src/main/antora/modules/ROOT/images/tools/tool-calling-01.png differ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 685951457..aac79ce8e 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -94,7 +94,8 @@ * xref:observability/index.adoc[] * xref:api/prompt.adoc[] * xref:api/structured-output-converter.adoc[Structured Output] -* xref:api/functions.adoc[Function Calling] +* xref:api/tools.adoc[Tool Calling] +* xref:api/functions.adoc[Function Calling (Deprecated)] ** xref:api/function-callback.adoc[FunctionCallback API] * xref:api/multimodality.adoc[Multimodality] * xref:api/etl-pipeline.adoc[] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc new file mode 100644 index 000000000..37135ed01 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc @@ -0,0 +1,318 @@ +[[Tools]] += Tool Calling + +_Tool calling_ (also known as _function calling_) is a common pattern in AI applications allowing a model to interact with a set of APIs, or _tools_, augmenting its capabilities. + +Tools are mainly used for: + +* **Information Retrieval**. Tools in this category can be used to retrieve information from external sources, such as a database, a web service, a file system, or a web search engine. The goal is to augment the knowledge of the model, allowing it to answer questions that it would not be able to answer otherwise. As such, they can be used in Retrieval Augmented Generation (RAG) scenarios. For example, a tool can be used to retrieve the current weather for a given location, to retrieve the latest news articles, or to query a database for a specific record. +* **Taking Action**. Tools in this category can be used to take action in a software system, such as sending an email, creating a new record in a database, submitting a form, or triggering a workflow. The goal is to automate tasks that would otherwise require human intervention or explicit programming. For example, a tool can be used to book a flight for a customer interacting with a chatbot, to fill out a form on a web page, or to implement a Java class based on an automated test (TDD) in a code generation scenario. + +Even though we typically refer to _tool calling_ as a model capability, it is actually up to the client application to provide the tool calling logic. The model can only request a tool call and provide the input arguments, whereas the application is responsible for executing the tool call from the input arguments and returning the result. The model never gets access to any of the APIs provided as tools, which is a critical security consideration. + +Spring AI provides convenient APIs to define tools, resolve tool call requests from a model, and execute the tool calls. The following sections provide an overview of the tool calling capabilities in Spring AI. + +== Quick Start + +Let's see how to start using tool calling in Spring AI. We'll implement two simple tools: one for information retrieval and one for taking action. The information retrieval tool will be used to get the current date and time in the user's time zone. The action tool will be used to set an alarm for a specified time. + +=== Information Retrieval + +AI models don't have access to real-time information. Any question that assumes awareness of information such as the current date or weather forecast cannot be answered by the model. However, we can provide a tool that can retrieve this information, and let the model call this tool when access to real-time information is needed. + +Let's implement a tool to get the current date and time in the user's time zone in a `DateTimeTools` class. The tool will take no argument. The `LocaleContextHolder` from Spring Framework can provide the user's time zone. The tool will be defined as a method annotated with `@Tool`. To help the model understand if and when to call this tool, we'll provide a detailed description of what the tools does. + +[source,java] +---- +import java.time.LocalDateTime; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.context.i18n.LocaleContextHolder; + +class DateTimeTools { + + @Tool(description = "Get the current date and time in the user's timezone") + String getCurrentDateTime() { + return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString(); + } + +} +---- + +Next, let's make the tool available to the model. In this example, we'll use the `ChatClient` to interact with the model. We'll provide the tool to the model by passing an instance of `DateTimeTools` via the `tools()` method. When the model needs to know the current date and time, it will request the tool to be called. Internally, the `ChatClient` will call the tool and return the result to the model, which will then use the tool call result to generate the final response to the original question. + +[source,java] +---- +ChatModel chatModel = ... + +String response = ChatClient.create(chatModel) + .prompt("What day is tomorrow?") + .tools(new DateTimeTools()) + .call() + .content(); + +System.out.println(response); +---- + +The output will be something like: + +[source] +---- +Tomorrow is 2015-10-21. +---- + +You can retry asking the same question again. This time, don't provide the tool to the model. The output will be something like: + +[source] +---- +I am an AI and do not have access to real-time information. Please provide the current date so I can accurately determine what day tomorrow will be. +---- + +Without the tool, the model doesn't know how to answer the question because it doesn't have the ability to determine the current date and time. + +=== Taking Actions + +AI models can be used to generate plans for accomplishing certain goals. For example, a model can generate a plan for booking a trip to Denmark. However, the model doesn't have the ability to execute the plan. That's where tools come in: they can be used to execute the plan that a model generates. + +In the previous example, we used a tool to determine the current date and time. In this example, we'll define a second tool for setting an alarm at a specific time. The goal is to set an alarm for 10 minutes from now, so we need to provide both tools to the model to accomplish this task. + +We'll add the new tool to the same `DateTimeTools` class as before. The new tool will take a single parameter, which is the time in ISO-8601 format. The tool will then print a message to the console indicating that the alarm has been set for the given time. Like before, the tool is defined as a method annotated with `@Tool`, which we also use to provide a detailed description to help the model understand when and how to use the tool. + +[source,java] +---- +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.context.i18n.LocaleContextHolder; + +class DateTimeTools { + + @Tool(description = "Get the current date and time in the user's timezone") + String getCurrentDateTime() { + return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString(); + } + + @Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format") + void setAlarm(String time) { + LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME); + System.out.println("Alarm set for " + alarmTime); + } + +} +---- + +Next, let's make both tools available to the model. We'll use the `ChatClient` to interact with the model. We'll provide the tools to the model by passing an instance of `DateTimeTools` via the `tools()` method. When we ask to set up an alarm 10 minutes from now, the model will first need to know the current date and time. Then, it will use the current date and time to calculate the alarm time. Finally, it will use the alarm time to set up the alarm. Internally, the `ChatClient` will handle any tool call request from the model and send back to it any tool call execution result, so that the model can generate the final response. + +[source,java] +---- +ChatModel chatModel = ... + +String response = ChatClient.create(chatModel) + .prompt("Can you set an alarm 10 minutes from now?") + .tools(new DateTimeTools()) + .call() + .content(); + +System.out.println(response); +---- + +In the application logs, you can check the alarm has been set at the correct time. + +== Overview + +Spring AI supports tool calling through a set of flexible abstractions that allow you to define, resolve, and execute tools in a consistent way. This section provides an overview of the main concepts and components of tool calling in Spring AI. + +image::tools/tool-calling-01.jpg[The main sequence of actions for tool calling, width=700, align="center"] + +1. When we want to make a tool available to the model, we include its definition in the chat request. Each tool definition comprises of a name, a description, and the schema of the input parameters. +2. When the model decides to call a tool, it sends a response with the tool name and the input parameters modeled after the defined schema. +3. The application is responsible for using the tool name to identify and execute the tool with the provided input parameters. +4. The result of the tool call is processed by the application. +5. The application sends the tool call result back to the model. +6. The model generates the final response using the tool call result as additional context. + +Tools are the building blocks of tool calling and they are modeled by the `ToolCallback` interface. Spring AI provides built-in support for specifying `ToolCallback`s from methods and functions, but you can always define your own `ToolCallback` implementations to support more use cases. + +`ChatModel` implementations transparently dispatch tool call requests to the corresponding `ToolCallback` implementations and will send the tool call results back to the model, which will ultimately generate the final response. They do so using the `ToolCallingManager` interface, which is responsible for managing the tool execution lifecycle. + +Both `ChatClient` and `ChatModel` accept a list of `ToolCallback` objects to make the tools available to the model and the `ToolCallingManager` that will eventually execute them. + +Besides passing the `ToolCallback` objects directly, you can also pass a list of tool names, that will be resolved using the `ToolCallbackResolver` interface. + +The following sections will go into more details about all these concepts and APIs, including how to customize and extend them to support more use cases. + +== Tool Specification + +In Spring AI, tools are modeled via the `ToolCallback` interface, which provides a way to define the tool name, description, input schema, and the actual tool execution logic. + +This section describes how to: + +- build `ToolCallback`(s) from methods and functions; +- define the schema for the tool input parameters; +- provide additional context to tools; +- return the tool call result directly. + +=== Methods as Tools + +Spring AI provides built-in support for specifying tools (i.e. `ToolCallback`(s)) from methods, either declaratively using the `@Tool` annotation or programmatically using the low-level `MethodToolCallback` implementation. + +==== Declarative Specification: `@Tool` + +You can turn a method into a tool by annotating it with `@Tool`. The annotation allows you to provide a description for the tool, which can be used by the model to understand when and how to call the tool. If you don't provide a description, the method name will be used as the tool description. +However, it's strongly recommended to provide a detailed description because that's paramount for the model to understand the tool's purpose and how to use it. Failing in providing a good description can lead to the model not using the tool when it should or using it incorrectly. + +[source,java] +---- +class DateTimeTools { + + @Tool(description = "Get the current date and time in the user's timezone") + String getCurrentDateTime() { + return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString(); + } + +} +---- + +The method can be either static or instance, and it can have any visibility (public, protected, package-private, or private). The class that contains the method can be either a top-level class or a nested class, and it can also have any visibility (as long as it's accessible where you're planning to instantiate it). + +You can define any number of arguments for the method (including no argument) with any type (primitives, POJOs, enums, lists, arrays, maps, and so on). Similarly, the method can return any type, including `void`. If the method returns a value, the return type must be a serializable type, as the result will be serialized and sent back to the model. + +NOTE: Some types are not supported. See: <>. + +===== Adding Tools to `ChatClient` + +When using the declarative specification approach, there are a few options for adding tools to a `ChatClient`. +Such tools will only be available for the specific chat request they are added to. + +* Pass the tool class instance directly to the `tools()` method. + +[source,java] +---- +ChatClient.create(chatModel) + .prompt("What day is tomorrow?") + .tools(new DateTimeTools()) + .call() + .content(); +---- + +* Generate `ToolCallback`(s) from the tool class instance and pass them to the `tools()` method. + +[source,java] +---- +ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools()); +ChatClient.create(chatModel) + .prompt("What day is tomorrow?") + .tools(dateTimeTools) + .call() + .content(); +---- + +===== Adding Default Tools to `ChatClient` + +When using the declarative specification approach, you can add default tools to a `ChatClient` by adding them to the `ChatClient.Builder` used to instantiate it. +Such tools will be available for ALL the chat requests performed by ALL the `ChatClient` instances built from that specific `ChatClient.Builder`. +They are useful for tools that are commonly used across different chat requests, but they can also be dangerous if not used carefully, risking to make them available when they shouldn't. + +* Pass the tool class instance directly to the `tools()` method. + +[source,java] +---- +ChatModel chatModel = ... +ChatClient chatClient = ChatClient.builder(chatModel) + .defaultTools(new DateTimeTools()) + .build(); +---- + +* Generate `ToolCallback`s from the tool class instance and pass them to the `tools()` method. + +[source,java] +---- +ChatModel chatModel = ... +ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools()); +ChatClient chatClient = ChatClient.builder(chatModel) + .defaultTools(dateTimeTools) + .build(); +---- + +===== Adding Tools to `ChatModel` + +When using the declarative specification approach, there are a few options for adding tools to a `ChatModel`. +Such tools will only be available for the specific chat request they are added to. + +* Generate `ToolCallback`(s) from the tool class instance and pass them to the `toolCallbacks()` method of `ToolCallingChatOptions`. + +[source,java] +---- +ChatModel chatModel = ... +ToolCallback[] dateTimeTools = ToolCallbacks.from(new DateTimeTools()); +ChatOptions chatOptions = ToolCallingChatOptions.builder() + .toolCallbacks(dateTimeTools) + .build(): +Prompt prompt = new Prompt("What day is tomorrow?", chatOptions); +chatModel.call(prompt); +---- + +==== Programmatic Specification: `MethodToolCallback` + +===== Adding Tools to `ChatClient` + +===== Adding Tools to `ChatModel` + +==== Limitations + +Methods using `Optional`, asynchronous (e.g. `CompletableFuture`, `Future`) or reactive types (e.g. `Flow`, `Mono`, `Flux`) as parameters or return types are not currently supported to be used as tools. + +Furthermore, methods returning a functional type (e.g. `Function`, `Supplier`, `Consumer`) are not supported to be used as tools using this approach, but they are supported using the function-based approach described in the next section. + +=== Functions as Tools + +Spring AI provides built-in support for specifying tools from functions, either programmatically using the low-level `FunctionToolCallback` implementation or dynamic using the `ToolCallbackResolver` interface for resolution at run-time. + +==== Programmatic Specification: `FunctionToolCallback` + +===== Adding Tools to `ChatClient` + +===== Adding Tools to `ChatModel` + +==== Dynamic Specification: `@Bean` + +===== Adding Tools to `ChatClient` + +===== Adding Tools to `ChatModel` + +==== Limitations + +* Only POJOs. +* Only public. +* No primitives. +* No lists or arrays. + +=== JSON Schema + +==== Description + +==== Required + +=== Result Conversion + +=== Exception Handling + +=== Tool Context + +=== Return Direct + +== Tool Execution + +=== Framework-Controlled Tool Execution + +=== User-Controlled Tool Execution + +== Tool Resolution + +=== Resolution from Application Context + +== Structured Outputs + +== Observability + +=== Logging diff --git a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/ToolCallingManagerTests.java b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/ToolCallingManagerTests.java index b28f1312f..06ea08685 100644 --- a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/ToolCallingManagerTests.java +++ b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/ToolCallingManagerTests.java @@ -22,9 +22,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.model.tool.ToolExecutionResult; import reactor.core.publisher.Flux; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; @@ -102,12 +102,13 @@ public class ToolCallingManagerTests { assertThat(chatResponse).isNotNull(); assertThat(chatResponse.hasToolCalls()).isTrue(); - List messages = toolCallingManager.executeToolCalls(prompt, chatResponse); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, chatResponse); - assertThat(messages).isNotEmpty(); - assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue(); + assertThat(toolExecutionResult.conversationHistory()).isNotEmpty(); + assertThat(toolExecutionResult.conversationHistory().stream().anyMatch(m -> m instanceof ToolResponseMessage)) + .isTrue(); - Prompt secondPrompt = new Prompt(messages, chatOptions); + Prompt secondPrompt = new Prompt(toolExecutionResult.conversationHistory(), chatOptions); ChatResponse secondChatResponse = openAiChatModel.call(secondPrompt); @@ -121,12 +122,14 @@ public class ToolCallingManagerTests { private void runExplicitToolCallingExecutionWithOptionsStream(ChatOptions chatOptions, Prompt prompt) { ChatResponse chatResponse = openAiChatModel.stream(prompt).flatMap(response -> { if (response.hasToolCalls()) { - List messages = toolCallingManager.executeToolCalls(prompt, response); + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - assertThat(messages).isNotEmpty(); - assertThat(messages.stream().anyMatch(m -> m instanceof ToolResponseMessage)).isTrue(); + assertThat(toolExecutionResult.conversationHistory()).isNotEmpty(); + assertThat(toolExecutionResult.conversationHistory() + .stream() + .anyMatch(m -> m instanceof ToolResponseMessage)).isTrue(); - Prompt secondPrompt = new Prompt(messages, chatOptions); + Prompt secondPrompt = new Prompt(toolExecutionResult.conversationHistory(), chatOptions); // return openAiChatModel.stream(secondPrompt); return Flux.just(openAiChatModel.call(secondPrompt)); } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java index 454f91b75..e3b29e75c 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -96,7 +96,7 @@ class FunctionCallWithFunctionBeanIT { "What's the weather like in San Francisco, Paris and in Tokyo? Use Multi-turn function calling."); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), - ToolCallingChatOptions.builder().tools("weatherFunction").build())); + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); logger.info("Response: {}", response); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java index 9a34b9613..6344b3e81 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,14 +64,14 @@ class FunctionCallWithFunctionBeanIT { "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), - ToolCallingChatOptions.builder().tools("weatherFunction").build())); + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); logger.info("Response: {}", response); assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); response = chatModel.call(new Prompt(List.of(userMessage), - ToolCallingChatOptions.builder().tools("weatherFunction3").build())); + ToolCallingChatOptions.builder().toolNames("weatherFunction3").build())); logger.info("Response: {}", response); @@ -93,7 +93,7 @@ class FunctionCallWithFunctionBeanIT { "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); Flux responses = chatModel.stream(new Prompt(List.of(userMessage), - ToolCallingChatOptions.builder().tools("weatherFunction").build())); + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); String content = responses.collectList() .block() diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java index 1e82e5fdf..3e739c8b6 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -119,7 +119,7 @@ public class OllamaFunctionCallbackIT extends BaseOllamaIT { UserMessage userMessage = new UserMessage( "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); - ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("WeatherInfo").build(); + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("WeatherInfo").build(); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java index 1440a7cd2..6b6380f74 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java @@ -103,7 +103,7 @@ public class OllamaFunctionToolBeanIT extends BaseOllamaIT { "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); ChatResponse response = chatModel - .call(new Prompt(List.of(userMessage), OllamaOptions.builder().tools("weatherInfo").build())); + .call(new Prompt(List.of(userMessage), OllamaOptions.builder().toolNames("weatherInfo").build())); logger.info("Response: {}", response); @@ -147,7 +147,7 @@ public class OllamaFunctionToolBeanIT extends BaseOllamaIT { UserMessage userMessage = new UserMessage( "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); - ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("weatherInfo").build(); + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("weatherInfo").build(); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java index 4c5df9a30..bed893c10 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -155,7 +155,9 @@ class FunctionCallbackWithPlainFunctionBeanIT { UserMessage userMessage = new UserMessage( "Please schedule a train from San Francisco to Los Angeles on 2023-12-25"); - ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("trainReservation").build(); + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder() + .toolNames("trainReservation") + .build(); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); @@ -264,7 +266,9 @@ class FunctionCallbackWithPlainFunctionBeanIT { // Test weatherFunction UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); - ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().tools("weatherFunction").build(); + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder() + .toolNames("weatherFunction") + .build(); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));