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 <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-05-21 01:57:53 +02:00
committed by Ilayaperumal Gopinathan
parent fd9fe798a1
commit 3068c04dca
6 changed files with 124 additions and 14 deletions

View File

@@ -77,8 +77,8 @@ public class ToolCallingAutoConfiguration {
@Bean
@ConditionalOnMissingBean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(false);
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor(ToolCallingProperties properties) {
return new DefaultToolExecutionExceptionProcessor(properties.isThrowExceptionOnError());
}
@Bean

View File

@@ -31,6 +31,21 @@ public class ToolCallingProperties {
private final Observations observations = new Observations();
/**
* 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.
*/
private boolean throwExceptionOnError = false;
public boolean isThrowExceptionOnError() {
return this.throwExceptionOnError;
}
public void setThrowExceptionOnError(boolean throwExceptionOnError) {
this.throwExceptionOnError = throwExceptionOnError;
}
public static class Observations {
/**

View File

@@ -26,7 +26,9 @@ import org.springframework.ai.tool.StaticToolCallbackProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.ai.tool.method.MethodToolCallback;
@@ -127,6 +129,62 @@ class ToolCallingAutoConfigurationTests {
.run(context -> assertThat(context).hasSingleBean(ToolCallingContentObservationFilter.class));
}
@Test
void throwExceptionOnErrorDefault() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class))
.withUserConfiguration(Config.class)
.run(context -> {
var toolExecutionExceptionProcessor = context.getBean(ToolExecutionExceptionProcessor.class);
assertThat(toolExecutionExceptionProcessor).isInstanceOf(DefaultToolExecutionExceptionProcessor.class);
// Test behavior instead of accessing private field
// Create a mock tool definition and exception
var toolDefinition = ToolDefinition.builder()
.name("testTool")
.description("Test tool for exception handling")
.inputSchema("{\"type\":\"object\",\"properties\":{\"test\":{\"type\":\"string\"}}}")
.build();
var cause = new RuntimeException("Test error");
var exception = new ToolExecutionException(toolDefinition, cause);
// Default behavior should not throw exception
String result = toolExecutionExceptionProcessor.process(exception);
assertThat(result).isEqualTo("Test error");
});
}
@Test
void throwExceptionOnErrorEnabled() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class))
.withPropertyValues("spring.ai.tools.throw-exception-on-error=true")
.withUserConfiguration(Config.class)
.run(context -> {
var toolExecutionExceptionProcessor = context.getBean(ToolExecutionExceptionProcessor.class);
assertThat(toolExecutionExceptionProcessor).isInstanceOf(DefaultToolExecutionExceptionProcessor.class);
// Test behavior instead of accessing private field
// Create a mock tool definition and exception
var toolDefinition = ToolDefinition.builder()
.name("testTool")
.description("Test tool for exception handling")
.inputSchema("{\"type\":\"object\",\"properties\":{\"test\":{\"type\":\"string\"}}}")
.build();
var cause = new RuntimeException("Test error");
var exception = new ToolExecutionException(toolDefinition, cause);
// When property is set to true, it should throw the exception
assertThat(toolExecutionExceptionProcessor).extracting(processor -> {
try {
processor.process(exception);
return "No exception thrown";
}
catch (ToolExecutionException e) {
return "Exception thrown";
}
}).isEqualTo("Exception thrown");
});
}
static class WeatherService {
@Tool(description = "Get the weather in location. Return temperature in 36°F or 36°C format.")

View File

@@ -27,6 +27,7 @@ import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
/**
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -41,7 +42,9 @@ import org.springframework.ai.tool.definition.ToolDefinition;
* <li>Manages JSON serialization/deserialization of tool inputs and outputs</li>
* </ul>
* <p>
* Example usage: <pre>{@code
* Example usage:
*
* <pre>{@code
* McpAsyncClient mcpClient = // obtain MCP client
* Tool mcpTool = // obtain MCP tool definition
* ToolCallback callback = new AsyncMcpToolCallback(mcpClient, mcpTool);
@@ -109,12 +112,19 @@ public class AsyncMcpToolCallback implements ToolCallback {
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
// Note that we use the original tool name here, not the adapted one from
// getToolDefinition
return this.asyncMcpClient.callTool(new CallToolRequest(this.tool.name(), arguments)).map(response -> {
if (response.isError() != null && response.isError()) {
throw new IllegalStateException("Error calling tool: " + response.content());
}
return ModelOptionsUtils.toJsonString(response.content());
}).block();
try {
return this.asyncMcpClient.callTool(new CallToolRequest(this.tool.name(), arguments)).map(response -> {
if (response.isError() != null && response.isError()) {
throw new ToolExecutionException(this.getToolDefinition(),
new IllegalStateException("Error calling tool: " + response.content()));
}
return ModelOptionsUtils.toJsonString(response.content());
}).block();
}
catch (Exception ex) {
throw new ToolExecutionException(this.getToolDefinition(), ex.getCause());
}
}
@Override

View File

@@ -16,18 +16,23 @@
package org.springframework.ai.mcp;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.core.log.LogAccessor;
/**
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -61,6 +66,8 @@ import org.springframework.ai.tool.definition.ToolDefinition;
*/
public class SyncMcpToolCallback implements ToolCallback {
private static final Logger logger = LoggerFactory.getLogger(SyncMcpToolCallback.class);
private final McpSyncClient mcpClient;
private final Tool tool;
@@ -113,11 +120,20 @@ public class SyncMcpToolCallback implements ToolCallback {
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
// Note that we use the original tool name here, not the adapted one from
// getToolDefinition
CallToolResult response = this.mcpClient.callTool(new CallToolRequest(this.tool.name(), arguments));
if (response.isError() != null && response.isError()) {
throw new IllegalStateException("Error calling tool: " + response.content());
try {
CallToolResult response = this.mcpClient.callTool(new CallToolRequest(this.tool.name(), arguments));
if (response.isError() != null && response.isError()) {
logger.error("Error calling tool: {}", response.content());
throw new ToolExecutionException(this.getToolDefinition(),
new IllegalStateException("Error calling tool: " + response.content()));
}
return ModelOptionsUtils.toJsonString(response.content());
}
return ModelOptionsUtils.toJsonString(response.content());
catch (Exception ex) {
logger.error("Exception while tool calling: ", ex);
throw new ToolExecutionException(this.getToolDefinition(), ex.getCause());
}
}
@Override

View File

@@ -1180,7 +1180,8 @@ ChatResponse newResponse = chatModel.call(new Prompt(chatMemory.get(conversation
=== Exception Handling
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.
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.
[source,java]
----
@@ -1198,6 +1199,16 @@ public interface ToolExecutionExceptionProcessor {
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.
You can use the ``spring.ai.tools.throw-exception-on-error` property to control the behavior of the `DefaultToolExecutionExceptionProcessor` bean:
[cols="6,3,1", stripes=even]
|====
| Property | Description | Default
| `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`
|====
[source,java]
----
@Bean