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<String,Integer>, 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 <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-05-10 11:38:27 +02:00
committed by Mark Pollack
parent dd7a0469fb
commit 5f6c618f76
5 changed files with 375 additions and 5 deletions

View File

@@ -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<String, Object> 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<Room> 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();
}
}
}

View File

@@ -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<String, Object> 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<Room> 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();
}
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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<String>
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<String, Integer>
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<Map<String, Integer>>
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<String> strings) {
return strings.size() + " strings processed: " + strings;
}
public String processStringIntMap(Map<String, Integer> map) {
return map.size() + " entries processed: " + map;
}
public String processListOfMaps(List<Map<String, Integer>> listOfMaps) {
return listOfMaps.size() + " maps processed: " + listOfMaps;
}
}
}