diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java index c0776541f..88d7b9621 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java @@ -295,19 +295,23 @@ private Object convertSingleParameter(Parameter parameter, Map i return null; } - Class paramType = parameter.getType(); - - // Direct assignment if types match - if (paramType.isAssignableFrom(value.getClass())) { + Class rawType = parameter.getType(); + Type paramType = parameter.getParameterizedType(); + + // Direct assignment only if: + // 1. Raw types match, AND + // 2. The parameter is not a parameterized type (no generic info to preserve) + if (rawType.isAssignableFrom(value.getClass()) + && !(paramType instanceof ParameterizedType)) { return value; } - // Try JsonCodec conversion first + // Use JsonCodec conversion with full type information to preserve generics. try { return JsonUtils.getJsonCodec().convertValue(value, paramType); } catch (Exception e) { // Fallback to string-based conversion for primitives - return convertFromString(value.toString(), paramType); + return convertFromString(value.toString(), rawType); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JacksonJsonCodec.java b/agentscope-core/src/main/java/io/agentscope/core/util/JacksonJsonCodec.java index a95de271c..08d6be9ee 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JacksonJsonCodec.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JacksonJsonCodec.java @@ -19,8 +19,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.lang.reflect.Type; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -137,4 +139,14 @@ public T convertValue(Object from, TypeReference toTypeRef) { throw new JsonException("Failed to convert value", e); } } + + @Override + public Object convertValue(Object from, Type toType) { + try { + JavaType javaType = objectMapper.getTypeFactory().constructType(toType); + return objectMapper.convertValue(from, javaType); + } catch (IllegalArgumentException e) { + throw new JsonException("Failed to convert value to " + toType.getTypeName(), e); + } + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonCodec.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonCodec.java index 9b8f5a4b9..e4789406c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonCodec.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonCodec.java @@ -17,6 +17,7 @@ package io.agentscope.core.util; import com.fasterxml.jackson.core.type.TypeReference; +import java.lang.reflect.Type; /** * Interface for JSON serialization and deserialization operations. @@ -110,4 +111,24 @@ public interface JsonCodec { * @throws JsonException if conversion fails */ T convertValue(Object from, TypeReference toTypeRef); + + /** + * Convert an object to another type using Java reflection Type. + * + *

This method preserves generic type information from parameterized types + * such as {@code List} or {@code Map}. + * + *

Example usage: + *

{@code
+     * // For a method parameter declared as List
+     * Type paramType = parameter.getParameterizedType();
+     * List items = (List) codec.convertValue(value, paramType);
+     * }
+ * + * @param from the source object + * @param toType the target type (can be Class, ParameterizedType, etc.) + * @return converted object + * @throws JsonException if conversion fails + */ + Object convertValue(Object from, Type toType); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java index 73b5a2f4f..adce1f985 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java @@ -19,8 +19,11 @@ import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.tool.test.ToolTestUtils; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -121,6 +124,89 @@ public int parsableIntString( @ToolParam(name = "value", description = "value") String value) { return Integer.parseInt(value); } + + // Methods for testing generic type handling (Issue #677) + public int listSizeMethod( + @ToolParam(name = "items", description = "list of items") List items) { + return items.size(); + } + + public String processOrderItems( + @ToolParam(name = "items", description = "list of order items") + List items) { + StringBuilder sb = new StringBuilder(); + for (OrderItem item : items) { + sb.append(item.getName()).append(":").append(item.getQuantity()).append(";"); + } + return sb.toString(); + } + + public String mapMethod( + @ToolParam(name = "data", description = "map of data") + Map data) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : data.entrySet()) { + sb.append(entry.getKey()) + .append("=") + .append(entry.getValue().getName()) + .append(";"); + } + return sb.toString(); + } + + public String nestedListMethod( + @ToolParam(name = "matrix", description = "nested list") + List> matrix) { + int sum = 0; + for (List row : matrix) { + for (Integer val : row) { + sum += val; + } + } + return String.valueOf(sum); + } + } + + /** Test POJO for generic type testing (Issue #677). */ + static class OrderItem { + private String name; + private int quantity; + + public OrderItem() {} + + public OrderItem(String name, int quantity) { + this.name = name; + this.quantity = quantity; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrderItem orderItem = (OrderItem) o; + return quantity == orderItem.quantity && Objects.equals(name, orderItem.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, quantity); + } } @Test @@ -538,4 +624,159 @@ void testConvertFromString_ZeroValues() throws Exception { Assertions.assertFalse(ToolTestUtils.isErrorResponse(response2)); Assertions.assertEquals("0.0", ToolTestUtils.extractContent(response2)); } + + // ========== Tests for Generic Type Handling (Issue #677) ========== + + /** + * Test that List<CustomClass> parameters are correctly deserialized. + * This is the core fix for Issue #677 - previously this would fail with ClassCastException + * because LinkedHashMap could not be cast to OrderItem. + */ + @Test + void testGenericList_WithCustomClass() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("processOrderItems", List.class); + + // Simulate JSON input as it would come from LLM - a list of maps + List> itemsList = new ArrayList<>(); + Map item1 = new HashMap<>(); + item1.put("name", "Coffee"); + item1.put("quantity", 2); + itemsList.add(item1); + + Map item2 = new HashMap<>(); + item2.put("name", "Tea"); + item2.put("quantity", 3); + itemsList.add(item2); + + Map input = new HashMap<>(); + input.put("items", itemsList); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse( + ToolTestUtils.isErrorResponse(response), "Should not fail with ClassCastException"); + String content = ToolTestUtils.extractContent(response); + Assertions.assertEquals("\"Coffee:2;Tea:3;\"", content); + } + + /** + * Test that List<CustomClass> size can be accessed after deserialization. + * Verifies that elements are properly typed, not LinkedHashMap. + */ + @Test + void testGenericList_SizeMethod() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("listSizeMethod", List.class); + + List> itemsList = new ArrayList<>(); + Map item1 = new HashMap<>(); + item1.put("name", "Item1"); + item1.put("quantity", 1); + itemsList.add(item1); + + Map item2 = new HashMap<>(); + item2.put("name", "Item2"); + item2.put("quantity", 2); + itemsList.add(item2); + + Map item3 = new HashMap<>(); + item3.put("name", "Item3"); + item3.put("quantity", 3); + itemsList.add(item3); + + Map input = new HashMap<>(); + input.put("items", itemsList); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse(ToolTestUtils.isErrorResponse(response)); + Assertions.assertEquals("3", ToolTestUtils.extractContent(response)); + } + + /** Test that empty List<CustomClass> works correctly. */ + @Test + void testGenericList_EmptyList() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("listSizeMethod", List.class); + + List> itemsList = new ArrayList<>(); + + Map input = new HashMap<>(); + input.put("items", itemsList); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse(ToolTestUtils.isErrorResponse(response)); + Assertions.assertEquals("0", ToolTestUtils.extractContent(response)); + } + + /** Test that Map<String, CustomClass> parameters are correctly deserialized. */ + @Test + void testGenericMap_WithCustomClassValue() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("mapMethod", Map.class); + + // Simulate JSON input - a map of string to object + Map dataMap = new HashMap<>(); + + Map item1 = new HashMap<>(); + item1.put("name", "ProductA"); + item1.put("quantity", 10); + dataMap.put("key1", item1); + + Map item2 = new HashMap<>(); + item2.put("name", "ProductB"); + item2.put("quantity", 20); + dataMap.put("key2", item2); + + Map input = new HashMap<>(); + input.put("data", dataMap); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse( + ToolTestUtils.isErrorResponse(response), "Should not fail with ClassCastException"); + String content = ToolTestUtils.extractContent(response); + // The order of map entries is not guaranteed, so check that both key-value pairs are + // present. + Assertions.assertTrue( + content.contains("key1=ProductA") && content.contains("key2=ProductB"), + "Response should contain both key-value pairs. Actual: " + content); + } + + /** Test nested generic types like List<List<Integer>>. */ + @Test + void testNestedGenericList() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("nestedListMethod", List.class); + + // Create a 2x3 matrix: [[1,2,3], [4,5,6]] + List> matrix = new ArrayList<>(); + List row1 = new ArrayList<>(); + row1.add(1); + row1.add(2); + row1.add(3); + matrix.add(row1); + + List row2 = new ArrayList<>(); + row2.add(4); + row2.add(5); + row2.add(6); + matrix.add(row2); + + Map input = new HashMap<>(); + input.put("matrix", matrix); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse(ToolTestUtils.isErrorResponse(response)); + // Sum of 1+2+3+4+5+6 = 21 + Assertions.assertEquals("\"21\"", ToolTestUtils.extractContent(response)); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonUtilsTest.java index 1cde8dc4d..3ea0c8411 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonUtilsTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.core.type.TypeReference; +import java.lang.reflect.Type; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -146,5 +147,10 @@ public T convertValue(Object from, Class toType) { public T convertValue(Object from, TypeReference toTypeRef) { return null; } + + @Override + public Object convertValue(Object from, Type toType) { + return null; + } } }