From a20736daa0a5f3910ade8b3d393e6eb4ff12085a Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 13 Oct 2025 12:38:30 +0200 Subject: [PATCH 1/7] feat(advisors): enable recursive advisor execution with two new built-in advisors Add support for recursive (repetitive) advisor execution patterns, allowing advisors to re-invoke themselves or the remaining chain multiple times. This enables advanced patterns like retry logic, iterative refinement, and multi-pass processing. - Add AdvisorUtils.copyChainAfterAdvisor() utility to enable recursive chain invocation - Implement ToolCallAdvisor for recursive tool calling with configurable tool execution - Implement StructuredOutputValidationAdvisor for recursive output validation with retry logic - Add MCP JSON Jackson2 dependency for JSON schema validation - Add test suites for both new advisors and utility methods - Add documentation for recursive advisor patterns Signed-off-by: Christian Tzolov --- spring-ai-client-chat/pom.xml | 6 + .../ai/chat/client/advisor/AdvisorUtils.java | 33 + .../client/advisor/SimpleLoggerAdvisor.java | 4 +- .../StructuredOutputValidationAdvisor.java | 289 ++++++++ .../chat/client/advisor/ToolCallAdvisor.java | 192 ++++++ .../client/advisor/AdvisorUtilsTests.java | 154 +++++ ...tructuredOutputValidationAdvisorTests.java | 642 ++++++++++++++++++ .../client/advisor/ToolCallAdvisorTests.java | 417 ++++++++++++ .../ROOT/images/advisors-recursive.png | Bin 0 -> 35768 bytes .../src/main/antora/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/api/advisors-recursive.adoc | 78 +++ .../modules/ROOT/pages/api/advisors.adoc | 1 - .../util/json/JsonSchemaGeneratorTests.java | 1 + 13 files changed, 1815 insertions(+), 3 deletions(-) create mode 100644 spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java create mode 100644 spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java create mode 100644 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisorTests.java create mode 100644 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/images/advisors-recursive.png create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc diff --git a/spring-ai-client-chat/pom.xml b/spring-ai-client-chat/pom.xml index 5253a775a01..8bd7b9a9ffe 100644 --- a/spring-ai-client-chat/pom.xml +++ b/spring-ai-client-chat/pom.xml @@ -44,6 +44,12 @@ ${project.version} + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp.sdk.version} + + com.fasterxml.jackson.module jackson-module-jsonSchema diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java index 172484dd7ed..292e563c44d 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java @@ -16,14 +16,20 @@ package org.springframework.ai.chat.client.advisor; +import java.util.List; import java.util.function.Predicate; 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.model.ChatResponse; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** * Utilities to work with advisors. + * + * @author Christian Tzolov */ public final class AdvisorUtils { @@ -46,4 +52,31 @@ public static Predicate onFinishReason() { }; } + /** + * Creates a new CallAdvisorChain copy that contains all advisors after the specified + * advisor. + * @param callAdvisorChain the original CallAdvisorChain + * @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 + */ + public static CallAdvisorChain copyChainAfterAdvisor(CallAdvisorChain callAdvisorChain, CallAdvisor after) { + + Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null"); + Assert.notNull(after, "The after call advisor must not be null"); + + List callAdvisors = callAdvisorChain.getCallAdvisors(); + int afterAdvisorIndex = callAdvisors.indexOf(after); + + if (afterAdvisorIndex < 0) { + throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName()); + } + + var remainingCallAdvisors = callAdvisors.subList(afterAdvisorIndex + 1, callAdvisors.size()); + + return DefaultAroundAdvisorChain.builder(callAdvisorChain.getObservationRegistry()) + .pushAll(remainingCallAdvisors) + .build(); + } + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java index be2693c52cd..f63df8e5bd6 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java @@ -88,11 +88,11 @@ public Flux 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..5c534ed108f --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java @@ -0,0 +1,289 @@ +/* + * 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 java.util.concurrent.atomic.AtomicInteger; + +import com.fasterxml.jackson.core.type.TypeReference; +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.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 repeatAttempts; + + private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int repeatAttempts) { + 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(repeatAttempts >= 0, "repeatAttempts must be greater than or equal to 0"); + + this.advisorOrder = advisorOrder; + + this.jsonvalidator = new DefaultJsonSchemaValidator(); + + 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.repeatAttempts = repeatAttempts; + } + + @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 counter = new AtomicInteger(this.repeatAttempts); + + do { + // Before Call + counter.decrementAndGet(); + + // Next Call + chatClientResponse = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this).nextCall(chatClientRequest); + + // After Call + } + while (!isOutputSchemaValid(chatClientResponse) && counter.get() >= 0); + + return chatClientResponse; + } + + @SuppressWarnings("null") + private boolean isOutputSchemaValid(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 false; + } + + String json = chatClientResponse.chatResponse().getResult().getOutput().getText(); + + logger.info("Validating JSON output against schema. Attempts left: " + this.repeatAttempts); + + ValidationResponse validationResponse = this.jsonvalidator.validate(this.jsonSchema, json); + + if (!validationResponse.valid()) { + logger.warn("JSON validation failed: " + validationResponse); + } + else { + logger.info("JSON validation succeeded"); + } + + return validationResponse.valid(); + } + + @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 repeatAttempts = 3; + + 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 repeatAttempts(int repeatAttempts) { + this.repeatAttempts = repeatAttempts; + 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.repeatAttempts); + } + + } + +} 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..57b8dd37c1a --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java @@ -0,0 +1,192 @@ +/* + * 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.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 = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this) + .nextCall(processedChatClientRequest); + + // After Call + + // TODO: check that this is tool call is sufficiant for all chat models + // that support tool calls. + isToolCall = chatClientResponse.chatResponse().hasToolCalls(); + + if (isToolCall) { + + ToolExecutionResult toolExecutionResult = this.toolCallingManager + .executeToolCalls(processedChatClientRequest.prompt(), chatClientResponse.chatResponse()); + + 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/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..bd1016ec21f 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 @@ -18,15 +18,21 @@ import java.util.List; +import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +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.messages.AssistantMessage; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.BDDMockito.given; @@ -37,6 +43,7 @@ * * @author ghdcksgml1 * @author Thomas Vitale + * @author Christian Tzolov */ class AdvisorUtilsTests { @@ -99,4 +106,151 @@ void whenChatIsStopThenReturnTrue() { } + @Nested + class CopyChainAfterAdvisor { + + @Test + void whenCallAdvisorChainIsNullThenThrowException() { + CallAdvisor advisor = mock(CallAdvisor.class); + + assertThatThrownBy(() -> AdvisorUtils.copyChainAfterAdvisor(null, advisor)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("callAdvisorChain must not be null"); + } + + @Test + void whenAfterAdvisorIsNullThenThrowException() { + CallAdvisorChain chain = mock(CallAdvisorChain.class); + + assertThatThrownBy(() -> AdvisorUtils.copyChainAfterAdvisor(chain, 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(() -> AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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..f9596338976 --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisorTests.java @@ -0,0 +1,642 @@ +/* + * 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() { + }).repeatAttempts(-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) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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

() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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); + } + + // 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; + } + + } + +} 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..badb16073a6 --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java @@ -0,0 +1,417 @@ +/* + * 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 io.micrometer.observation.ObservationRegistry; +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.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.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 CallAdvisor() { + @Override + public String getName() { + return "terminal"; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) { + return 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 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 CallAdvisor() { + @Override + public String getName() { + return "terminal"; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain 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"), + new AssistantMessage("", Map.of(), List.of()), new ToolResponseMessage(List.of())); + 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 CallAdvisor() { + @Override + public String getName() { + return "terminal"; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain 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"), + new AssistantMessage("", Map.of(), List.of()), new ToolResponseMessage(List.of())); + 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 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 CallAdvisor() { + @Override + public String getName() { + return "capturing"; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain 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, + org.mockito.Mockito.withSettings().lenient()); + // Create a separate mock for the copy that tracks the internal state + ToolCallingChatOptions copiedOptions = mock(ToolCallingChatOptions.class, + org.mockito.Mockito.withSettings().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 + org.mockito.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) { + ChatResponse chatResponse = mock(ChatResponse.class, org.mockito.Mockito.withSettings().lenient()); + when(chatResponse.hasToolCalls()).thenReturn(hasToolCalls); + + Generation generation = mock(Generation.class, org.mockito.Mockito.withSettings().lenient()); + when(generation.getOutput()).thenReturn(new AssistantMessage("response")); + when(chatResponse.getResults()).thenReturn(List.of(generation)); + + ChatClientResponse response = mock(ChatClientResponse.class, org.mockito.Mockito.withSettings().lenient()); + when(response.chatResponse()).thenReturn(chatResponse); + + return response; + } + +} 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 0000000000000000000000000000000000000000..e377090c6ec0579bec5915c5fa299b86c47bab1c GIT binary patch literal 35768 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≥yJe6AlR5 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 literal 0 HcmV?d00001 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..4528f5893d1 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc @@ -0,0 +1,78 @@ +[[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 `AdvisorUtils.copyChainAfterAdvisor()` 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 current 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 +* Uses `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls + +Example usage: + +[source,java] +---- +var toolCallAdvisor = ToolCallAdvisor.builder() + .toolCallingManager(toolCallingManager) + .advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300) + .build(); + +var chatClient = ChatClient.builder(chatModel) + .defaultAdvisors(toolCallAdvisor) + .build(); +---- + +=== 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 +* Uses `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls + +Example usage: + +[source,java] +---- +var validationAdvisor = StructuredOutputValidationAdvisor.builder() + .outputType(MyResponseType.class) + .repeatAttempts(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 { From 80cdea1a624eeaac88b100dd08dd79b16cc09332 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 13 Oct 2025 18:06:22 +0200 Subject: [PATCH 2/7] refactor(advisor): enhance StructuredOutputValidationAdvisor with validation feedback loop Refactor retry logic to provide validation error feedback to LLM for self-correction. Extract validation into separate method and augment prompts with error messages on retry. Add comprehensive test coverage for various validation scenarios including nested objects, lists, malformed JSON, and type mismatches. Signed-off-by: Christian Tzolov --- .../StructuredOutputValidationAdvisor.java | 60 ++- ...tructuredOutputValidationAdvisorTests.java | 500 ++++++++++++++++++ .../ROOT/pages/api/advisors-recursive.adoc | 1 + 3 files changed, 556 insertions(+), 5 deletions(-) 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 index 5c534ed108f..0a8a38bc152 100644 --- 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 @@ -37,6 +37,7 @@ 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; @@ -128,22 +129,70 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd ChatClientResponse chatClientResponse = null; - var counter = new AtomicInteger(this.repeatAttempts); + var repeatCounter = new AtomicInteger(0); + + boolean isValidationSuccess = true; + + var processedChatClientRequest = chatClientRequest; do { // Before Call - counter.decrementAndGet(); + repeatCounter.incrementAndGet(); // Next Call - chatClientResponse = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this).nextCall(chatClientRequest); + chatClientResponse = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this) + .nextCall(processedChatClientRequest); // After Call + ValidationResponse validationResponse = 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 (!isOutputSchemaValid(chatClientResponse) && counter.get() >= 0); + while (!isValidationSuccess && repeatCounter.get() <= this.repeatAttempts); 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.repeatAttempts); + + return this.jsonvalidator.validate(this.jsonSchema, json); + } + @SuppressWarnings("null") private boolean isOutputSchemaValid(ChatClientResponse chatClientResponse) { @@ -155,9 +204,10 @@ private boolean isOutputSchemaValid(ChatClientResponse chatClientResponse) { return false; } + // TODO: should we consider validation for multiple results? String json = chatClientResponse.chatResponse().getResult().getOutput().getText(); - logger.info("Validating JSON output against schema. Attempts left: " + this.repeatAttempts); + logger.debug("Validating JSON output against schema. Attempts left: " + this.repeatAttempts); ValidationResponse validationResponse = this.jsonvalidator.validate(this.jsonSchema, json); 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 index f9596338976..57fc4b1775b 100644 --- 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 @@ -562,6 +562,472 @@ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain cha assertThat(callCount[0]).isEqualTo(4); } + @Test + void testPromptAugmentationWithValidationError() { + StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() + .outputType(new TypeRef() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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>() { + }) + .repeatAttempts(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>() { + }) + .repeatAttempts(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() { + }) + .repeatAttempts(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() { @@ -639,4 +1105,38 @@ public void setZipCode(String 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-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 index 4528f5893d1..75b17e175e3 100644 --- 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 @@ -60,6 +60,7 @@ 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 `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls Example usage: From df6c6024464312badc663c7fa11ed7fd9308a262 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 14 Oct 2025 10:12:34 +0200 Subject: [PATCH 3/7] Improve StructuredOutputValidationAdvisor logic Signed-off-by: Christian Tzolov --- .../StructuredOutputValidationAdvisor.java | 84 +++++++------------ ...tructuredOutputValidationAdvisorTests.java | 38 ++++----- 2 files changed, 50 insertions(+), 72 deletions(-) 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 index 0a8a38bc152..8055f57ab7d 100644 --- 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 @@ -81,7 +81,7 @@ public final class StructuredOutputValidationAdvisor implements CallAdvisor, Str */ private final DefaultJsonSchemaValidator jsonvalidator; - private final int repeatAttempts; + private final int maxRepeatAttempts; private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int repeatAttempts) { Assert.notNull(advisorOrder, "advisorOrder must not be null"); @@ -107,7 +107,7 @@ private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int throw new IllegalArgumentException("Failed to parse JSON schema", e); } - this.repeatAttempts = repeatAttempts; + this.maxRepeatAttempts = repeatAttempts; } @SuppressWarnings("null") @@ -144,32 +144,38 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd .nextCall(processedChatClientRequest); // After Call - ValidationResponse validationResponse = validateOutputSchema(chatClientResponse); - isValidationSuccess = validationResponse.valid(); + // We should not validate tool call requests, only the content of the final + // response. + if (chatClientResponse.chatResponse() == null || !chatClientResponse.chatResponse().hasToolCalls()) { - if (!isValidationSuccess) { + ValidationResponse validationResponse = this.validateOutputSchema(chatClientResponse); - // 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); + isValidationSuccess = validationResponse.valid(); - String validationErrorMessage = "Output JSON validation failed because of: " - + validationResponse.errorMessage(); + if (!isValidationSuccess) { - Prompt augmentedPrompt = chatClientRequest.prompt() - .augmentUserMessage(userMessage -> userMessage.mutate() - .text(userMessage.getText() + System.lineSeparator() + validationErrorMessage) - .build()); + // 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); - processedChatClientRequest = chatClientRequest.mutate().prompt(augmentedPrompt).build(); + 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.get() <= this.repeatAttempts); + while (!isValidationSuccess && repeatCounter.get() <= this.maxRepeatAttempts); return chatClientResponse; } @@ -188,39 +194,11 @@ private ValidationResponse validateOutputSchema(ChatClientResponse chatClientRes // 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.repeatAttempts); + logger.debug("Validating JSON output against schema. Attempts left: " + this.maxRepeatAttempts); return this.jsonvalidator.validate(this.jsonSchema, json); } - @SuppressWarnings("null") - private boolean isOutputSchemaValid(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 false; - } - - // 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.repeatAttempts); - - ValidationResponse validationResponse = this.jsonvalidator.validate(this.jsonSchema, json); - - if (!validationResponse.valid()) { - logger.warn("JSON validation failed: " + validationResponse); - } - else { - logger.info("JSON validation succeeded"); - } - - return validationResponse.valid(); - } - @SuppressWarnings("null") @Override public Flux adviseStream(ChatClientRequest chatClientRequest, @@ -254,7 +232,7 @@ public final static class Builder { private Type outputType; - private int repeatAttempts = 3; + private int maxRepeatAttempts = 3; private Builder() { } @@ -317,8 +295,8 @@ public Builder outputType(ParameterizedTypeReference outputType) { * @param repeatAttempts the number of repeat attempts * @return this builder */ - public Builder repeatAttempts(int repeatAttempts) { - this.repeatAttempts = repeatAttempts; + public Builder maxRepeatAttempts(int repeatAttempts) { + this.maxRepeatAttempts = repeatAttempts; return this; } @@ -331,7 +309,7 @@ public StructuredOutputValidationAdvisor build() { if (this.outputType == null) { throw new IllegalArgumentException("outputType must be set"); } - return new StructuredOutputValidationAdvisor(this.advisorOrder, this.outputType, this.repeatAttempts); + return new StructuredOutputValidationAdvisor(this.advisorOrder, this.outputType, this.maxRepeatAttempts); } } 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 index 57fc4b1775b..994eb7277ce 100644 --- 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 @@ -80,7 +80,7 @@ void whenAdvisorOrderIsOutOfRangeThenThrow() { @Test void whenRepeatAttemptsIsNegativeThenThrow() { assertThatThrownBy(() -> StructuredOutputValidationAdvisor.builder().outputType(new TypeRef() { - }).repeatAttempts(-1).build()).isInstanceOf(IllegalArgumentException.class) + }).maxRepeatAttempts(-1).build()).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("repeatAttempts must be greater than or equal to 0"); } @@ -94,7 +94,7 @@ void testBuilderMethodChainingWithTypeRef() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(typeRef) .advisorOrder(customOrder) - .repeatAttempts(customAttempts) + .maxRepeatAttempts(customAttempts) .build(); assertThat(advisor).isNotNull(); @@ -175,7 +175,7 @@ void testAdviseCallWithValidJsonOnFirstAttempt() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(3) + .maxRepeatAttempts(3) .build(); ChatClientRequest request = createMockRequest(); @@ -217,7 +217,7 @@ void testAdviseCallWithInvalidJsonRetries() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(2) + .maxRepeatAttempts(2) .build(); ChatClientRequest request = createMockRequest(); @@ -261,7 +261,7 @@ void testAdviseCallExhaustsAllRetries() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(2) + .maxRepeatAttempts(2) .build(); ChatClientRequest request = createMockRequest(); @@ -304,7 +304,7 @@ void testAdviseCallWithZeroRetries() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(0) + .maxRepeatAttempts(0) .build(); ChatClientRequest request = createMockRequest(); @@ -347,7 +347,7 @@ void testAdviseCallWithNullChatResponse() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -392,7 +392,7 @@ void testAdviseCallWithNullResult() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -439,7 +439,7 @@ void testAdviseCallWithComplexType() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef
() { }) - .repeatAttempts(2) + .maxRepeatAttempts(2) .build(); ChatClientRequest request = createMockRequest(); @@ -513,7 +513,7 @@ void testMultipleRetriesWithDifferentInvalidResponses() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(3) + .maxRepeatAttempts(3) .build(); ChatClientRequest request = createMockRequest(); @@ -567,7 +567,7 @@ void testPromptAugmentationWithValidationError() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -625,7 +625,7 @@ void testValidationWithEmptyJsonString() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -669,7 +669,7 @@ void testValidationWithMalformedJson() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -714,7 +714,7 @@ void testValidationWithExtraFields() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(0) + .maxRepeatAttempts(0) .build(); ChatClientRequest request = createMockRequest(); @@ -754,7 +754,7 @@ void testValidationWithNestedObject() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(2) + .maxRepeatAttempts(2) .build(); ChatClientRequest request = createMockRequest(); @@ -792,7 +792,7 @@ void testValidationWithInvalidNestedObject() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -837,7 +837,7 @@ void testValidationWithListType() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef>() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -875,7 +875,7 @@ void testValidationWithInvalidListType() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef>() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); @@ -920,7 +920,7 @@ void testValidationWithWrongTypeInField() { StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder() .outputType(new TypeRef() { }) - .repeatAttempts(1) + .maxRepeatAttempts(1) .build(); ChatClientRequest request = createMockRequest(); From 04198885bbb1a73921786188bb203c7a7ca4148d Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 14 Oct 2025 15:28:55 +0200 Subject: [PATCH 4/7] feat: Add return direct support and null safety to ToolCallAdvisor Implements return direct functionality allowing tools to bypass the LLM and return results directly to clients. Adds null safety checks for chatResponse and comprehensive test coverage. Signed-off-by: Christian Tzolov --- .../chat/client/advisor/ToolCallAdvisor.java | 21 +- .../client/advisor/ToolCallAdvisorTests.java | 222 +++++++++++------- .../ROOT/pages/api/advisors-recursive.adoc | 17 ++ 3 files changed, 177 insertions(+), 83 deletions(-) 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 index 57b8dd37c1a..3d8286fe320 100644 --- 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 @@ -25,6 +25,7 @@ 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; @@ -112,14 +113,30 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd // After Call // TODO: check that this is tool call is sufficiant for all chat models - // that support tool calls. - isToolCall = chatClientResponse.chatResponse().hasToolCalls(); + // 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()) { + // Interupt the tool calling loop and return the tool execution result + // directly to the client application instead of returning it tothe + // LLM. + isToolCall = false; + + // Return tool execution result directly to the application client. + chatClientResponse = chatClientResponse.mutate() + .chatResponse(ChatResponse.builder() + .from(chatClientResponse.chatResponse()) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build()) + .build(); + } + instructions = toolExecutionResult.conversationHistory(); } 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 index badb16073a6..a56b54f0810 100644 --- 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 @@ -18,12 +18,15 @@ 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; @@ -36,6 +39,7 @@ 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; @@ -162,22 +166,28 @@ void testAdviseCallWithoutToolCalls() { ChatClientResponse response = createMockResponse(false); // Create a terminal advisor that returns the response - CallAdvisor terminalAdvisor = new CallAdvisor() { - @Override - public String getName() { - return "terminal"; - } + CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> response); - @Override - public int getOrder() { - return 0; - } + // Create a real chain with both advisors + CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP) + .pushAll(List.of(advisor, terminalAdvisor)) + .build(); - @Override - public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) { - return response; - } - }; + 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) @@ -186,7 +196,7 @@ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain cha ChatClientResponse result = advisor.adviseCall(request, realChain); - assertThat(result).isEqualTo(response); + assertThat(result).isEqualTo(responseWithNullChatResponse); verify(this.toolCallingManager, times(0)).executeToolCalls(any(), any()); } @@ -200,23 +210,11 @@ void testAdviseCallWithSingleToolCallIteration() { // Create a terminal advisor that returns responses in sequence int[] callCount = { 0 }; - CallAdvisor terminalAdvisor = new CallAdvisor() { - @Override - public String getName() { - return "terminal"; - } - @Override - public int getOrder() { - return 0; - } - - @Override - public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) { - callCount[0]++; - return callCount[0] == 1 ? responseWithToolCall : finalResponse; - } - }; + 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) @@ -225,7 +223,7 @@ public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain cha // Mock tool execution result List conversationHistory = List.of(new UserMessage("test"), - new AssistantMessage("", Map.of(), List.of()), new ToolResponseMessage(List.of())); + AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build()); ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder() .conversationHistory(conversationHistory) .build(); @@ -250,31 +248,18 @@ void testAdviseCallWithMultipleToolCallIterations() { // Create a terminal advisor that returns responses in sequence int[] callCount = { 0 }; - CallAdvisor terminalAdvisor = new CallAdvisor() { - @Override - public String getName() { - return "terminal"; + CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> { + callCount[0]++; + if (callCount[0] == 1) { + return firstToolCallResponse; } - - @Override - public int getOrder() { - return 0; + else if (callCount[0] == 2) { + return secondToolCallResponse; } - - @Override - public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) { - callCount[0]++; - if (callCount[0] == 1) { - return firstToolCallResponse; - } - else if (callCount[0] == 2) { - return secondToolCallResponse; - } - else { - return finalResponse; - } + else { + return finalResponse; } - }; + }); // Create a real chain with both advisors CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP) @@ -284,7 +269,7 @@ else if (callCount[0] == 2) { // Mock tool execution results AssistantMessage.builder().build(); List conversationHistory = List.of(new UserMessage("test"), - new AssistantMessage("", Map.of(), List.of()), new ToolResponseMessage(List.of())); + AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build()); ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder() .conversationHistory(conversationHistory) .build(); @@ -298,6 +283,49 @@ else if (callCount[0] == 2) { 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(); @@ -307,23 +335,11 @@ void testInternalToolExecutionIsDisabled() { // Use a simple holder to capture the request ChatClientRequest[] capturedRequest = new ChatClientRequest[1]; - CallAdvisor capturingAdvisor = new CallAdvisor() { - @Override - public String getName() { - return "capturing"; - } - - @Override - public int getOrder() { - return 0; - } - @Override - public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) { - capturedRequest[0] = req; - return response; - } - }; + CallAdvisor capturingAdvisor = new TerminalCallAdvisor((req, chain) -> { + capturedRequest[0] = req; + return response; + }); CallAdvisorChain capturingChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP) .pushAll(List.of(advisor, capturingAdvisor)) @@ -369,10 +385,10 @@ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) { ChatOptions options = null; if (withToolCallingOptions) { ToolCallingChatOptions toolOptions = mock(ToolCallingChatOptions.class, - org.mockito.Mockito.withSettings().lenient()); + Mockito.withSettings().strictness(Strictness.LENIENT)); // Create a separate mock for the copy that tracks the internal state ToolCallingChatOptions copiedOptions = mock(ToolCallingChatOptions.class, - org.mockito.Mockito.withSettings().lenient()); + Mockito.withSettings().strictness(Strictness.LENIENT)); // Use a holder to track the state boolean[] internalToolExecutionEnabled = { true }; @@ -387,7 +403,7 @@ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) { // When setInternalToolExecutionEnabled is called on the copy, update the // state - org.mockito.Mockito.doAnswer(invocation -> { + Mockito.doAnswer(invocation -> { internalToolExecutionEnabled[0] = invocation.getArgument(0); return null; }).when(copiedOptions).setInternalToolExecutionEnabled(org.mockito.ArgumentMatchers.anyBoolean()); @@ -401,17 +417,61 @@ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) { } private ChatClientResponse createMockResponse(boolean hasToolCalls) { - ChatResponse chatResponse = mock(ChatResponse.class, org.mockito.Mockito.withSettings().lenient()); - when(chatResponse.hasToolCalls()).thenReturn(hasToolCalls); - - Generation generation = mock(Generation.class, org.mockito.Mockito.withSettings().lenient()); + Generation generation = mock(Generation.class, Mockito.withSettings().strictness(Strictness.LENIENT)); when(generation.getOutput()).thenReturn(new AssistantMessage("response")); - when(chatResponse.getResults()).thenReturn(List.of(generation)); - ChatClientResponse response = mock(ChatClientResponse.class, org.mockito.Mockito.withSettings().lenient()); - when(response.chatResponse()).thenReturn(chatResponse); + // 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/pages/api/advisors-recursive.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc index 75b17e175e3..86a9a0c5eda 100644 --- 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 @@ -35,7 +35,9 @@ 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 `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls +* Includes null safety checks to handle cases where the chat response might be null Example usage: @@ -51,6 +53,21 @@ var chatClient = ChatClient.builder(chatModel) .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. Interrupt 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. From 77fd4c54e469e182e299e80ae0d099017ca36e32 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 16 Oct 2025 14:22:02 +0200 Subject: [PATCH 5/7] refactor: Move chain copying logic from AdvisorUtils to CallAdvisorChain interface - Add CallAdvisorChain.copy(CallAdvisor after) method to replace AdvisorUtils.copyChainAfterAdvisor() - Implement copy() method in DefaultAroundAdvisorChain - Update StructuredOutputValidationAdvisor and ToolCallAdvisor to use new copy() API - Add ObjectMapper parameter support to StructuredOutputValidationAdvisor for custom JSON processing - Improve ToolCallAdvisor return direct logic using break instead of flag - Move tests from AdvisorUtilsTests to DefaultAroundAdvisorChainTests - Update documentation to reflect API changes This refactoring improves the API design by moving chain manipulation logic closer to where it belongs (on the chain itself) rather than in a utility class. Signed-off-by: Christian Tzolov --- .../ai/chat/client/advisor/AdvisorUtils.java | 27 ---- .../advisor/DefaultAroundAdvisorChain.java | 19 +++ .../StructuredOutputValidationAdvisor.java | 36 +++-- .../chat/client/advisor/ToolCallAdvisor.java | 12 +- .../client/advisor/api/CallAdvisorChain.java | 11 ++ .../client/advisor/AdvisorUtilsTests.java | 147 ------------------ .../DefaultAroundAdvisorChainTests.java | 131 ++++++++++++++++ .../ROOT/pages/api/advisors-recursive.adoc | 15 +- 8 files changed, 200 insertions(+), 198 deletions(-) diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java index 292e563c44d..de379f51e70 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java @@ -52,31 +52,4 @@ public static Predicate onFinishReason() { }; } - /** - * Creates a new CallAdvisorChain copy that contains all advisors after the specified - * advisor. - * @param callAdvisorChain the original CallAdvisorChain - * @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 - */ - public static CallAdvisorChain copyChainAfterAdvisor(CallAdvisorChain callAdvisorChain, CallAdvisor after) { - - Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null"); - Assert.notNull(after, "The after call advisor must not be null"); - - List callAdvisors = callAdvisorChain.getCallAdvisors(); - int afterAdvisorIndex = callAdvisors.indexOf(after); - - if (afterAdvisorIndex < 0) { - throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName()); - } - - var remainingCallAdvisors = callAdvisors.subList(afterAdvisorIndex + 1, callAdvisors.size()); - - return DefaultAroundAdvisorChain.builder(callAdvisorChain.getObservationRegistry()) - .pushAll(remainingCallAdvisors) - .build(); - } - } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java index 310687a2f87..97beb4fbe6c 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java @@ -30,6 +30,7 @@ import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.client.advisor.api.BaseAdvisorChain; 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.observation.AdvisorObservationContext; import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; @@ -133,6 +134,24 @@ public Flux nextStream(ChatClientRequest chatClientRequest) }); } + @Override + public CallAdvisorChain copy(CallAdvisor after) { + + Assert.notNull(after, "The after call advisor must not be null"); + + List callAdvisors = this.getCallAdvisors(); + + int afterAdvisorIndex = callAdvisors.indexOf(after); + + if (afterAdvisorIndex < 0) { + throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName()); + } + + var remainingCallAdvisors = callAdvisors.subList(afterAdvisorIndex + 1, callAdvisors.size()); + + return DefaultAroundAdvisorChain.builder(this.getObservationRegistry()).pushAll(remainingCallAdvisors).build(); + } + @Override public List getCallAdvisors() { return this.originalCallAdvisors; 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 index 8055f57ab7d..9f86abdf311 100644 --- 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 @@ -19,9 +19,9 @@ import java.lang.reflect.Type; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; 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; @@ -83,16 +83,18 @@ public final class StructuredOutputValidationAdvisor implements CallAdvisor, Str private final int maxRepeatAttempts; - private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int repeatAttempts) { + 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(repeatAttempts >= 0, "repeatAttempts must be greater than or equal to 0"); + 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(); + this.jsonvalidator = new DefaultJsonSchemaValidator(objectMapper); String jsonSchemaText = JsonSchemaGenerator.generateForType(outputType); @@ -107,7 +109,7 @@ private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int throw new IllegalArgumentException("Failed to parse JSON schema", e); } - this.maxRepeatAttempts = repeatAttempts; + this.maxRepeatAttempts = maxRepeatAttempts; } @SuppressWarnings("null") @@ -129,7 +131,7 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd ChatClientResponse chatClientResponse = null; - var repeatCounter = new AtomicInteger(0); + var repeatCounter = 0; boolean isValidationSuccess = true; @@ -137,11 +139,10 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd do { // Before Call - repeatCounter.incrementAndGet(); + repeatCounter++; // Next Call - chatClientResponse = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this) - .nextCall(processedChatClientRequest); + chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest); // After Call @@ -175,7 +176,7 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd } } } - while (!isValidationSuccess && repeatCounter.get() <= this.maxRepeatAttempts); + while (!isValidationSuccess && repeatCounter <= this.maxRepeatAttempts); return chatClientResponse; } @@ -234,6 +235,8 @@ public final static class Builder { private int maxRepeatAttempts = 3; + private ObjectMapper objectMapper = JsonParser.getObjectMapper(); + private Builder() { } @@ -300,6 +303,16 @@ public Builder maxRepeatAttempts(int 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 @@ -309,7 +322,8 @@ public StructuredOutputValidationAdvisor build() { if (this.outputType == null) { throw new IllegalArgumentException("outputType must be set"); } - return new StructuredOutputValidationAdvisor(this.advisorOrder, this.outputType, this.maxRepeatAttempts); + 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 index 3d8286fe320..650d92c4dcc 100644 --- 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 @@ -107,8 +107,7 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd .build(); // Next Call - chatClientResponse = AdvisorUtils.copyChainAfterAdvisor(callAdvisorChain, this) - .nextCall(processedChatClientRequest); + chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest); // After Call @@ -123,10 +122,6 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd .executeToolCalls(processedChatClientRequest.prompt(), chatClientResponse.chatResponse()); if (toolExecutionResult.returnDirect()) { - // Interupt the tool calling loop and return the tool execution result - // directly to the client application instead of returning it tothe - // LLM. - isToolCall = false; // Return tool execution result directly to the application client. chatClientResponse = chatClientResponse.mutate() @@ -135,6 +130,11 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd .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(); 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..bd7dcc1d395 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 @@ -20,6 +20,8 @@ import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.DefaultAroundAdvisorChain; +import org.springframework.util.Assert; /** * A chain of {@link CallAdvisor} instances orchestrating the execution of a @@ -44,4 +46,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 + */ + public 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 bd1016ec21f..dc783bfe7bf 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 @@ -106,151 +106,4 @@ void whenChatIsStopThenReturnTrue() { } - @Nested - class CopyChainAfterAdvisor { - - @Test - void whenCallAdvisorChainIsNullThenThrowException() { - CallAdvisor advisor = mock(CallAdvisor.class); - - assertThatThrownBy(() -> AdvisorUtils.copyChainAfterAdvisor(null, advisor)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("callAdvisorChain must not be null"); - } - - @Test - void whenAfterAdvisorIsNullThenThrowException() { - CallAdvisorChain chain = mock(CallAdvisorChain.class); - - assertThatThrownBy(() -> AdvisorUtils.copyChainAfterAdvisor(chain, 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(() -> AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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 = AdvisorUtils.copyChainAfterAdvisor(chain, 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/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-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 index 86a9a0c5eda..0c19258fcc5 100644 --- 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 @@ -2,7 +2,7 @@ = Recursive Advisors -== What is a Recursive Advisor +== 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. @@ -13,8 +13,8 @@ This pattern is useful when you need to repeatedly call the LLM until a certain * Implementing Evaluation logic with modifications to the request * Implementing retry logic with modifications to the request -The `AdvisorUtils.copyChainAfterAdvisor()` 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 current advisor in the original chain +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: @@ -36,7 +36,7 @@ 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 `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls +* 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: @@ -65,7 +65,7 @@ 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. Interrupt the tool calling loop +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 @@ -78,7 +78,8 @@ Key features: * 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 `AdvisorUtils.copyChainAfterAdvisor()` to create a sub-chain for recursive calls +* Uses `callAdvisorChain.copy(this)` to create a sub-chain for recursive calls +* Optionally supports a custom `ObjectMapper` for JSON processing Example usage: @@ -86,7 +87,7 @@ Example usage: ---- var validationAdvisor = StructuredOutputValidationAdvisor.builder() .outputType(MyResponseType.class) - .repeatAttempts(3) + .maxRepeatAttempts(3) .advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 1000) .build(); From 6d38cf6d6b75f779e0b518769da1e83b1797ca15 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 16 Oct 2025 15:43:22 +0200 Subject: [PATCH 6/7] fix checkstyle issues Signed-off-by: Christian Tzolov --- .../ai/chat/client/advisor/AdvisorUtils.java | 4 ---- .../ai/chat/client/advisor/api/CallAdvisorChain.java | 4 +--- .../ai/chat/client/advisor/AdvisorUtilsTests.java | 6 ------ 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java index de379f51e70..5a214cb15ae 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java @@ -16,14 +16,10 @@ package org.springframework.ai.chat.client.advisor; -import java.util.List; import java.util.function.Predicate; 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.model.ChatResponse; -import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** 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 bd7dcc1d395..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 @@ -20,8 +20,6 @@ import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; -import org.springframework.ai.chat.client.advisor.DefaultAroundAdvisorChain; -import org.springframework.util.Assert; /** * A chain of {@link CallAdvisor} instances orchestrating the execution of a @@ -53,6 +51,6 @@ public interface CallAdvisorChain extends AdvisorChain { * @return a new CallAdvisorChain containing all advisors after the specified advisor * @throws IllegalArgumentException if the specified advisor is not part of the chain */ - public CallAdvisorChain copy(CallAdvisor after); + 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 dc783bfe7bf..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 @@ -18,21 +18,15 @@ import java.util.List; -import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -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.messages.AssistantMessage; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.BDDMockito.given; From fe50ccbd72402d403764f548b54320114dbbe465 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 16 Oct 2025 15:48:08 +0200 Subject: [PATCH 7/7] revier the max attmept to allow 0 Signed-off-by: Christian Tzolov --- .../chat/client/advisor/StructuredOutputValidationAdvisor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9f86abdf311..24172c66461 100644 --- 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 @@ -89,7 +89,7 @@ private StructuredOutputValidationAdvisor(int advisorOrder, Type outputType, int 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.isTrue(maxRepeatAttempts >= 0, "repeatAttempts must be greater than or equal to 0"); Assert.notNull(objectMapper, "objectMapper must not be null"); this.advisorOrder = advisorOrder;