Skip to content

Commit 051e430

Browse files
author
HuidongYin
committed
Fix tool call with empty arguments in streaming mode
Signed-off-by: huidong.yin <[email protected]>
1 parent eaca392 commit 051e430

File tree

4 files changed

+198
-13
lines changed

4 files changed

+198
-13
lines changed

mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
import io.modelcontextprotocol.client.McpAsyncClient;
2020
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
2121
import io.modelcontextprotocol.spec.McpSchema.Tool;
22-
import java.util.Map;
23-
22+
import org.slf4j.Logger;
23+
import org.slf4j.LoggerFactory;
2424
import org.springframework.ai.chat.model.ToolContext;
2525
import org.springframework.ai.model.ModelOptionsUtils;
2626
import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder;
@@ -29,6 +29,8 @@
2929
import org.springframework.ai.tool.definition.ToolDefinition;
3030
import org.springframework.ai.tool.execution.ToolExecutionException;
3131

32+
import java.util.Map;
33+
3234
/**
3335
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
3436
* interface with asynchronous execution support.
@@ -61,6 +63,8 @@
6163
*/
6264
public class AsyncMcpToolCallback implements ToolCallback {
6365

66+
private static final Logger logger = LoggerFactory.getLogger(AsyncMcpToolCallback.class);
67+
6468
private final McpAsyncClient asyncMcpClient;
6569

6670
private final Tool tool;
@@ -109,6 +113,12 @@ public ToolDefinition getToolDefinition() {
109113
*/
110114
@Override
111115
public String call(String functionInput) {
116+
// Handle the possible null parameter situation in streaming mode.
117+
if (functionInput == null || functionInput.trim().isEmpty()) {
118+
logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name());
119+
functionInput = "{}";
120+
}
121+
112122
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
113123
// Note that we use the original tool name here, not the adapted one from
114124
// getToolDefinition

mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@
2020
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
2121
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
2222
import io.modelcontextprotocol.spec.McpSchema.Tool;
23-
import java.util.Map;
2423
import org.slf4j.Logger;
2524
import org.slf4j.LoggerFactory;
26-
2725
import org.springframework.ai.chat.model.ToolContext;
2826
import org.springframework.ai.model.ModelOptionsUtils;
2927
import org.springframework.ai.tool.ToolCallback;
3028
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3129
import org.springframework.ai.tool.definition.ToolDefinition;
3230
import org.springframework.ai.tool.execution.ToolExecutionException;
3331

32+
import java.util.Map;
33+
3434
/**
3535
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
3636
* interface.
@@ -114,6 +114,12 @@ public ToolDefinition getToolDefinition() {
114114
*/
115115
@Override
116116
public String call(String functionInput) {
117+
// Handle the possible null parameter situation in streaming mode.
118+
if (functionInput == null || functionInput.trim().isEmpty()) {
119+
logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name());
120+
functionInput = "{}";
121+
}
122+
117123
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
118124

119125
CallToolResult response;

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,9 @@
1616

1717
package org.springframework.ai.model.tool;
1818

19-
import java.util.ArrayList;
20-
import java.util.HashMap;
21-
import java.util.List;
22-
import java.util.Map;
23-
import java.util.Optional;
24-
2519
import io.micrometer.observation.ObservationRegistry;
2620
import org.slf4j.Logger;
2721
import org.slf4j.LoggerFactory;
28-
2922
import org.springframework.ai.chat.messages.AssistantMessage;
3023
import org.springframework.ai.chat.messages.Message;
3124
import org.springframework.ai.chat.messages.ToolResponseMessage;
@@ -47,6 +40,8 @@
4740
import org.springframework.util.Assert;
4841
import org.springframework.util.CollectionUtils;
4942

43+
import java.util.*;
44+
5045
/**
5146
* Default implementation of {@link ToolCallingManager}.
5247
*
@@ -189,6 +184,16 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
189184
String toolName = toolCall.name();
190185
String toolInputArguments = toolCall.arguments();
191186

187+
// Handle the possible null parameter situation in streaming mode.
188+
final String finalToolInputArguments;
189+
if (toolInputArguments == null || toolInputArguments.trim().isEmpty()) {
190+
logger.debug("Tool call arguments are null or empty for tool: {}. Using empty JSON object as default.",
191+
toolName);
192+
finalToolInputArguments = "{}";
193+
} else {
194+
finalToolInputArguments = toolInputArguments;
195+
}
196+
192197
ToolCallback toolCallback = toolCallbacks.stream()
193198
.filter(tool -> toolName.equals(tool.getToolDefinition().name()))
194199
.findFirst()
@@ -208,7 +213,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
208213
ToolCallingObservationContext observationContext = ToolCallingObservationContext.builder()
209214
.toolDefinition(toolCallback.getToolDefinition())
210215
.toolMetadata(toolCallback.getToolMetadata())
211-
.toolCallArguments(toolInputArguments)
216+
.toolCallArguments(finalToolInputArguments)
212217
.build();
213218

214219
String toolCallResult = ToolCallingObservationDocumentation.TOOL_CALL
@@ -217,7 +222,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
217222
.observe(() -> {
218223
String toolResult;
219224
try {
220-
toolResult = toolCallback.call(toolInputArguments, toolContext);
225+
toolResult = toolCallback.call(finalToolInputArguments, toolContext);
221226
}
222227
catch (ToolExecutionException ex) {
223228
toolResult = this.toolExecutionExceptionProcessor.process(ex);
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* Copyright 2023-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+
17+
package org.springframework.ai.model.tool;
18+
19+
import io.micrometer.observation.ObservationRegistry;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.ai.chat.messages.AssistantMessage;
22+
import org.springframework.ai.chat.messages.UserMessage;
23+
import org.springframework.ai.chat.model.ChatResponse;
24+
import org.springframework.ai.chat.model.Generation;
25+
import org.springframework.ai.chat.prompt.Prompt;
26+
import org.springframework.ai.tool.ToolCallback;
27+
import org.springframework.ai.tool.definition.DefaultToolDefinition;
28+
import org.springframework.ai.tool.definition.ToolDefinition;
29+
import org.springframework.ai.tool.metadata.ToolMetadata;
30+
31+
import java.util.List;
32+
import java.util.Map;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
import static org.assertj.core.api.Assertions.assertThatNoException;
36+
37+
/**
38+
* Tests for {@link DefaultToolCallingManager} with empty/null arguments handling.
39+
*
40+
* @author Spring AI Team
41+
*/
42+
class DefaultToolCallingManagerTest {
43+
44+
@Test
45+
void shouldHandleNullArgumentsInStreamMode() {
46+
// Create a mock tool callback
47+
ToolCallback mockToolCallback = new ToolCallback() {
48+
@Override
49+
public ToolDefinition getToolDefinition() {
50+
return DefaultToolDefinition.builder()
51+
.name("testTool")
52+
.description("A test tool")
53+
.inputSchema("{}")
54+
.build();
55+
}
56+
57+
@Override
58+
public ToolMetadata getToolMetadata() {
59+
return ToolMetadata.builder().build();
60+
}
61+
62+
@Override
63+
public String call(String toolInput) {
64+
// Verify the input is not null or empty
65+
assertThat(toolInput).isNotNull();
66+
assertThat(toolInput).isNotEmpty();
67+
return "{\"result\": \"success\"}";
68+
}
69+
};
70+
71+
// Create DefaultToolCallingManager with tool callback
72+
DefaultToolCallingManager manager = DefaultToolCallingManager.builder()
73+
.observationRegistry(ObservationRegistry.NOOP)
74+
.build();
75+
76+
// Create a ToolCall with empty parameters
77+
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", null);
78+
79+
// Create a ChatResponse
80+
AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall));
81+
Generation generation = new Generation(assistantMessage);
82+
ChatResponse chatResponse = new ChatResponse(List.of(generation));
83+
84+
// Create a Prompt with tool callbacks
85+
Prompt prompt = new Prompt(List.of(new UserMessage("test")));
86+
87+
// Mock the tool callbacks resolution by creating a custom ToolCallbackResolver
88+
DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder()
89+
.observationRegistry(ObservationRegistry.NOOP)
90+
.toolCallbackResolver(toolName -> {
91+
if ("testTool".equals(toolName)) {
92+
return mockToolCallback;
93+
}
94+
return null;
95+
})
96+
.build();
97+
98+
// Verify that no exception is thrown
99+
assertThatNoException().isThrownBy(() -> {
100+
managerWithCallback.executeToolCalls(prompt, chatResponse);
101+
});
102+
}
103+
104+
@Test
105+
void shouldHandleEmptyArgumentsInStreamMode() {
106+
// Create a mock tool callback
107+
ToolCallback mockToolCallback = new ToolCallback() {
108+
@Override
109+
public ToolDefinition getToolDefinition() {
110+
return DefaultToolDefinition.builder()
111+
.name("testTool")
112+
.description("A test tool")
113+
.inputSchema("{}")
114+
.build();
115+
}
116+
117+
@Override
118+
public ToolMetadata getToolMetadata() {
119+
return ToolMetadata.builder().build();
120+
}
121+
122+
@Override
123+
public String call(String toolInput) {
124+
// Verify the input is not null or empty
125+
assertThat(toolInput).isNotNull();
126+
assertThat(toolInput).isNotEmpty();
127+
return "{\"result\": \"success\"}";
128+
}
129+
};
130+
131+
// Create DefaultToolCallingManager with tool callback
132+
DefaultToolCallingManager manager = DefaultToolCallingManager.builder()
133+
.observationRegistry(ObservationRegistry.NOOP)
134+
.build();
135+
136+
// Create a ToolCall with empty parameters
137+
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", "");
138+
139+
// Create a ChatResponse
140+
AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall));
141+
Generation generation = new Generation(assistantMessage);
142+
ChatResponse chatResponse = new ChatResponse(List.of(generation));
143+
144+
// Create a Prompt with tool callbacks
145+
Prompt prompt = new Prompt(List.of(new UserMessage("test")));
146+
147+
// Mock the tool callbacks resolution by creating a custom ToolCallbackResolver
148+
DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder()
149+
.observationRegistry(ObservationRegistry.NOOP)
150+
.toolCallbackResolver(toolName -> {
151+
if ("testTool".equals(toolName)) {
152+
return mockToolCallback;
153+
}
154+
return null;
155+
})
156+
.build();
157+
158+
// Verify that no exception is thrown
159+
assertThatNoException().isThrownBy(() -> {
160+
managerWithCallback.executeToolCalls(prompt, chatResponse);
161+
});
162+
}
163+
164+
}

0 commit comments

Comments
 (0)