Skip to content

Commit d4f22ce

Browse files
tzolovilayaperumalg
authored andcommitted
fix: Improve JSON error handling in MethodToolCallback
Add proper exception handling for JSON processing failures in tool argument extraction and conversion. Wrap JsonProcessingException in ToolExecutionException to provide better error context when tools receive malformed input. Resolves: #3924 #4987 #3933 Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
1 parent e56b344 commit d4f22ce

File tree

3 files changed

+138
-12
lines changed

3 files changed

+138
-12
lines changed

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/ChatClientToolsWithGenericArgumentTypesIT.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ void beforeEach() {
5757
@Autowired
5858
ChatModel chatModel;
5959

60+
@Test
61+
void toolWithGenericArgumentTypes2() {
62+
// @formatter:off
63+
String response = ChatClient.create(this.chatModel).prompt()
64+
.user("Turn light YELLOW in the living room and the kitchen. You can violate the color enum for this request.")
65+
.tools(new TestToolProvider())
66+
.call()
67+
.content();
68+
// @formatter:on
69+
70+
logger.info("Response: {}", response);
71+
72+
assertThat(arguments).containsEntry("living room", LightColor.RED);
73+
assertThat(arguments).containsEntry("kitchen", LightColor.RED);
74+
75+
assertThat(callCounter.get()).isEqualTo(1);
76+
}
77+
6078
@Test
6179
void toolWithGenericArgumentTypes() {
6280
// @formatter:off

spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Map;
2424
import java.util.stream.Stream;
2525

26+
import com.fasterxml.jackson.core.JsonProcessingException;
2627
import com.fasterxml.jackson.core.type.TypeReference;
2728
import org.slf4j.Logger;
2829
import org.slf4j.LoggerFactory;
@@ -79,11 +80,13 @@ public MethodToolCallback(ToolDefinition toolDefinition, @Nullable ToolMetadata
7980
: DEFAULT_RESULT_CONVERTER;
8081
}
8182

83+
@SuppressWarnings("null")
8284
@Override
8385
public ToolDefinition getToolDefinition() {
8486
return this.toolDefinition;
8587
}
8688

89+
@SuppressWarnings("null")
8790
@Override
8891
public ToolMetadata getToolMetadata() {
8992
return this.toolMetadata;
@@ -100,13 +103,13 @@ public String call(String toolInput, @Nullable ToolContext toolContext) {
100103

101104
logger.debug("Starting execution of tool: {}", this.toolDefinition.name());
102105

103-
validateToolContextSupport(toolContext);
106+
this.validateToolContextSupport(toolContext);
104107

105-
Map<String, Object> toolArguments = extractToolArguments(toolInput);
108+
Map<String, Object> toolArguments = this.extractToolArguments(toolInput);
106109

107-
Object[] methodArguments = buildMethodArguments(toolArguments, toolContext);
110+
Object[] methodArguments = this.buildMethodArguments(toolArguments, toolContext);
108111

109-
Object result = callMethod(methodArguments);
112+
Object result = this.callMethod(methodArguments);
110113

111114
logger.debug("Successful execution of tool: {}", this.toolDefinition.name());
112115

@@ -125,11 +128,21 @@ private void validateToolContextSupport(@Nullable ToolContext toolContext) {
125128
}
126129

127130
private Map<String, Object> extractToolArguments(String toolInput) {
128-
return JsonParser.fromJson(toolInput, new TypeReference<>() {
129-
});
131+
try {
132+
return JsonParser.fromJson(toolInput, new TypeReference<>() {
133+
});
134+
}
135+
catch (IllegalStateException ex) {
136+
if (ex.getCause() instanceof JsonProcessingException jsonExp) {
137+
logger.warn("Conversion from JSON failed", ex);
138+
throw new ToolExecutionException(this.getToolDefinition(), jsonExp);
139+
}
140+
throw ex;
141+
}
130142
}
131143

132144
// Based on the implementation in MethodToolCallback.
145+
@SuppressWarnings("null")
133146
private Object[] buildMethodArguments(Map<String, Object> toolInputArguments, @Nullable ToolContext toolContext) {
134147
return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
135148
if (parameter.getType().isAssignableFrom(ToolContext.class)) {
@@ -145,16 +158,26 @@ private Object buildTypedArgument(@Nullable Object value, Type type) {
145158
if (value == null) {
146159
return null;
147160
}
161+
try {
162+
if (type instanceof Class<?>) {
163+
return JsonParser.toTypedObject(value, (Class<?>) type);
164+
}
148165

149-
if (type instanceof Class<?>) {
150-
return JsonParser.toTypedObject(value, (Class<?>) type);
151-
}
166+
// For generic types, use the fromJson method that accepts Type
152167

153-
// For generic types, use the fromJson method that accepts Type
154-
String json = JsonParser.toJson(value);
155-
return JsonParser.fromJson(json, type);
168+
String json = JsonParser.toJson(value);
169+
return JsonParser.fromJson(json, type);
170+
}
171+
catch (IllegalStateException ex) {
172+
if (ex.getCause() instanceof JsonProcessingException jsonExp) {
173+
logger.warn("Conversion from JSON failed", ex);
174+
throw new ToolExecutionException(this.getToolDefinition(), jsonExp);
175+
}
176+
throw ex;
177+
}
156178
}
157179

180+
@SuppressWarnings("null")
158181
@Nullable
159182
private Object callMethod(Object[] methodArguments) {
160183
if (isObjectNotPublic() || isMethodNotPublic()) {
@@ -232,6 +255,7 @@ public Builder toolCallResultConverter(ToolCallResultConverter toolCallResultCon
232255
return this;
233256
}
234257

258+
@SuppressWarnings("null")
235259
public MethodToolCallback build() {
236260
return new MethodToolCallback(this.toolDefinition, this.toolMetadata, this.toolMethod, this.toolObject,
237261
this.toolCallResultConverter);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2025 - 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.ai.tool.method;
17+
18+
import java.util.List;
19+
20+
import org.junit.jupiter.api.Test;
21+
22+
import org.springframework.ai.tool.annotation.Tool;
23+
import org.springframework.ai.tool.execution.ToolExecutionException;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
27+
28+
/**
29+
* @author Christian Tzolov
30+
*/
31+
public class MethodToolCallbackExceptionHandlingTest {
32+
33+
@Test
34+
void testGenericListType() throws Exception {
35+
// Create a test object with a method that takes a List<String>
36+
TestTools testObject = new TestTools();
37+
38+
var callback = MethodToolCallbackProvider.builder().toolObjects(testObject).build().getToolCallbacks()[0];
39+
40+
// Create a JSON input with a list of strings
41+
String toolInput = """
42+
{
43+
"strings": ["one", "two", "three"]
44+
}
45+
""";
46+
47+
// Call the tool
48+
String result = callback.call(toolInput);
49+
50+
// Verify the result
51+
assertThat(result).isEqualTo("3 strings processed: [one, two, three]");
52+
53+
// Verify
54+
String ivalidToolInput = """
55+
{
56+
"strings": 678
57+
}
58+
""";
59+
60+
// Call the tool
61+
assertThatThrownBy(() -> callback.call(ivalidToolInput)).isInstanceOf(ToolExecutionException.class)
62+
.hasMessageContaining("Cannot deserialize value");
63+
64+
// Verify extractToolArguments
65+
66+
String ivalidToolInput2 = """
67+
nill
68+
""";
69+
70+
// Call the tool
71+
assertThatThrownBy(() -> callback.call(ivalidToolInput2)).isInstanceOf(ToolExecutionException.class)
72+
.hasMessageContaining("Unrecognized token");
73+
}
74+
75+
public static class TestTools {
76+
77+
@Tool(description = "Process a list of strings")
78+
public String stringList(List<String> strings) {
79+
return strings.size() + " strings processed: " + strings;
80+
}
81+
82+
}
83+
84+
}

0 commit comments

Comments
 (0)