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:
committed by
Mark Pollack
parent
dd7a0469fb
commit
5f6c618f76
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user