spring-projects / spring-projects/spring-ai
MethodToolCallback: a missing primitive tool parameter throws a raw IllegalArgumentException that bypasses ToolExecutionException
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.5k
- Forks
- 2.9k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 5
Description
When the model omits an argument for a @Tool method parameter that is a primitive (boolean, int, long, …), MethodToolCallback resolves it to null and passes it to Method.invoke. Reflection cannot unbox null, so invoke itself throws IllegalArgumentException — thrown by invoke, not by the tool body, so it is not an InvocationTargetException.
callMethod catches only IllegalAccessException and InvocationTargetException, so this exception escapes MethodToolCallback.call(...) unwrapped. It never becomes a ToolExecutionException, never reaches ToolExecutionExceptionProcessor, and therefore cannot be turned into a tool result the model could read and retry from. Instead it propagates out of the tool-calling machinery and aborts the entire chat run — one omitted boolean kills the whole conversation turn rather than failing a single tool call.
Two things make this easy to hit in practice: smaller models omit optional arguments routinely, and required = false in the generated schema actively invites them to. The same tool with a Boolean parameter behaves perfectly (the gap arrives as null), so the failure depends entirely on a signature detail that nothing in the API warns about.
The relevant code, from the v2.0.0 tag:
private @Nullable Object buildTypedArgument(@Nullable Object value, Type type) {
if (value == null) {
return null; // ← a missing argument leaves here, before any conversion
}
try {
...
}
catch (Exception ex) { // ← the GH-3924 / PR #5032 catch lives inside this try
...
throw new ToolExecutionException(this.getToolDefinition(), cause);
}
}
private @Nullable Object callMethod(Object[] methodArguments) {
...
Object result;
try {
result = this.toolMethod.invoke(this.toolObject, methodArguments);
}
catch (IllegalAccessException ex) {
throw new IllegalStateException("Could not access method: " + ex.getMessage(), ex);
}
catch (InvocationTargetException ex) {
throw new ToolExecutionException(this.toolDefinition, ex.getCause());
}
return result;
}
I confirmed this against the bytecode of the published spring-ai-model-2.0.0.jar rather than only the source — javap -c shows callMethod's exception table covering the invoke call with exactly two entries, IllegalAccessException and InvocationTargetException, and shows call(String, ToolContext) with no exception table at all:
Exception table:
from to target type
22 35 38 Class java/lang/IllegalAccessException
22 35 57 Class java/lang/reflect/InvocationTargetException
Existing issues I checked first (none cover this): #3924 / PR #5032 (invalid enum) and #4987 (malformed JSON) both fail during argument conversion, inside the try that the value == null early return skips. #3884 / PR #6018 (blank string → numeric type, still open) guards JsonParser.toTypedObject(), which likewise only runs for a value that is actually present. All of them address a provided but unusable argument; this report is about an argument that is absent, which fails one call later inside invoke.
Environment
- Spring AI 2.0.0
- Java 25.0.3 (Ubuntu). Also reproduces on Java 21 — the JDK only changes the exception message, not the type: on 21 it is
argument type mismatch, on 25 it wraps an unboxing NPE (see the trace below). - Spring Boot 3.x, no vector store involved; reproducible with a plain
ToolCallback, no chat model or provider needed.
Steps to reproduce
- Declare a
@Toolmethod with a primitive parameter alongside a required one. - Build a
ToolCallbackfrom it withToolCallbacks.from(...). - Call it with JSON that omits the primitive argument, as a model would:
tool.call("{\"city\": \"Rome\"}"). - A raw
IllegalArgumentExceptioncomes out ofcall(...)instead of aToolExecutionException.
Expected behavior
The failure should reach the caller as a ToolExecutionException, the way every other bad-argument case already does, so ToolExecutionExceptionProcessor can convert it into a tool result naming the missing argument and the model gets a chance to retry. A single malformed tool call should never terminate the chat run.
Minimal Complete Reproducible example
Both tests below pass as written against 2.0.0 — the second one documents the current, undesired behavior:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.execution.ToolExecutionException;
class PrimitiveToolArgumentTest {
static class WeatherTools {
@Tool(description = "Get the forecast, optionally including the hourly breakdown.")
String forecast(String city, boolean includeHourly) {
return city + " / hourly=" + includeHourly;
}
@Tool(description = "Same tool, but the flag is a wrapper instead of a primitive.")
String forecastBoxed(String city, Boolean includeHourly) {
return city + " / hourly=" + includeHourly;
}
}
/** The model omitted "includeHourly" — a routine occurrence with smaller models. */
private static final String MODEL_OUTPUT = "{\"city\": \"Rome\"}";
private static ToolCallback toolNamed(String name) {
for (ToolCallback candidate : ToolCallbacks.from(new WeatherTools())) {
if (candidate.getToolDefinition().name().equals(name)) {
return candidate;
}
}
throw new AssertionError("no such tool: " + name);
}
@Test
void wrapperParameter_isFine() {
// The gap arrives as null and the tool body can deal with it.
ToolCallback tool = toolNamed("forecastBoxed");
assertThat(tool.call(MODEL_OUTPUT)).isEqualTo("\"Rome / hourly=null\"");
}
@Test
void primitiveParameter_escapesAsRawIllegalArgumentException() {
ToolCallback tool = toolNamed("forecast");
// Actual: a bare IllegalArgumentException escapes MethodToolCallback.call(),
// so ToolExecutionExceptionProcessor never sees it and the whole run dies.
assertThatThrownBy(() -> tool.call(MODEL_OUTPUT))
.isInstanceOf(IllegalArgumentException.class)
.isNotInstanceOf(ToolExecutionException.class);
// Expected: it should arrive as a ToolExecutionException, like every other
// bad-argument case does, so the error can be returned to the model.
}
}
Stack trace (Java 25.0.3, Spring AI 2.0.0), showing the exception leaving MethodToolCallback.call unwrapped:
java.lang.IllegalArgumentException: java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, Object, boolean)" is null
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:114)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.springframework.ai.tool.method.MethodToolCallback.callMethod(MethodToolCallback.java:186)
at org.springframework.ai.tool.method.MethodToolCallback.call(MethodToolCallback.java:114)
...
Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, Object, boolean)" is null
at java.base/sun.invoke.util.ValueConversions.unboxBoolean(ValueConversions.java:108)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
... 94 more
Suggested fix
Catch IllegalArgumentException alongside the existing catches in callMethod and wrap it in ToolExecutionException, mirroring the InvocationTargetException branch. A more informative variant would check parameter.getType().isPrimitive() in buildMethodArguments when the looked-up value is null and fail there with the parameter name in the message — which also gives the model something actionable to retry with, in the spirit of PR #6018.
Workaround
I ban primitive parameters in @Tool signatures project-wide (Boolean/Integer/Long instead) and answer each missing wrapper argument explicitly.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at MethodToolCallback.callMethod and buildMethodArguments, then run the PrimitiveToolArgumentTest reproduction with the omitted primitive argument. Verify that the failure from tool.call reaches the caller as a ToolExecutionException and can be handled by ToolExecutionExceptionProcessor, while the boxed-parameter case remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100