diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
index 5149a98a85c..17c6b5b987b 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
@@ -34,6 +34,7 @@
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.consent.ConsentAwareToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.execution.ToolExecutionException;
@@ -217,7 +218,12 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
.observe(() -> {
String toolResult;
try {
- toolResult = toolCallback.call(toolInputArguments, toolContext);
+ if (toolCallback instanceof ConsentAwareToolCallback consentAwareCallback) {
+ toolResult = consentAwareCallback.call(toolInputArguments, toolContext);
+ }
+ else {
+ toolResult = toolCallback.call(toolInputArguments, toolContext);
+ }
}
catch (ToolExecutionException ex) {
toolResult = this.toolExecutionExceptionProcessor.process(ex);
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/RequiresConsent.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/RequiresConsent.java
new file mode 100644
index 00000000000..4368d3c4d88
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/RequiresConsent.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation to indicate that a tool method requires user consent before execution. When
+ * applied to a method annotated with {@link Tool}, the execution will be intercepted to
+ * request user approval before proceeding.
+ *
+ *
+ * Example usage:
+ *
{@code
+ * @Tool(description = "Deletes a book from the database")
+ * @RequiresConsent(message = "The book {bookId} will be permanently deleted. Do you approve?")
+ * public void deleteBook(String bookId) {
+ * // Implementation
+ * }
+ * }
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RequiresConsent {
+
+ /**
+ * The message to display when requesting consent. Supports placeholder syntax using
+ * curly braces (e.g., {paramName}) which will be replaced with actual parameter
+ * values at runtime.
+ * @return the consent message template
+ */
+ String message() default "This action requires your approval. Do you want to proceed?";
+
+ /**
+ * The level of consent required. This can be used to implement different consent
+ * strategies (e.g., one-time consent, session-based consent, etc.).
+ * @return the consent level
+ */
+ ConsentLevel level() default ConsentLevel.EVERY_TIME;
+
+ /**
+ * Optional categories for grouping consent requests. This can be used to manage
+ * consent preferences by category.
+ * @return array of consent categories
+ */
+ String[] categories() default {};
+
+ /**
+ * Defines the consent level for tool execution.
+ */
+ enum ConsentLevel {
+
+ /**
+ * Requires consent every time the tool is called.
+ */
+ EVERY_TIME,
+
+ /**
+ * Requires consent once per session.
+ */
+ SESSION,
+
+ /**
+ * Requires consent once and remembers the preference.
+ */
+ REMEMBER
+
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareMethodToolCallbackProvider.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareMethodToolCallbackProvider.java
new file mode 100644
index 00000000000..a1357a5e1d9
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareMethodToolCallbackProvider.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.method.MethodToolCallbackProvider;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.util.Assert;
+
+/**
+ * Extension of {@link MethodToolCallbackProvider} that wraps tool callbacks requiring
+ * consent with {@link ConsentAwareToolCallback}.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+public class ConsentAwareMethodToolCallbackProvider extends MethodToolCallbackProvider {
+
+ private final ConsentManager consentManager;
+
+ /**
+ * Creates a new consent-aware method tool callback provider.
+ * @param toolObjects the objects containing tool methods
+ * @param consentManager the consent manager for handling consent requests
+ */
+ public ConsentAwareMethodToolCallbackProvider(List toolObjects, ConsentManager consentManager) {
+ super(toolObjects);
+ Assert.notNull(consentManager, "consentManager must not be null");
+ this.consentManager = consentManager;
+ }
+
+ @Override
+ public ToolCallback[] getToolCallbacks() {
+ ToolCallback[] callbacks = super.getToolCallbacks();
+
+ // Wrap callbacks that require consent
+ for (int i = 0; i < callbacks.length; i++) {
+ ToolCallback callback = callbacks[i];
+ RequiresConsent requiresConsent = findRequiresConsentAnnotation(callback);
+
+ if (requiresConsent != null) {
+ callbacks[i] = new ConsentAwareToolCallback(callback, this.consentManager, requiresConsent);
+ }
+ }
+
+ return callbacks;
+ }
+
+ /**
+ * Finds the @RequiresConsent annotation for a tool callback. This method checks the
+ * original method that the callback was created from.
+ * @param callback the tool callback
+ * @return the RequiresConsent annotation or null if not present
+ */
+ private RequiresConsent findRequiresConsentAnnotation(ToolCallback callback) {
+ // For MethodToolCallback, we need to find the original method
+ // This requires accessing the method through reflection or storing it
+ // For now, we'll check all methods in the tool objects
+
+ for (Object toolObject : getToolObjects()) {
+ Method[] methods = toolObject.getClass().getDeclaredMethods();
+ for (Method method : methods) {
+ // Check if this method corresponds to the callback
+ if (method.getName().equals(callback.getName())) {
+ RequiresConsent annotation = AnnotationUtils.findAnnotation(method, RequiresConsent.class);
+ if (annotation != null) {
+ return annotation;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the list of tool objects from the parent class. This is a workaround since the
+ * field is private in the parent.
+ * @return the list of tool objects
+ */
+ private List getToolObjects() {
+ // This would need to be implemented properly, possibly by:
+ // 1. Making the field protected in the parent class
+ // 2. Adding a getter in the parent class
+ // 3. Storing a copy in this class
+ // For now, we'll throw an exception indicating this needs to be addressed
+ throw new UnsupportedOperationException(
+ "Need to access tool objects from parent class. Consider making the field protected or adding a getter.");
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private List toolObjects;
+
+ private ConsentManager consentManager;
+
+ private Builder() {
+ }
+
+ public Builder toolObjects(Object... toolObjects) {
+ Assert.notNull(toolObjects, "toolObjects cannot be null");
+ this.toolObjects = Arrays.asList(toolObjects);
+ return this;
+ }
+
+ public Builder consentManager(ConsentManager consentManager) {
+ Assert.notNull(consentManager, "consentManager cannot be null");
+ this.consentManager = consentManager;
+ return this;
+ }
+
+ public ConsentAwareMethodToolCallbackProvider build() {
+ Assert.notNull(this.toolObjects, "toolObjects must be set");
+ Assert.notNull(this.consentManager, "consentManager must be set");
+ return new ConsentAwareMethodToolCallbackProvider(this.toolObjects, this.consentManager);
+ }
+
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareToolCallback.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareToolCallback.java
new file mode 100644
index 00000000000..3a73584efcc
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentAwareToolCallback.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.consent.exception.ConsentDeniedException;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+import org.springframework.util.Assert;
+
+/**
+ * A decorator for {@link ToolCallback} that enforces consent requirements before
+ * delegating to the actual tool implementation.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+public class ConsentAwareToolCallback implements ToolCallback {
+
+ private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+ private final ToolCallback delegate;
+
+ private final ConsentManager consentManager;
+
+ private final RequiresConsent requiresConsent;
+
+ /**
+ * Creates a new consent-aware tool callback.
+ * @param delegate the actual tool callback to delegate to
+ * @param consentManager the consent manager for handling consent requests
+ * @param requiresConsent the consent requirements annotation
+ */
+ public ConsentAwareToolCallback(ToolCallback delegate, ConsentManager consentManager,
+ RequiresConsent requiresConsent) {
+ Assert.notNull(delegate, "delegate must not be null");
+ Assert.notNull(consentManager, "consentManager must not be null");
+ Assert.notNull(requiresConsent, "requiresConsent must not be null");
+ this.delegate = delegate;
+ this.consentManager = consentManager;
+ this.requiresConsent = requiresConsent;
+ }
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Override
+ public String call(String toolInput) {
+ // Parse input JSON to get parameters
+ Map parameters;
+ try {
+ parameters = OBJECT_MAPPER.readValue(toolInput, new TypeReference>() {
+ });
+ }
+ catch (Exception e) {
+ parameters = Map.of();
+ }
+ String toolName = this.delegate.getToolDefinition().name();
+
+ // Check if consent was already granted based on consent level
+ if (this.consentManager.hasValidConsent(toolName, this.requiresConsent.level(),
+ this.requiresConsent.categories())) {
+ return this.delegate.call(toolInput);
+ }
+
+ // Prepare consent message with parameter substitution
+ String message = prepareConsentMessage(this.requiresConsent.message(), parameters);
+
+ // Request consent
+ boolean consentGranted = this.consentManager.requestConsent(toolName, message, this.requiresConsent.level(),
+ this.requiresConsent.categories(), parameters);
+
+ if (!consentGranted) {
+ throw new ConsentDeniedException(String.format("User denied consent for tool '%s' execution", toolName));
+ }
+
+ // Execute the tool if consent was granted
+ return this.delegate.call(toolInput);
+ }
+
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return this.delegate.getToolDefinition();
+ }
+
+ @Override
+ public ToolMetadata getToolMetadata() {
+ return this.delegate.getToolMetadata();
+ }
+
+ /**
+ * Prepares the consent message by replacing placeholders with actual parameter
+ * values.
+ * @param template the message template with placeholders
+ * @param parameters the parameters to substitute
+ * @return the prepared message
+ */
+ private String prepareConsentMessage(String template, Map parameters) {
+ if (parameters == null || parameters.isEmpty()) {
+ return template;
+ }
+
+ Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
+ StringBuffer result = new StringBuffer();
+
+ while (matcher.find()) {
+ String paramName = matcher.group(1);
+ Object value = parameters.get(paramName);
+ String replacement = value != null ? String.valueOf(value) : "{" + paramName + "}";
+ matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
+ }
+ matcher.appendTail(result);
+
+ return result.toString();
+ }
+
+ /**
+ * Returns the underlying delegate tool callback.
+ * @return the delegate tool callback
+ */
+ public ToolCallback getDelegate() {
+ return this.delegate;
+ }
+
+ /**
+ * Returns the consent requirements for this tool.
+ * @return the consent requirements
+ */
+ public RequiresConsent getRequiresConsent() {
+ return this.requiresConsent;
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentChecker.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentChecker.java
new file mode 100644
index 00000000000..c824215b82e
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentChecker.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+/**
+ * Interface for checking user consent before tool execution. Implementations can provide
+ * different strategies for obtaining and validating user consent.
+ *
+ * @author Assistant
+ * @since 1.0.0
+ */
+@FunctionalInterface
+public interface ConsentChecker {
+
+ /**
+ * Check if consent is granted for the given context.
+ * @param context the consent context containing tool information
+ * @return true if consent is granted, false otherwise
+ */
+ boolean checkConsent(ConsentContext context);
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentContext.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentContext.java
new file mode 100644
index 00000000000..17ca19b8df6
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentContext.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.lang.Nullable;
+
+/**
+ * Context information for consent checking. Contains all relevant information needed to
+ * make consent decisions.
+ *
+ * @author Assistant
+ * @since 1.0.0
+ */
+public class ConsentContext {
+
+ private final String toolName;
+
+ private final String toolInput;
+
+ @Nullable
+ private final ToolContext toolContext;
+
+ @Nullable
+ private final RequiresConsent requiresConsent;
+
+ @Nullable
+ private final String userId;
+
+ @Nullable
+ private final String sessionId;
+
+ private ConsentContext(Builder builder) {
+ this.toolName = builder.toolName;
+ this.toolInput = builder.toolInput;
+ this.toolContext = builder.toolContext;
+ this.requiresConsent = builder.requiresConsent;
+ this.userId = builder.userId;
+ this.sessionId = builder.sessionId;
+ }
+
+ public String getToolName() {
+ return toolName;
+ }
+
+ public String getToolInput() {
+ return toolInput;
+ }
+
+ @Nullable
+ public ToolContext getToolContext() {
+ return toolContext;
+ }
+
+ @Nullable
+ public RequiresConsent getRequiresConsent() {
+ return requiresConsent;
+ }
+
+ @Nullable
+ public String getUserId() {
+ return userId;
+ }
+
+ @Nullable
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+
+ private String toolName;
+
+ private String toolInput;
+
+ private ToolContext toolContext;
+
+ private RequiresConsent requiresConsent;
+
+ private String userId;
+
+ private String sessionId;
+
+ public Builder toolName(String toolName) {
+ this.toolName = toolName;
+ return this;
+ }
+
+ public Builder toolInput(String toolInput) {
+ this.toolInput = toolInput;
+ return this;
+ }
+
+ public Builder toolContext(ToolContext toolContext) {
+ this.toolContext = toolContext;
+ return this;
+ }
+
+ public Builder requiresConsent(RequiresConsent requiresConsent) {
+ this.requiresConsent = requiresConsent;
+ return this;
+ }
+
+ public Builder userId(String userId) {
+ this.userId = userId;
+ return this;
+ }
+
+ public Builder sessionId(String sessionId) {
+ this.sessionId = sessionId;
+ return this;
+ }
+
+ public ConsentContext build() {
+ return new ConsentContext(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentManager.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentManager.java
new file mode 100644
index 00000000000..fbb25eb40ae
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentManager.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.Map;
+
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+
+/**
+ * Strategy interface for managing user consent for tool execution. Implementations can
+ * provide different consent mechanisms such as UI-based prompts, API calls, or
+ * configuration-based consent.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+public interface ConsentManager {
+
+ /**
+ * Requests consent for executing a tool with the given parameters.
+ * @param toolName the name of the tool requiring consent
+ * @param message the consent message (may contain placeholders)
+ * @param level the consent level required
+ * @param categories the categories associated with this consent request
+ * @param parameters the actual parameters that will be passed to the tool
+ * @return {@code true} if consent is granted, {@code false} otherwise
+ */
+ boolean requestConsent(String toolName, String message, ConsentLevel level, String[] categories,
+ Map parameters);
+
+ /**
+ * Checks if consent has already been granted for a tool based on the consent level.
+ * This method is called before {@link #requestConsent} to avoid unnecessary prompts.
+ * @param toolName the name of the tool
+ * @param level the consent level
+ * @param categories the categories associated with this tool
+ * @return {@code true} if consent was previously granted and is still valid
+ */
+ boolean hasValidConsent(String toolName, ConsentLevel level, String[] categories);
+
+ /**
+ * Revokes any stored consent for the specified tool.
+ * @param toolName the name of the tool
+ * @param categories the categories to revoke consent for (empty to revoke all)
+ */
+ void revokeConsent(String toolName, String[] categories);
+
+ /**
+ * Clears all stored consents. Typically called at session end or on user request.
+ */
+ void clearAllConsents();
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentRequiredException.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentRequiredException.java
new file mode 100644
index 00000000000..2508ad995d6
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/ConsentRequiredException.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+/**
+ * Exception thrown when a tool requires user consent but consent was not granted.
+ *
+ * @author Assistant
+ * @since 1.0.0
+ */
+public class ConsentRequiredException extends RuntimeException {
+
+ public ConsentRequiredException(String message) {
+ super(message);
+ }
+
+ public ConsentRequiredException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentChecker.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentChecker.java
new file mode 100644
index 00000000000..fdc479837f3
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentChecker.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Default implementation of {@link ConsentChecker} that maintains an in-memory store of
+ * granted consents.
+ *
+ * @author Assistant
+ * @since 1.0.0
+ */
+public class DefaultConsentChecker implements ConsentChecker {
+
+ private static final Logger logger = LoggerFactory.getLogger(DefaultConsentChecker.class);
+
+ private final Set grantedConsents = ConcurrentHashMap.newKeySet();
+
+ private final ConsentRequestHandler consentRequestHandler;
+
+ public DefaultConsentChecker(ConsentRequestHandler consentRequestHandler) {
+ this.consentRequestHandler = consentRequestHandler;
+ }
+
+ @Override
+ public boolean checkConsent(ConsentContext context) {
+ String consentKey = buildConsentKey(context);
+
+ // Check if consent already granted
+ if (grantedConsents.contains(consentKey)) {
+ logger.debug("Consent already granted for tool: {}", context.getToolName());
+ return true;
+ }
+
+ // Request consent
+ boolean consentGranted = consentRequestHandler.requestConsent(context);
+
+ if (consentGranted) {
+ // Store consent if granted
+ if (context.getRequiresConsent() != null
+ && context.getRequiresConsent().level() != RequiresConsent.ConsentLevel.ONE_TIME) {
+ grantedConsents.add(consentKey);
+ }
+ logger.info("Consent granted for tool: {}", context.getToolName());
+ }
+ else {
+ logger.warn("Consent denied for tool: {}", context.getToolName());
+ }
+
+ return consentGranted;
+ }
+
+ private String buildConsentKey(ConsentContext context) {
+ StringBuilder key = new StringBuilder();
+ key.append(context.getToolName());
+
+ if (context.getUserId() != null) {
+ key.append(":").append(context.getUserId());
+ }
+
+ if (context.getSessionId() != null && context.getRequiresConsent() != null
+ && context.getRequiresConsent().level() == RequiresConsent.ConsentLevel.SESSION) {
+ key.append(":").append(context.getSessionId());
+ }
+
+ return key.toString();
+ }
+
+ public void revokeConsent(String toolName, String userId) {
+ String prefix = toolName + ":" + userId;
+ grantedConsents.removeIf(key -> key.startsWith(prefix));
+ }
+
+ public void clearAllConsents() {
+ grantedConsents.clear();
+ }
+
+ /**
+ * Interface for handling consent requests from users.
+ */
+ @FunctionalInterface
+ public interface ConsentRequestHandler {
+
+ /**
+ * Request consent from the user.
+ * @param context the consent context
+ * @return true if consent is granted, false otherwise
+ */
+ boolean requestConsent(ConsentContext context);
+
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentManager.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentManager.java
new file mode 100644
index 00000000000..b75f70a624d
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/DefaultConsentManager.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiFunction;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+
+/**
+ * Default implementation of {@link ConsentManager} that uses a configurable consent
+ * handler function and stores consent decisions in memory.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+public class DefaultConsentManager implements ConsentManager {
+
+ private static final Logger logger = LoggerFactory.getLogger(DefaultConsentManager.class);
+
+ private final BiFunction, Boolean> consentHandler;
+
+ private final Map consentStore = new ConcurrentHashMap<>();
+
+ /**
+ * Creates a default consent manager with the given consent handler.
+ * @param consentHandler function that takes a consent message and parameters, returns
+ * true if consent is granted
+ */
+ public DefaultConsentManager(BiFunction, Boolean> consentHandler) {
+ this.consentHandler = consentHandler;
+ }
+
+ /**
+ * Creates a default consent manager that always denies consent. Useful for testing or
+ * when consent should be managed externally.
+ */
+ public DefaultConsentManager() {
+ this((message, params) -> {
+ logger.warn("No consent handler configured. Denying consent for: {}", message);
+ return false;
+ });
+ }
+
+ @Override
+ public boolean requestConsent(String toolName, String message, ConsentLevel level, String[] categories,
+ Map parameters) {
+
+ logger.debug("Requesting consent for tool '{}' with message: {}", toolName, message);
+
+ // Check if we already have valid consent
+ if (hasValidConsent(toolName, level, categories)) {
+ logger.debug("Valid consent already exists for tool '{}'", toolName);
+ return true;
+ }
+
+ // Request new consent
+ boolean granted = this.consentHandler.apply(message, parameters);
+
+ if (granted) {
+ // Store consent decision based on level
+ if (level != ConsentLevel.EVERY_TIME) {
+ String key = createConsentKey(toolName, categories);
+ this.consentStore.put(key, new ConsentRecord(level, System.currentTimeMillis()));
+ logger.debug("Stored consent for tool '{}' with level '{}'", toolName, level);
+ }
+ }
+
+ return granted;
+ }
+
+ @Override
+ public boolean hasValidConsent(String toolName, ConsentLevel level, String[] categories) {
+ if (level == ConsentLevel.EVERY_TIME) {
+ return false; // Always require new consent
+ }
+
+ String key = createConsentKey(toolName, categories);
+ ConsentRecord record = this.consentStore.get(key);
+
+ if (record == null) {
+ return false;
+ }
+
+ // For REMEMBER level, consent is valid indefinitely
+ // For SESSION level, we could implement session timeout logic here
+ return true;
+ }
+
+ @Override
+ public void revokeConsent(String toolName, String[] categories) {
+ if (categories == null || categories.length == 0) {
+ // Revoke all consents for this tool
+ this.consentStore.entrySet().removeIf(entry -> entry.getKey().startsWith(toolName + ":"));
+ }
+ else {
+ // Revoke specific category consent
+ String key = createConsentKey(toolName, categories);
+ this.consentStore.remove(key);
+ }
+ logger.debug("Revoked consent for tool '{}'", toolName);
+ }
+
+ @Override
+ public void clearAllConsents() {
+ this.consentStore.clear();
+ logger.debug("Cleared all stored consents");
+ }
+
+ /**
+ * Creates a unique key for storing consent based on tool name and categories.
+ */
+ private String createConsentKey(String toolName, String[] categories) {
+ if (categories == null || categories.length == 0) {
+ return toolName + ":default";
+ }
+ String sortedCategories = Arrays.stream(categories).sorted().reduce((a, b) -> a + "," + b).orElse("");
+ return toolName + ":" + sortedCategories;
+ }
+
+ /**
+ * Internal record for storing consent information.
+ */
+ private static class ConsentRecord {
+
+ final ConsentLevel level;
+
+ final long timestamp;
+
+ ConsentRecord(ConsentLevel level, long timestamp) {
+ this.level = level;
+ this.timestamp = timestamp;
+ }
+
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/exception/ConsentDeniedException.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/exception/ConsentDeniedException.java
new file mode 100644
index 00000000000..1494658413e
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/exception/ConsentDeniedException.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent.exception;
+
+/**
+ * Exception thrown when user consent is required but denied for tool execution.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+public class ConsentDeniedException extends RuntimeException {
+
+ /**
+ * Constructs a new consent denied exception with the specified detail message.
+ * @param message the detail message
+ */
+ public ConsentDeniedException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs a new consent denied exception with the specified detail message and
+ * cause.
+ * @param message the detail message
+ * @param cause the cause
+ */
+ public ConsentDeniedException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/package-info.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/package-info.java
new file mode 100644
index 00000000000..68cfe2d8ff7
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/consent/package-info.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Consent management framework for Spring AI tool execution.
+ *
+ *
+ * This package provides a comprehensive consent management system that allows tools to
+ * require user approval before execution. Key components include:
+ *
+ *
+ * {@link org.springframework.ai.tool.annotation.RequiresConsent} - Annotation to mark
+ * tool methods that require consent
+ * {@link org.springframework.ai.tool.consent.ConsentManager} - Strategy interface for
+ * managing consent requests and decisions
+ * {@link org.springframework.ai.tool.consent.ConsentAwareToolCallback} - Decorator
+ * that enforces consent requirements before tool execution
+ * {@link org.springframework.ai.tool.consent.DefaultConsentManager} - Default
+ * implementation with configurable consent handlers
+ *
+ *
+ *
+ * Example usage:
{@code
+ * @Tool(description = "Deletes a record")
+ * @RequiresConsent(
+ * message = "Delete record {id}? This cannot be undone.",
+ * level = ConsentLevel.EVERY_TIME
+ * )
+ * public void deleteRecord(String id) {
+ * // Implementation
+ * }
+ * }
+ *
+ * @since 1.0.0
+ */
+package org.springframework.ai.tool.consent;
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/method/ConsentAwareMethodToolCallbackProvider.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/method/ConsentAwareMethodToolCallbackProvider.java
new file mode 100644
index 00000000000..969f46157c0
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/method/ConsentAwareMethodToolCallbackProvider.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.method;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.consent.ConsentAwareToolCallback;
+import org.springframework.ai.tool.consent.ConsentChecker;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+import org.springframework.ai.tool.support.ToolDefinitions;
+import org.springframework.ai.tool.support.ToolUtils;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
+
+/**
+ * A {@link ToolCallbackProvider} that builds {@link ToolCallback} instances from
+ * {@link Tool}-annotated methods with consent awareness support.
+ *
+ * @author Assistant
+ * @since 1.0.0
+ */
+public final class ConsentAwareMethodToolCallbackProvider implements ToolCallbackProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(ConsentAwareMethodToolCallbackProvider.class);
+
+ private final List toolObjects;
+
+ private final ConsentChecker consentChecker;
+
+ private ConsentAwareMethodToolCallbackProvider(List toolObjects, ConsentChecker consentChecker) {
+ Assert.notNull(toolObjects, "toolObjects cannot be null");
+ Assert.notNull(consentChecker, "consentChecker cannot be null");
+ Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
+ assertToolAnnotatedMethodsPresent(toolObjects);
+ this.toolObjects = toolObjects;
+ this.consentChecker = consentChecker;
+ validateToolCallbacks(getToolCallbacks());
+ }
+
+ private void assertToolAnnotatedMethodsPresent(List toolObjects) {
+ for (Object toolObject : toolObjects) {
+ List toolMethods = Stream
+ .of(ReflectionUtils.getDeclaredMethods(
+ AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
+ .filter(this::isToolAnnotatedMethod)
+ .filter(toolMethod -> !isFunctionalType(toolMethod))
+ .toList();
+
+ if (toolMethods.isEmpty()) {
+ throw new IllegalStateException("No @Tool annotated methods found in " + toolObject + "."
+ + "Did you mean to pass a ToolCallback or ToolCallbackProvider? If so, you have to use .toolCallbacks() instead of .tool()");
+ }
+ }
+ }
+
+ @Override
+ public ToolCallback[] getToolCallbacks() {
+ var toolCallbacks = this.toolObjects.stream()
+ .map(toolObject -> Stream
+ .of(ReflectionUtils.getDeclaredMethods(
+ AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
+ .filter(this::isToolAnnotatedMethod)
+ .filter(toolMethod -> !isFunctionalType(toolMethod))
+ .map(toolMethod -> {
+ ToolCallback baseCallback = MethodToolCallback.builder()
+ .toolDefinition(ToolDefinitions.from(toolMethod))
+ .toolMetadata(ToolMetadata.from(toolMethod))
+ .toolMethod(toolMethod)
+ .toolObject(toolObject)
+ .toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
+ .build();
+
+ // Wrap with consent checking if needed
+ if (ToolUtils.requiresConsent(toolMethod)) {
+ return new ConsentAwareToolCallback(baseCallback, this.consentChecker, true);
+ }
+ return baseCallback;
+ })
+ .toArray(ToolCallback[]::new))
+ .flatMap(Stream::of)
+ .toArray(ToolCallback[]::new);
+
+ validateToolCallbacks(toolCallbacks);
+
+ return toolCallbacks;
+ }
+
+ private boolean isFunctionalType(Method toolMethod) {
+ var isFunction = ClassUtils.isAssignable(Function.class, toolMethod.getReturnType())
+ || ClassUtils.isAssignable(Supplier.class, toolMethod.getReturnType())
+ || ClassUtils.isAssignable(Consumer.class, toolMethod.getReturnType());
+
+ if (isFunction) {
+ logger.warn("Method {} is annotated with @Tool but returns a functional type. "
+ + "This is not supported and the method will be ignored.", toolMethod.getName());
+ }
+
+ return isFunction;
+ }
+
+ private boolean isToolAnnotatedMethod(Method method) {
+ Tool annotation = AnnotationUtils.findAnnotation(method, Tool.class);
+ return Objects.nonNull(annotation);
+ }
+
+ private void validateToolCallbacks(ToolCallback[] toolCallbacks) {
+ List duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
+ if (!duplicateToolNames.isEmpty()) {
+ throw new IllegalStateException("Multiple tools with the same name (%s) found in sources: %s".formatted(
+ String.join(", ", duplicateToolNames),
+ this.toolObjects.stream().map(o -> o.getClass().getName()).collect(Collectors.joining(", "))));
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private List toolObjects;
+
+ private ConsentChecker consentChecker;
+
+ private Builder() {
+ }
+
+ public Builder toolObjects(Object... toolObjects) {
+ Assert.notNull(toolObjects, "toolObjects cannot be null");
+ this.toolObjects = Arrays.asList(toolObjects);
+ return this;
+ }
+
+ public Builder consentChecker(ConsentChecker consentChecker) {
+ Assert.notNull(consentChecker, "consentChecker cannot be null");
+ this.consentChecker = consentChecker;
+ return this;
+ }
+
+ public ConsentAwareMethodToolCallbackProvider build() {
+ Assert.notNull(this.consentChecker, "consentChecker must be set");
+ return new ConsentAwareMethodToolCallbackProvider(this.toolObjects, this.consentChecker);
+ }
+
+ }
+
+}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerConsentTests.java b/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerConsentTests.java
new file mode 100644
index 00000000000..06aadf9adbc
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerConsentTests.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.model.tool;
+
+import java.util.List;
+import java.util.Map;
+
+import io.micrometer.observation.ObservationRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+import org.springframework.ai.tool.consent.ConsentAwareToolCallback;
+import org.springframework.ai.tool.consent.ConsentManager;
+import org.springframework.ai.tool.consent.exception.ConsentDeniedException;
+import org.springframework.ai.tool.definition.DefaultToolDefinition;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
+import org.springframework.ai.tool.metadata.DefaultToolMetadata;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+import org.springframework.ai.tool.resolution.ToolCallbackResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link DefaultToolCallingManager} with consent management.
+ *
+ * @author Hyunjoon Park
+ * @since 1.0.0
+ */
+class DefaultToolCallingManagerConsentTests {
+
+ private DefaultToolCallingManager toolCallingManager;
+
+ private ConsentManager consentManager;
+
+ private ToolCallback mockToolCallback;
+
+ private ConsentAwareToolCallback consentAwareToolCallback;
+
+ @BeforeEach
+ void setUp() {
+ ObservationRegistry observationRegistry = ObservationRegistry.create();
+ ToolCallbackResolver toolCallbackResolver = Mockito.mock(ToolCallbackResolver.class);
+ DefaultToolExecutionExceptionProcessor exceptionProcessor = DefaultToolExecutionExceptionProcessor.builder()
+ .build();
+
+ this.toolCallingManager = new DefaultToolCallingManager(observationRegistry, toolCallbackResolver,
+ exceptionProcessor);
+
+ // Set up mock tool callback
+ this.mockToolCallback = Mockito.mock(ToolCallback.class);
+ ToolDefinition toolDefinition = DefaultToolDefinition.builder()
+ .name("deleteBook")
+ .description("Delete a book")
+ .inputSchema("{\"type\":\"object\",\"properties\":{\"bookId\":{\"type\":\"string\"}}}")
+ .build();
+ ToolMetadata toolMetadata = DefaultToolMetadata.builder().build();
+ when(this.mockToolCallback.getToolDefinition()).thenReturn(toolDefinition);
+ when(this.mockToolCallback.getToolMetadata()).thenReturn(toolMetadata);
+
+ // Set up consent manager
+ this.consentManager = Mockito.mock(ConsentManager.class);
+
+ // Create mock RequiresConsent annotation
+ RequiresConsent requiresConsent = Mockito.mock(RequiresConsent.class);
+ when(requiresConsent.message()).thenReturn("Delete book {bookId}?");
+ when(requiresConsent.level()).thenReturn(ConsentLevel.EVERY_TIME);
+ when(requiresConsent.categories()).thenReturn(new String[0]);
+
+ // Create consent-aware wrapper
+ this.consentAwareToolCallback = new ConsentAwareToolCallback(this.mockToolCallback, this.consentManager,
+ requiresConsent);
+ }
+
+ @Test
+ void testManualExecutionWithConsentGranted() {
+ // Given
+ // ConsentAwareToolCallback will first check hasValidConsent, then call
+ // requestConsent if needed
+ // For this test, we'll make hasValidConsent return false to trigger
+ // requestConsent
+ when(this.consentManager.hasValidConsent(anyString(), any(ConsentLevel.class), any(String[].class)))
+ .thenReturn(false);
+ when(this.consentManager.requestConsent(anyString(), anyString(), any(ConsentLevel.class), any(String[].class),
+ any(Map.class)))
+ .thenReturn(true);
+ when(this.mockToolCallback.call(anyString(), any())).thenReturn("Book deleted");
+
+ List toolCallbacks = List.of(this.consentAwareToolCallback);
+ ToolCallingChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(toolCallbacks).build();
+
+ UserMessage userMessage = new UserMessage("Delete book with ID 123");
+ Prompt prompt = new Prompt(List.of(userMessage), chatOptions);
+
+ AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "tool-call", "deleteBook",
+ "{\"bookId\":\"123\"}");
+ AssistantMessage assistantMessage = new AssistantMessage("I'll delete the book.", Map.of(), List.of(toolCall));
+
+ Generation generation = new Generation(assistantMessage);
+ ChatResponse chatResponse = new ChatResponse(List.of(generation));
+
+ // When
+ ToolExecutionResult result = this.toolCallingManager.executeToolCalls(prompt, chatResponse);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.conversationHistory()).hasSize(3); // user, assistant, tool
+ // response
+ // Verify consent was requested
+ verify(this.consentManager, times(1)).hasValidConsent(anyString(), any(ConsentLevel.class),
+ any(String[].class));
+ verify(this.consentManager, times(1)).requestConsent(anyString(), anyString(), any(ConsentLevel.class),
+ any(String[].class), any(Map.class));
+ verify(this.mockToolCallback, times(1)).call(anyString(), any());
+ }
+
+ @Test
+ void testManualExecutionWithConsentDenied() {
+ // Given
+ // ConsentAwareToolCallback will first check hasValidConsent, then call
+ // requestConsent if needed
+ when(this.consentManager.hasValidConsent(anyString(), any(ConsentLevel.class), any(String[].class)))
+ .thenReturn(false);
+ when(this.consentManager.requestConsent(anyString(), anyString(), any(ConsentLevel.class), any(String[].class),
+ any(Map.class)))
+ .thenReturn(false);
+
+ List toolCallbacks = List.of(this.consentAwareToolCallback);
+ ToolCallingChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(toolCallbacks).build();
+
+ UserMessage userMessage = new UserMessage("Delete book with ID 123");
+ Prompt prompt = new Prompt(List.of(userMessage), chatOptions);
+
+ AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "tool-call", "deleteBook",
+ "{\"bookId\":\"123\"}");
+ AssistantMessage assistantMessage = new AssistantMessage("I'll delete the book.", Map.of(), List.of(toolCall));
+
+ Generation generation = new Generation(assistantMessage);
+ ChatResponse chatResponse = new ChatResponse(List.of(generation));
+
+ // When & Then
+ assertThatThrownBy(() -> this.toolCallingManager.executeToolCalls(prompt, chatResponse))
+ .isInstanceOf(ConsentDeniedException.class)
+ .hasMessageContaining("User denied consent for tool");
+
+ // Verify consent was requested but denied
+ verify(this.consentManager, times(1)).hasValidConsent(anyString(), any(ConsentLevel.class),
+ any(String[].class));
+ verify(this.consentManager, times(1)).requestConsent(anyString(), anyString(), any(ConsentLevel.class),
+ any(String[].class), any(Map.class));
+ verify(this.mockToolCallback, times(0)).call(anyString(), any());
+ }
+
+ @Test
+ void testManualExecutionWithNonConsentAwareToolCallback() {
+ // Given
+ when(this.mockToolCallback.call(anyString(), any())).thenReturn("Book deleted");
+
+ List toolCallbacks = List.of(this.mockToolCallback); // Regular
+ // callback,
+ // not
+ // consent-aware
+ ToolCallingChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(toolCallbacks).build();
+
+ UserMessage userMessage = new UserMessage("Delete book with ID 123");
+ Prompt prompt = new Prompt(List.of(userMessage), chatOptions);
+
+ AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "tool-call", "deleteBook",
+ "{\"bookId\":\"123\"}");
+ AssistantMessage assistantMessage = new AssistantMessage("I'll delete the book.", Map.of(), List.of(toolCall));
+
+ Generation generation = new Generation(assistantMessage);
+ ChatResponse chatResponse = new ChatResponse(List.of(generation));
+
+ // When
+ ToolExecutionResult result = this.toolCallingManager.executeToolCalls(prompt, chatResponse);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.conversationHistory()).hasSize(3);
+ verify(this.mockToolCallback, times(1)).call(anyString(), any());
+ // ConsentManager should not be called for non-consent-aware callbacks
+ }
+
+ @Test
+ void testManualExecutionWithMixedToolCallbacks() {
+ // Given
+ ToolCallback regularCallback = Mockito.mock(ToolCallback.class);
+ ToolDefinition regularToolDef = DefaultToolDefinition.builder()
+ .name("getBook")
+ .description("Get a book")
+ .inputSchema("{\"type\":\"object\",\"properties\":{\"bookId\":{\"type\":\"string\"}}}")
+ .build();
+ when(regularCallback.getToolDefinition()).thenReturn(regularToolDef);
+ when(regularCallback.getToolMetadata()).thenReturn(DefaultToolMetadata.builder().build());
+ when(regularCallback.call(anyString(), any())).thenReturn("Book found");
+
+ // For the consent-aware callback
+ when(this.consentManager.hasValidConsent(anyString(), any(ConsentLevel.class), any(String[].class)))
+ .thenReturn(false);
+ when(this.consentManager.requestConsent(anyString(), anyString(), any(ConsentLevel.class), any(String[].class),
+ any(Map.class)))
+ .thenReturn(true);
+ when(this.mockToolCallback.call(anyString(), any())).thenReturn("Book deleted");
+
+ List toolCallbacks = List.of(regularCallback, this.consentAwareToolCallback);
+ ToolCallingChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(toolCallbacks).build();
+
+ UserMessage userMessage = new UserMessage("Get and delete book with ID 123");
+ Prompt prompt = new Prompt(List.of(userMessage), chatOptions);
+
+ AssistantMessage.ToolCall getCall = new AssistantMessage.ToolCall("1", "tool-call", "getBook",
+ "{\"bookId\":\"123\"}");
+ AssistantMessage.ToolCall deleteCall = new AssistantMessage.ToolCall("2", "tool-call", "deleteBook",
+ "{\"bookId\":\"123\"}");
+ AssistantMessage assistantMessage = new AssistantMessage("I'll get and delete the book.", Map.of(),
+ List.of(getCall, deleteCall));
+
+ Generation generation = new Generation(assistantMessage);
+ ChatResponse chatResponse = new ChatResponse(List.of(generation));
+
+ // When
+ ToolExecutionResult result = this.toolCallingManager.executeToolCalls(prompt, chatResponse);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.conversationHistory()).hasSize(3);
+ verify(regularCallback, times(1)).call(anyString(), any());
+ // Verify consent was requested for the consent-aware callback only
+ verify(this.consentManager, times(1)).hasValidConsent(anyString(), any(ConsentLevel.class),
+ any(String[].class));
+ verify(this.consentManager, times(1)).requestConsent(anyString(), anyString(), any(ConsentLevel.class),
+ any(String[].class), any(Map.class));
+ verify(this.mockToolCallback, times(1)).call(anyString(), any());
+ }
+
+}
\ No newline at end of file
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTest.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTest.java
new file mode 100644
index 00000000000..d5227d57deb
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.method.ConsentAwareMethodToolCallbackProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for consent-aware tool execution.
+ *
+ * @author Assistant
+ */
+class ConsentAwareToolCallbackTest {
+
+ @Test
+ void testToolWithConsentGranted() {
+ // Create a consent checker that always grants consent
+ ConsentChecker alwaysGrantChecker = context -> true;
+
+ ConsentAwareMethodToolCallbackProvider provider = ConsentAwareMethodToolCallbackProvider.builder()
+ .toolObjects(new ConsentRequiredTools())
+ .consentChecker(alwaysGrantChecker)
+ .build();
+
+ ToolCallback[] callbacks = provider.getToolCallbacks();
+ assertThat(callbacks).hasSize(2);
+
+ // Find the sendEmail tool
+ ToolCallback sendEmailTool = findToolByName(callbacks, "sendEmail");
+ assertThat(sendEmailTool).isNotNull();
+
+ // Execute should succeed when consent is granted
+ String result = sendEmailTool.call("{\"to\":\"test@example.com\",\"subject\":\"Test\",\"body\":\"Hello\"}");
+ assertThat(result).isEqualTo("Email sent to test@example.com");
+ }
+
+ @Test
+ void testToolWithConsentDenied() {
+ // Create a consent checker that always denies consent
+ ConsentChecker alwaysDenyChecker = context -> false;
+
+ ConsentAwareMethodToolCallbackProvider provider = ConsentAwareMethodToolCallbackProvider.builder()
+ .toolObjects(new ConsentRequiredTools())
+ .consentChecker(alwaysDenyChecker)
+ .build();
+
+ ToolCallback[] callbacks = provider.getToolCallbacks();
+ ToolCallback sendEmailTool = findToolByName(callbacks, "sendEmail");
+
+ // Execute should fail when consent is denied
+ assertThatThrownBy(
+ () -> sendEmailTool.call("{\"to\":\"test@example.com\",\"subject\":\"Test\",\"body\":\"Hello\"}"))
+ .isInstanceOf(ConsentRequiredException.class)
+ .hasMessageContaining("User consent required for tool: sendEmail");
+ }
+
+ @Test
+ void testToolWithoutConsentAnnotation() {
+ // Tools without @RequiresConsent should work normally
+ ConsentChecker neverCalledChecker = context -> {
+ throw new AssertionError("Consent checker should not be called for tools without @RequiresConsent");
+ };
+
+ ConsentAwareMethodToolCallbackProvider provider = ConsentAwareMethodToolCallbackProvider.builder()
+ .toolObjects(new ConsentRequiredTools())
+ .consentChecker(neverCalledChecker)
+ .build();
+
+ ToolCallback[] callbacks = provider.getToolCallbacks();
+ ToolCallback getTimeTool = findToolByName(callbacks, "getCurrentTime");
+
+ // Should execute without consent check
+ String result = getTimeTool.call("{}");
+ assertThat(result).contains("Current time:");
+ }
+
+ @Test
+ void testDefaultConsentChecker() {
+ // Test with the default consent checker
+ DefaultConsentChecker.ConsentRequestHandler handler = context -> {
+ // Simulate user interaction - grant consent for sendEmail only
+ return "sendEmail".equals(context.getToolName());
+ };
+
+ DefaultConsentChecker consentChecker = new DefaultConsentChecker(handler);
+
+ ConsentAwareMethodToolCallbackProvider provider = ConsentAwareMethodToolCallbackProvider.builder()
+ .toolObjects(new ConsentRequiredTools())
+ .consentChecker(consentChecker)
+ .build();
+
+ ToolCallback[] callbacks = provider.getToolCallbacks();
+ ToolCallback sendEmailTool = findToolByName(callbacks, "sendEmail");
+
+ // First call should request and grant consent
+ String result = sendEmailTool.call("{\"to\":\"test@example.com\",\"subject\":\"Test\",\"body\":\"Hello\"}");
+ assertThat(result).isEqualTo("Email sent to test@example.com");
+
+ // Second call should use cached consent (for SESSION level)
+ result = sendEmailTool.call("{\"to\":\"another@example.com\",\"subject\":\"Test2\",\"body\":\"Hello again\"}");
+ assertThat(result).isEqualTo("Email sent to another@example.com");
+ }
+
+ private ToolCallback findToolByName(ToolCallback[] callbacks, String name) {
+ for (ToolCallback callback : callbacks) {
+ if (callback.getToolDefinition().name().equals(name)) {
+ return callback;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Test class with tools that require consent.
+ */
+ static class ConsentRequiredTools {
+
+ @Tool(description = "Send an email to a recipient")
+ @RequiresConsent(reason = "This tool will send an email on your behalf",
+ level = RequiresConsent.ConsentLevel.SESSION, categories = { "communication", "email" })
+ public String sendEmail(String to, String subject, String body) {
+ return "Email sent to " + to;
+ }
+
+ @Tool(description = "Get the current time")
+ public String getCurrentTime() {
+ return "Current time: " + System.currentTimeMillis();
+ }
+
+ }
+
+}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTests.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTests.java
new file mode 100644
index 00000000000..57cf0fa17ba
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/ConsentAwareToolCallbackTests.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+import org.springframework.ai.tool.consent.exception.ConsentDeniedException;
+import org.springframework.ai.tool.definition.ToolDefinition;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link ConsentAwareToolCallback}.
+ *
+ * @author Hyunjoon Park
+ */
+class ConsentAwareToolCallbackTests {
+
+ @Mock
+ private ToolCallback delegate;
+
+ @Mock
+ private ConsentManager consentManager;
+
+ @Mock
+ private RequiresConsent requiresConsent;
+
+ private ConsentAwareToolCallback consentAwareCallback;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ when(requiresConsent.message()).thenReturn("Do you approve this action?");
+ when(requiresConsent.level()).thenReturn(ConsentLevel.EVERY_TIME);
+ when(requiresConsent.categories()).thenReturn(new String[0]);
+
+ consentAwareCallback = new ConsentAwareToolCallback(delegate, consentManager, requiresConsent);
+ }
+
+ @Test
+ void callWithConsentGranted() {
+ // Given
+ Map parameters = Map.of("param1", "value1");
+ Object expectedResult = "Tool executed successfully";
+
+ when(delegate.getName()).thenReturn("testTool");
+ when(consentManager.hasValidConsent("testTool", ConsentLevel.EVERY_TIME, new String[0])).thenReturn(false);
+ when(consentManager.requestConsent(eq("testTool"), any(), any(), any(), eq(parameters))).thenReturn(true);
+ when(delegate.call(parameters)).thenReturn(expectedResult);
+
+ // When
+ Object result = consentAwareCallback.call(parameters);
+
+ // Then
+ assertThat(result).isEqualTo(expectedResult);
+ verify(delegate).call(parameters);
+ verify(consentManager).requestConsent(eq("testTool"), any(), any(), any(), eq(parameters));
+ }
+
+ @Test
+ void callWithConsentDenied() {
+ // Given
+ Map parameters = Map.of("param1", "value1");
+
+ when(delegate.getName()).thenReturn("testTool");
+ when(consentManager.hasValidConsent("testTool", ConsentLevel.EVERY_TIME, new String[0])).thenReturn(false);
+ when(consentManager.requestConsent(eq("testTool"), any(), any(), any(), eq(parameters))).thenReturn(false);
+
+ // When/Then
+ assertThatThrownBy(() -> consentAwareCallback.call(parameters)).isInstanceOf(ConsentDeniedException.class)
+ .hasMessageContaining("User denied consent for tool 'testTool' execution");
+
+ verify(delegate, never()).call(any());
+ }
+
+ @Test
+ void callWithExistingValidConsent() {
+ // Given
+ Map parameters = Map.of("param1", "value1");
+ Object expectedResult = "Tool executed successfully";
+
+ when(delegate.getName()).thenReturn("testTool");
+ when(consentManager.hasValidConsent("testTool", ConsentLevel.EVERY_TIME, new String[0])).thenReturn(true);
+ when(delegate.call(parameters)).thenReturn(expectedResult);
+
+ // When
+ Object result = consentAwareCallback.call(parameters);
+
+ // Then
+ assertThat(result).isEqualTo(expectedResult);
+ verify(delegate).call(parameters);
+ verify(consentManager, never()).requestConsent(any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void messageSubstitutionWithParameters() {
+ // Given
+ Map parameters = new HashMap<>();
+ parameters.put("bookId", "12345");
+ parameters.put("title", "Spring in Action");
+
+ when(requiresConsent.message()).thenReturn("Delete book {bookId} titled '{title}'?");
+ consentAwareCallback = new ConsentAwareToolCallback(delegate, consentManager, requiresConsent);
+
+ when(delegate.getName()).thenReturn("deleteBook");
+ when(consentManager.hasValidConsent("deleteBook", ConsentLevel.EVERY_TIME, new String[0])).thenReturn(false);
+ when(consentManager.requestConsent(eq("deleteBook"), eq("Delete book 12345 titled 'Spring in Action'?"), any(),
+ any(), eq(parameters)))
+ .thenReturn(true);
+ when(delegate.call(parameters)).thenReturn("Deleted");
+
+ // When
+ Object result = consentAwareCallback.call(parameters);
+
+ // Then
+ assertThat(result).isEqualTo("Deleted");
+ verify(consentManager).requestConsent(eq("deleteBook"), eq("Delete book 12345 titled 'Spring in Action'?"),
+ any(), any(), eq(parameters));
+ }
+
+ @Test
+ void delegateMethodsAreProxied() {
+ // Given
+ when(delegate.getName()).thenReturn("testTool");
+ when(delegate.getDescription()).thenReturn("Test tool description");
+ ToolDefinition toolDef = mock(ToolDefinition.class);
+ when(delegate.getToolDefinition()).thenReturn(toolDef);
+
+ // When/Then
+ assertThat(consentAwareCallback.getName()).isEqualTo("testTool");
+ assertThat(consentAwareCallback.getDescription()).isEqualTo("Test tool description");
+ assertThat(consentAwareCallback.getToolDefinition()).isEqualTo(toolDef);
+ }
+
+ @Test
+ void gettersReturnCorrectValues() {
+ // When/Then
+ assertThat(consentAwareCallback.getDelegate()).isEqualTo(delegate);
+ assertThat(consentAwareCallback.getRequiresConsent()).isEqualTo(requiresConsent);
+ }
+
+}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/DefaultConsentManagerTests.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/DefaultConsentManagerTests.java
new file mode 100644
index 00000000000..ae06431a6e6
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/DefaultConsentManagerTests.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiFunction;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link DefaultConsentManager}.
+ *
+ * @author Hyunjoon Park
+ */
+class DefaultConsentManagerTests {
+
+ private DefaultConsentManager consentManager;
+
+ private AtomicInteger consentRequestCount;
+
+ private BiFunction, Boolean> alwaysApproveHandler;
+
+ private BiFunction, Boolean> alwaysDenyHandler;
+
+ @BeforeEach
+ void setUp() {
+ consentRequestCount = new AtomicInteger(0);
+ alwaysApproveHandler = (message, params) -> {
+ consentRequestCount.incrementAndGet();
+ return true;
+ };
+ alwaysDenyHandler = (message, params) -> {
+ consentRequestCount.incrementAndGet();
+ return false;
+ };
+ }
+
+ @Test
+ void everyTimeConsentAlwaysRequiresNewConsent() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - First call
+ boolean granted1 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.EVERY_TIME, new String[0],
+ params);
+
+ // Then
+ assertThat(granted1).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Second call
+ boolean granted2 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.EVERY_TIME, new String[0],
+ params);
+
+ // Then
+ assertThat(granted2).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(2);
+ }
+
+ @Test
+ void sessionConsentRemembersApproval() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - First call
+ boolean granted1 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0],
+ params);
+
+ // Then
+ assertThat(granted1).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Second call (should use stored consent)
+ boolean granted2 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0],
+ params);
+
+ // Then
+ assertThat(granted2).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1); // No new request
+ }
+
+ @Test
+ void rememberConsentPersistsApproval() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - First call
+ boolean granted1 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.REMEMBER, new String[0],
+ params);
+
+ // Then
+ assertThat(granted1).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Multiple subsequent calls
+ for (int i = 0; i < 5; i++) {
+ boolean granted = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.REMEMBER, new String[0],
+ params);
+ assertThat(granted).isTrue();
+ }
+
+ // Then - Still only one consent request
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+ }
+
+ @Test
+ void deniedConsentIsNotStored() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysDenyHandler);
+ Map params = Map.of("param", "value");
+
+ // When - First call (denied)
+ boolean granted1 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0],
+ params);
+
+ // Then
+ assertThat(granted1).isFalse();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Second call (should request again since previous was denied)
+ boolean granted2 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0],
+ params);
+
+ // Then
+ assertThat(granted2).isFalse();
+ assertThat(consentRequestCount.get()).isEqualTo(2);
+ }
+
+ @Test
+ void consentWithCategories() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+ String[] categories = { "destructive", "data-modification" };
+
+ // When - Grant consent for specific categories
+ boolean granted1 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, categories, params);
+
+ // Then
+ assertThat(granted1).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Same tool, same categories (should use stored consent)
+ boolean granted2 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, categories, params);
+
+ // Then
+ assertThat(granted2).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Same tool, different categories (should request new consent)
+ String[] differentCategories = { "read-only" };
+ boolean granted3 = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, differentCategories,
+ params);
+
+ // Then
+ assertThat(granted3).isTrue();
+ assertThat(consentRequestCount.get()).isEqualTo(2);
+ }
+
+ @Test
+ void revokeConsentForSpecificTool() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - Grant consent
+ consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0], params);
+ assertThat(consentRequestCount.get()).isEqualTo(1);
+
+ // When - Revoke consent
+ consentManager.revokeConsent("tool1", new String[0]);
+
+ // When - Request consent again (should need new approval)
+ consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0], params);
+
+ // Then
+ assertThat(consentRequestCount.get()).isEqualTo(2);
+ }
+
+ @Test
+ void clearAllConsents() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - Grant consent for multiple tools
+ consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0], params);
+ consentManager.requestConsent("tool2", "Approve?", ConsentLevel.REMEMBER, new String[0], params);
+ assertThat(consentRequestCount.get()).isEqualTo(2);
+
+ // When - Clear all consents
+ consentManager.clearAllConsents();
+
+ // When - Request consent again (should need new approvals)
+ consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0], params);
+ consentManager.requestConsent("tool2", "Approve?", ConsentLevel.REMEMBER, new String[0], params);
+
+ // Then
+ assertThat(consentRequestCount.get()).isEqualTo(4);
+ }
+
+ @Test
+ void defaultConstructorAlwaysDeniesConsent() {
+ // Given
+ consentManager = new DefaultConsentManager();
+ Map params = Map.of("param", "value");
+
+ // When
+ boolean granted = consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0],
+ params);
+
+ // Then
+ assertThat(granted).isFalse();
+ }
+
+ @Test
+ void hasValidConsentChecksStoredConsent() {
+ // Given
+ consentManager = new DefaultConsentManager(alwaysApproveHandler);
+ Map params = Map.of("param", "value");
+
+ // When - No consent stored yet
+ boolean hasConsent1 = consentManager.hasValidConsent("tool1", ConsentLevel.SESSION, new String[0]);
+
+ // Then
+ assertThat(hasConsent1).isFalse();
+
+ // When - Grant consent
+ consentManager.requestConsent("tool1", "Approve?", ConsentLevel.SESSION, new String[0], params);
+
+ // Then - Now has valid consent
+ boolean hasConsent2 = consentManager.hasValidConsent("tool1", ConsentLevel.SESSION, new String[0]);
+ assertThat(hasConsent2).isTrue();
+
+ // When - Check EVERY_TIME consent (always false)
+ boolean hasConsent3 = consentManager.hasValidConsent("tool1", ConsentLevel.EVERY_TIME, new String[0]);
+ assertThat(hasConsent3).isFalse();
+ }
+
+}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/RequiresConsentIntegrationTests.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/RequiresConsentIntegrationTests.java
new file mode 100644
index 00000000000..27542ebac00
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/consent/RequiresConsentIntegrationTests.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.tool.consent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.tool.annotation.RequiresConsent;
+import org.springframework.ai.tool.annotation.RequiresConsent.ConsentLevel;
+import org.springframework.ai.tool.annotation.Tool;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests demonstrating the use of @RequiresConsent with @Tool annotations.
+ *
+ * @author Hyunjoon Park
+ */
+class RequiresConsentIntegrationTests {
+
+ private List consentRequests;
+
+ private ConsentManager consentManager;
+
+ private BookService bookService;
+
+ @BeforeEach
+ void setUp() {
+ consentRequests = new ArrayList<>();
+
+ // Create a consent manager that logs requests and approves based on message
+ // content
+ consentManager = new DefaultConsentManager((message, params) -> {
+ consentRequests.add(message);
+ // Approve if message contains "approve" (case insensitive)
+ return message.toLowerCase().contains("approve");
+ });
+
+ bookService = new BookService();
+ }
+
+ @Test
+ void demonstrateRequiresConsentUsage() {
+ // This test demonstrates how the @RequiresConsent annotation would be used
+ // in practice with tool methods
+
+ // The deleteBook method requires consent every time
+ assertThat(bookService.getClass().getDeclaredMethods())
+ .filteredOn(method -> method.getName().equals("deleteBook"))
+ .hasSize(1)
+ .allSatisfy(method -> {
+ assertThat(method.isAnnotationPresent(Tool.class)).isTrue();
+ assertThat(method.isAnnotationPresent(RequiresConsent.class)).isTrue();
+
+ RequiresConsent consent = method.getAnnotation(RequiresConsent.class);
+ assertThat(consent.message()).contains("{bookId}");
+ assertThat(consent.level()).isEqualTo(ConsentLevel.EVERY_TIME);
+ });
+
+ // The updateBookPrice method requires session-level consent
+ assertThat(bookService.getClass().getDeclaredMethods())
+ .filteredOn(method -> method.getName().equals("updateBookPrice"))
+ .hasSize(1)
+ .allSatisfy(method -> {
+ assertThat(method.isAnnotationPresent(Tool.class)).isTrue();
+ assertThat(method.isAnnotationPresent(RequiresConsent.class)).isTrue();
+
+ RequiresConsent consent = method.getAnnotation(RequiresConsent.class);
+ assertThat(consent.level()).isEqualTo(ConsentLevel.SESSION);
+ assertThat(consent.categories()).contains("financial");
+ });
+ }
+
+ /**
+ * Example service class demonstrating @RequiresConsent usage.
+ */
+ static class BookService {
+
+ @Tool(description = "Deletes a book from the database")
+ @RequiresConsent(message = "The book with ID {bookId} will be permanently deleted. Do you approve?",
+ level = ConsentLevel.EVERY_TIME)
+ public String deleteBook(String bookId) {
+ return "Book " + bookId + " deleted";
+ }
+
+ @Tool(description = "Updates the price of a book")
+ @RequiresConsent(message = "Update book {bookId} price from ${oldPrice} to ${newPrice}? Please approve.",
+ level = ConsentLevel.SESSION, categories = { "financial", "data-modification" })
+ public String updateBookPrice(String bookId, double oldPrice, double newPrice) {
+ return String.format("Book %s price updated from %.2f to %.2f", bookId, oldPrice, newPrice);
+ }
+
+ @Tool(description = "Gets book information")
+ // No @RequiresConsent - this operation doesn't need consent
+ public String getBook(String bookId) {
+ return "Book details for " + bookId;
+ }
+
+ @Tool(description = "Sends book recommendation email")
+ @RequiresConsent(message = "Send book recommendations to {email}? (We'll remember your preference)",
+ level = ConsentLevel.REMEMBER, categories = { "communication", "marketing" })
+ public String sendBookRecommendations(String email) {
+ return "Recommendations sent to " + email;
+ }
+
+ }
+
+}