Skip to content

Issue 3884 Improve tool parameter conversion error messages #3970

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.ai.tool.execution;

import java.lang.reflect.Type;

import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.lang.Nullable;

/**
* An exception thrown when a tool parameter conversion fails, typically when the model
* provides a value that cannot be converted to the expected parameter type.
*
* <p>
* This exception provides detailed context about the conversion failure including:
* <ul>
* <li>The parameter name that failed conversion</li>
* <li>The expected parameter type</li>
* <li>The actual value provided by the model</li>
* <li>Helpful suggestions for fixing the issue</li>
* </ul>
*
* @author Christian Tzolov
* @since 1.1.0
*/
public class ToolParameterConversionException extends ToolExecutionException {

@Nullable
private final String parameterName;

private final Type expectedType;

@Nullable
private final Object actualValue;

/**
* Creates a new ToolParameterConversionException with detailed parameter context.
* @param toolDefinition the tool definition where the conversion failed
* @param parameterName the name of the parameter that failed conversion (may be null
* if not available)
* @param expectedType the expected parameter type
* @param actualValue the actual value provided by the model that failed conversion
* @param cause the underlying exception that caused the conversion failure
*/
public ToolParameterConversionException(ToolDefinition toolDefinition, @Nullable String parameterName,
Type expectedType, @Nullable Object actualValue, Throwable cause) {
super(toolDefinition, new RuntimeException(
buildMessage(toolDefinition, parameterName, expectedType, actualValue, cause), cause));
this.parameterName = parameterName;
this.expectedType = expectedType;
this.actualValue = actualValue;
}

/**
* Creates a new ToolParameterConversionException without parameter name context.
* @param toolDefinition the tool definition where the conversion failed
* @param expectedType the expected parameter type
* @param actualValue the actual value provided by the model that failed conversion
* @param cause the underlying exception that caused the conversion failure
*/
public ToolParameterConversionException(ToolDefinition toolDefinition, Type expectedType,
@Nullable Object actualValue, Throwable cause) {
this(toolDefinition, null, expectedType, actualValue, cause);
}

private static String buildMessage(ToolDefinition toolDefinition, @Nullable String parameterName, Type expectedType,
@Nullable Object actualValue, Throwable cause) {

StringBuilder message = new StringBuilder("Tool parameter conversion failed");

if (parameterName != null) {
message.append(" for parameter '").append(parameterName).append("'");
}

message.append(" in tool '").append(toolDefinition.name()).append("': ");

String typeName = expectedType instanceof Class<?> ? ((Class<?>) expectedType).getSimpleName()
: expectedType.getTypeName();
message.append("Expected type: ").append(typeName);

if (actualValue != null) {
if (actualValue instanceof String && ((String) actualValue).isEmpty()) {
message.append(", but received: \"\" (empty string)");
}
else {
String valueStr = actualValue.toString();
if (valueStr.length() > 50) {
valueStr = valueStr.substring(0, 47) + "...";
}
message.append(", but received: \"").append(valueStr).append("\"");
message.append(" (").append(actualValue.getClass().getSimpleName()).append(")");
}
}
else {
message.append(", but received: null");
}

// Add helpful suggestions
message.append(". ");
if (isNumericType(expectedType) && actualValue instanceof String && ((String) actualValue).isEmpty()) {
message.append(
"Suggestion: Ensure your prompt clearly specifies that numeric parameters should contain valid numbers, not empty strings. ");
message.append(
"Consider making the parameter optional or providing a default value in your tool description.");
}
else if (isNumericType(expectedType)) {
message.append("Suggestion: Verify that the model is providing numeric values for numeric parameters. ");
message.append("Review your tool description and prompt to ensure clarity about expected parameter types.");
}
else {
message.append(
"Suggestion: Review your tool description and prompt to ensure the model provides values compatible with the expected parameter type.");
}

if (cause != null && cause.getMessage() != null) {
message.append(" Original error: ").append(cause.getMessage());
}

return message.toString();
}

private static boolean isNumericType(Type type) {
if (type instanceof Class<?>) {
Class<?> clazz = (Class<?>) type;
return clazz == Byte.class || clazz == byte.class || clazz == Short.class || clazz == short.class
|| clazz == Integer.class || clazz == int.class || clazz == Long.class || clazz == long.class
|| clazz == Float.class || clazz == float.class || clazz == Double.class || clazz == double.class;
}
return false;
}

/**
* Returns the name of the parameter that failed conversion.
* @return the parameter name, or null if not available
*/
@Nullable
public String getParameterName() {
return this.parameterName;
}

/**
* Returns the expected parameter type.
* @return the expected type
*/
public Type getExpectedType() {
return this.expectedType;
}

/**
* Returns the actual value provided by the model that failed conversion.
* @return the actual value, or null if the value was null
*/
@Nullable
public Object getActualValue() {
return this.actualValue;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.springframework.ai.tool.execution.DefaultToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolCallResultConverter;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.execution.ToolParameterConversionException;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.lang.Nullable;
Expand Down Expand Up @@ -136,23 +137,28 @@ private Object[] buildMethodArguments(Map<String, Object> toolInputArguments, @N
return toolContext;
}
Object rawArgument = toolInputArguments.get(parameter.getName());
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
return buildTypedArgument(rawArgument, parameter.getParameterizedType(), parameter.getName());
}).toArray();
}

@Nullable
private Object buildTypedArgument(@Nullable Object value, Type type) {
private Object buildTypedArgument(@Nullable Object value, Type type, @Nullable String parameterName) {
if (value == null) {
return null;
}

if (type instanceof Class<?>) {
return JsonParser.toTypedObject(value, (Class<?>) type);
}
try {
if (type instanceof Class<?>) {
return JsonParser.toTypedObject(value, (Class<?>) type);
}

// For generic types, use the fromJson method that accepts Type
String json = JsonParser.toJson(value);
return JsonParser.fromJson(json, type);
// For generic types, use the fromJson method that accepts Type
String json = JsonParser.toJson(value);
return JsonParser.fromJson(json, type);
}
catch (NumberFormatException | IllegalStateException ex) {
throw new ToolParameterConversionException(this.toolDefinition, parameterName, type, value, ex);
}
}

@Nullable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,39 +137,155 @@ public static Object toTypedObject(Object value, Class<?> type) {
Assert.notNull(type, "type cannot be null");

var javaType = ClassUtils.resolvePrimitiveIfNecessary(type);
String valueString = value.toString();

if (javaType == String.class) {
return value.toString();
return valueString;
}
else if (javaType == Byte.class) {
return Byte.parseByte(value.toString());
return parseByteWithContext(valueString, javaType);
}
else if (javaType == Integer.class) {
BigDecimal bigDecimal = new BigDecimal(value.toString());
return bigDecimal.intValueExact();
return parseIntegerWithContext(valueString, javaType);
}
else if (javaType == Short.class) {
return Short.parseShort(value.toString());
return parseShortWithContext(valueString, javaType);
}
else if (javaType == Long.class) {
BigDecimal bigDecimal = new BigDecimal(value.toString());
return bigDecimal.longValueExact();
return parseLongWithContext(valueString, javaType);
}
else if (javaType == Double.class) {
return Double.parseDouble(value.toString());
return parseDoubleWithContext(valueString, javaType);
}
else if (javaType == Float.class) {
return Float.parseFloat(value.toString());
return parseFloatWithContext(valueString, javaType);
}
else if (javaType == Boolean.class) {
return Boolean.parseBoolean(value.toString());
return Boolean.parseBoolean(valueString);
}
else if (javaType.isEnum()) {
return Enum.valueOf((Class<Enum>) javaType, value.toString());
return parseEnumWithContext(valueString, (Class<Enum>) javaType);
}

String json = JsonParser.toJson(value);
return JsonParser.fromJson(json, javaType);
}

private static Byte parseByteWithContext(String value, Class<?> targetType) {
try {
return Byte.parseByte(value);
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

private static Integer parseIntegerWithContext(String value, Class<?> targetType) {
try {
BigDecimal bigDecimal = new BigDecimal(value);
return bigDecimal.intValueExact();
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

private static Short parseShortWithContext(String value, Class<?> targetType) {
try {
return Short.parseShort(value);
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

private static Long parseLongWithContext(String value, Class<?> targetType) {
try {
BigDecimal bigDecimal = new BigDecimal(value);
return bigDecimal.longValueExact();
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

private static Double parseDoubleWithContext(String value, Class<?> targetType) {
try {
return Double.parseDouble(value);
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

private static Float parseFloatWithContext(String value, Class<?> targetType) {
try {
return Float.parseFloat(value);
}
catch (NumberFormatException ex) {
throw new NumberFormatException(buildNumberFormatMessage(value, targetType, ex));
}
}

@SuppressWarnings({ "rawtypes", "unchecked" })
private static Enum parseEnumWithContext(String value, Class<Enum> enumType) {
try {
return Enum.valueOf(enumType, value);
}
catch (IllegalArgumentException ex) {
throw new IllegalArgumentException(buildEnumFormatMessage(value, enumType, ex), ex);
}
}

private static String buildNumberFormatMessage(String value, Class<?> targetType,
NumberFormatException originalException) {
StringBuilder message = new StringBuilder("Cannot convert value to ").append(targetType.getSimpleName())
.append(": ");

if (value.isEmpty()) {
message.append("empty string provided");
}
else {
message.append("'").append(value).append("'");
}

message.append(". Expected a valid ").append(targetType.getSimpleName().toLowerCase()).append(" value");

// Only append original exception message if it's not redundant
if (originalException.getMessage() != null && !isRedundantErrorMessage(originalException.getMessage(), value)) {
message.append(". ").append(originalException.getMessage());
}

return message.toString();
}

private static boolean isRedundantErrorMessage(String errorMessage, String originalValue) {
return errorMessage.equalsIgnoreCase("empty string")
|| (originalValue.isEmpty() && errorMessage.equals("For input string: \"\""));
}

private static String buildEnumFormatMessage(String value, Class<Enum> enumType,
IllegalArgumentException originalException) {
StringBuilder message = new StringBuilder("Cannot convert value to ").append(enumType.getSimpleName())
.append(": ");

if (value.isEmpty()) {
message.append("empty string provided");
}
else {
message.append("'").append(value).append("'");
}

message.append(". Expected one of: ");
Enum[] constants = enumType.getEnumConstants();
for (int i = 0; i < constants.length; i++) {
if (i > 0) {
message.append(", ");
}
message.append(constants[i].name());
}

return message.toString();
}

}
Loading