Skip to content
Merged
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 @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -80,8 +86,10 @@ public static String validateInput(String input, Map<String, Object> schema) {
// Create Schema from the schema string
Schema jsonSchema = SCHEMA_REGISTRY.getSchema(schemaJson);

String normalizedInput = normalizeOptionalNullFields(input, schemaJson);

// Validate
List<Error> errors = jsonSchema.validate(input, InputFormat.JSON);
List<Error> errors = jsonSchema.validate(normalizedInput, InputFormat.JSON);

if (errors.isEmpty()) {
return null; // Validation passed
Expand All @@ -95,6 +103,104 @@ public static String validateInput(String input, Map<String, Object> schema) {
}
}

/**
* Removes null-valued object properties before schema validation.
*
* <p>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.
Comment thread
LearningGp marked this conversation as resolved.
*/
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<String> requiredFields = getRequiredFields(resolvedSchema);
JsonNode additionalPropertiesNode = resolvedSchema.get("additionalProperties");
List<String> 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) {
Comment thread
LearningGp marked this conversation as resolved.
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<String> getRequiredFields(JsonNode schemaNode) {
JsonNode requiredNode = schemaNode.get("required");
if (requiredNode == null || !requiredNode.isArray()) {
return Set.of();
}

Set<String> requiredFields = new HashSet<>();
requiredNode.forEach(item -> requiredFields.add(item.asText()));
return requiredFields;
}

// ==================== HITL Validation ====================

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<String, Object> payload = new HashMap<>();
payload.put("requiredField", "value");

Map<String, Object> 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<String, Object> payload = new HashMap<>();
payload.put("requiredField", "value");
payload.put("optionalField", null);

Map<String, Object> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -625,6 +657,68 @@ void testNestedDefsRefFailsWithoutFix() {
}
}

@Nested
@DisplayName("validateInput - Generated Bean Schema")
class ValidateInputGeneratedBeanSchema {

private Map<String, Object> 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<String, Object> 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<String, Object> 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");
}
Comment thread
LearningGp marked this conversation as resolved.

@Test
@DisplayName("Should reject explicit null required nested bean field")
void testGeneratedBeanSchema_ExplicitNullRequiredField() throws Exception {
Map<String, Object> 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<String, Object> toolSchema = buildBeanToolSchema();
@SuppressWarnings("unchecked")
Map<String, Object> payloadSchema =
(Map<String, Object>)
((Map<String, Object>) 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
Expand Down
Loading