Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -295,19 +295,23 @@ private Object convertSingleParameter(Parameter parameter, Map<String, Object> 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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -137,4 +139,14 @@ public <T> T convertValue(Object from, TypeReference<T> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -110,4 +111,24 @@ public interface JsonCodec {
* @throws JsonException if conversion fails
*/
<T> T convertValue(Object from, TypeReference<T> toTypeRef);

/**
* Convert an object to another type using Java reflection Type.
*
* <p>This method preserves generic type information from parameterized types
* such as {@code List<MyClass>} or {@code Map<String, MyClass>}.
*
* <p>Example usage:
* <pre>{@code
* // For a method parameter declared as List<OrderItem>
* Type paramType = parameter.getParameterizedType();
* List<OrderItem> items = (List<OrderItem>) codec.convertValue(value, paramType);
* }</pre>
*
* @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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OrderItem> items) {
return items.size();
}

public String processOrderItems(
@ToolParam(name = "items", description = "list of order items")
List<OrderItem> 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<String, OrderItem> data) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, OrderItem> 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<List<Integer>> matrix) {
int sum = 0;
for (List<Integer> 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
Expand Down Expand Up @@ -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&lt;CustomClass&gt; 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<Map<String, Object>> itemsList = new ArrayList<>();
Map<String, Object> item1 = new HashMap<>();
item1.put("name", "Coffee");
item1.put("quantity", 2);
itemsList.add(item1);

Map<String, Object> item2 = new HashMap<>();
item2.put("name", "Tea");
item2.put("quantity", 3);
itemsList.add(item2);

Map<String, Object> 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&lt;CustomClass&gt; 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<Map<String, Object>> itemsList = new ArrayList<>();
Map<String, Object> item1 = new HashMap<>();
item1.put("name", "Item1");
item1.put("quantity", 1);
itemsList.add(item1);

Map<String, Object> item2 = new HashMap<>();
item2.put("name", "Item2");
item2.put("quantity", 2);
itemsList.add(item2);

Map<String, Object> item3 = new HashMap<>();
item3.put("name", "Item3");
item3.put("quantity", 3);
itemsList.add(item3);

Map<String, Object> 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&lt;CustomClass&gt; works correctly. */
@Test
void testGenericList_EmptyList() throws Exception {
TestTools tools = new TestTools();
Method method = TestTools.class.getMethod("listSizeMethod", List.class);

List<Map<String, Object>> itemsList = new ArrayList<>();

Map<String, Object> 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&lt;String, CustomClass&gt; 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<String, Object> dataMap = new HashMap<>();

Map<String, Object> item1 = new HashMap<>();
item1.put("name", "ProductA");
item1.put("quantity", 10);
dataMap.put("key1", item1);

Map<String, Object> item2 = new HashMap<>();
item2.put("name", "ProductB");
item2.put("quantity", 20);
dataMap.put("key2", item2);

Map<String, Object> 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&lt;List&lt;Integer&gt;&gt;. */
@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<List<Integer>> matrix = new ArrayList<>();
List<Integer> row1 = new ArrayList<>();
row1.add(1);
row1.add(2);
row1.add(3);
matrix.add(row1);

List<Integer> row2 = new ArrayList<>();
row2.add(4);
row2.add(5);
row2.add(6);
matrix.add(row2);

Map<String, Object> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -146,5 +147,10 @@ public <T> T convertValue(Object from, Class<T> toType) {
public <T> T convertValue(Object from, TypeReference<T> toTypeRef) {
return null;
}

@Override
public Object convertValue(Object from, Type toType) {
return null;
}
}
}
Loading