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:
committed by
Ilayaperumal Gopinathan
parent
fd9fe798a1
commit
3068c04dca
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user