From ae0c8d5410f032a960e05c684ad8256267e34b9b Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Fri, 6 Mar 2026 10:59:01 +0100 Subject: [PATCH] Disable validation for filtered fields This commit disables Jakarta validation for fields/properties that have been disabled using the field filter. Closes: gh-1471 Signed-off-by: Arjen Poutsma --- .../spi/support/AbstractLlmOperations.kt | 19 ++++ .../DefaultValidationPromptGenerator.kt | 28 ++++-- .../validation/ValidationPromptGenerator.kt | 3 + .../support/ChatClientLlmOperationsTest.kt | 60 +++++++++++++ .../DefaultValidationPromptGeneratorTest.kt | 89 +++++++++++++++++++ 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/AbstractLlmOperations.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/AbstractLlmOperations.kt index b08d32d68..076204637 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/AbstractLlmOperations.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/AbstractLlmOperations.kt @@ -36,13 +36,16 @@ import com.embabel.common.ai.model.ModelProvider import com.embabel.common.ai.model.ModelSelectionCriteria import com.embabel.common.core.thinking.ThinkingResponse import com.embabel.common.util.time +import jakarta.validation.ConstraintViolation import jakarta.validation.Validator import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.lang.reflect.Field import java.time.Duration import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import java.util.function.Predicate // Log message constants to avoid duplication private const val LLM_TIMEOUT_MESSAGE = "LLM {}: attempt {} timed out after {}ms" @@ -162,6 +165,7 @@ abstract class AbstractLlmOperations( validationPromptGenerator.generateRequirementsPrompt( validator = validator, outputClass = outputClass, + fieldFilter = interaction.fieldFilter, ) ) } else { @@ -186,6 +190,8 @@ abstract class AbstractLlmOperations( } if (interaction.validation) { var constraintViolations = validator.validate(candidate) + constraintViolations = + filterConstraintViolations(constraintViolations, outputClass, interaction.fieldFilter) if (constraintViolations.isNotEmpty()) { // If we had violations, try again, once, before throwing an exception candidate = dataBindingProperties.retryTemplate(interaction.id.value) @@ -207,6 +213,8 @@ abstract class AbstractLlmOperations( } } constraintViolations = validator.validate(candidate) + constraintViolations = + filterConstraintViolations(constraintViolations, outputClass, interaction.fieldFilter) if (constraintViolations.isNotEmpty()) { throw InvalidLlmReturnTypeException( returnedObject = candidate as Any, @@ -227,6 +235,17 @@ abstract class AbstractLlmOperations( return createdObject } + private fun filterConstraintViolations( + constraintViolations: Set>, + outputClass: Class, + fieldFilter: Predicate, + ): Set> = + constraintViolations.filterTo(mutableSetOf()) { violation -> + runCatching { outputClass.getDeclaredField(violation.propertyPath.toString()) } + .map { fieldFilter.test(it) } + .getOrDefault(true) + } + final override fun createObjectIfPossible( messages: List, interaction: LlmInteraction, diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGenerator.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGenerator.kt index 0191052e7..1d5e2c2bd 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGenerator.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGenerator.kt @@ -17,25 +17,30 @@ package com.embabel.agent.spi.validation import jakarta.validation.ConstraintViolation import jakarta.validation.Validator +import java.lang.reflect.Field +import java.util.function.Predicate class DefaultValidationPromptGenerator : ValidationPromptGenerator { override fun generateRequirementsPrompt( validator: Validator, outputClass: Class<*>, + fieldFilter: Predicate, ): String { val descriptor = validator.getConstraintsForClass(outputClass) val requirements = mutableListOf() descriptor.constrainedProperties.forEach { propertyDescriptor -> val propertyName = propertyDescriptor.propertyName - val constraints = propertyDescriptor.constraintDescriptors + if (filter(propertyName, outputClass, fieldFilter)) { + val constraints = propertyDescriptor.constraintDescriptors - constraints.forEach { constraint -> - val annotationType = constraint.annotation.annotationClass.simpleName - val message = constraint.messageTemplate + constraints.forEach { constraint -> + val annotationType = constraint.annotation.annotationClass.simpleName + val message = constraint.messageTemplate - requirements.add("- Field '$propertyName': $annotationType constraint ($message)") + requirements.add("- Field '$propertyName': $annotationType constraint ($message)") + } } } @@ -46,6 +51,19 @@ class DefaultValidationPromptGenerator : ValidationPromptGenerator { } } + private fun filter( + propertyName: String, + outputClass: Class<*>, + fieldFilter: Predicate, + ): Boolean = try { + val field = outputClass.getDeclaredField(propertyName) + fieldFilter.test(field) + } catch (_: NoSuchFieldException) { + true + } catch (_: SecurityException) { + true + } + /** * (b) Generate a string based on actual constraint violations * This describes what went wrong after validation diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/ValidationPromptGenerator.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/ValidationPromptGenerator.kt index dc7960e05..1ea0ff038 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/ValidationPromptGenerator.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/validation/ValidationPromptGenerator.kt @@ -17,6 +17,8 @@ package com.embabel.agent.spi.validation import jakarta.validation.ConstraintViolation import jakarta.validation.Validator +import java.lang.reflect.Field +import java.util.function.Predicate /** * Generate validation prompts for JSR-380 annotated types @@ -30,6 +32,7 @@ interface ValidationPromptGenerator { fun generateRequirementsPrompt( validator: Validator, outputClass: Class<*>, + fieldFilter: Predicate = Predicate { true }, ): String /** diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt index 2a93c0fe9..fb3cc2170 100644 --- a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt @@ -59,6 +59,7 @@ import org.springframework.ai.chat.prompt.Prompt import org.springframework.ai.model.tool.ToolCallingChatOptions import java.time.LocalDate import java.util.concurrent.Executors +import java.util.function.Predicate import kotlin.test.assertEquals /** @@ -1339,6 +1340,65 @@ class ChatClientLlmOperationsTest { ) assertEquals(invalidHusky, createdDog, "Invalid response should have been corrected") } + + @Test + fun `field filter suppresses constraint violation for excluded field`() { + data class BorderCollie( + val name: String, + @field:Pattern(regexp = "^mince$", message = "eats field must be 'mince'") + val eats: String, + ) + + val invalidHusky = BorderCollie("Husky", eats = "kibble") + val fakeChatModel = FakeChatModel(jacksonObjectMapper().writeValueAsString(invalidHusky)) + val setup = createChatClientLlmOperations(fakeChatModel) + + // Exclude 'eats' from the field filter — its constraint violation should be ignored + val result = setup.llmOperations.createObject( + messages = listOf(UserMessage("prompt")), + interaction = LlmInteraction( + id = InteractionId("id"), + llm = LlmOptions(), + fieldFilter = Predicate { field -> field.name != "eats" }, + ), + outputClass = BorderCollie::class.java, + action = SimpleTestAgent.actions.first(), + agentProcess = setup.mockAgentProcess, + ) + + assertEquals(invalidHusky, result, "Filtered-out field violation should not block the result") + } + + @Test + fun `field filter does not suppress constraint violation for included field`() { + data class BorderCollie( + val name: String, + @field:Pattern(regexp = "^mince$", message = "eats field must be 'mince'") + val eats: String, + ) + + val invalidHusky = BorderCollie("Husky", eats = "kibble") + val fakeChatModel = FakeChatModel(jacksonObjectMapper().writeValueAsString(invalidHusky)) + val setup = createChatClientLlmOperations(fakeChatModel) + + // 'eats' is still included in the filter — violation should be raised + try { + setup.llmOperations.createObject( + messages = listOf(UserMessage("prompt")), + interaction = LlmInteraction( + id = InteractionId("id"), + llm = LlmOptions(), + fieldFilter = Predicate { true }, + ), + outputClass = BorderCollie::class.java, + action = SimpleTestAgent.actions.first(), + agentProcess = setup.mockAgentProcess, + ) + fail("Should have thrown InvalidLlmReturnTypeException") + } catch (e: InvalidLlmReturnTypeException) { + assertTrue(e.constraintViolations.any { it.propertyPath.toString() == "eats" }) + } + } } } diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGeneratorTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGeneratorTest.kt index 8ce6e4469..e6ed0ba38 100644 --- a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGeneratorTest.kt +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/validation/DefaultValidationPromptGeneratorTest.kt @@ -23,7 +23,9 @@ import jakarta.validation.constraints.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import java.lang.reflect.Field import java.time.LocalDate +import java.util.function.Predicate import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -87,6 +89,13 @@ class DefaultValidationPromptGeneratorTest { class EmptyClass + data class TwoFieldClass( + @field:NotBlank(message = "Name cannot be blank") + val name: String, + @field:Email(message = "Must be a valid email address") + val email: String, + ) + @Nested inner class GenerateRequirementsPrompt { @@ -302,6 +311,86 @@ class DefaultValidationPromptGeneratorTest { } } + @Nested + inner class FieldFilterBehavior { + + @Test + fun `default field filter includes all constrained fields in requirements`() { + val result = generator.generateRequirementsPrompt(validator, TwoFieldClass::class.java) + + assertTrue(result.contains("Field 'name'"), "name should be included with default filter") + assertTrue(result.contains("Field 'email'"), "email should be included with default filter") + } + + @Test + fun `field filter excluding a field omits its requirements from prompt`() { + val filterExcludeEmail = Predicate { it.name != "email" } + + val result = generator.generateRequirementsPrompt(validator, TwoFieldClass::class.java, filterExcludeEmail) + + assertTrue(result.contains("Field 'name'"), "name should still appear") + assertTrue(!result.contains("Field 'email'"), "email should be omitted when filtered out") + } + + @Test + fun `field filter excluding all fields yields no constraints message`() { + val excludeAll = Predicate { false } + + val result = generator.generateRequirementsPrompt(validator, TwoFieldClass::class.java, excludeAll) + + assertEquals("No validation constraints defined.", result) + } + + @Test + fun `field filter including only one field omits the other`() { + val nameOnly = Predicate { it.name == "name" } + + val result = generator.generateRequirementsPrompt(validator, TwoFieldClass::class.java, nameOnly) + + assertTrue(result.contains("Field 'name'")) + assertTrue(!result.contains("Field 'email'")) + } + + @Test + fun `filterConstraintViolations drops violations for fields excluded by filter`() { + val invalid = TwoFieldClass(name = "", email = "not-an-email") + val violations = validator.validate(invalid) + assertTrue(violations.isNotEmpty()) + + val nameOnly = Predicate { it.name == "name" } + val filtered = violations.filterTo(mutableSetOf()) { violation -> + runCatching { TwoFieldClass::class.java.getDeclaredField(violation.propertyPath.toString()) } + .map { nameOnly.test(it) } + .getOrDefault(true) + } + + assertTrue( + filtered.all { it.propertyPath.toString() == "name" }, + "only name violations should survive the filter" + ) + assertTrue( + filtered.none { it.propertyPath.toString() == "email" }, + "email violations should be dropped by the filter" + ) + } + + @Test + fun `filterConstraintViolations keeps violations when field cannot be resolved`() { + val invalid = TwoFieldClass(name = "", email = "not-an-email") + val violations = validator.validate(invalid) + + val excludeAll = Predicate { false } + // Look up fields on an unrelated class so lookup always fails → default true → all kept + val filtered = violations.filterTo(mutableSetOf()) { violation -> + runCatching { String::class.java.getDeclaredField(violation.propertyPath.toString()) } + .map { excludeAll.test(it) } + .getOrDefault(true) + } + + assertEquals(violations.size, filtered.size) + } + } + // Validation groups for testing interface CreateGroup interface UpdateGroup