diff --git a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java index bdc647db8f7..5ab883df2b4 100644 --- a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java +++ b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java @@ -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 diff --git a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java index 8511baad413..9ac33eb620a 100644 --- a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java +++ b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java @@ -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 { /** diff --git a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/test/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfigurationTests.java b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/test/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfigurationTests.java index 0b7b638160f..af42d744158 100644 --- a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/test/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfigurationTests.java +++ b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/test/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfigurationTests.java @@ -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; @@ -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.") diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java index d54e1a8b12a..9d634b73999 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java @@ -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 @@ -41,7 +42,9 @@ *
- * Example usage:
{@code
+ * Example usage:
+ *
+ * {@code
* McpAsyncClient mcpClient = // obtain MCP client
* Tool mcpTool = // obtain MCP tool definition
* ToolCallback callback = new AsyncMcpToolCallback(mcpClient, mcpTool);
@@ -109,12 +112,19 @@ public String call(String functionInput) {
Map 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
diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java
index 441b3d4d508..442f21eb89a 100644
--- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java
+++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java
@@ -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 @@
*/
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 String call(String functionInput) {
Map 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
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
index d6982733536..e136e9a6e04 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
@@ -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