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 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")); + } }