spring-projects / spring-projects/spring-ai

MethodToolCallback: a missing primitive tool parameter throws a raw IllegalArgumentException that bypasses ToolExecutionException

Open
#6,723 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
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

  1. Declare a @Tool method with a primitive parameter alongside a required one.
  2. Build a ToolCallback from it with ToolCallbacks.from(...).
  3. Call it with JSON that omits the primitive argument, as a model would: tool.call("{\"city\": \"Rome\"}").
  4. A raw IllegalArgumentException comes out of call(...) instead of a ToolExecutionException.

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.