diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
index 703d7a4a8c..f2ea59f9eb 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
@@ -27,8 +27,17 @@
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
import io.agentscope.core.tool.ToolSchemaModule;
+import java.lang.reflect.Executable;
+import java.lang.reflect.GenericArrayType;
+import java.lang.reflect.GenericDeclaration;
+import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.lang.reflect.WildcardType;
import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
/**
* Utility class for JSON Schema operations.
@@ -53,9 +62,37 @@
*
{@code @JsonClassDescription(...)} - add class description
*
*
- * 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.
+ * All public methods are thread-safe. Generated schemas are cached per {@link Class} /
+ * {@link Type}; the shared victools {@code SchemaGenerator} is not designed for concurrent use,
+ * so the internal lock is taken only on a cache miss (the first time a given class or type is
+ * seen). A cache hit converts a fresh, independently mutable {@code Map} from the cached,
+ * never-mutated {@link JsonNode}, so it needs no lock.
+ *
+ * Cache entries are scoped to the class they describe instead of living in a static map keyed
+ * by {@code Class}, so an entry cannot outlive that class or pin the classloader that defined it.
+ * That matters because the structured-output and tool-parameter classes reaching this utility are
+ * not always compile-time-fixed: extensions can load skills and tools at runtime, and
+ * multi-tenant deployments may load classes per tenant.
+ *
+ * A parameterized type is scoped to the deepest application class it mentions rather than to
+ * its raw class: {@code List} is held on {@code TenantDto} and dies with it. Scoping
+ * such an entry to {@code java.util.List} instead would park it, and the {@code TenantDto} its key
+ * references, on a class the JVM never unloads, which is the same classloader pinning in a
+ * different place. See {@link #scopeClassOf} for how the class is picked.
+ *
+ * The number of entries a single class's cache can hold is bounded by the distinct generic
+ * signatures the code produces against it: every {@link Type} reaching this utility originates in
+ * a {@link TypeReference} literal or a reflective method signature, and two structurally equal
+ * signatures share one entry. Loading a class at runtime therefore adds a class with its own
+ * cache rather than another entry on an existing one. A caller that synthesizes {@code Type}
+ * instances at runtime can add entries beyond that bound, but those entries are released together
+ * with the class that owns the cache, which is the first application class the type mentions when
+ * it mentions more than one.
+ *
+ * One case is deliberately outside that bound: a signature built only from JDK classes, such as
+ * {@code List}, has no application class to be scoped to, so it stays on its raw class
+ * ({@code java.util.List}), which the JVM never unloads. Those entries live as long as the JVM and
+ * cannot pin an application class, because no application class appears in them.
*
* @hidden
*/
@@ -68,10 +105,55 @@ 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_SLOT} and
+ * {@link #TYPE_SCHEMA_SLOT} take this lock; cache hits never do.
*/
private static final Object SCHEMA_LOCK = new Object();
+ /**
+ * Schema cache slot of each class. A schema is a deterministic function of the class and the
+ * static, never-changing generator config, so entries never need invalidation. Cached nodes
+ * are never mutated after being stored: every call still converts a fresh, independently
+ * mutable {@link Map} from the cached node, so callers that mutate the returned map (e.g.
+ * {@code ToolSchemaGenerator}) cannot corrupt the cache or interfere with one another.
+ *
+ * Creating the slot through {@link ClassValue} holds it on the class it describes, rather
+ * than in a static map that strongly references the class as a key, so a slot cannot keep that
+ * class — or the classloader which defined it — reachable once the rest of the application has
+ * let go of them.
+ */
+ private static final ClassValue> CLASS_SCHEMA_SLOT =
+ new ClassValue<>() {
+ @Override
+ protected AtomicReference computeValue(Class> clazz) {
+ return new AtomicReference<>();
+ }
+ };
+
+ /**
+ * Schema cache slot of each class, keyed by generic {@link Type} to support parameterized
+ * structured-output and tool-parameter types. The variants of one class (the type variables it
+ * declares, and every signature mentioning it) share the map held on that class, so these slots
+ * are scoped to a classloader in the same way as {@link #CLASS_SCHEMA_SLOT}. {@link
+ * #scopeClassOf} picks that class: the deepest application class the type mentions, which keeps
+ * {@code List} on {@code TenantDto} instead of on {@code java.util.List}.
+ *
+ * The map grows with the distinct generic signatures the code writes against that class,
+ * not with anything a caller supplies at runtime, so it carries the same bound the previous
+ * static {@code Map} relied on. See the class javadoc for the full argument,
+ * including the signatures that stay on a JDK class because they mention no other.
+ *
+ * Because unrelated classes never share a map, a miss takes {@link #SCHEMA_LOCK} without
+ * grouping unrelated types behind the same lock.
+ */
+ private static final ClassValue