From 5f6c618f7629ba28c9742c36eba0ea46a40387f6 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 10 May 2025 11:38:27 +0200 Subject: [PATCH] feat: Add support for generic argument types in tool callbacks - Enhance MethodToolCallback to properly handle generic types by using parameterized types - Add unit tests for generic type handling (List, Map, nested generics) - Add integration tests for both Anthropic and OpenAI clients to verify tool calls with generic argument types Resolves #2462 Signed-off-by: Christian Tzolov --- ...ClientToolsWithGenericArgumentTypesIT.java | 102 +++++++++++ ...ClientToolsWithGenericArgumentTypesIT.java | 104 ++++++++++++ .../OpenAiChatClientMemoryAdvisorReproIT.java | 2 - .../ai/tool/method/MethodToolCallback.java | 13 +- .../MethodToolCallbackGenericTypesTest.java | 159 ++++++++++++++++++ 5 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/ChatClientToolsWithGenericArgumentTypesIT.java create mode 100644 models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/ChatClientToolsWithGenericArgumentTypesIT.java create mode 100644 spring-ai-model/src/test/java/org/springframework/ai/tool/method/MethodToolCallbackGenericTypesTest.java diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/ChatClientToolsWithGenericArgumentTypesIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/ChatClientToolsWithGenericArgumentTypesIT.java new file mode 100644 index 000000000..525a6502c --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/ChatClientToolsWithGenericArgumentTypesIT.java @@ -0,0 +1,102 @@ +/* + * Copyright 2023-2024 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.anthropic.client; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.BeforeEach; +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.anthropic.AnthropicTestConfiguration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = AnthropicTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") +class ChatClientToolsWithGenericArgumentTypesIT { + + private static final Logger logger = LoggerFactory.getLogger(ChatClientToolsWithGenericArgumentTypesIT.class); + + public static Map arguments = new ConcurrentHashMap<>(); + + public static AtomicLong callCounter = new AtomicLong(0); + + @BeforeEach + void beforeEach() { + arguments.clear(); + } + + @Autowired + ChatModel chatModel; + + @Test + void toolWithGenericArgumentTypes() { + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .user("Turn light red in the living room and the kitchen. Please group the romms with the same color in a single tool call.") + .tools(new TestToolProvider()) + .call() + .content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(arguments).containsEntry("living room", LightColor.RED); + assertThat(arguments).containsEntry("kitchen", LightColor.RED); + + assertThat(callCounter.get()).isEqualTo(1); + } + + record Room(String name) { + } + + enum LightColor { + + RED, GREEN, BLUE + + } + + public static class TestToolProvider { + + @Tool(description = "Change the lamp color in a room.") + public void changeRoomLightColor( + @ToolParam(description = "List of rooms to change the ligth color for") List rooms, + @ToolParam(description = "light color to change to") LightColor color) { + + logger.info("Change light color in rooms: {} to color: {}", rooms, color); + + for (Room room : rooms) { + arguments.put(room.name(), color); + } + callCounter.incrementAndGet(); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/ChatClientToolsWithGenericArgumentTypesIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/ChatClientToolsWithGenericArgumentTypesIT.java new file mode 100644 index 000000000..779009fe0 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/ChatClientToolsWithGenericArgumentTypesIT.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023-2024 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.openai.chat.client; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.openai.OpenAiTestConfiguration; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = OpenAiTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +@ActiveProfiles("logging-test") +class ChatClientToolsWithGenericArgumentTypesIT { + + private static final Logger logger = LoggerFactory.getLogger(ChatClientToolsWithGenericArgumentTypesIT.class); + + public static Map arguments = new ConcurrentHashMap<>(); + + public static AtomicLong callCounter = new AtomicLong(0); + + @BeforeEach + void beforeEach() { + arguments.clear(); + } + + @Autowired + ChatModel chatModel; + + @Test + void toolWithGenericArgumentTypes() { + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .user("Turn light red in the living room and the kitchen. Please group the romms with the same color in a single tool call.") + .tools(new TestToolProvider()) + .call() + .content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(arguments).containsEntry("living room", LightColor.RED); + assertThat(arguments).containsEntry("kitchen", LightColor.RED); + + assertThat(callCounter.get()).isEqualTo(1); + } + + record Room(String name) { + } + + enum LightColor { + + RED, GREEN, BLUE + + } + + public static class TestToolProvider { + + @Tool(description = "Change the lamp color in a room.") + public void changeRoomLightColor( + @ToolParam(description = "List of rooms to change the ligth color for") List rooms, + @ToolParam(description = "light color to change to") LightColor color) { + + logger.info("Change light color in rooms: {} to color: {}", rooms, color); + + for (Room room : rooms) { + arguments.put(room.name(), color); + } + callCounter.incrementAndGet(); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientMemoryAdvisorReproIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientMemoryAdvisorReproIT.java index e7036cedd..8701f8161 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientMemoryAdvisorReproIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientMemoryAdvisorReproIT.java @@ -18,8 +18,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - @SpringBootTest(classes = OpenAiTestConfiguration.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") @ActiveProfiles("logging-test") diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java index 1d49a4560..cc320a54d 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java @@ -136,16 +136,23 @@ public final class MethodToolCallback implements ToolCallback { return toolContext; } Object rawArgument = toolInputArguments.get(parameter.getName()); - return buildTypedArgument(rawArgument, parameter.getType()); + return buildTypedArgument(rawArgument, parameter.getParameterizedType()); }).toArray(); } @Nullable - private Object buildTypedArgument(@Nullable Object value, Class type) { + private Object buildTypedArgument(@Nullable Object value, Type type) { if (value == null) { return null; } - return JsonParser.toTypedObject(value, type); + + if (type instanceof Class) { + return JsonParser.toTypedObject(value, (Class) type); + } + + // For generic types, use the fromJson method that accepts Type + String json = JsonParser.toJson(value); + return JsonParser.fromJson(json, type); } @Nullable diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/method/MethodToolCallbackGenericTypesTest.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/method/MethodToolCallbackGenericTypesTest.java new file mode 100644 index 000000000..aaadaf5fc --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/method/MethodToolCallbackGenericTypesTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool.method; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MethodToolCallback} with generic types. + */ +class MethodToolCallbackGenericTypesTest { + + @Test + void testGenericListType() throws Exception { + // Create a test object with a method that takes a List + TestGenericClass testObject = new TestGenericClass(); + Method method = TestGenericClass.class.getMethod("processStringList", List.class); + + // Create a tool definition + ToolDefinition toolDefinition = DefaultToolDefinition.builder() + .name("processStringList") + .description("Process a list of strings") + .inputSchema("{}") + .build(); + + // Create a MethodToolCallback + MethodToolCallback callback = MethodToolCallback.builder() + .toolDefinition(toolDefinition) + .toolMethod(method) + .toolObject(testObject) + .build(); + + // Create a JSON input with a list of strings + String toolInput = """ + { + "strings": ["one", "two", "three"] + } + """; + + // Call the tool + String result = callback.call(toolInput); + + // Verify the result + assertThat(result).isEqualTo("\"3 strings processed: [one, two, three]\""); + } + + @Test + void testGenericMapType() throws Exception { + // Create a test object with a method that takes a Map + TestGenericClass testObject = new TestGenericClass(); + Method method = TestGenericClass.class.getMethod("processStringIntMap", Map.class); + + // Create a tool definition + ToolDefinition toolDefinition = DefaultToolDefinition.builder() + .name("processStringIntMap") + .description("Process a map of string to integer") + .inputSchema("{}") + .build(); + + // Create a MethodToolCallback + MethodToolCallback callback = MethodToolCallback.builder() + .toolDefinition(toolDefinition) + .toolMethod(method) + .toolObject(testObject) + .build(); + + // Create a JSON input with a map of string to integer + String toolInput = """ + { + "map": {"one": 1, "two": 2, "three": 3} + } + """; + + // Call the tool + String result = callback.call(toolInput); + + // Verify the result + assertThat(result).isEqualTo("\"3 entries processed: {one=1, two=2, three=3}\""); + } + + @Test + void testNestedGenericType() throws Exception { + // Create a test object with a method that takes a List> + TestGenericClass testObject = new TestGenericClass(); + Method method = TestGenericClass.class.getMethod("processListOfMaps", List.class); + + // Create a tool definition + ToolDefinition toolDefinition = DefaultToolDefinition.builder() + .name("processListOfMaps") + .description("Process a list of maps") + .inputSchema("{}") + .build(); + + // Create a MethodToolCallback + MethodToolCallback callback = MethodToolCallback.builder() + .toolDefinition(toolDefinition) + .toolMethod(method) + .toolObject(testObject) + .build(); + + // Create a JSON input with a list of maps + String toolInput = """ + { + "listOfMaps": [ + {"a": 1, "b": 2}, + {"c": 3, "d": 4} + ] + } + """; + + // Call the tool + String result = callback.call(toolInput); + + // Verify the result + assertThat(result).isEqualTo("\"2 maps processed: [{a=1, b=2}, {c=3, d=4}]\""); + } + + /** + * Test class with methods that use generic types. + */ + public static class TestGenericClass { + + public String processStringList(List strings) { + return strings.size() + " strings processed: " + strings; + } + + public String processStringIntMap(Map map) { + return map.size() + " entries processed: " + map; + } + + public String processListOfMaps(List> listOfMaps) { + return listOfMaps.size() + " maps processed: " + listOfMaps; + } + + } + +}