Skip to content
Draft
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 @@ -29,6 +29,7 @@
import io.agentscope.core.tool.ToolSchemaModule;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Utility class for JSON Schema operations.
Expand All @@ -55,7 +56,8 @@
*
* <p>All public methods are thread-safe. Schema generation through the shared victools
* {@code SchemaGenerator} is serialized by an internal lock, because the generator itself
* is not designed for concurrent use.</p>
* is not designed for concurrent use. Generated schemas are cached per {@link Class}/{@link Type}
* so that the lock is only needed the first time a given class or type is seen.</p>
*
* @hidden
*/
Expand All @@ -68,10 +70,26 @@ public class JsonSchemaUtils {
/**
* Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its
* JacksonModule keeps an unsynchronized introspection cache, so concurrent schema
* generation must be serialized.
* generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_CACHE} and
* {@link #TYPE_SCHEMA_CACHE} take this lock.
*/
private static final Object SCHEMA_LOCK = new Object();

/**
* Caches the schema {@link JsonNode} generated for each class, since it is a deterministic
* function of the class and the static, never-changing generator config, so no invalidation
* is needed. Values are never mutated after being cached; every call still converts a fresh,
* independently mutable {@code Map} from the cached node. Unbounded, but keys are the
* compile-time-fixed structured-output and tool-parameter classes declared by application
* code, so the entry count is bounded by the (small, finite) set of classes the JVM loads for
* that purpose, not by request volume or untrusted input.
*/
private static final Map<Class<?>, JsonNode> CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>();

/** Same caching strategy and bound rationale as {@link #CLASS_SCHEMA_CACHE}, keyed by
* generic {@link Type}. */
private static final Map<Type, JsonNode> TYPE_SCHEMA_CACHE = new ConcurrentHashMap<>();

static {
// JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations
JacksonModule jacksonModule =
Expand Down Expand Up @@ -106,10 +124,14 @@ public class JsonSchemaUtils {
*/
public static Map<String, Object> generateSchemaFromClass(Class<?> clazz) {
try {
JsonNode schemaNode;
synchronized (SCHEMA_LOCK) {
schemaNode = schemaGenerator.generateSchema(clazz);
}
JsonNode schemaNode =
CLASS_SCHEMA_CACHE.computeIfAbsent(
clazz,
c -> {
synchronized (SCHEMA_LOCK) {
return schemaGenerator.generateSchema(c);
}
});
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
Expand Down Expand Up @@ -144,10 +166,14 @@ public static Map<String, Object> generateSchemaFromJsonNode(JsonNode schema) {
*/
public static Map<String, Object> generateSchemaFromType(Type type) {
try {
JsonNode schemaNode;
synchronized (SCHEMA_LOCK) {
schemaNode = schemaGenerator.generateSchema(type);
}
JsonNode schemaNode =
TYPE_SCHEMA_CACHE.computeIfAbsent(
type,
t -> {
synchronized (SCHEMA_LOCK) {
return schemaGenerator.generateSchema(t);
}
});
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,52 @@ void testConvertToObjectInvalidData() {
() -> JsonSchemaUtils.convertToObject(invalidData, SimpleModel.class));
}

@Test
void testGenerateSchemaFromClassRepeatedCallsReturnEqualIndependentMaps() {
Map<String, Object> first = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
Map<String, Object> second = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);

assertEquals(first, second);

// Mutating a schema returned from one call must not leak into a later call, matching
// callers (e.g. ToolSchemaGenerator) that mutate the returned map in place.
first.put("description", "mutated");
assertTrue(!second.containsKey("description"));

Map<String, Object> third = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
assertTrue(!third.containsKey("description"));
assertEquals(second, third);
}

@Test
void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() {
Type listType = new TypeReference<List<String>>() {}.getType();

Map<String, Object> first = JsonSchemaUtils.generateSchemaFromType(listType);
Map<String, Object> second = JsonSchemaUtils.generateSchemaFromType(listType);

assertEquals(first, second);

first.put("description", "mutated");
assertTrue(!second.containsKey("description"));

Map<String, Object> third = JsonSchemaUtils.generateSchemaFromType(listType);
assertTrue(!third.containsKey("description"));
assertEquals(second, third);
}

@Test
void testGenerateSchemaFromClassNullThrowsNullPointerException() {
assertThrows(
NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromClass(null));
}

@Test
void testGenerateSchemaFromTypeNullThrowsNullPointerException() {
assertThrows(
NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null));
}

@Test
void testGenerateSchemaFromType() {
// Test List<String>
Expand Down