From 7b1a5b800e9dd3f3329976e708585b7404a3a3e4 Mon Sep 17 00:00:00 2001 From: "jixing.xia" Date: Mon, 5 Oct 2026 21:02:22 +0800 Subject: [PATCH] fix(spring-ai): preserve enum and items in tool schema conversion convertSchemaToSpringAi mapped only type/properties/description/required, silently dropping enum constraints and array item schemas. Enum parameters degraded to free-form text (the model invents values) and array parameters lost their element schema. Map both, recursing into items so nested enums and object arrays survive conversion. Fixes #1609 --- .../adk/models/springai/ToolConverter.java | 14 +- .../models/springai/ToolConverterTest.java | 139 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java index dbdf3f574..16de5d9a7 100644 --- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java +++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java @@ -71,7 +71,10 @@ public Map createToolRegistry(Map tools) /** * Converts ADK Schema to Spring AI compatible parameter schema. * - *

This provides basic schema conversion for tool parameters. + *

Maps the {@code type}, {@code description}, {@code properties}, {@code required}, {@code + * enum} and {@code items} keywords ({@code properties} values and {@code items} recursively). + * Other JSON-Schema keywords (e.g. {@code nullable}, {@code anyOf}, {@code format}) are not + * carried over yet. * * @param schema The ADK schema to convert * @return A Map representing the Spring AI compatible schema @@ -97,6 +100,15 @@ public Map convertSchemaToSpringAi(Schema schema) { schema.required().ifPresent(required -> springAiSchema.put("required", required)); + // Preserve enum constraints and array item schemas; skip empty enums (unsatisfiable). + schema + .enum_() + .filter(values -> !values.isEmpty()) + .ifPresent(values -> springAiSchema.put("enum", values)); + schema + .items() + .ifPresent(itemSchema -> springAiSchema.put("items", convertSchemaToSpringAi(itemSchema))); + return springAiSchema; } diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java index 6c08eaf80..24ad7b93b 100644 --- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java +++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java @@ -19,6 +19,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.tools.BaseTool; import com.google.adk.tools.ToolContext; import com.google.genai.types.FunctionDeclaration; @@ -158,6 +160,143 @@ void testConvertSchemaToSpringAiWithObjectType() { assertThat(converted).containsEntry("required", List.of("name")); } + @Test + void testConvertSchemaToSpringAiPreservesEnumAndItems() { + // Regression: convertSchemaToSpringAi used to silently drop "enum" and "items", + // degrading enum parameters to free-form text and losing array element schemas. + Schema enumParam = + Schema.builder() + .type("STRING") + .description("Report detail level") + .enum_("basic", "detailed") + .build(); + Schema itemsSchema = Schema.builder().type("STRING").enum_("cardio", "dental").build(); + Schema arrayParam = + Schema.builder().type("ARRAY").description("Included services").items(itemsSchema).build(); + Schema objectSchema = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "level", enumParam, + "services", arrayParam)) + .required(List.of("level")) + .build(); + + Map converted = toolConverter.convertSchemaToSpringAi(objectSchema); + + Map properties = asMap(converted.get("properties")); + + Map convertedEnum = asMap(properties.get("level")); + assertThat(convertedEnum).containsEntry("type", "string"); + assertThat(convertedEnum).containsEntry("enum", List.of("basic", "detailed")); + + Map convertedArray = asMap(properties.get("services")); + assertThat(convertedArray).containsEntry("type", "array"); + Map convertedItems = asMap(convertedArray.get("items")); + assertThat(convertedItems).containsEntry("type", "string"); + assertThat(convertedItems).containsEntry("enum", List.of("cardio", "dental")); + } + + @Test + void testConvertSchemaToSpringAiRecursesIntoObjectItemsProperties() { + // Array-of-objects parameters (the shape FunctionTool generates for List<>) must keep + // the properties of each item: recursion has to flow through properties inside items. + Schema itemObject = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "name", Schema.builder().type("STRING").build(), + "level", Schema.builder().type("STRING").enum_("basic", "detailed").build())) + .required(List.of("name")) + .build(); + Schema arrayOfObjects = + Schema.builder().type("ARRAY").description("Line items").items(itemObject).build(); + + Map converted = toolConverter.convertSchemaToSpringAi(arrayOfObjects); + + assertThat(converted).containsEntry("type", "array"); + Map items = asMap(converted.get("items")); + assertThat(items).containsEntry("type", "object"); + Map itemProperties = asMap(items.get("properties")); + Map itemEnum = asMap(itemProperties.get("level")); + assertThat(itemEnum).containsEntry("enum", List.of("basic", "detailed")); + assertThat(items).containsEntry("required", List.of("name")); + } + + @Test + void testConvertToSpringAiToolsOutputSchemaContainsEnumAndItems() throws Exception { + // End-to-end over the user-visible output (the repro from the issue): the serialized + // inputSchema JSON of a converted tool must contain enum and items. + FunctionDeclaration declaration = + FunctionDeclaration.builder() + .name("reportTool") + .description("Generates a report") + .parameters( + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "level", + Schema.builder().type("STRING").enum_("basic", "detailed").build(), + "services", + Schema.builder() + .type("ARRAY") + .items( + Schema.builder() + .type("STRING") + .enum_("cardio", "dental") + .build()) + .build())) + .required(List.of("level")) + .build()) + .build(); + BaseTool tool = + new BaseTool("reportTool", "Generates a report") { + @Override + public Optional declaration() { + return Optional.of(declaration); + } + + @Override + public Single> runAsync( + Map args, ToolContext toolContext) { + return Single.just(Map.of()); + } + }; + + List callbacks = toolConverter.convertToSpringAiTools(Map.of("reportTool", tool)); + + assertThat(callbacks).hasSize(1); + String inputSchema = callbacks.get(0).getToolDefinition().inputSchema(); + Map schemaJson = + new ObjectMapper().readValue(inputSchema, new TypeReference>() {}); + Map properties = asMap(schemaJson.get("properties")); + assertThat(asMap(properties.get("level"))).containsEntry("enum", List.of("basic", "detailed")); + Map services = asMap(properties.get("services")); + assertThat(services).containsEntry("type", "array"); + assertThat(asMap(services.get("items"))).containsEntry("enum", List.of("cardio", "dental")); + } + + @Test + void testConvertSchemaToSpringAiSkipsEmptyEnum() { + // "enum": [] is unsatisfiable under JSON Schema semantics (no valid value exists); + // an empty enum must be skipped rather than forwarded. + Schema emptyEnumSchema = Schema.builder().type("STRING").enum_(List.of()).build(); + + Map converted = toolConverter.convertSchemaToSpringAi(emptyEnumSchema); + + assertThat(converted).containsEntry("type", "string"); + assertThat(converted).doesNotContainKey("enum"); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + assertThat(value).isInstanceOf(Map.class); + return (Map) value; + } + @Test void testCreateToolRegistryWithToolWithoutDeclaration() { BaseTool testTool =