Skip to content

feat(tools): Add configurable exception handling for tool execution #3264

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

Closed
Closed
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
Expand Up @@ -77,8 +77,8 @@ ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationC

@Bean
@ConditionalOnMissingBean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(false);
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor(ToolCallingProperties properties) {
return new DefaultToolExecutionExceptionProcessor(properties.isThrowExceptionOnError());
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
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;
Expand Down Expand Up @@ -127,6 +129,62 @@ void observationFilterEnabled() {
.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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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
Expand All @@ -41,7 +42,9 @@
* <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);
Expand Down Expand Up @@ -109,12 +112,19 @@ public String call(String functionInput) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +66,8 @@
*/
public class SyncMcpToolCallback implements ToolCallback {

private static final Logger logger = LoggerFactory.getLogger(SyncMcpToolCallback.class);

private final McpSyncClient mcpClient;

private final Tool tool;
Expand Down Expand Up @@ -113,11 +120,20 @@ public String call(String functionInput) {
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());
}
catch (Exception ex) {
logger.error("Exception while tool calling: ", ex);
throw new ToolExecutionException(this.getToolDefinition(), ex.getCause());
}
return ModelOptionsUtils.toJsonString(response.content());

}

@Override
Expand Down
13 changes: 12 additions & 1 deletion spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
----
Expand All @@ -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
Expand Down