diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolValidator.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolValidator.java index 8ae733cfd0..8f0d0fe818 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolValidator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolValidator.java @@ -15,6 +15,10 @@ */ package io.agentscope.core.tool; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.networknt.schema.Error; import com.networknt.schema.InputFormat; import com.networknt.schema.Schema; @@ -24,6 +28,7 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -43,6 +48,7 @@ public final class ToolValidator { private static final SchemaRegistry SCHEMA_REGISTRY = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private ToolValidator() { // Utility class @@ -80,8 +86,10 @@ public static String validateInput(String input, Map schema) { // Create Schema from the schema string Schema jsonSchema = SCHEMA_REGISTRY.getSchema(schemaJson); + String normalizedInput = normalizeOptionalNullFields(input, schemaJson); + // Validate - List errors = jsonSchema.validate(input, InputFormat.JSON); + List errors = jsonSchema.validate(normalizedInput, InputFormat.JSON); if (errors.isEmpty()) { return null; // Validation passed @@ -95,6 +103,104 @@ public static String validateInput(String input, Map schema) { } } + /** + * Removes null-valued object properties before schema validation. + * + *

This treats explicit nulls the same as omitted optional fields, while still allowing + * required-field validation to fail naturally after the null-valued property is removed. + */ + private static String normalizeOptionalNullFields(String input, String schemaJson) + throws Exception { + if (input == null || input.isBlank()) { + return input; + } + + JsonNode root = OBJECT_MAPPER.readTree(input); + JsonNode schemaRoot = OBJECT_MAPPER.readTree(schemaJson); + pruneOptionalNullObjectFields(root, schemaRoot, schemaRoot); + return OBJECT_MAPPER.writeValueAsString(root); + } + + private static void pruneOptionalNullObjectFields( + JsonNode inputNode, JsonNode schemaNode, JsonNode schemaRoot) { + if (inputNode == null || schemaNode == null) { + return; + } + + JsonNode resolvedSchema = resolveSchemaNode(schemaNode, schemaRoot); + if (resolvedSchema == null) { + return; + } + + if (inputNode.isObject()) { + ObjectNode objectNode = (ObjectNode) inputNode; + JsonNode propertiesNode = resolvedSchema.get("properties"); + Set requiredFields = getRequiredFields(resolvedSchema); + JsonNode additionalPropertiesNode = resolvedSchema.get("additionalProperties"); + List nullFieldNames = new ArrayList<>(); + objectNode + .fields() + .forEachRemaining( + entry -> { + JsonNode propertySchema = + propertiesNode != null + ? propertiesNode.get(entry.getKey()) + : null; + if (entry.getValue().isNull() + && propertySchema != null + && !requiredFields.contains(entry.getKey())) { + nullFieldNames.add(entry.getKey()); + } else { + JsonNode childSchema = propertySchema; + if (childSchema == null + && additionalPropertiesNode != null + && additionalPropertiesNode.isObject()) { + childSchema = additionalPropertiesNode; + } + if (childSchema != null) { + pruneOptionalNullObjectFields( + entry.getValue(), childSchema, schemaRoot); + } + } + }); + nullFieldNames.forEach(objectNode::remove); + return; + } + + if (inputNode.isArray()) { + ArrayNode arrayNode = (ArrayNode) inputNode; + JsonNode itemsSchema = resolvedSchema.get("items"); + for (JsonNode item : arrayNode) { + if (itemsSchema != null) { + pruneOptionalNullObjectFields(item, itemsSchema, schemaRoot); + } + } + } + } + + private static JsonNode resolveSchemaNode(JsonNode schemaNode, JsonNode schemaRoot) { + JsonNode current = schemaNode; + while (current != null && current.has("$ref")) { + String ref = current.get("$ref").asText(); + if (!ref.startsWith("#/")) { + return current; + } + current = schemaRoot.at(ref.substring(1)); + } + return current; + } + + private static Set getRequiredFields(JsonNode schemaNode) { + JsonNode requiredNode = schemaNode.get("required"); + if (requiredNode == null || !requiredNode.isArray()) { + return Set.of(); + } + + Set requiredFields = new HashSet<>(); + requiredNode.forEach(item -> requiredFields.add(item.asText())); + return requiredFields; + } + // ==================== HITL Validation ==================== /** diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java index 2fb341bef6..d0d25f2f49 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolMethodInvokerTest.java @@ -170,6 +170,11 @@ public String nestedListMethod( return String.valueOf(sum); } + public String beanParamMethod( + @ToolParam(name = "payload", description = "bean payload") BeanParam payload) { + return payload.getRequiredField() + "|" + payload.getOptionalField(); + } + public String suspendTool( @ToolParam(name = "reason", description = "reason") String reason) { throw new ToolSuspendException(reason); @@ -249,6 +254,27 @@ public int hashCode() { } } + static class BeanParam { + private String requiredField; + private String optionalField; + + public String getRequiredField() { + return requiredField; + } + + public void setRequiredField(String requiredField) { + this.requiredField = requiredField; + } + + public String getOptionalField() { + return optionalField; + } + + public void setOptionalField(String optionalField) { + this.optionalField = optionalField; + } + } + @Test void testConvertFromString_Integer() throws Exception { TestTools tools = new TestTools(); @@ -896,4 +922,41 @@ void testNestedGenericList() throws Exception { // Sum of 1+2+3+4+5+6 = 21 Assertions.assertEquals("\"21\"", ToolTestUtils.extractContent(response)); } + + @Test + void testBeanParam_WithMissingOptionalField() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("beanParamMethod", BeanParam.class); + + Map payload = new HashMap<>(); + payload.put("requiredField", "value"); + + Map input = new HashMap<>(); + input.put("payload", payload); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse(ToolTestUtils.isErrorResponse(response)); + Assertions.assertEquals("\"value|null\"", ToolTestUtils.extractContent(response)); + } + + @Test + void testBeanParam_WithExplicitNullOptionalField() throws Exception { + TestTools tools = new TestTools(); + Method method = TestTools.class.getMethod("beanParamMethod", BeanParam.class); + + Map payload = new HashMap<>(); + payload.put("requiredField", "value"); + payload.put("optionalField", null); + + Map input = new HashMap<>(); + input.put("payload", payload); + + ToolResultBlock response = invokeWithParam(tools, method, input); + + Assertions.assertNotNull(response); + Assertions.assertFalse(ToolTestUtils.isErrorResponse(response)); + Assertions.assertEquals("\"value|null\"", ToolTestUtils.extractContent(response)); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolValidatorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolValidatorTest.java index 49202d3cd8..2bc521ff10 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolValidatorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolValidatorTest.java @@ -27,6 +27,7 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.util.JsonUtils; +import java.lang.reflect.Method; import java.util.Collections; import java.util.List; import java.util.Map; @@ -39,6 +40,37 @@ @DisplayName("ToolValidator Tests") class ToolValidatorTest { + static class BeanPayload { + @ToolParam(name = "requiredField", description = "required field", required = true) + private String requiredField; + + @ToolParam(name = "optionalField", description = "optional field", required = false) + private String optionalField; + + public String getRequiredField() { + return requiredField; + } + + public void setRequiredField(String requiredField) { + this.requiredField = requiredField; + } + + public String getOptionalField() { + return optionalField; + } + + public void setOptionalField(String optionalField) { + this.optionalField = optionalField; + } + } + + static class BeanPayloadTool { + public String echo( + @ToolParam(name = "payload", description = "payload") BeanPayload payload) { + return payload.getRequiredField(); + } + } + // ==================== validateInput Tests ==================== @Nested @@ -625,6 +657,68 @@ void testNestedDefsRefFailsWithoutFix() { } } + @Nested + @DisplayName("validateInput - Generated Bean Schema") + class ValidateInputGeneratedBeanSchema { + + private Map buildBeanToolSchema() throws Exception { + Method method = BeanPayloadTool.class.getMethod("echo", BeanPayload.class); + return new ToolSchemaGenerator().generateParameterSchema(method, null); + } + + @Test + @DisplayName("Should allow missing optional nested bean field") + void testGeneratedBeanSchema_MissingOptionalField() throws Exception { + Map toolSchema = buildBeanToolSchema(); + + String input = "{\"payload\":{\"requiredField\":\"value\"}}"; + + String result = ToolValidator.validateInput(input, toolSchema); + assertNull(result, "Missing optional nested field should be accepted"); + } + + @Test + @DisplayName("Should allow explicit null optional nested bean field") + void testGeneratedBeanSchema_ExplicitNullOptionalField() throws Exception { + Map toolSchema = buildBeanToolSchema(); + + String input = "{\"payload\":{\"requiredField\":\"value\",\"optionalField\":null}}"; + + String result = ToolValidator.validateInput(input, toolSchema); + assertNull(result, "Explicit null optional nested field should be accepted"); + } + + @Test + @DisplayName("Should reject explicit null required nested bean field") + void testGeneratedBeanSchema_ExplicitNullRequiredField() throws Exception { + Map toolSchema = buildBeanToolSchema(); + + String input = "{\"payload\":{\"requiredField\":null}}"; + + String result = ToolValidator.validateInput(input, toolSchema); + assertNotNull(result, "Explicit null required nested field should be rejected"); + } + + @Test + @DisplayName("Should preserve unknown null field for additionalProperties validation") + void testGeneratedBeanSchema_UnknownNullFieldStillFails() throws Exception { + Map toolSchema = buildBeanToolSchema(); + @SuppressWarnings("unchecked") + Map payloadSchema = + (Map) + ((Map) toolSchema.get("properties")).get("payload"); + payloadSchema.put("additionalProperties", false); + + String input = "{\"payload\":{\"requiredField\":\"value\",\"unknownField\":null}}"; + + String result = ToolValidator.validateInput(input, toolSchema); + assertNotNull(result, "Unknown null field should still be rejected"); + assertTrue( + result.toLowerCase().contains("additional") || result.contains("unknownField"), + "Error should indicate unknown/additional property, but got: " + result); + } + } + // ==================== validateToolResultMatch Tests ==================== @Nested