From 0f2436f3910ccc9552a2b4d9f494dab01d6ddc93 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 21 May 2025 01:57:53 +0200 Subject: [PATCH] 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 --- .../ToolCallingAutoConfiguration.java | 4 +- .../autoconfigure/ToolCallingProperties.java | 15 +++++ .../ToolCallingAutoConfigurationTests.java | 58 +++++++++++++++++++ .../ai/mcp/AsyncMcpToolCallback.java | 24 +++++--- .../ai/mcp/SyncMcpToolCallback.java | 24 ++++++-- .../antora/modules/ROOT/pages/api/tools.adoc | 13 ++++- 6 files changed, 124 insertions(+), 14 deletions(-) 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 @@ *
  • Manages JSON serialization/deserialization of tool inputs and outputs
  • * *

    - * 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