spring-projects/spring-ai · error · IllegalStateException

No ToolCallback found for tool name

Error message

No ToolCallback found for tool name: ${toolName}

What it means

DefaultToolCallingManager.executeToolCall throws IllegalStateException when the tool name sent back by the LLM does not match any registered ToolCallback. This happens when the model hallucinates or renames a tool, or when the callbacks resolved at execution time differ from the ones the request was built with. Execution aborts since there is no implementation to invoke.

Solutions

  1. Log and compare the tool names the model returned against registered callbacks; add the missing ToolCallback.
  2. Use sanitized, simple tool names (alphanumeric, underscore, hyphen, dot) so the LLM echoes them exactly.
  3. Ensure the same ToolCallback/tool registry instance is used for both request building and execution.
  4. Retry the request; if the model intermittently mangles names, consider a different model or stricter tool descriptions.

Example fix

// before
callbacks were registered only on a second request:
ChatClient.create(chatModel).prompt().tools(weatherTool).call()

// after — register all callbacks consistently on every call
ToolCallbacks toolCallbacks = ToolCallbacks.from(weatherTool, stockTool);
chatClient.prompt().tools(toolCallbacks).call()
Defensive patterns

Strategy: try-catch

Validate before calling

Set<String> names = toolCallbacks.stream().map(cb -> cb.getToolDefinition().name()).collect(Collectors.toSet());
if (!names.contains(expectedToolName)) throw new IllegalArgumentException("Tool not registered: " + expectedToolName);

Try / catch

try {
    return chatClient.prompt().tools(toolCallbacks).call().content();
} catch (IllegalStateException e) {
    if (e.getMessage().startsWith("No ToolCallback found")) {
        logger.error("Model returned unknown tool name; registered={}", registeredNames, e);
        return retryWithStrictPrompt();
    }
    throw e;
}

Prevention

When it happens

Trigger: internalToolExecutionResult -> executeToolCall resolves toolCallbacks by name and finds none matching the ToolCall's name returned by the model.

Common situations: LLM name mangling (model alters the tool name); tool callbacks added/removed between request building and execution; using a ChatClient with different tools than the ones advertised; case-sensitivity or truncation by the provider.

Understand the failure class

Background: 'Could not be found', 'does not exist', 'not found in database': the resource-not-found family when an ID, slug, key, or URI lookup comes back empty — this error's family across 20 libraries.

Related errors


AI-assisted analysis of spring-projects/spring-ai@98a7beda4f (2026-09-11). Data as JSON: /api/errors/56f7c4130684a446. Report an issue: GitHub.

Appendix: source

Thrown at spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java:296

							+ ". Using empty JSON object as default.");
				}
				finalToolInputArguments = "{}";
			}
			else {
				finalToolInputArguments = toolInputArguments;
			}

			ToolCallback toolCallback = toolCallbacks.stream()
				.filter(tool -> toolName.equals(tool.getToolDefinition().name()))
				.findFirst()
				.orElseGet(() -> this.resolutionFallbackEnabled ? this.toolCallbackResolver.resolve(toolName) : null);

			if (toolCallback == null) {
				if (logger.isWarnEnabled()) {
					logger.warn(POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_START + toolName
							+ POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_END);
				}
				throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
			}

			if (returnDirect == null) {
				returnDirect = toolCallback.getToolMetadata().returnDirect();
			}
			else {
				returnDirect = returnDirect && toolCallback.getToolMetadata().returnDirect();
			}

			// In streaming/reactive mode the parent observation is propagated through the
			// Reactor context captured in ToolCallReactiveContextHolder. In blocking mode
			// that holder is never populated, so fall back to the observation currently
			// in scope on the calling thread to keep the observation hierarchy intact.
			Observation parent = ToolCallReactiveContextHolder.getContext()
				.getOrDefault(ObservationThreadLocalAccessor.KEY, this.observationRegistry.getCurrentObservation());

			ToolCallingObservationContext observationContext = ToolCallingObservationContext.builder()
				.toolDefinition(toolCallback.getToolDefinition())

View on GitHub (pinned to 98a7beda4f)