Skip to content

Commit def9973

Browse files
committed
GH-2947: Add @ToolParam annotation support for parameter name binding
Closes #2947 * Implement @ToolParam to bind method parameters by custom name * Update MethodToolCallback to resolve parameter by annotation value * Add unit tests for generic types and annotation usage Signed-off-by: kudoha <[email protected]> Signed-off-by: Dongha Koo <[email protected]>
1 parent aa590e8 commit def9973

File tree

3 files changed

+181
-3
lines changed

3 files changed

+181
-3
lines changed

spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolParam.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,25 @@
2323
import java.lang.annotation.Target;
2424

2525
/**
26-
* Marks a tool argument.
26+
* Marks a tool argument for method-based tools.
27+
* <p>
28+
* This annotation can be used to specify metadata for a tool parameter, including whether
29+
* it is required, a description, or a custom name to bind to.
30+
* <p>
31+
* When the parameter name cannot be inferred (e.g. compiled without `-parameters`), the
32+
* {@code value} field can be used to manually specify the name that should match the key
33+
* in the tool input map.
34+
*
35+
* <p>
36+
* <b>Example:</b> <pre class="code">
37+
* public String greet(
38+
* {@code @ToolParam(value = "user_name")} String name) {
39+
* return "Hello, " + name;
40+
* }
41+
* </pre>
2742
*
2843
* @author Thomas Vitale
44+
* @author Dongha Koo
2945
* @since 1.0.0
3046
*/
3147
@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@@ -43,4 +59,9 @@
4359
*/
4460
String description() default "";
4561

62+
/**
63+
* The name of the parameter to bind to.
64+
*/
65+
String value() default "";
66+
4667
}

spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929

3030
import org.springframework.ai.chat.model.ToolContext;
3131
import org.springframework.ai.tool.ToolCallback;
32+
import org.springframework.ai.tool.annotation.ToolParam;
3233
import org.springframework.ai.tool.definition.ToolDefinition;
3334
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
3435
import org.springframework.ai.tool.execution.ToolCallResultConverter;
3536
import org.springframework.ai.tool.execution.ToolExecutionException;
3637
import org.springframework.ai.tool.metadata.ToolMetadata;
3738
import org.springframework.ai.util.json.JsonParser;
39+
import org.springframework.core.annotation.AnnotationUtils;
3840
import org.springframework.lang.Nullable;
3941
import org.springframework.util.Assert;
4042
import org.springframework.util.ClassUtils;
@@ -44,6 +46,7 @@
4446
* A {@link ToolCallback} implementation to invoke methods as tools.
4547
*
4648
* @author Thomas Vitale
49+
* @author Dongha Koo
4750
* @since 1.0.0
4851
*/
4952
public final class MethodToolCallback implements ToolCallback {
@@ -129,13 +132,43 @@ private Map<String, Object> extractToolArguments(String toolInput) {
129132
});
130133
}
131134

132-
// Based on the implementation in MethodToolCallback.
135+
/**
136+
* Builds the array of arguments to be passed into the target method, based on the
137+
* input tool arguments and method parameter metadata.
138+
*
139+
* <p>
140+
* This method handles special cases like:
141+
* <ul>
142+
* <li>{@link ToolContext} parameters are injected directly.</li>
143+
* <li>When a {@link ToolParam} annotation is present on a parameter, its
144+
* {@code value} is used to bind input keys to parameters — useful when method
145+
* parameter names are not retained (e.g. missing {@code -parameters} during
146+
* compilation).</li>
147+
* <li>Otherwise, falls back to {@link java.lang.reflect.Parameter#getName()}.</li>
148+
* </ul>
149+
*
150+
* <p>
151+
* Examples: <pre>{@code
152+
* public String greet(@ToolParam("user_name") String name) {
153+
* return "Hi, " + name;
154+
* }
155+
* }</pre> If the tool input contains {"user_name": "Alice"}, the {@code name}
156+
* parameter is populated with "Alice".
157+
* @param toolInputArguments the parsed input map from JSON
158+
* @param toolContext optional tool context, injected if required
159+
* @return an array of method arguments to invoke the tool method with
160+
*/
133161
private Object[] buildMethodArguments(Map<String, Object> toolInputArguments, @Nullable ToolContext toolContext) {
134162
return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
135163
if (parameter.getType().isAssignableFrom(ToolContext.class)) {
136164
return toolContext;
137165
}
138-
Object rawArgument = toolInputArguments.get(parameter.getName());
166+
167+
ToolParam toolParam = AnnotationUtils.getAnnotation(parameter, ToolParam.class);
168+
String paramName = (toolParam != null && !toolParam.value().isEmpty()) ? toolParam.value()
169+
: parameter.getName();
170+
171+
Object rawArgument = toolInputArguments.get(paramName);
139172
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
140173
}).toArray();
141174
}

spring-ai-model/src/test/java/org/springframework/ai/tool/method/MethodToolCallbackGenericTypesTest.java

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.junit.jupiter.api.Test;
2424

2525
import org.springframework.ai.chat.model.ToolContext;
26+
import org.springframework.ai.tool.annotation.ToolParam;
2627
import org.springframework.ai.tool.definition.DefaultToolDefinition;
2728
import org.springframework.ai.tool.definition.ToolDefinition;
2829

@@ -173,6 +174,117 @@ void testToolContextType() throws Exception {
173174
assertThat(result).isEqualTo("1 entries processed {foo=bar}");
174175
}
175176

177+
@Test
178+
void testToolParamAnnotationValueUsedAsBindingKey() throws Exception {
179+
TestGenericClass testObject = new TestGenericClass();
180+
Method method = TestGenericClass.class.getMethod("greetWithAlias", String.class);
181+
182+
ToolDefinition toolDefinition = DefaultToolDefinition.builder()
183+
.name("greet")
184+
.description("Greet a user with alias binding")
185+
.inputSchema("{}")
186+
.build();
187+
188+
MethodToolCallback callback = MethodToolCallback.builder()
189+
.toolDefinition(toolDefinition)
190+
.toolMethod(method)
191+
.toolObject(testObject)
192+
.build();
193+
194+
String toolInput = """
195+
{
196+
"user_name": "Alice"
197+
}
198+
""";
199+
200+
String result = callback.call(toolInput);
201+
202+
assertThat(result).isEqualTo("\"Hello, Alice\"");
203+
}
204+
205+
@Test
206+
void testToolParamEmptyValueUsesParameterName() throws Exception {
207+
TestGenericClass testObject = new TestGenericClass();
208+
Method method = TestGenericClass.class.getMethod("greet", String.class);
209+
210+
ToolDefinition toolDefinition = DefaultToolDefinition.builder()
211+
.name("greet")
212+
.description("Greet a user with implicit binding")
213+
.inputSchema("{}")
214+
.build();
215+
216+
MethodToolCallback callback = MethodToolCallback.builder()
217+
.toolDefinition(toolDefinition)
218+
.toolMethod(method)
219+
.toolObject(testObject)
220+
.build();
221+
222+
String toolInput = """
223+
{
224+
"name": "Bob"
225+
}
226+
""";
227+
228+
String result = callback.call(toolInput);
229+
230+
assertThat(result).isEqualTo("\"Hello, Bob\"");
231+
}
232+
233+
@Test
234+
void testToolParamMissingInputHandledAsNull() throws Exception {
235+
TestGenericClass testObject = new TestGenericClass();
236+
Method method = TestGenericClass.class.getMethod("greetWithAlias", String.class);
237+
238+
ToolDefinition toolDefinition = DefaultToolDefinition.builder()
239+
.name("greet")
240+
.description("Greet a user with missing input")
241+
.inputSchema("{}")
242+
.build();
243+
244+
MethodToolCallback callback = MethodToolCallback.builder()
245+
.toolDefinition(toolDefinition)
246+
.toolMethod(method)
247+
.toolObject(testObject)
248+
.build();
249+
250+
String toolInput = """
251+
{}
252+
""";
253+
254+
String result = callback.call(toolInput);
255+
256+
assertThat(result).isEqualTo("\"Hello, null\"");
257+
}
258+
259+
@Test
260+
void testMultipleToolParamsBinding() throws Exception {
261+
TestGenericClass testObject = new TestGenericClass();
262+
Method method = TestGenericClass.class.getMethod("greetFullName", String.class, String.class);
263+
264+
ToolDefinition toolDefinition = DefaultToolDefinition.builder()
265+
.name("greetFullName")
266+
.description("Greet a user by full name")
267+
.inputSchema("{}")
268+
.build();
269+
270+
MethodToolCallback callback = MethodToolCallback.builder()
271+
.toolDefinition(toolDefinition)
272+
.toolMethod(method)
273+
.toolObject(testObject)
274+
.build();
275+
276+
String toolInput = """
277+
{
278+
"first": "Jane",
279+
"last": "Doe"
280+
}
281+
""";
282+
283+
String result = callback.call(toolInput);
284+
285+
assertThat(result).isEqualTo("\"Hello, Jane Doe\"");
286+
}
287+
176288
/**
177289
* Test class with methods that use generic types.
178290
*/
@@ -195,6 +307,18 @@ public String processStringListInToolContext(ToolContext toolContext) {
195307
return context.size() + " entries processed " + context;
196308
}
197309

310+
public String greetWithAlias(@ToolParam("user_name") String name) {
311+
return "Hello, " + name;
312+
}
313+
314+
public String greet(@ToolParam String name) {
315+
return "Hello, " + name;
316+
}
317+
318+
public String greetFullName(@ToolParam("first") String first, @ToolParam("last") String last) {
319+
return "Hello, " + first + " " + last;
320+
}
321+
198322
}
199323

200324
}

0 commit comments

Comments
 (0)