Skip to content

Fix silent null-arg tool dispatch causing runaway tool-call loops#5790

Open
laran wants to merge 1 commit intospring-projects:mainfrom
laran:fix-silent-tool-arg-loop
Open

Fix silent null-arg tool dispatch causing runaway tool-call loops#5790
laran wants to merge 1 commit intospring-projects:mainfrom
laran:fix-silent-tool-arg-loop

Conversation

@laran
Copy link
Copy Markdown

@laran laran commented Apr 10, 2026

Related: #5754
Related: #3333
Related: #2383
Related: #4464
Related: #4617

The "Handle the possible null parameter situation in streaming mode" logic added in b059cdf silently replaced null or empty tool-call arguments with "{}". When a tool has required parameters, the downstream MethodToolCallback then called the method with null for every required argument and silently returned whatever the tool produced. Many tool implementations return a valid-looking but empty result in that case, which the model interprets as a transient failure and retries — often with the identical call. Combined with the absence of any iteration limit on Spring AI's tool-call recursion (#3333), this can produce multi-million-token runaway loops in a single turn.

Fix:

  1. DefaultToolCallingManager: when tool arguments are null or empty, raise a ToolExecutionException and route it through the standard ToolExecutionExceptionProcessor so the resulting error becomes a proper tool response. The model can then adjust its approach rather than retry blindly. Well-formed tool calls in the same batch still execute normally.

  2. MethodToolCallback.buildMethodArguments: when a required parameter (default: @ToolParam.required = true) is missing from the tool input map, throw a ToolExecutionException with a clear "Missing required parameter" message. Previously, buildTypedArgument silently returned null for missing values, allowing method invocation to proceed with null arguments. Optional parameters marked with @ToolParam(required = false) still pass null through unchanged, and zero-parameter tools are unaffected.

  3. Sync/AsyncMcpToolCallback: apply the same fix as (1) for MCP callbacks that had the identical silent-"{}" fallback.

Tests:

  • DefaultToolCallingManagerTest: the two tests that previously asserted the buggy behavior (shouldHandleNullArgumentsInStreamMode, shouldHandleEmptyArgumentsInStreamMode) are rewritten to assert that the tool callback is NOT invoked and that the conversation history contains a tool response with the error from the exception processor. A new test (shouldExecuteValidToolsWhileReturningErrorForMalformedTool) verifies that a batch containing a malformed call and a valid call processes both independently.

  • MethodToolCallbackExceptionHandlingTest: new tests verify that (a) missing required parameters throw ToolExecutionException and the underlying method is never invoked, (b) optional parameters are still allowed to be null, and (c) zero-param tools remain callable with "{}".

  • SyncMcpToolCallbackTests / AsyncMcpToolCallbackTest: rewritten null/empty input tests to assert the new error path and verify that the MCP client is never invoked.

  • DefaultToolCallingManagerTests#whenMixedMethodToolCallsInChatResponse ThenExecute was implicitly relying on the silent-null behavior by calling TestGenericClass.call(String) with an empty args map. Updated to supply a value for the required parameter.

Loop safety of the new tests: every new test invokes its callback exactly once and asserts on the single result. There is no recursion, retry loop, or chat model involved, so the tests cannot themselves reproduce the runaway loop they guard against.

Thank you for taking time to contribute this pull request!
You might have already read the contributor guide, but as a reminder, please make sure to:

  • Add a Signed-off-by line to each commit (git commit -s) per the DCO
  • Rebase your changes on the latest main branch and squash your commits
  • Add/Update unit tests as needed
  • Run a build and make sure all tests pass prior to submission

For more details, please check the contributor guide.
Thank you upfront!

Related: spring-projects#5754
Related: spring-projects#3333
Related: spring-projects#2383
Related: spring-projects#4464
Related: spring-projects#4617

The "Handle the possible null parameter situation in streaming mode"
logic added in b059cdf silently replaced null or empty tool-call
arguments with "{}". When a tool has required parameters, the downstream
MethodToolCallback then called the method with null for every required
argument and silently returned whatever the tool produced. Many tool
implementations return a valid-looking but empty result in that case,
which the model interprets as a transient failure and retries — often
with the identical call. Combined with the absence of any iteration
limit on Spring AI's tool-call recursion (spring-projects#3333), this can produce
multi-million-token runaway loops in a single turn.

Fix:

1. DefaultToolCallingManager: when tool arguments are null or empty,
   raise a ToolExecutionException and route it through the standard
   ToolExecutionExceptionProcessor so the resulting error becomes a
   proper tool response. The model can then adjust its approach rather
   than retry blindly. Well-formed tool calls in the same batch still
   execute normally.

2. MethodToolCallback.buildMethodArguments: when a required parameter
   (default: @ToolParam.required = true) is missing from the tool input
   map, throw a ToolExecutionException with a clear
   "Missing required parameter" message. Previously, buildTypedArgument
   silently returned null for missing values, allowing method invocation
   to proceed with null arguments. Optional parameters marked with
   @ToolParam(required = false) still pass null through unchanged, and
   zero-parameter tools are unaffected.

3. Sync/AsyncMcpToolCallback: apply the same fix as (1) for MCP
   callbacks that had the identical silent-"{}" fallback.

Tests:

- DefaultToolCallingManagerTest: the two tests that previously asserted
  the buggy behavior (shouldHandleNullArgumentsInStreamMode,
  shouldHandleEmptyArgumentsInStreamMode) are rewritten to assert that
  the tool callback is NOT invoked and that the conversation history
  contains a tool response with the error from the exception processor.
  A new test (shouldExecuteValidToolsWhileReturningErrorForMalformedTool)
  verifies that a batch containing a malformed call and a valid call
  processes both independently.

- MethodToolCallbackExceptionHandlingTest: new tests verify that
  (a) missing required parameters throw ToolExecutionException and the
  underlying method is never invoked,
  (b) optional parameters are still allowed to be null, and
  (c) zero-param tools remain callable with "{}".

- SyncMcpToolCallbackTests / AsyncMcpToolCallbackTest: rewritten
  null/empty input tests to assert the new error path and verify that
  the MCP client is never invoked.

- DefaultToolCallingManagerTests#whenMixedMethodToolCallsInChatResponse
  ThenExecute was implicitly relying on the silent-null behavior by
  calling TestGenericClass.call(String) with an empty args map. Updated
  to supply a value for the required parameter.

Loop safety of the new tests: every new test invokes its callback
exactly once and asserts on the single result. There is no recursion,
retry loop, or chat model involved, so the tests cannot themselves
reproduce the runaway loop they guard against.

Signed-off-by: Laran Evans <laran@laranevans.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant