diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
index 25584abd5..37e76a4fc 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
@@ -130,4 +130,18 @@
* @see DefaultToolResultConverter
*/
Class extends ToolResultConverter> converter() default DefaultToolResultConverter.class;
+
+ /**
+ * Whether to allow additional properties beyond those defined in the schema.
+ *
+ *
Corresponds to the {@code additionalProperties} keyword in JSON Schema. When set to
+ * {@code false}, any extra parameters passed by the LLM that are not defined in the schema will
+ * cause a validation error, preventing the LLM from hallucinating undefined parameters.
+ *
+ *
This setting is applied recursively to all nested objects within the generated schema.
+ *
+ * @return {@code true} to allow additional properties (default, backward compatible), {@code
+ * false} to disallow
+ */
+ boolean additionalProperties() default true;
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaGenerator.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaGenerator.java
index f77257880..977bd690e 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaGenerator.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaGenerator.java
@@ -33,6 +33,77 @@
*/
class ToolSchemaGenerator {
+ /**
+ * Generate parameter schema with additionalProperties control.
+ *
+ * @param method the method to generate schema for
+ * @param excludeParams set of parameter names to exclude (may be null or empty)
+ * @param allowAdditionalProperties whether to allow additional properties in the schema
+ * @return JSON Schema map
+ */
+ @SuppressWarnings("unchecked")
+ Map generateParameterSchema(
+ Method method, Set excludeParams, boolean allowAdditionalProperties) {
+ Map schema = generateParameterSchema(method, excludeParams);
+
+ if (!allowAdditionalProperties) {
+ addAdditionalPropertiesFalseRecursively(schema);
+ }
+
+ return schema;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void addAdditionalPropertiesFalseRecursively(Map schema) {
+ if (!"object".equals(schema.get("type"))) {
+ return;
+ }
+ // Skip if user already set additionalProperties explicitly
+ if (schema.containsKey("additionalProperties")) {
+ return;
+ }
+ schema.put("additionalProperties", false);
+
+ Object propsObj = schema.get("properties");
+ if (propsObj instanceof Map) {
+ Map props = (Map) propsObj;
+ for (Map.Entry entry : props.entrySet()) {
+ if (entry.getValue() instanceof Map) {
+ addAdditionalPropertiesFalseRecursively((Map) entry.getValue());
+ }
+ }
+ }
+
+ Object itemsObj = schema.get("items");
+ if (itemsObj instanceof Map) {
+ addAdditionalPropertiesFalseRecursively((Map) itemsObj);
+ }
+
+ for (String key : new String[] {"oneOf", "anyOf", "allOf"}) {
+ Object listObj = schema.get(key);
+ if (listObj instanceof Iterable) {
+ for (Object item : (Iterable>) listObj) {
+ if (item instanceof Map) {
+ addAdditionalPropertiesFalseRecursively((Map) item);
+ }
+ }
+ }
+ }
+
+ for (String defsKey : new String[] {"$defs", "definitions"}) {
+ Object defsObj = schema.get(defsKey);
+ if (defsObj instanceof Map) {
+ Map defs = (Map) defsObj;
+ for (Map.Entry entry : defs.entrySet()) {
+ if (entry.getValue() instanceof Map) {
+ addAdditionalPropertiesFalseRecursively(
+ (Map) entry.getValue());
+ }
+ }
+ }
+ }
+ }
+
/**
* Generate parameter schema for a method with excluded parameters.
*
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
index 88a890636..345f2ee4f 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
@@ -373,7 +373,8 @@ public Map getParameters() {
presetParameters != null
? presetParameters.keySet()
: Collections.emptySet();
- return schemaGenerator.generateParameterSchema(method, excludeParams);
+ return schemaGenerator.generateParameterSchema(
+ method, excludeParams, toolAnnotation.additionalProperties());
}
@Override
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolAnnotationTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolAnnotationTest.java
index d60926121..2a766d9cf 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolAnnotationTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolAnnotationTest.java
@@ -15,7 +15,10 @@
*/
package io.agentscope.core.tool;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.tool.test.SampleTools;
@@ -142,4 +145,104 @@ void testInvalidAnnotations() {
assertTrue(
registeredTools <= methods.length, "Should not register more tools than methods");
}
+
+ @SuppressWarnings("unchecked")
+ @Test
+ @DisplayName("Should include additionalProperties=false in schema when set")
+ void testAdditionalPropertiesFalseInSchema() {
+ toolkit.registerTool(
+ new Object() {
+ @Tool(
+ name = "query_data",
+ description = "Query data",
+ additionalProperties = false)
+ public String queryData(
+ @ToolParam(name = "keyword", description = "keyword") String keyword) {
+ return "result";
+ }
+ });
+
+ Map schema = toolkit.getToolSchemas().get(0).getParameters();
+
+ assertTrue(
+ schema.containsKey("additionalProperties"),
+ "Schema should contain additionalProperties key");
+ assertEquals(
+ false, schema.get("additionalProperties"), "additionalProperties should be false");
+
+ Map properties = (Map) schema.get("properties");
+ assertFalse(
+ properties.containsKey("additionalProperties"),
+ "additionalProperties should NOT be inside properties map");
+ }
+
+ @Test
+ @DisplayName("Should not include additionalProperties when default (true)")
+ void testAdditionalPropertiesDefaultNotInSchema() {
+ toolkit.registerTool(
+ new Object() {
+ @Tool(name = "query_data", description = "Query data")
+ public String queryData(
+ @ToolParam(name = "keyword", description = "keyword") String keyword) {
+ return "result";
+ }
+ });
+
+ Map schema = toolkit.getToolSchemas().get(0).getParameters();
+
+ assertFalse(
+ schema.containsKey("additionalProperties"),
+ "Default behavior should NOT add additionalProperties to schema");
+ }
+
+ @Test
+ @DisplayName("Should reject hallucinated params when additionalProperties=false")
+ void testRejectHallucinatedParamsWhenStrict() {
+ toolkit.registerTool(
+ new Object() {
+ @Tool(
+ name = "query_data",
+ description = "Query data",
+ additionalProperties = false)
+ public String queryData(
+ @ToolParam(name = "keyword", description = "keyword") String keyword) {
+ return "result";
+ }
+ });
+
+ Map schema = toolkit.getToolSchemas().get(0).getParameters();
+ String hallucinatedInput = "{\"keyword\": \"test\", \"owners\": [\"admin\"]}";
+
+ String validationError = ToolValidator.validateInput(hallucinatedInput, schema);
+
+ assertNotNull(
+ validationError,
+ "ToolValidator should reject hallucinated params when "
+ + "additionalProperties=false");
+ assertTrue(
+ validationError.contains("owners"),
+ "Error message should mention the hallucinated param");
+ }
+
+ @Test
+ @DisplayName("Should allow extra params by default (backward compatible)")
+ void testAllowExtraParamsByDefault() {
+ toolkit.registerTool(
+ new Object() {
+ @Tool(name = "query_data", description = "Query data")
+ public String queryData(
+ @ToolParam(name = "keyword", description = "keyword") String keyword) {
+ return "result";
+ }
+ });
+
+ Map schema = toolkit.getToolSchemas().get(0).getParameters();
+ String hallucinatedInput = "{\"keyword\": \"test\", \"owners\": [\"admin\"]}";
+
+ String validationError = ToolValidator.validateInput(hallucinatedInput, schema);
+
+ assertNull(
+ validationError,
+ "Default behavior should allow extra params (backward compatible)");
+ }
}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSchemaGeneratorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSchemaGeneratorTest.java
index 9a7b7cb9c..818d43c56 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSchemaGeneratorTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSchemaGeneratorTest.java
@@ -19,12 +19,14 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
@@ -83,4 +85,125 @@ void testHoistDefsEquivalent() throws Exception {
assertEquals(definition, target.get("Material"));
assertFalse(paramSchema.containsKey("$defs"));
}
+
+ @SuppressWarnings("unchecked")
+ @Test
+ @DisplayName("Should inject additionalProperties=false recursively into nested structures")
+ void testAddAdditionalPropertiesFalseRecursively() throws Exception {
+ Method recMethod =
+ ToolSchemaGenerator.class.getDeclaredMethod(
+ "addAdditionalPropertiesFalseRecursively", Map.class);
+ recMethod.setAccessible(true);
+
+ // Object with items
+ Map schemaWithItems = new HashMap<>();
+ schemaWithItems.put("type", "object");
+ Map itemsObj = new HashMap<>();
+ itemsObj.put("type", "object");
+ schemaWithItems.put("items", itemsObj);
+
+ recMethod.invoke(generator, schemaWithItems);
+
+ assertEquals(false, schemaWithItems.get("additionalProperties"));
+ assertEquals(false, itemsObj.get("additionalProperties"));
+
+ // Object with oneOf
+ Map schemaWithOneOf = new HashMap<>();
+ schemaWithOneOf.put("type", "object");
+ Map oneOfItem = new HashMap<>();
+ oneOfItem.put("type", "object");
+ schemaWithOneOf.put("oneOf", List.of(oneOfItem));
+
+ recMethod.invoke(generator, schemaWithOneOf);
+
+ assertEquals(false, schemaWithOneOf.get("additionalProperties"));
+ assertEquals(false, oneOfItem.get("additionalProperties"));
+
+ // Object with anyOf and allOf
+ Map schemaWithAnyOf = new HashMap<>();
+ schemaWithAnyOf.put("type", "object");
+ Map anyOfItem = new HashMap<>();
+ anyOfItem.put("type", "object");
+ Map allOfItem = new HashMap<>();
+ allOfItem.put("type", "object");
+ schemaWithAnyOf.put("anyOf", List.of(anyOfItem));
+ schemaWithAnyOf.put("allOf", List.of(allOfItem));
+
+ recMethod.invoke(generator, schemaWithAnyOf);
+
+ assertEquals(false, anyOfItem.get("additionalProperties"));
+ assertEquals(false, allOfItem.get("additionalProperties"));
+
+ // Object with $defs
+ Map schemaWithDefs = new HashMap<>();
+ schemaWithDefs.put("type", "object");
+ Map defEntry = new HashMap<>();
+ defEntry.put("type", "object");
+ schemaWithDefs.put("$defs", Map.of("MyType", defEntry));
+
+ recMethod.invoke(generator, schemaWithDefs);
+
+ assertEquals(false, defEntry.get("additionalProperties"));
+
+ // Object with definitions (legacy key)
+ Map schemaWithDefinitions = new HashMap<>();
+ schemaWithDefinitions.put("type", "object");
+ Map defEntry2 = new HashMap<>();
+ defEntry2.put("type", "object");
+ schemaWithDefinitions.put("definitions", Map.of("LegacyType", defEntry2));
+
+ recMethod.invoke(generator, schemaWithDefinitions);
+
+ assertEquals(false, defEntry2.get("additionalProperties"));
+ }
+
+ @Test
+ @DisplayName("Should skip when additionalProperties already set")
+ void testSkipWhenAdditionalPropertiesAlreadySet() throws Exception {
+ Method recMethod =
+ ToolSchemaGenerator.class.getDeclaredMethod(
+ "addAdditionalPropertiesFalseRecursively", Map.class);
+ recMethod.setAccessible(true);
+
+ Map schema = new HashMap<>();
+ schema.put("type", "object");
+ schema.put("additionalProperties", true);
+
+ recMethod.invoke(generator, schema);
+
+ assertEquals(true, schema.get("additionalProperties"));
+ }
+
+ @Test
+ @DisplayName("Should skip non-object schemas")
+ void testSkipNonObjectSchemas() throws Exception {
+ Method recMethod =
+ ToolSchemaGenerator.class.getDeclaredMethod(
+ "addAdditionalPropertiesFalseRecursively", Map.class);
+ recMethod.setAccessible(true);
+
+ Map schema = new HashMap<>();
+ schema.put("type", "string");
+
+ recMethod.invoke(generator, schema);
+
+ assertNull(schema.get("additionalProperties"));
+ }
+
+ @Test
+ @DisplayName("Should handle properties with non-Map values")
+ void testPropertiesWithNonMapValues() throws Exception {
+ Method recMethod =
+ ToolSchemaGenerator.class.getDeclaredMethod(
+ "addAdditionalPropertiesFalseRecursively", Map.class);
+ recMethod.setAccessible(true);
+
+ Map schema = new HashMap<>();
+ schema.put("type", "object");
+ schema.put("properties", Map.of("simple", "not-a-map"));
+
+ recMethod.invoke(generator, schema);
+
+ assertEquals(false, schema.get("additionalProperties"));
+ }
}