Skip to content

Commit 3068c04

Browse files
tzolovilayaperumalg
authored andcommitted
feat(tools): Add configurable exception handling for tool execution
Adds a new configuration property `spring.ai.tools.throw-exception-on-error` that controls how tool execution errors are handled: - When false (default): errors are converted to messages and sent back to the AI model - When true: errors are thrown as exceptions for the caller to handle The implementation: - Adds the property to ToolCallingProperties - Updates ToolCallingAutoConfiguration to use the property - Improves error handling in MCP tool callbacks to use ToolExecutionException - Adds tests to verify both behaviors - Updates documentation with the new property Signed-off-by: Christian Tzolov <[email protected]>
1 parent fd9fe79 commit 3068c04

File tree

6 files changed

+124
-14
lines changed

6 files changed

+124
-14
lines changed

auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationC
7777

7878
@Bean
7979
@ConditionalOnMissingBean
80-
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
81-
return new DefaultToolExecutionExceptionProcessor(false);
80+
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor(ToolCallingProperties properties) {
81+
return new DefaultToolExecutionExceptionProcessor(properties.isThrowExceptionOnError());
8282
}
8383

8484
@Bean

auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ public class ToolCallingProperties {
3131

3232
private final Observations observations = new Observations();
3333

34+
/**
35+
* If true, tool calling errors are thrown as exceptions for the caller to handle. If
36+
* false, errors are converted to messages and sent back to the AI model, allowing it
37+
* to process and respond to the error.
38+
*/
39+
private boolean throwExceptionOnError = false;
40+
41+
public boolean isThrowExceptionOnError() {
42+
return this.throwExceptionOnError;
43+
}
44+
45+
public void setThrowExceptionOnError(boolean throwExceptionOnError) {
46+
this.throwExceptionOnError = throwExceptionOnError;
47+
}
48+
3449
public static class Observations {
3550

3651
/**

auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/test/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfigurationTests.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
import org.springframework.ai.tool.ToolCallback;
2727
import org.springframework.ai.tool.ToolCallbackProvider;
2828
import org.springframework.ai.tool.annotation.Tool;
29+
import org.springframework.ai.tool.definition.ToolDefinition;
2930
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
31+
import org.springframework.ai.tool.execution.ToolExecutionException;
3032
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
3133
import org.springframework.ai.tool.function.FunctionToolCallback;
3234
import org.springframework.ai.tool.method.MethodToolCallback;
@@ -127,6 +129,62 @@ void observationFilterEnabled() {
127129
.run(context -> assertThat(context).hasSingleBean(ToolCallingContentObservationFilter.class));
128130
}
129131

132+
@Test
133+
void throwExceptionOnErrorDefault() {
134+
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class))
135+
.withUserConfiguration(Config.class)
136+
.run(context -> {
137+
var toolExecutionExceptionProcessor = context.getBean(ToolExecutionExceptionProcessor.class);
138+
assertThat(toolExecutionExceptionProcessor).isInstanceOf(DefaultToolExecutionExceptionProcessor.class);
139+
140+
// Test behavior instead of accessing private field
141+
// Create a mock tool definition and exception
142+
var toolDefinition = ToolDefinition.builder()
143+
.name("testTool")
144+
.description("Test tool for exception handling")
145+
.inputSchema("{\"type\":\"object\",\"properties\":{\"test\":{\"type\":\"string\"}}}")
146+
.build();
147+
var cause = new RuntimeException("Test error");
148+
var exception = new ToolExecutionException(toolDefinition, cause);
149+
150+
// Default behavior should not throw exception
151+
String result = toolExecutionExceptionProcessor.process(exception);
152+
assertThat(result).isEqualTo("Test error");
153+
});
154+
}
155+
156+
@Test
157+
void throwExceptionOnErrorEnabled() {
158+
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class))
159+
.withPropertyValues("spring.ai.tools.throw-exception-on-error=true")
160+
.withUserConfiguration(Config.class)
161+
.run(context -> {
162+
var toolExecutionExceptionProcessor = context.getBean(ToolExecutionExceptionProcessor.class);
163+
assertThat(toolExecutionExceptionProcessor).isInstanceOf(DefaultToolExecutionExceptionProcessor.class);
164+
165+
// Test behavior instead of accessing private field
166+
// Create a mock tool definition and exception
167+
var toolDefinition = ToolDefinition.builder()
168+
.name("testTool")
169+
.description("Test tool for exception handling")
170+
.inputSchema("{\"type\":\"object\",\"properties\":{\"test\":{\"type\":\"string\"}}}")
171+
.build();
172+
var cause = new RuntimeException("Test error");
173+
var exception = new ToolExecutionException(toolDefinition, cause);
174+
175+
// When property is set to true, it should throw the exception
176+
assertThat(toolExecutionExceptionProcessor).extracting(processor -> {
177+
try {
178+
processor.process(exception);
179+
return "No exception thrown";
180+
}
181+
catch (ToolExecutionException e) {
182+
return "Exception thrown";
183+
}
184+
}).isEqualTo("Exception thrown");
185+
});
186+
}
187+
130188
static class WeatherService {
131189

132190
@Tool(description = "Get the weather in location. Return temperature in 36°F or 36°C format.")

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.ai.tool.ToolCallback;
2828
import org.springframework.ai.tool.definition.DefaultToolDefinition;
2929
import org.springframework.ai.tool.definition.ToolDefinition;
30+
import org.springframework.ai.tool.execution.ToolExecutionException;
3031

3132
/**
3233
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -41,7 +42,9 @@
4142
* <li>Manages JSON serialization/deserialization of tool inputs and outputs</li>
4243
* </ul>
4344
* <p>
44-
* Example usage: <pre>{@code
45+
* Example usage:
46+
*
47+
* <pre>{@code
4548
* McpAsyncClient mcpClient = // obtain MCP client
4649
* Tool mcpTool = // obtain MCP tool definition
4750
* ToolCallback callback = new AsyncMcpToolCallback(mcpClient, mcpTool);
@@ -109,12 +112,19 @@ public String call(String functionInput) {
109112
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
110113
// Note that we use the original tool name here, not the adapted one from
111114
// getToolDefinition
112-
return this.asyncMcpClient.callTool(new CallToolRequest(this.tool.name(), arguments)).map(response -> {
113-
if (response.isError() != null && response.isError()) {
114-
throw new IllegalStateException("Error calling tool: " + response.content());
115-
}
116-
return ModelOptionsUtils.toJsonString(response.content());
117-
}).block();
115+
try {
116+
return this.asyncMcpClient.callTool(new CallToolRequest(this.tool.name(), arguments)).map(response -> {
117+
if (response.isError() != null && response.isError()) {
118+
throw new ToolExecutionException(this.getToolDefinition(),
119+
new IllegalStateException("Error calling tool: " + response.content()));
120+
}
121+
return ModelOptionsUtils.toJsonString(response.content());
122+
}).block();
123+
}
124+
catch (Exception ex) {
125+
throw new ToolExecutionException(this.getToolDefinition(), ex.getCause());
126+
}
127+
118128
}
119129

120130
@Override

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,23 @@
1616

1717
package org.springframework.ai.mcp;
1818

19+
import java.lang.reflect.InvocationTargetException;
1920
import java.util.Map;
2021

2122
import io.modelcontextprotocol.client.McpSyncClient;
2223
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
2324
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
2425
import io.modelcontextprotocol.spec.McpSchema.Tool;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
2528

2629
import org.springframework.ai.chat.model.ToolContext;
2730
import org.springframework.ai.model.ModelOptionsUtils;
2831
import org.springframework.ai.tool.ToolCallback;
2932
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3033
import org.springframework.ai.tool.definition.ToolDefinition;
34+
import org.springframework.ai.tool.execution.ToolExecutionException;
35+
import org.springframework.core.log.LogAccessor;
3136

3237
/**
3338
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -61,6 +66,8 @@
6166
*/
6267
public class SyncMcpToolCallback implements ToolCallback {
6368

69+
private static final Logger logger = LoggerFactory.getLogger(SyncMcpToolCallback.class);
70+
6471
private final McpSyncClient mcpClient;
6572

6673
private final Tool tool;
@@ -113,11 +120,20 @@ public String call(String functionInput) {
113120
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
114121
// Note that we use the original tool name here, not the adapted one from
115122
// getToolDefinition
116-
CallToolResult response = this.mcpClient.callTool(new CallToolRequest(this.tool.name(), arguments));
117-
if (response.isError() != null && response.isError()) {
118-
throw new IllegalStateException("Error calling tool: " + response.content());
123+
try {
124+
CallToolResult response = this.mcpClient.callTool(new CallToolRequest(this.tool.name(), arguments));
125+
if (response.isError() != null && response.isError()) {
126+
logger.error("Error calling tool: {}", response.content());
127+
throw new ToolExecutionException(this.getToolDefinition(),
128+
new IllegalStateException("Error calling tool: " + response.content()));
129+
}
130+
return ModelOptionsUtils.toJsonString(response.content());
131+
}
132+
catch (Exception ex) {
133+
logger.error("Exception while tool calling: ", ex);
134+
throw new ToolExecutionException(this.getToolDefinition(), ex.getCause());
119135
}
120-
return ModelOptionsUtils.toJsonString(response.content());
136+
121137
}
122138

123139
@Override

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,8 @@ ChatResponse newResponse = chatModel.call(new Prompt(chatMemory.get(conversation
11801180

11811181
=== Exception Handling
11821182

1183-
When a tool call fails, the exception is propagated as a `ToolExecutionException` which can be caught to handle the error. A `ToolExecutionExceptionProcessor` can be used to handle a `ToolExecutionException` with two outcomes: either producing an error message to be sent back to the AI model or throwing an exception to be handled by the caller.
1183+
When a tool call fails, the exception is propagated as a `ToolExecutionException` which can be caught to handle the error.
1184+
A `ToolExecutionExceptionProcessor` can be used to handle a `ToolExecutionException` with two outcomes: either producing an error message to be sent back to the AI model or throwing an exception to be handled by the caller.
11841185

11851186
[source,java]
11861187
----
@@ -1198,6 +1199,16 @@ public interface ToolExecutionExceptionProcessor {
11981199

11991200
If you're using any of the Spring AI Spring Boot Starters, `DefaultToolExecutionExceptionProcessor` is the autoconfigured implementation of the `ToolExecutionExceptionProcessor` interface. By default, the error message is sent back to the model. The `DefaultToolExecutionExceptionProcessor` constructor lets you set the `alwaysThrow` attribute to `true` or `false`. If `true`, an exception will be thrown instead of sending an error message back to the model.
12001201

1202+
You can use the ``spring.ai.tools.throw-exception-on-error` property to control the behavior of the `DefaultToolExecutionExceptionProcessor` bean:
1203+
1204+
[cols="6,3,1", stripes=even]
1205+
|====
1206+
| Property | Description | Default
1207+
1208+
| `spring.ai.tools.throw-exception-on-error` | If `true`, tool calling errors are thrown as exceptions for the caller to handle. If `false`, errors are converted to messages and sent back to the AI model, allowing it to process and respond to the error.| `false`
1209+
|====
1210+
1211+
12011212
[source,java]
12021213
----
12031214
@Bean

0 commit comments

Comments
 (0)