Skip to content
Open
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
14 changes: 14 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,18 @@
* @see DefaultToolResultConverter
*/
Class<? extends ToolResultConverter> converter() default DefaultToolResultConverter.class;

/**
* Whether to allow additional properties beyond those defined in the schema.
*
* <p>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.
*
* <p>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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> generateParameterSchema(
Method method, Set<String> excludeParams, boolean allowAdditionalProperties) {
Map<String, Object> schema = generateParameterSchema(method, excludeParams);

if (!allowAdditionalProperties) {
addAdditionalPropertiesFalseRecursively(schema);
}

return schema;
}

@SuppressWarnings("unchecked")
private void addAdditionalPropertiesFalseRecursively(Map<String, Object> 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<String, Object> props = (Map<String, Object>) propsObj;
for (Map.Entry<String, Object> entry : props.entrySet()) {
if (entry.getValue() instanceof Map) {
addAdditionalPropertiesFalseRecursively((Map<String, Object>) entry.getValue());
}
}
}

Object itemsObj = schema.get("items");
if (itemsObj instanceof Map) {
addAdditionalPropertiesFalseRecursively((Map<String, Object>) 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<String, Object>) item);
}
}
}
}

for (String defsKey : new String[] {"$defs", "definitions"}) {
Object defsObj = schema.get(defsKey);
if (defsObj instanceof Map) {
Map<String, Object> defs = (Map<String, Object>) defsObj;
for (Map.Entry<String, Object> entry : defs.entrySet()) {
if (entry.getValue() instanceof Map) {
addAdditionalPropertiesFalseRecursively(
(Map<String, Object>) entry.getValue());
}
}
}
}
}

/**
* Generate parameter schema for a method with excluded parameters.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ public Map<String, Object> getParameters() {
presetParameters != null
? presetParameters.keySet()
: Collections.emptySet();
return schemaGenerator.generateParameterSchema(method, excludeParams);
return schemaGenerator.generateParameterSchema(
method, excludeParams, toolAnnotation.additionalProperties());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> 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<String, Object> properties = (Map<String, Object>) 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<String, Object> 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<String, Object> 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<String, Object> 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)");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> schemaWithItems = new HashMap<>();
schemaWithItems.put("type", "object");
Map<String, Object> 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<String, Object> schemaWithOneOf = new HashMap<>();
schemaWithOneOf.put("type", "object");
Map<String, Object> 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<String, Object> schemaWithAnyOf = new HashMap<>();
schemaWithAnyOf.put("type", "object");
Map<String, Object> anyOfItem = new HashMap<>();
anyOfItem.put("type", "object");
Map<String, Object> 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<String, Object> schemaWithDefs = new HashMap<>();
schemaWithDefs.put("type", "object");
Map<String, Object> 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<String, Object> schemaWithDefinitions = new HashMap<>();
schemaWithDefinitions.put("type", "object");
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"));
}
}
Loading