adviseStream(ChatClientRequest chatClientRequest
return new ChatClientMessageAggregator().aggregateChatClientResponse(chatClientResponses, this::logResponse);
}
- private void logRequest(ChatClientRequest request) {
+ protected void logRequest(ChatClientRequest request) {
logger.debug("request: {}", this.requestToString.apply(request));
}
- private void logResponse(ChatClientResponse chatClientResponse) {
+ protected void logResponse(ChatClientResponse chatClientResponse) {
logger.debug("response: {}", this.responseToString.apply(chatClientResponse.chatResponse()));
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java
new file mode 100644
index 00000000000..24172c66461
--- /dev/null
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java
@@ -0,0 +1,331 @@
+/*
+ * 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.chat.client.advisor;
+
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.json.TypeRef;
+import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
+import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
+import io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.util.json.JsonParser;
+import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.util.Assert;
+
+/**
+ * Recursive Advisor that validates the structured JSON output of a chat client entity
+ * response against a generated JSON schema for the expected output type.
+ *
+ * If the validation fails, the advisor will repeat the call up to a specified number of
+ * attempts.
+ *
+ * Note: This advisor does not support streaming responses and will throw an
+ * UnsupportedOperationException if used in a streaming context.
+ *
+ * @author Christian Tzolov
+ */
+public final class StructuredOutputValidationAdvisor implements CallAdvisor, StreamAdvisor {
+
+ private static final Logger logger = LoggerFactory.getLogger(StructuredOutputValidationAdvisor.class);
+
+ private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() {
+ };
+
+ /**
+ * Set the order close to Ordered.LOWEST_PRECEDENCE to ensure an advisor is executed
+ * toward the last (but before the model call) in the chain (last for request
+ * processing, first for response processing).
+ *
+ * https://docs.spring.io/spring-ai/reference/api/advisors.html#_advisor_order
+ */
+ private final int advisorOrder;
+
+ /**
+ * The JSON schema used for validation.
+ */
+ private final Map jsonSchema;
+
+ /**
+ * The JSON schema validator.
+ */
+ private final DefaultJsonSchemaValidator jsonvalidator;
+
+ private final int maxRepeatAttempts;
+
+ private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int maxRepeatAttempts,
+ ObjectMapper objectMapper) {
+ Assert.notNull(advisorOrder, "advisorOrder must not be null");
+ Assert.notNull(outputType, "outputType must not be null");
+ Assert.isTrue(advisorOrder > BaseAdvisor.HIGHEST_PRECEDENCE && advisorOrder < BaseAdvisor.LOWEST_PRECEDENCE,
+ "advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+ Assert.isTrue(maxRepeatAttempts >= 0, "repeatAttempts must be greater than or equal to 0");
+ Assert.notNull(objectMapper, "objectMapper must not be null");
+
+ this.advisorOrder = advisorOrder;
+
+ this.jsonvalidator = new DefaultJsonSchemaValidator(objectMapper);
+
+ String jsonSchemaText = JsonSchemaGenerator.generateForType(outputType);
+
+ logger.info("Generated JSON Schema:\n" + jsonSchemaText);
+
+ var jsonMapper = new JacksonMcpJsonMapper(JsonParser.getObjectMapper());
+
+ try {
+ this.jsonSchema = jsonMapper.readValue(jsonSchemaText, MAP_TYPE_REF);
+ }
+ catch (Exception e) {
+ throw new IllegalArgumentException("Failed to parse JSON schema", e);
+ }
+
+ this.maxRepeatAttempts = maxRepeatAttempts;
+ }
+
+ @SuppressWarnings("null")
+ @Override
+ public String getName() {
+ return "Structured Output Validation Advisor";
+ }
+
+ @Override
+ public int getOrder() {
+ return this.advisorOrder;
+ }
+
+ @SuppressWarnings("null")
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
+ Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null");
+ Assert.notNull(chatClientRequest, "chatClientRequest must not be null");
+
+ ChatClientResponse chatClientResponse = null;
+
+ var repeatCounter = 0;
+
+ boolean isValidationSuccess = true;
+
+ var processedChatClientRequest = chatClientRequest;
+
+ do {
+ // Before Call
+ repeatCounter++;
+
+ // Next Call
+ chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
+
+ // After Call
+
+ // We should not validate tool call requests, only the content of the final
+ // response.
+ if (chatClientResponse.chatResponse() == null || !chatClientResponse.chatResponse().hasToolCalls()) {
+
+ ValidationResponse validationResponse = this.validateOutputSchema(chatClientResponse);
+
+ isValidationSuccess = validationResponse.valid();
+
+ if (!isValidationSuccess) {
+
+ // Add the validation error message to the next user message
+ // to let the LLM fix its output.
+ // Note: We could also consider adding the previous invalid output.
+ // However, this might lead to confusion and more complex prompts.
+ // Instead, we rely on the LLM to generate a new output based on the
+ // validation error.
+ logger.warn("JSON validation failed: " + validationResponse);
+
+ String validationErrorMessage = "Output JSON validation failed because of: "
+ + validationResponse.errorMessage();
+
+ Prompt augmentedPrompt = chatClientRequest.prompt()
+ .augmentUserMessage(userMessage -> userMessage.mutate()
+ .text(userMessage.getText() + System.lineSeparator() + validationErrorMessage)
+ .build());
+
+ processedChatClientRequest = chatClientRequest.mutate().prompt(augmentedPrompt).build();
+ }
+ }
+ }
+ while (!isValidationSuccess && repeatCounter <= this.maxRepeatAttempts);
+
+ return chatClientResponse;
+ }
+
+ @SuppressWarnings("null")
+ private ValidationResponse validateOutputSchema(ChatClientResponse chatClientResponse) {
+
+ if (chatClientResponse.chatResponse() == null || chatClientResponse.chatResponse().getResult() == null
+ || chatClientResponse.chatResponse().getResult().getOutput() == null
+ || chatClientResponse.chatResponse().getResult().getOutput().getText() == null) {
+
+ logger.warn("ChatClientResponse is missing required json output for validation.");
+ return ValidationResponse.asInvalid("Missing required json output for validation.");
+ }
+
+ // TODO: should we consider validation for multiple results?
+ String json = chatClientResponse.chatResponse().getResult().getOutput().getText();
+
+ logger.debug("Validating JSON output against schema. Attempts left: " + this.maxRepeatAttempts);
+
+ return this.jsonvalidator.validate(this.jsonSchema, json);
+ }
+
+ @SuppressWarnings("null")
+ @Override
+ public Flux adviseStream(ChatClientRequest chatClientRequest,
+ StreamAdvisorChain streamAdvisorChain) {
+
+ return Flux.error(new UnsupportedOperationException(
+ "The Structured Output Validation Advisor does not support streaming."));
+ }
+
+ /**
+ * Creates a new Builder for StructuredOutputValidationAdvisor.
+ * @return a new Builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder class for StructuredOutputValidationAdvisor.
+ */
+ public final static class Builder {
+
+ /**
+ * Set the order close to Ordered.LOWEST_PRECEDENCE to ensure an advisor is
+ * executed toward the last (but before the model call) in the chain (last for
+ * request processing, first for response processing).
+ *
+ * https://docs.spring.io/spring-ai/reference/api/advisors.html#_advisor_order
+ */
+ private int advisorOrder = BaseAdvisor.LOWEST_PRECEDENCE - 2000;
+
+ private Type outputType;
+
+ private int maxRepeatAttempts = 3;
+
+ private ObjectMapper objectMapper = JsonParser.getObjectMapper();
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the advisor order.
+ * @param advisorOrder the advisor order
+ * @return this builder
+ */
+ public Builder advisorOrder(int advisorOrder) {
+ this.advisorOrder = advisorOrder;
+ return this;
+ }
+
+ /**
+ * Sets the output type using a Type.
+ * @param outputType the output type
+ * @return this builder
+ */
+ public Builder outputType(Type outputType) {
+ this.outputType = outputType;
+ return this;
+ }
+
+ /**
+ * Sets the output type using a TypeRef.
+ * @param the type parameter
+ * @param outputType the output type
+ * @return this builder
+ */
+ public Builder outputType(TypeRef outputType) {
+ this.outputType = outputType.getType();
+ return this;
+ }
+
+ /**
+ * Sets the output type using a TypeReference.
+ * @param the type parameter
+ * @param outputType the output type
+ * @return this builder
+ */
+ public Builder outputType(TypeReference outputType) {
+ this.outputType = outputType.getType();
+ return this;
+ }
+
+ /**
+ * Sets the output type using a ParameterizedTypeReference.
+ * @param the type parameter
+ * @param outputType the output type
+ * @return this builder
+ */
+ public Builder outputType(ParameterizedTypeReference outputType) {
+ this.outputType = outputType.getType();
+ return this;
+ }
+
+ /**
+ * Sets the number of repeat attempts.
+ * @param repeatAttempts the number of repeat attempts
+ * @return this builder
+ */
+ public Builder maxRepeatAttempts(int repeatAttempts) {
+ this.maxRepeatAttempts = repeatAttempts;
+ return this;
+ }
+
+ /**
+ * Sets the ObjectMapper to be used for JSON processing.
+ * @param objectMapper the ObjectMapper
+ * @return this builder
+ */
+ public Builder objectMapper(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ return this;
+ }
+
+ /**
+ * Builds the StructuredOutputValidationAdvisor.
+ * @return a new StructuredOutputValidationAdvisor instance
+ * @throws IllegalArgumentException if outputType is not set
+ */
+ public StructuredOutputValidationAdvisor build() {
+ if (this.outputType == null) {
+ throw new IllegalArgumentException("outputType must be set");
+ }
+ return new StructuredOutputValidationAdvisor(this.advisorOrder, this.outputType, this.maxRepeatAttempts,
+ this.objectMapper);
+ }
+
+ }
+
+}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java
new file mode 100644
index 00000000000..650d92c4dcc
--- /dev/null
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright 2025-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.chat.client.advisor;
+
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.model.tool.ToolCallingManager;
+import org.springframework.ai.model.tool.ToolExecutionResult;
+import org.springframework.util.Assert;
+
+/**
+ * Recursive Advisor that disables the internal tool execution flow and instead implements
+ * the tool calling loop as part of the advisor chain.
+ *
+ * It uses the CallAdvisorChainUtil to implement looping advisor chain calls.
+ *
+ * This enables intercepting the the tool calling loop by the rest of the advisors next in
+ * the chain.
+ *
+ * @author Christian Tzolov
+ */
+public final class ToolCallAdvisor implements CallAdvisor, StreamAdvisor {
+
+ private final ToolCallingManager toolCallingManager;
+
+ /**
+ * Set the order close to Ordered.HIGHEST_PRECEDENCE to ensure an advisor is executed
+ * first in the chain (first for request processing, last for response processing).
+ *
+ * https://docs.spring.io/spring-ai/reference/api/advisors.html#_advisor_order
+ */
+ private final int advisorOrder;
+
+ private ToolCallAdvisor(ToolCallingManager toolCallingManager, int advisorOrder) {
+ Assert.notNull(toolCallingManager, "toolCallingManager must not be null");
+ Assert.isTrue(advisorOrder > BaseAdvisor.HIGHEST_PRECEDENCE && advisorOrder < BaseAdvisor.LOWEST_PRECEDENCE,
+ "advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+
+ this.toolCallingManager = toolCallingManager;
+ this.advisorOrder = advisorOrder;
+ }
+
+ @Override
+ public String getName() {
+ return "Tool Calling Advisor";
+ }
+
+ @Override
+ public int getOrder() {
+ return this.advisorOrder;
+ }
+
+ @SuppressWarnings("null")
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
+ Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null");
+ Assert.notNull(chatClientRequest, "chatClientRequest must not be null");
+
+ if (chatClientRequest.prompt().getOptions() == null
+ || !(chatClientRequest.prompt().getOptions() instanceof ToolCallingChatOptions)) {
+ throw new IllegalArgumentException(
+ "ToolCall Advisor requires ToolCallingChatOptions to be set in the ChatClientRequest options.");
+ }
+
+ // Overwrite the ToolCallingChatOptions to disable internal tool execution.
+ var optionsCopy = (ToolCallingChatOptions) chatClientRequest.prompt().getOptions().copy();
+
+ // Disable internal tool execution to allow ToolCallAdvisor to handle tool calls
+ optionsCopy.setInternalToolExecutionEnabled(false);
+
+ var instructions = chatClientRequest.prompt().getInstructions();
+
+ ChatClientResponse chatClientResponse = null;
+
+ boolean isToolCall = false;
+
+ do {
+
+ // Before Call
+ var processedChatClientRequest = ChatClientRequest.builder()
+ .prompt(new Prompt(instructions, optionsCopy))
+ .context(chatClientRequest.context())
+ .build();
+
+ // Next Call
+ chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
+
+ // After Call
+
+ // TODO: check that this is tool call is sufficiant for all chat models
+ // that support tool calls. (e.g. Anthropic and Bedrock are checking for
+ // finish status as well)
+ isToolCall = chatClientResponse.chatResponse() != null && chatClientResponse.chatResponse().hasToolCalls();
+
+ if (isToolCall) {
+
+ ToolExecutionResult toolExecutionResult = this.toolCallingManager
+ .executeToolCalls(processedChatClientRequest.prompt(), chatClientResponse.chatResponse());
+
+ if (toolExecutionResult.returnDirect()) {
+
+ // Return tool execution result directly to the application client.
+ chatClientResponse = chatClientResponse.mutate()
+ .chatResponse(ChatResponse.builder()
+ .from(chatClientResponse.chatResponse())
+ .generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
+ .build())
+ .build();
+
+ // Interupt the tool calling loop and return the tool execution result
+ // directly to the client application instead of returning it to the
+ // LLM.
+ break;
+ }
+
+ instructions = toolExecutionResult.conversationHistory();
+ }
+
+ }
+ while (isToolCall); // loop until no tool calls are present
+
+ return chatClientResponse;
+ }
+
+ @Override
+ public Flux adviseStream(ChatClientRequest chatClientRequest,
+ StreamAdvisorChain streamAdvisorChain) {
+ return Flux.error(new UnsupportedOperationException("Unimplemented method 'adviseStream'"));
+ }
+
+ /**
+ * Creates a new Builder instance for constructing a ToolCallAdvisor.
+ * @return a new Builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for creating instances of ToolCallAdvisor.
+ */
+ public final static class Builder {
+
+ private ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
+
+ private int advisorOrder = BaseAdvisor.HIGHEST_PRECEDENCE + 300;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the ToolCallingManager to be used by the advisor.
+ * @param toolCallingManager the ToolCallingManager instance
+ * @return this Builder instance for method chaining
+ */
+ public Builder toolCallingManager(ToolCallingManager toolCallingManager) {
+ this.toolCallingManager = toolCallingManager;
+ return this;
+ }
+
+ /**
+ * Sets the order of the advisor in the advisor chain.
+ * @param advisorOrder the order value, must be between HIGHEST_PRECEDENCE and
+ * LOWEST_PRECEDENCE
+ * @return this Builder instance for method chaining
+ */
+ public Builder advisorOrder(int advisorOrder) {
+ this.advisorOrder = advisorOrder;
+ return this;
+ }
+
+ /**
+ * Builds and returns a new ToolCallAdvisor instance with the configured
+ * properties.
+ * @return a new ToolCallAdvisor instance
+ * @throws IllegalArgumentException if toolCallingManager is null or advisorOrder
+ * is out of valid range
+ */
+ public ToolCallAdvisor build() {
+ return new ToolCallAdvisor(this.toolCallingManager, this.advisorOrder);
+ }
+
+ }
+
+}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java
index cfb009b2fb8..5aa0cd6b3ce 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java
@@ -44,4 +44,13 @@ public interface CallAdvisorChain extends AdvisorChain {
*/
List getCallAdvisors();
+ /**
+ * Creates a new CallAdvisorChain copy that contains all advisors after the specified
+ * advisor.
+ * @param after the CallAdvisor after which to copy the chain
+ * @return a new CallAdvisorChain containing all advisors after the specified advisor
+ * @throws IllegalArgumentException if the specified advisor is not part of the chain
+ */
+ CallAdvisorChain copy(CallAdvisor after);
+
}
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/AdvisorUtilsTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/AdvisorUtilsTests.java
index f5aaa9ea115..2c1622a37d8 100644
--- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/AdvisorUtilsTests.java
+++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/AdvisorUtilsTests.java
@@ -37,6 +37,7 @@
*
* @author ghdcksgml1
* @author Thomas Vitale
+ * @author Christian Tzolov
*/
class AdvisorUtilsTests {
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java
index ed00537f716..5e0c2b54119 100644
--- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java
+++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java
@@ -125,4 +125,135 @@ void getStreamAdvisors() {
assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0]));
}
+ @Test
+ void whenAfterAdvisorIsNullThenThrowException() {
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).build();
+
+ assertThatThrownBy(() -> chain.copy(null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("The after call advisor must not be null");
+ }
+
+ @Test
+ void whenAdvisorNotInChainThenThrowException() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+ CallAdvisor notInChain = createMockAdvisor("notInChain", 3);
+
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor1, advisor2))
+ .build();
+
+ assertThatThrownBy(() -> chain.copy(notInChain)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("The specified advisor is not part of the chain")
+ .hasMessageContaining("notInChain");
+ }
+
+ @Test
+ void whenAdvisorIsLastInChainThenReturnEmptyChain() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+ CallAdvisor advisor3 = createMockAdvisor("advisor3", 3);
+
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor1, advisor2, advisor3))
+ .build();
+
+ CallAdvisorChain newChain = chain.copy(advisor3);
+
+ assertThat(newChain.getCallAdvisors()).isEmpty();
+ }
+
+ @Test
+ void whenAdvisorIsFirstInChainThenReturnChainWithRemainingAdvisors() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+ CallAdvisor advisor3 = createMockAdvisor("advisor3", 3);
+
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor1, advisor2, advisor3))
+ .build();
+
+ CallAdvisorChain newChain = chain.copy(advisor1);
+
+ assertThat(newChain.getCallAdvisors()).hasSize(2);
+ assertThat(newChain.getCallAdvisors().get(0).getName()).isEqualTo("advisor2");
+ assertThat(newChain.getCallAdvisors().get(1).getName()).isEqualTo("advisor3");
+ }
+
+ @Test
+ void whenAdvisorIsInMiddleOfChainThenReturnChainWithRemainingAdvisors() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+ CallAdvisor advisor3 = createMockAdvisor("advisor3", 3);
+ CallAdvisor advisor4 = createMockAdvisor("advisor4", 4);
+
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor1, advisor2, advisor3, advisor4))
+ .build();
+
+ CallAdvisorChain newChain = chain.copy(advisor2);
+
+ assertThat(newChain.getCallAdvisors()).hasSize(2);
+ assertThat(newChain.getCallAdvisors().get(0).getName()).isEqualTo("advisor3");
+ assertThat(newChain.getCallAdvisors().get(1).getName()).isEqualTo("advisor4");
+ }
+
+ @Test
+ void whenCopyingChainThenOriginalChainRemainsUnchanged() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+ CallAdvisor advisor3 = createMockAdvisor("advisor3", 3);
+
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor1, advisor2, advisor3))
+ .build();
+
+ CallAdvisorChain newChain = chain.copy(advisor1);
+
+ // Original chain should still have all advisors
+ assertThat(chain.getCallAdvisors()).hasSize(3);
+ assertThat(chain.getCallAdvisors().get(0).getName()).isEqualTo("advisor1");
+ assertThat(chain.getCallAdvisors().get(1).getName()).isEqualTo("advisor2");
+ assertThat(chain.getCallAdvisors().get(2).getName()).isEqualTo("advisor3");
+
+ // New chain should only have remaining advisors
+ assertThat(newChain.getCallAdvisors()).hasSize(2);
+ assertThat(newChain.getCallAdvisors().get(0).getName()).isEqualTo("advisor2");
+ assertThat(newChain.getCallAdvisors().get(1).getName()).isEqualTo("advisor3");
+ }
+
+ @Test
+ void whenCopyingChainThenObservationRegistryIsPreserved() {
+ CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
+ CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
+
+ ObservationRegistry customRegistry = ObservationRegistry.create();
+ CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(customRegistry)
+ .pushAll(List.of(advisor1, advisor2))
+ .build();
+
+ CallAdvisorChain newChain = chain.copy(advisor1);
+
+ assertThat(newChain.getObservationRegistry()).isSameAs(customRegistry);
+ }
+
+ private CallAdvisor createMockAdvisor(String name, int order) {
+ return new CallAdvisor() {
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public int getOrder() {
+ return order;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
+ return chain.nextCall(request);
+ }
+ };
+ }
+
}
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisorTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisorTests.java
new file mode 100644
index 00000000000..994eb7277ce
--- /dev/null
+++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisorTests.java
@@ -0,0 +1,1142 @@
+/*
+ * 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.chat.client.advisor;
+
+import java.util.List;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.micrometer.observation.ObservationRegistry;
+import io.modelcontextprotocol.json.TypeRef;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+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.core.Ordered;
+import org.springframework.core.ParameterizedTypeReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link StructuredOutputValidationAdvisor}.
+ *
+ * @author Christian Tzolov
+ */
+@ExtendWith(MockitoExtension.class)
+public class StructuredOutputValidationAdvisorTests {
+
+ @Mock
+ private CallAdvisorChain callAdvisorChain;
+
+ @Mock
+ private StreamAdvisorChain streamAdvisorChain;
+
+ @Test
+ void whenOutputTypeIsNullThenThrow() {
+ assertThatThrownBy(() -> StructuredOutputValidationAdvisor.builder().build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("outputType must be set");
+ }
+
+ @Test
+ void whenAdvisorOrderIsOutOfRangeThenThrow() {
+ assertThatThrownBy(() -> StructuredOutputValidationAdvisor.builder().outputType(new TypeRef() {
+ }).advisorOrder(Ordered.HIGHEST_PRECEDENCE).build()).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+
+ assertThatThrownBy(() -> StructuredOutputValidationAdvisor.builder().outputType(new TypeRef() {
+ }).advisorOrder(Ordered.LOWEST_PRECEDENCE).build()).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+ }
+
+ @Test
+ void whenRepeatAttemptsIsNegativeThenThrow() {
+ assertThatThrownBy(() -> StructuredOutputValidationAdvisor.builder().outputType(new TypeRef() {
+ }).maxRepeatAttempts(-1).build()).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("repeatAttempts must be greater than or equal to 0");
+ }
+
+ @Test
+ void testBuilderMethodChainingWithTypeRef() {
+ TypeRef typeRef = new TypeRef<>() {
+ };
+ int customOrder = Ordered.HIGHEST_PRECEDENCE + 500;
+ int customAttempts = 5;
+
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(typeRef)
+ .advisorOrder(customOrder)
+ .maxRepeatAttempts(customAttempts)
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ @Test
+ void testBuilderMethodChainingWithTypeReference() {
+ TypeReference typeReference = new TypeReference<>() {
+ };
+ int customOrder = Ordered.HIGHEST_PRECEDENCE + 600;
+
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(typeReference)
+ .advisorOrder(customOrder)
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ @Test
+ void testBuilderMethodChainingWithParameterizedTypeReference() {
+ ParameterizedTypeReference parameterizedTypeReference = new ParameterizedTypeReference<>() {
+ };
+ int customOrder = Ordered.HIGHEST_PRECEDENCE + 700;
+
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(parameterizedTypeReference)
+ .advisorOrder(customOrder)
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ @Test
+ void testDefaultValues() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE - 2000);
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ @Test
+ void whenChatClientRequestIsNullThenThrow() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .build();
+
+ assertThatThrownBy(() -> advisor.adviseCall(null, this.callAdvisorChain))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("chatClientRequest must not be null");
+ }
+
+ @Test
+ void whenCallAdvisorChainIsNullThenThrow() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .build();
+ ChatClientRequest request = createMockRequest();
+
+ assertThatThrownBy(() -> advisor.adviseCall(request, null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("callAdvisorChain must not be null");
+ }
+
+ @Test
+ void testAdviseCallWithValidJsonOnFirstAttempt() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(3)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor that returns the valid response
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(1);
+ }
+
+ @Test
+ void testAdviseCallWithInvalidJsonRetries() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(2)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String invalidJson = "{\"name\":\"John Doe\"}"; // Missing required 'age' field
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor that returns invalid response first, then valid
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? invalidResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testAdviseCallExhaustsAllRetries() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(2)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String invalidJson = "{\"invalid\":\"json\"}";
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+
+ // Create a terminal advisor that always returns invalid response
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return invalidResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(invalidResponse);
+ // Initial attempt + 2 retries = 3 total calls
+ assertThat(callCount[0]).isEqualTo(3);
+ }
+
+ @Test
+ void testAdviseCallWithZeroRetries() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(0)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String invalidJson = "{\"invalid\":\"json\"}";
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+
+ // Create a terminal advisor
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return invalidResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(invalidResponse);
+ // Only initial attempt, no retries
+ assertThat(callCount[0]).isEqualTo(1);
+ }
+
+ @Test
+ void testAdviseCallWithNullChatResponse() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ ChatClientResponse nullResponse = mock(ChatClientResponse.class);
+ when(nullResponse.chatResponse()).thenReturn(null);
+
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor that returns null response first, then valid
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? nullResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testAdviseCallWithNullResult() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ ChatResponse chatResponse = mock(ChatResponse.class);
+ when(chatResponse.getResult()).thenReturn(null);
+ ChatClientResponse nullResultResponse = mock(ChatClientResponse.class);
+ when(nullResultResponse.chatResponse()).thenReturn(chatResponse);
+
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? nullResultResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testAdviseCallWithComplexType() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(2)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String validJson = "{\"street\":\"123 Main St\",\"city\":\"Springfield\",\"zipCode\":\"12345\"}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ }
+
+ @Test
+ void testAdviseStreamThrowsUnsupportedOperationException() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .build();
+ ChatClientRequest request = createMockRequest();
+
+ Flux result = advisor.adviseStream(request, this.streamAdvisorChain);
+
+ assertThatThrownBy(() -> result.blockFirst()).isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Structured Output Validation Advisor does not support streaming");
+ }
+
+ @Test
+ void testGetName() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .build();
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ @Test
+ void testGetOrder() {
+ int customOrder = Ordered.HIGHEST_PRECEDENCE + 1500;
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .advisorOrder(customOrder)
+ .build();
+
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ }
+
+ @Test
+ void testMultipleRetriesWithDifferentInvalidResponses() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(3)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String invalidJson1 = "{\"name\":\"John\"}"; // Missing age
+ String invalidJson2 = "{\"age\":30}"; // Missing name
+ String invalidJson3 = "not json at all";
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+
+ ChatClientResponse invalidResponse1 = createMockResponse(invalidJson1);
+ ChatClientResponse invalidResponse2 = createMockResponse(invalidJson2);
+ ChatClientResponse invalidResponse3 = createMockResponse(invalidJson3);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create a terminal advisor that cycles through invalid responses
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return switch (callCount[0]) {
+ case 1 -> invalidResponse1;
+ case 2 -> invalidResponse2;
+ case 3 -> invalidResponse3;
+ default -> validResponse;
+ };
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(4);
+ }
+
+ @Test
+ void testPromptAugmentationWithValidationError() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String invalidJson = "{\"name\":\"John\"}"; // Missing age
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Track the requests to verify prompt augmentation
+ ChatClientRequest[] capturedRequests = new ChatClientRequest[2];
+ int[] callCount = { 0 };
+
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ capturedRequests[callCount[0]] = req;
+ callCount[0]++;
+ return callCount[0] == 1 ? invalidResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+
+ // Verify that the second request has augmented prompt with validation error
+ assertThat(capturedRequests[0]).isNotNull();
+ assertThat(capturedRequests[1]).isNotNull();
+
+ String firstPromptText = capturedRequests[0].prompt().getInstructions().get(0).getText();
+ String secondPromptText = capturedRequests[1].prompt().getInstructions().get(0).getText();
+
+ assertThat(secondPromptText).contains(firstPromptText);
+ assertThat(secondPromptText).contains("Output JSON validation failed because of:");
+ }
+
+ @Test
+ void testValidationWithEmptyJsonString() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String emptyJson = "";
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+
+ ChatClientResponse emptyResponse = createMockResponse(emptyJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? emptyResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testValidationWithMalformedJson() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String malformedJson = "{\"name\":\"John\", age:30}"; // Missing quotes around age
+ // key
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+
+ ChatClientResponse malformedResponse = createMockResponse(malformedJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? malformedResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testValidationWithExtraFields() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(0)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ // JSON with extra fields that aren't in the Person class
+ String jsonWithExtraFields = "{\"name\":\"John Doe\",\"age\":30,\"extraField\":\"value\"}";
+ ChatClientResponse response = createMockResponse(jsonWithExtraFields);
+
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return response;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ // Should still be valid as extra fields are typically allowed
+ assertThat(result).isEqualTo(response);
+ }
+
+ @Test
+ void testValidationWithNestedObject() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(2)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String validJson = "{\"name\":\"John Doe\",\"age\":30,\"address\":{\"street\":\"123 Main St\",\"city\":\"Springfield\",\"zipCode\":\"12345\"}}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ }
+
+ @Test
+ void testValidationWithInvalidNestedObject() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ // Missing required fields in nested address object
+ String invalidJson = "{\"name\":\"John Doe\",\"age\":30,\"address\":{\"street\":\"123 Main St\"}}";
+ String validJson = "{\"name\":\"John Doe\",\"age\":30,\"address\":{\"street\":\"123 Main St\",\"city\":\"Springfield\",\"zipCode\":\"12345\"}}";
+
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? invalidResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testValidationWithListType() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef>() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String validJson = "[{\"name\":\"John Doe\",\"age\":30},{\"name\":\"Jane Doe\",\"age\":25}]";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ }
+
+ @Test
+ void testValidationWithInvalidListType() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef>() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ // One person in the list is missing the age field
+ String invalidJson = "[{\"name\":\"John Doe\",\"age\":30},{\"name\":\"Jane Doe\"}]";
+ String validJson = "[{\"name\":\"John Doe\",\"age\":30},{\"name\":\"Jane Doe\",\"age\":25}]";
+
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? invalidResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testValidationWithWrongTypeInField() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .maxRepeatAttempts(1)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ // Age is a string instead of an integer
+ String invalidJson = "{\"name\":\"John Doe\",\"age\":\"thirty\"}";
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+
+ ChatClientResponse invalidResponse = createMockResponse(invalidJson);
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ callCount[0]++;
+ return callCount[0] == 1 ? invalidResponse : validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ }
+
+ @Test
+ void testAdvisorOrderingInChain() {
+ int customOrder = Ordered.HIGHEST_PRECEDENCE + 1000;
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(new TypeRef() {
+ })
+ .advisorOrder(customOrder)
+ .build();
+
+ ChatClientRequest request = createMockRequest();
+ String validJson = "{\"name\":\"John Doe\",\"age\":30}";
+ ChatClientResponse validResponse = createMockResponse(validJson);
+
+ // Create another advisor with different order
+ CallAdvisor otherAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "other";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.HIGHEST_PRECEDENCE + 500;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return chain.nextCall(req);
+ }
+ };
+
+ CallAdvisor terminalAdvisor = new CallAdvisor() {
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return validResponse;
+ }
+ };
+
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(otherAdvisor, advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = realChain.nextCall(request);
+
+ assertThat(result).isEqualTo(validResponse);
+ }
+
+ @Test
+ void testBuilderWithTypeOnly() {
+ StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(Person.class)
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE - 2000);
+ assertThat(advisor.getName()).isEqualTo("Structured Output Validation Advisor");
+ }
+
+ // Helper methods
+
+ private ChatClientRequest createMockRequest() {
+ Prompt prompt = new Prompt(List.of(new UserMessage("test message")));
+ return ChatClientRequest.builder().prompt(prompt).build();
+ }
+
+ private ChatClientResponse createMockResponse(String jsonOutput) {
+ AssistantMessage assistantMessage = new AssistantMessage(jsonOutput);
+ Generation generation = new Generation(assistantMessage);
+ ChatResponse chatResponse = new ChatResponse(List.of(generation));
+
+ ChatClientResponse response = mock(ChatClientResponse.class);
+ when(response.chatResponse()).thenReturn(chatResponse);
+
+ return response;
+ }
+
+ // Test DTOs
+ public static class Person {
+
+ private String name;
+
+ private int age;
+
+ public String getName() {
+ return this.name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return this.age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+
+ }
+
+ public static class Address {
+
+ private String street;
+
+ private String city;
+
+ private String zipCode;
+
+ public String getStreet() {
+ return this.street;
+ }
+
+ public void setStreet(String street) {
+ this.street = street;
+ }
+
+ public String getCity() {
+ return this.city;
+ }
+
+ public void setCity(String city) {
+ this.city = city;
+ }
+
+ public String getZipCode() {
+ return this.zipCode;
+ }
+
+ public void setZipCode(String zipCode) {
+ this.zipCode = zipCode;
+ }
+
+ }
+
+ public static class PersonWithAddress {
+
+ private String name;
+
+ private int age;
+
+ private Address address;
+
+ public String getName() {
+ return this.name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return this.age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+
+ public Address getAddress() {
+ return this.address;
+ }
+
+ public void setAddress(Address address) {
+ this.address = address;
+ }
+
+ }
+
+}
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java
new file mode 100644
index 00000000000..a56b54f0810
--- /dev/null
+++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java
@@ -0,0 +1,477 @@
+/*
+ * Copyright 2025-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.chat.client.advisor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+import io.micrometer.observation.ObservationRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.quality.Strictness;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.model.tool.ToolCallingManager;
+import org.springframework.ai.model.tool.ToolExecutionResult;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ToolCallAdvisor}.
+ *
+ * @author Christian Tzolov
+ */
+@ExtendWith(MockitoExtension.class)
+public class ToolCallAdvisorTests {
+
+ @Mock
+ private ToolCallingManager toolCallingManager;
+
+ @Mock
+ private CallAdvisorChain callAdvisorChain;
+
+ @Mock
+ private StreamAdvisorChain streamAdvisorChain;
+
+ @Test
+ void whenToolCallingManagerIsNullThenThrow() {
+ assertThatThrownBy(() -> ToolCallAdvisor.builder().toolCallingManager(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("toolCallingManager must not be null");
+ }
+
+ @Test
+ void whenAdvisorOrderIsOutOfRangeThenThrow() {
+ assertThatThrownBy(() -> ToolCallAdvisor.builder().advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+
+ assertThatThrownBy(() -> ToolCallAdvisor.builder().advisorOrder(BaseAdvisor.LOWEST_PRECEDENCE).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
+ }
+
+ @Test
+ void testBuilderMethodChaining() {
+ ToolCallingManager customManager = mock(ToolCallingManager.class);
+ int customOrder = BaseAdvisor.HIGHEST_PRECEDENCE + 500;
+
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder()
+ .toolCallingManager(customManager)
+ .advisorOrder(customOrder)
+ .build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ assertThat(advisor.getName()).isEqualTo("Tool Calling Advisor");
+ }
+
+ @Test
+ void testDefaultValues() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+
+ assertThat(advisor).isNotNull();
+ assertThat(advisor.getOrder()).isEqualTo(BaseAdvisor.HIGHEST_PRECEDENCE + 300);
+ assertThat(advisor.getName()).isEqualTo("Tool Calling Advisor");
+ }
+
+ @Test
+ void whenChatClientRequestIsNullThenThrow() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+
+ assertThatThrownBy(() -> advisor.adviseCall(null, this.callAdvisorChain))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("chatClientRequest must not be null");
+ }
+
+ @Test
+ void whenCallAdvisorChainIsNullThenThrow() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+ ChatClientRequest request = createMockRequest(true);
+
+ assertThatThrownBy(() -> advisor.adviseCall(request, null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("callAdvisorChain must not be null");
+ }
+
+ @Test
+ void whenOptionsAreNullThenThrow() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+
+ Prompt prompt = new Prompt(List.of(new UserMessage("test")));
+ ChatClientRequest request = ChatClientRequest.builder().prompt(prompt).build();
+
+ assertThatThrownBy(() -> advisor.adviseCall(request, this.callAdvisorChain))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("ToolCall Advisor requires ToolCallingChatOptions");
+ }
+
+ @Test
+ void whenOptionsAreNotToolCallingChatOptionsThenThrow() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+
+ ChatOptions nonToolOptions = mock(ChatOptions.class);
+ Prompt prompt = new Prompt(List.of(new UserMessage("test")), nonToolOptions);
+ ChatClientRequest request = ChatClientRequest.builder().prompt(prompt).build();
+
+ assertThatThrownBy(() -> advisor.adviseCall(request, this.callAdvisorChain))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("ToolCall Advisor requires ToolCallingChatOptions");
+ }
+
+ @Test
+ void testAdviseCallWithoutToolCalls() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse response = createMockResponse(false);
+
+ // Create a terminal advisor that returns the response
+ CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> response);
+
+ // Create a real chain with both advisors
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = advisor.adviseCall(request, realChain);
+
+ assertThat(result).isEqualTo(response);
+ verify(this.toolCallingManager, times(0)).executeToolCalls(any(), any());
+ }
+
+ @Test
+ void testAdviseCallWithNullChatResponse() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse responseWithNullChatResponse = ChatClientResponse.builder().build();
+
+ // Create a terminal advisor that returns the response with null chatResponse
+ CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> responseWithNullChatResponse);
+
+ // Create a real chain with both advisors
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ ChatClientResponse result = advisor.adviseCall(request, realChain);
+
+ assertThat(result).isEqualTo(responseWithNullChatResponse);
+ verify(this.toolCallingManager, times(0)).executeToolCalls(any(), any());
+ }
+
+ @Test
+ void testAdviseCallWithSingleToolCallIteration() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse responseWithToolCall = createMockResponse(true);
+ ChatClientResponse finalResponse = createMockResponse(false);
+
+ // Create a terminal advisor that returns responses in sequence
+ int[] callCount = { 0 };
+
+ CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> {
+ callCount[0]++;
+ return callCount[0] == 1 ? responseWithToolCall : finalResponse;
+ });
+
+ // Create a real chain with both advisors
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ // Mock tool execution result
+ List conversationHistory = List.of(new UserMessage("test"),
+ AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build());
+ ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder()
+ .conversationHistory(conversationHistory)
+ .build();
+ when(this.toolCallingManager.executeToolCalls(any(Prompt.class), any(ChatResponse.class)))
+ .thenReturn(toolExecutionResult);
+
+ ChatClientResponse result = advisor.adviseCall(request, realChain);
+
+ assertThat(result).isEqualTo(finalResponse);
+ assertThat(callCount[0]).isEqualTo(2);
+ verify(this.toolCallingManager, times(1)).executeToolCalls(any(Prompt.class), any(ChatResponse.class));
+ }
+
+ @Test
+ void testAdviseCallWithMultipleToolCallIterations() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse firstToolCallResponse = createMockResponse(true);
+ ChatClientResponse secondToolCallResponse = createMockResponse(true);
+ ChatClientResponse finalResponse = createMockResponse(false);
+
+ // Create a terminal advisor that returns responses in sequence
+ int[] callCount = { 0 };
+ CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> {
+ callCount[0]++;
+ if (callCount[0] == 1) {
+ return firstToolCallResponse;
+ }
+ else if (callCount[0] == 2) {
+ return secondToolCallResponse;
+ }
+ else {
+ return finalResponse;
+ }
+ });
+
+ // Create a real chain with both advisors
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ // Mock tool execution results
+ AssistantMessage.builder().build();
+ List conversationHistory = List.of(new UserMessage("test"),
+ AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build());
+ ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder()
+ .conversationHistory(conversationHistory)
+ .build();
+ when(this.toolCallingManager.executeToolCalls(any(Prompt.class), any(ChatResponse.class)))
+ .thenReturn(toolExecutionResult);
+
+ ChatClientResponse result = advisor.adviseCall(request, realChain);
+
+ assertThat(result).isEqualTo(finalResponse);
+ assertThat(callCount[0]).isEqualTo(3);
+ verify(this.toolCallingManager, times(2)).executeToolCalls(any(Prompt.class), any(ChatResponse.class));
+ }
+
+ @Test
+ void testAdviseCallWithReturnDirectToolExecution() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse responseWithToolCall = createMockResponse(true);
+
+ // Create a terminal advisor that returns the response
+ CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> responseWithToolCall);
+
+ // Create a real chain with both advisors
+ CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, terminalAdvisor))
+ .build();
+
+ // Mock tool execution result with returnDirect = true
+ ToolResponseMessage.ToolResponse toolResponse = new ToolResponseMessage.ToolResponse("tool-1", "testTool",
+ "Tool result data");
+ ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
+ .responses(List.of(toolResponse))
+ .build();
+ List conversationHistory = List.of(new UserMessage("test"),
+ AssistantMessage.builder().content("").build(), toolResponseMessage);
+ ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder()
+ .conversationHistory(conversationHistory)
+ .returnDirect(true)
+ .build();
+ when(this.toolCallingManager.executeToolCalls(any(Prompt.class), any(ChatResponse.class)))
+ .thenReturn(toolExecutionResult);
+
+ ChatClientResponse result = advisor.adviseCall(request, realChain);
+
+ // Verify that the tool execution was called only once (no loop continuation)
+ verify(this.toolCallingManager, times(1)).executeToolCalls(any(Prompt.class), any(ChatResponse.class));
+
+ // Verify that the result contains the tool execution result as generations
+ assertThat(result.chatResponse()).isNotNull();
+ assertThat(result.chatResponse().getResults()).hasSize(1);
+ assertThat(result.chatResponse().getResults().get(0).getOutput().getText()).isEqualTo("Tool result data");
+ assertThat(result.chatResponse().getResults().get(0).getMetadata().getFinishReason())
+ .isEqualTo(ToolExecutionResult.FINISH_REASON);
+ }
+
+ @Test
+ void testInternalToolExecutionIsDisabled() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
+
+ ChatClientRequest request = createMockRequest(true);
+ ChatClientResponse response = createMockResponse(false);
+
+ // Use a simple holder to capture the request
+ ChatClientRequest[] capturedRequest = new ChatClientRequest[1];
+
+ CallAdvisor capturingAdvisor = new TerminalCallAdvisor((req, chain) -> {
+ capturedRequest[0] = req;
+ return response;
+ });
+
+ CallAdvisorChain capturingChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
+ .pushAll(List.of(advisor, capturingAdvisor))
+ .build();
+
+ advisor.adviseCall(request, capturingChain);
+
+ ToolCallingChatOptions capturedOptions = (ToolCallingChatOptions) capturedRequest[0].prompt().getOptions();
+
+ assertThat(capturedOptions.getInternalToolExecutionEnabled()).isFalse();
+ }
+
+ @Test
+ void testAdviseStreamThrowsUnsupportedOperationException() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+ ChatClientRequest request = createMockRequest(true);
+
+ Flux result = advisor.adviseStream(request, this.streamAdvisorChain);
+
+ assertThatThrownBy(() -> result.blockFirst()).isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Unimplemented method 'adviseStream'");
+ }
+
+ @Test
+ void testGetName() {
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().build();
+ assertThat(advisor.getName()).isEqualTo("Tool Calling Advisor");
+ }
+
+ @Test
+ void testGetOrder() {
+ int customOrder = BaseAdvisor.HIGHEST_PRECEDENCE + 400;
+ ToolCallAdvisor advisor = ToolCallAdvisor.builder().advisorOrder(customOrder).build();
+
+ assertThat(advisor.getOrder()).isEqualTo(customOrder);
+ }
+
+ // Helper methods
+
+ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) {
+ List instructions = List.of(new UserMessage("test message"));
+
+ ChatOptions options = null;
+ if (withToolCallingOptions) {
+ ToolCallingChatOptions toolOptions = mock(ToolCallingChatOptions.class,
+ Mockito.withSettings().strictness(Strictness.LENIENT));
+ // Create a separate mock for the copy that tracks the internal state
+ ToolCallingChatOptions copiedOptions = mock(ToolCallingChatOptions.class,
+ Mockito.withSettings().strictness(Strictness.LENIENT));
+
+ // Use a holder to track the state
+ boolean[] internalToolExecutionEnabled = { true };
+
+ when(toolOptions.copy()).thenReturn(copiedOptions);
+ when(toolOptions.getInternalToolExecutionEnabled()).thenReturn(true);
+
+ // When getInternalToolExecutionEnabled is called on the copy, return the
+ // current state
+ when(copiedOptions.getInternalToolExecutionEnabled())
+ .thenAnswer(invocation -> internalToolExecutionEnabled[0]);
+
+ // When setInternalToolExecutionEnabled is called on the copy, update the
+ // state
+ Mockito.doAnswer(invocation -> {
+ internalToolExecutionEnabled[0] = invocation.getArgument(0);
+ return null;
+ }).when(copiedOptions).setInternalToolExecutionEnabled(org.mockito.ArgumentMatchers.anyBoolean());
+
+ options = toolOptions;
+ }
+
+ Prompt prompt = new Prompt(instructions, options);
+
+ return ChatClientRequest.builder().prompt(prompt).build();
+ }
+
+ private ChatClientResponse createMockResponse(boolean hasToolCalls) {
+ Generation generation = mock(Generation.class, Mockito.withSettings().strictness(Strictness.LENIENT));
+ when(generation.getOutput()).thenReturn(new AssistantMessage("response"));
+
+ // Mock metadata to avoid NullPointerException in ChatResponse.Builder.from()
+ ChatResponseMetadata metadata = mock(ChatResponseMetadata.class,
+ Mockito.withSettings().strictness(Strictness.LENIENT));
+ when(metadata.getModel()).thenReturn("");
+ when(metadata.getId()).thenReturn("");
+ when(metadata.getRateLimit()).thenReturn(null);
+ when(metadata.getUsage()).thenReturn(null);
+ when(metadata.getPromptMetadata()).thenReturn(null);
+ when(metadata.entrySet()).thenReturn(java.util.Collections.emptySet());
+
+ // Create a real ChatResponse instead of mocking it to avoid issues with
+ // ChatResponse.Builder.from()
+ ChatResponse chatResponse = ChatResponse.builder().generations(List.of(generation)).metadata(metadata).build();
+
+ // Mock hasToolCalls since it's not part of the builder
+ ChatResponse spyChatResponse = Mockito.spy(chatResponse);
+ when(spyChatResponse.hasToolCalls()).thenReturn(hasToolCalls);
+
+ ChatClientResponse response = mock(ChatClientResponse.class,
+ Mockito.withSettings().strictness(Strictness.LENIENT));
+ when(response.chatResponse()).thenReturn(spyChatResponse);
+
+ // Mock mutate() to return a real builder that can handle the mutation
+ when(response.mutate())
+ .thenAnswer(invocation -> ChatClientResponse.builder().chatResponse(spyChatResponse).context(Map.of()));
+
+ return response;
+ }
+
+ private static class TerminalCallAdvisor implements CallAdvisor {
+
+ private final BiFunction responseFunction;
+
+ TerminalCallAdvisor(BiFunction responseFunction) {
+ this.responseFunction = responseFunction;
+ }
+
+ @Override
+ public String getName() {
+ return "terminal";
+ }
+
+ @Override
+ public int getOrder() {
+ return 0;
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
+ return this.responseFunction.apply(req, chain);
+ }
+
+ };
+
+}
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/advisors-recursive.png b/spring-ai-docs/src/main/antora/modules/ROOT/images/advisors-recursive.png
new file mode 100644
index 00000000000..e377090c6ec
Binary files /dev/null and b/spring-ai-docs/src/main/antora/modules/ROOT/images/advisors-recursive.png differ
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
index e7d5614c5dd..d4b40d8dd3f 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
@@ -5,6 +5,7 @@
* Reference
** xref:api/chatclient.adoc[]
*** xref:api/advisors.adoc[Advisors]
+**** xref:api/advisors-recursive.adoc[Recursive Advisors]
** xref:api/prompt.adoc[]
** xref:api/structured-output-converter.adoc[Structured Output]
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc
new file mode 100644
index 00000000000..0c19258fcc5
--- /dev/null
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc
@@ -0,0 +1,97 @@
+[[Advisors-Recursive]]
+
+= Recursive Advisors
+
+== What is a Recursive Advisor?
+
+image:advisors-recursive.png[Advisors Recursive, width=230, float="right", align="center", alt="Advisors Recursive"]
+Recursive advisors are a special type of advisor that can loop through the downstream advisor chain multiple times.
+This pattern is useful when you need to repeatedly call the LLM until a certain condition is met, such as:
+
+* Executing tool calls in a loop until no more tools need to be called
+* Validating structured output and retrying if validation fails
+* Implementing Evaluation logic with modifications to the request
+* Implementing retry logic with modifications to the request
+
+The `CallAdvisorChain.copy(CallAdvisor after)` method is the key utility that enables recursive advisor patterns.
+It creates a new advisor chain that contains only the advisors that come after the specified advisor in the original chain
+and allows the recursive advisor to call this sub-chain as needed.
+This approach ensures that:
+
+* The recursive advisor can loop through the remaining advisors in the chain
+* Other advisors in the chain can observe and intercept each iteration
+* The advisor chain maintains proper ordering and observability
+* The recursive advisor doesn't re-execute advisors that came before it
+
+== Built-in Recursive Advisors
+
+Spring AI provides two built-in recursive advisors that demonstrate this pattern:
+
+=== ToolCallAdvisor
+
+The `ToolCallAdvisor` implements the tool calling loop as part of the advisor chain, rather than relying on the model's internal tool execution. This enables other advisors in the chain to intercept and observe the tool calling process.
+
+Key features:
+
+* Disables the model's internal tool execution by setting `setInternalToolExecutionEnabled(false)`
+* Loops through the advisor chain until no more tool calls are present
+* Supports "return direct" functionality - when a tool execution has `returnDirect=true`, it interrupts the tool calling loop and returns the tool execution result directly to the client application instead of sending it back to the LLM
+* Uses `callAdvisorChain.copy(this)` to create a sub-chain for recursive calls
+* Includes null safety checks to handle cases where the chat response might be null
+
+Example usage:
+
+[source,java]
+----
+var toolCallAdvisor = ToolCallAdvisor.builder()
+ .toolCallingManager(toolCallingManager)
+ .advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
+ .build();
+
+var chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(toolCallAdvisor)
+ .build();
+----
+
+==== Return Direct Functionality
+
+The "return direct" feature allows tools to bypass the LLM and return their results directly to the client application. This is useful when:
+
+* The tool's output is the final answer and doesn't need LLM processing
+* You want to reduce latency by avoiding an additional LLM call
+* The tool result should be returned as-is without interpretation
+
+When a tool execution has `returnDirect=true`, the `ToolCallAdvisor` will:
+
+1. Execute the tool call as normal
+2. Detect the `returnDirect` flag in the `ToolExecutionResult`
+3. Break out of the tool calling loop
+4. Return the tool execution result directly to the client application as a `ChatResponse` with the tool's output as the generation content
+
+=== StructuredOutputValidationAdvisor
+
+The `StructuredOutputValidationAdvisor` validates the structured JSON output against a generated JSON schema and retries the call if validation fails, up to a specified number of attempts.
+
+Key features:
+
+* Automatically generates a JSON schema from the expected output type
+* Validates the LLM response against the schema
+* Retries the call if validation fails, up to a configurable number of attempts
+* Augments the prompt with validation error messages on retry attempts to help the LLM correct its output
+* Uses `callAdvisorChain.copy(this)` to create a sub-chain for recursive calls
+* Optionally supports a custom `ObjectMapper` for JSON processing
+
+Example usage:
+
+[source,java]
+----
+var validationAdvisor = StructuredOutputValidationAdvisor.builder()
+ .outputType(MyResponseType.class)
+ .maxRepeatAttempts(3)
+ .advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 1000)
+ .build();
+
+var chatClient = ChatClient.builder(chatModel)
+ .defaultAdvisors(validationAdvisor)
+ .build();
+----
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors.adoc
index 3acf526ee7b..f6bd3302846 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors.adoc
@@ -208,7 +208,6 @@ public interface StreamAdvisorChain extends AdvisorChain {
}
```
-
== Implementing an Advisor
To create an advisor, implement either `CallAdvisor` or `StreamAdvisor` (or both). The key method to implement is `nextCall()` for non-streaming or `nextStream()` for streaming advisors.
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java
index 6921014ad87..f59de021aa6 100644
--- a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java
+++ b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java
@@ -43,6 +43,7 @@
* Unit tests for {@link JsonSchemaGenerator}.
*
* @author Thomas Vitale
+ * @author Christian Tzolov
*/
class JsonSchemaGeneratorTests {