Repository navigation
fix(spring-ai): preserve enum and items in tool schema conversion #1617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice that the nested item enum is covered. Could you also add a case where
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — added testConvertSchemaToSpringAiRecursesIntoObjectItemsProperties covering array-of-objects items with their own properties and required. |
||||||||
| Schema objectSchema = | ||||||||
| Schema.builder() | ||||||||
| .type("OBJECT") | ||||||||
| .properties( | ||||||||
| Map.of( | ||||||||
| "level", enumParam, | ||||||||
| "services", arrayParam)) | ||||||||
| .required(List.of("level")) | ||||||||
| .build(); | ||||||||
|
|
||||||||
| Map<String, Object> converted = toolConverter.convertSchemaToSpringAi(objectSchema); | ||||||||
|
|
||||||||
| Map<String, Object> properties = asMap(converted.get("properties")); | ||||||||
|
|
||||||||
| Map<String, Object> convertedEnum = asMap(properties.get("level")); | ||||||||
| assertThat(convertedEnum).containsEntry("type", "string"); | ||||||||
| assertThat(convertedEnum).containsEntry("enum", List.of("basic", "detailed")); | ||||||||
|
|
||||||||
| Map<String, Object> convertedArray = asMap(properties.get("services")); | ||||||||
| assertThat(convertedArray).containsEntry("type", "array"); | ||||||||
| Map<String, Object> 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<String, Object> converted = toolConverter.convertSchemaToSpringAi(arrayOfObjects); | ||||||||
|
|
||||||||
| assertThat(converted).containsEntry("type", "array"); | ||||||||
| Map<String, Object> items = asMap(converted.get("items")); | ||||||||
| assertThat(items).containsEntry("type", "object"); | ||||||||
| Map<String, Object> itemProperties = asMap(items.get("properties")); | ||||||||
| Map<String, Object> 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<FunctionDeclaration> declaration() { | ||||||||
| return Optional.of(declaration); | ||||||||
| } | ||||||||
|
|
||||||||
| @Override | ||||||||
| public Single<Map<String, Object>> runAsync( | ||||||||
| Map<String, Object> args, ToolContext toolContext) { | ||||||||
| return Single.just(Map.of()); | ||||||||
| } | ||||||||
| }; | ||||||||
|
|
||||||||
| List<ToolCallback> callbacks = toolConverter.convertToSpringAiTools(Map.of("reportTool", tool)); | ||||||||
|
|
||||||||
| assertThat(callbacks).hasSize(1); | ||||||||
| String inputSchema = callbacks.get(0).getToolDefinition().inputSchema(); | ||||||||
| Map<String, Object> schemaJson = | ||||||||
| new ObjectMapper().readValue(inputSchema, new TypeReference<Map<String, Object>>() {}); | ||||||||
| Map<String, Object> properties = asMap(schemaJson.get("properties")); | ||||||||
| assertThat(asMap(properties.get("level"))).containsEntry("enum", List.of("basic", "detailed")); | ||||||||
| Map<String, Object> 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<String, Object> converted = toolConverter.convertSchemaToSpringAi(emptyEnumSchema); | ||||||||
|
|
||||||||
| assertThat(converted).containsEntry("type", "string"); | ||||||||
| assertThat(converted).doesNotContainKey("enum"); | ||||||||
| } | ||||||||
|
|
||||||||
| @SuppressWarnings("unchecked") | ||||||||
| private static Map<String, Object> asMap(Object value) { | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the helper! The cast inside still raises an unchecked warning; since it's guarded by the
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — suppressed; the cast is guarded by the isInstanceOf assertion. |
||||||||
| assertThat(value).isInstanceOf(Map.class); | ||||||||
| return (Map<String, Object>) value; | ||||||||
| } | ||||||||
|
|
||||||||
| @Test | ||||||||
| void testCreateToolRegistryWithToolWithoutDeclaration() { | ||||||||
| BaseTool testTool = | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests exercise
convertSchemaToSpringAidirectly, but the bug as reported is in what ends up inconvertToSpringAiTools(...).get(0).getToolDefinition().inputSchema(). Could you add one test that goes throughconvertToSpringAiToolsand asserts the serializedinputSchemaJSON containsenumanditems(essentially the repro from the issue)? That guards the user-visible output, including the JSON serialization step.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done — added testConvertToSpringAiToolsOutputSchemaContainsEnumAndItems: it converts a tool end-to-end and asserts the serialized inputSchema JSON contains enum and items (the repro from the issue, as a regression test).