Skip to content

Commit 701297b

Browse files
feat(tool): add additionalProperties support to @tool annotation
Add additionalProperties parameter to @tool annotation to control whether LLM can pass extra parameters not defined in the schema. When set to false, ToolValidator rejects hallucinated params. Closes #1379
1 parent 9e83aa7 commit 701297b

4 files changed

Lines changed: 318 additions & 1 deletion

File tree

agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,18 @@
130130
* @see DefaultToolResultConverter
131131
*/
132132
Class<? extends ToolResultConverter> converter() default DefaultToolResultConverter.class;
133+
134+
/**
135+
* Whether to allow additional properties beyond those defined in the schema.
136+
*
137+
* <p>Corresponds to the {@code additionalProperties} keyword in JSON Schema. When set to
138+
* {@code false}, any extra parameters passed by the LLM that are not defined in the schema will
139+
* cause a validation error, preventing the LLM from hallucinating undefined parameters.
140+
*
141+
* <p>This setting is applied recursively to all nested objects within the generated schema.
142+
*
143+
* @return {@code true} to allow additional properties (default, backward compatible), {@code
144+
* false} to disallow
145+
*/
146+
boolean additionalProperties() default true;
133147
}

agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaGenerator.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,77 @@
3333
*/
3434
class ToolSchemaGenerator {
3535

36+
/**
37+
* Generate parameter schema with additionalProperties control.
38+
*
39+
* @param method the method to generate schema for
40+
* @param excludeParams set of parameter names to exclude (may be null or empty)
41+
* @param allowAdditionalProperties whether to allow additional properties in the schema
42+
* @return JSON Schema map
43+
*/
44+
@SuppressWarnings("unchecked")
45+
Map<String, Object> generateParameterSchema(
46+
Method method, Set<String> excludeParams, boolean allowAdditionalProperties) {
47+
Map<String, Object> schema = generateParameterSchema(method, excludeParams);
48+
49+
if (!allowAdditionalProperties) {
50+
addAdditionalPropertiesFalseRecursively(schema);
51+
}
52+
53+
return schema;
54+
}
55+
56+
@SuppressWarnings("unchecked")
57+
private void addAdditionalPropertiesFalseRecursively(Map<String, Object> schema) {
58+
if (!"object".equals(schema.get("type"))) {
59+
return;
60+
}
61+
// Skip if user already set additionalProperties explicitly
62+
if (schema.containsKey("additionalProperties")) {
63+
return;
64+
}
65+
schema.put("additionalProperties", false);
66+
67+
Object propsObj = schema.get("properties");
68+
if (propsObj instanceof Map) {
69+
Map<String, Object> props = (Map<String, Object>) propsObj;
70+
for (Map.Entry<String, Object> entry : props.entrySet()) {
71+
if (entry.getValue() instanceof Map) {
72+
addAdditionalPropertiesFalseRecursively((Map<String, Object>) entry.getValue());
73+
}
74+
}
75+
}
76+
77+
Object itemsObj = schema.get("items");
78+
if (itemsObj instanceof Map) {
79+
addAdditionalPropertiesFalseRecursively((Map<String, Object>) itemsObj);
80+
}
81+
82+
for (String key : new String[] {"oneOf", "anyOf", "allOf"}) {
83+
Object listObj = schema.get(key);
84+
if (listObj instanceof Iterable) {
85+
for (Object item : (Iterable<?>) listObj) {
86+
if (item instanceof Map) {
87+
addAdditionalPropertiesFalseRecursively((Map<String, Object>) item);
88+
}
89+
}
90+
}
91+
}
92+
93+
for (String defsKey : new String[] {"$defs", "definitions"}) {
94+
Object defsObj = schema.get(defsKey);
95+
if (defsObj instanceof Map) {
96+
Map<String, Object> defs = (Map<String, Object>) defsObj;
97+
for (Map.Entry<String, Object> entry : defs.entrySet()) {
98+
if (entry.getValue() instanceof Map) {
99+
addAdditionalPropertiesFalseRecursively(
100+
(Map<String, Object>) entry.getValue());
101+
}
102+
}
103+
}
104+
}
105+
}
106+
36107
/**
37108
* Generate parameter schema for a method with excluded parameters.
38109
*

agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,8 @@ public Map<String, Object> getParameters() {
373373
presetParameters != null
374374
? presetParameters.keySet()
375375
: Collections.emptySet();
376-
return schemaGenerator.generateParameterSchema(method, excludeParams);
376+
return schemaGenerator.generateParameterSchema(
377+
method, excludeParams, toolAnnotation.additionalProperties());
377378
}
378379

379380
@Override
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.tool;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import java.util.List;
24+
import java.util.Map;
25+
import org.junit.jupiter.api.DisplayName;
26+
import org.junit.jupiter.api.Test;
27+
28+
/** Verification test for Issue #1379 fix: @Tool additionalProperties support. */
29+
@DisplayName("Issue #1379 Fix Verification")
30+
class Issue1379FixVerificationTest {
31+
32+
@Test
33+
@DisplayName("Fix 1: additionalProperties=false should appear in schema")
34+
@SuppressWarnings("unchecked")
35+
void testSchemaContainsAdditionalPropertiesWhenFalse() {
36+
Toolkit toolkit = new Toolkit();
37+
toolkit.registerTool(
38+
new Object() {
39+
@Tool(
40+
name = "query_data",
41+
description = "Query business data",
42+
additionalProperties = false)
43+
public String queryData(
44+
@ToolParam(name = "keyword", description = "keyword") String keyword) {
45+
return "result";
46+
}
47+
});
48+
49+
Map<String, Object> schema = toolkit.getToolSchemas().get(0).getParameters();
50+
51+
System.out.println("=== Fix 1: Schema with additionalProperties=false ===");
52+
System.out.println("Full schema: " + schema);
53+
System.out.println(
54+
"Has 'additionalProperties' key? " + schema.containsKey("additionalProperties"));
55+
System.out.println("additionalProperties value: " + schema.get("additionalProperties"));
56+
57+
assertTrue(
58+
schema.containsKey("additionalProperties"),
59+
"Schema should contain additionalProperties key");
60+
assertEquals(
61+
false, schema.get("additionalProperties"), "additionalProperties should be false");
62+
63+
Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
64+
assertFalse(
65+
properties.containsKey("additionalProperties"),
66+
"additionalProperties should NOT be inside properties map");
67+
}
68+
69+
@Test
70+
@DisplayName("Fix 2: additionalProperties=true (default) should NOT appear in schema")
71+
void testSchemaNoAdditionalPropertiesWhenTrue() {
72+
Toolkit toolkit = new Toolkit();
73+
toolkit.registerTool(
74+
new Object() {
75+
@Tool(name = "query_data", description = "Query business data")
76+
public String queryData(
77+
@ToolParam(name = "keyword", description = "keyword") String keyword) {
78+
return "result";
79+
}
80+
});
81+
82+
Map<String, Object> schema = toolkit.getToolSchemas().get(0).getParameters();
83+
84+
System.out.println("=== Fix 2: Schema with additionalProperties=true (default) ===");
85+
System.out.println("Full schema: " + schema);
86+
System.out.println(
87+
"Has 'additionalProperties' key? " + schema.containsKey("additionalProperties"));
88+
89+
assertFalse(
90+
schema.containsKey("additionalProperties"),
91+
"Default behavior should NOT add additionalProperties to schema");
92+
}
93+
94+
@Test
95+
@DisplayName("Fix 3: ToolValidator rejects hallucinated params with additionalProperties=false")
96+
void testToolValidatorRejectsExtraParamsWithAdditionalPropertiesFalse() {
97+
Toolkit toolkit = new Toolkit();
98+
toolkit.registerTool(
99+
new Object() {
100+
@Tool(
101+
name = "query_data",
102+
description = "Query business data",
103+
additionalProperties = false)
104+
public String queryData(
105+
@ToolParam(name = "keyword", description = "keyword") String keyword) {
106+
return "result for: " + keyword;
107+
}
108+
});
109+
110+
Map<String, Object> schema = toolkit.getToolSchemas().get(0).getParameters();
111+
112+
// LLM sends a hallucinated parameter "owners" that is NOT in schema
113+
String hallucinatedInput = "{\"keyword\": \"test\", \"owners\": [\"admin\"]}";
114+
115+
System.out.println("=== Fix 3: ToolValidator with additionalProperties=false ===");
116+
System.out.println("Schema: " + schema);
117+
System.out.println("LLM input: " + hallucinatedInput);
118+
119+
String validationError = ToolValidator.validateInput(hallucinatedInput, schema);
120+
System.out.println("Validation result: " + validationError);
121+
122+
assertNotNull(
123+
validationError,
124+
"ToolValidator should reject hallucinated params when "
125+
+ "additionalProperties=false");
126+
assertTrue(
127+
validationError.contains("owners"),
128+
"Error message should mention the hallucinated param 'owners'");
129+
}
130+
131+
@Test
132+
@DisplayName("Fix 4: ToolValidator allows extra params with default (no additionalProperties)")
133+
void testToolValidatorAllowsExtraParamsByDefault() {
134+
Toolkit toolkit = new Toolkit();
135+
toolkit.registerTool(
136+
new Object() {
137+
@Tool(name = "query_data", description = "Query business data")
138+
public String queryData(
139+
@ToolParam(name = "keyword", description = "keyword") String keyword) {
140+
return "result for: " + keyword;
141+
}
142+
});
143+
144+
Map<String, Object> schema = toolkit.getToolSchemas().get(0).getParameters();
145+
146+
String hallucinatedInput = "{\"keyword\": \"test\", \"owners\": [\"admin\"]}";
147+
148+
System.out.println("=== Fix 4: ToolValidator with default (no additionalProperties) ===");
149+
System.out.println("Schema: " + schema);
150+
System.out.println("LLM input: " + hallucinatedInput);
151+
152+
String validationError = ToolValidator.validateInput(hallucinatedInput, schema);
153+
System.out.println("Validation result: " + validationError);
154+
155+
assertEquals(
156+
null,
157+
validationError,
158+
"Default behavior should allow extra params (backward compatible)");
159+
}
160+
161+
@SuppressWarnings("unchecked")
162+
@Test
163+
@DisplayName("Fix 5: additionalProperties=false propagates into nested object via List items")
164+
void testAdditionalPropertiesInNestedObjectViaList() {
165+
Toolkit toolkit = new Toolkit();
166+
toolkit.registerTool(
167+
new Object() {
168+
@Tool(
169+
name = "create_order",
170+
description = "Create order",
171+
additionalProperties = false)
172+
public String createOrder(
173+
@ToolParam(name = "items", description = "order items")
174+
List<OrderItem> items) {
175+
return "ok";
176+
}
177+
});
178+
179+
Map<String, Object> schema = toolkit.getToolSchemas().get(0).getParameters();
180+
181+
System.out.println("=== Fix 5: Nested object via List items ===");
182+
System.out.println("Full schema: " + schema);
183+
184+
assertTrue(
185+
schema.containsKey("additionalProperties"),
186+
"Root schema should have additionalProperties=false");
187+
188+
// items should contain an object with additionalProperties=false
189+
Map<String, Object> itemsSchema =
190+
(Map<String, Object>) ((Map<String, Object>) schema.get("properties")).get("items");
191+
System.out.println("items schema: " + itemsSchema);
192+
193+
// Check if items has $defs (from POJO) or direct object definition
194+
Object defsObj = schema.get("$defs");
195+
if (defsObj instanceof Map) {
196+
Map<String, Object> defs = (Map<String, Object>) defsObj;
197+
System.out.println("$defs keys: " + defs.keySet());
198+
// Each def should have additionalProperties=false
199+
for (Map.Entry<String, Object> entry : defs.entrySet()) {
200+
if (entry.getValue() instanceof Map) {
201+
Map<String, Object> defSchema = (Map<String, Object>) entry.getValue();
202+
assertTrue(
203+
defSchema.containsKey("additionalProperties"),
204+
"$defs/" + entry.getKey() + " should have additionalProperties=false");
205+
}
206+
}
207+
}
208+
}
209+
210+
/** Simple POJO for testing nested object schema generation. */
211+
public static class OrderItem {
212+
private String name;
213+
private int quantity;
214+
215+
public String getName() {
216+
return name;
217+
}
218+
219+
public void setName(String name) {
220+
this.name = name;
221+
}
222+
223+
public int getQuantity() {
224+
return quantity;
225+
}
226+
227+
public void setQuantity(int quantity) {
228+
this.quantity = quantity;
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)