diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/extensions/model/gemini/GeminiNullableToolSchemaE2ETest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/extensions/model/gemini/GeminiNullableToolSchemaE2ETest.java new file mode 100644 index 0000000000..fdd83296a2 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/extensions/model/gemini/GeminiNullableToolSchemaE2ETest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.extensions.model.gemini; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import io.agentscope.core.agent.test.TestUtils; +import io.agentscope.core.e2e.E2ETestCondition; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.gemini.formatter.GeminiChatFormatter; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Provider-specific acceptance test for nullable Gemini tool schemas. */ +@Tag("e2e") +@Tag("tools") +@ExtendWith(E2ETestCondition.class) +class GeminiNullableToolSchemaE2ETest { + + private static final String GOOGLE_API_KEY = "GOOGLE_API_KEY"; + private static final String GOOGLE_GEMINI_MODEL = "GOOGLE_GEMINI_MODEL"; + private static final String DEFAULT_MODEL = "gemini-3.6-flash"; + private static final String TOOL_NAME = "nullable_echo"; + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(90); + + @Test + @DisplayName("Gemini accepts a nullable JSON Schema tool parameter") + void acceptsNullableToolSchema() { + String apiKey = System.getenv(GOOGLE_API_KEY); + assumeTrue(apiKey != null && !apiKey.isBlank(), "GOOGLE_API_KEY is required"); + String modelName = System.getenv(GOOGLE_GEMINI_MODEL); + if (modelName == null || modelName.isBlank()) { + modelName = DEFAULT_MODEL; + } + + GeminiChatModel model = + GeminiChatModel.builder() + .apiKey(apiKey) + .modelName(modelName) + .formatter(new GeminiChatFormatter()) + .build(); + + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of("value", Map.of("type", List.of("string", "null")))); + ToolSchema toolSchema = + ToolSchema.builder() + .name(TOOL_NAME) + .description("Echo an optional value") + .parameters(parameters) + .build(); + + Msg request = TestUtils.createUserMessage("User", "Call nullable_echo with value hello."); + GenerateOptions options = + GenerateOptions.builder().toolChoice(new ToolChoice.Specific(TOOL_NAME)).build(); + + List responses = + model.stream(List.of(request), List.of(toolSchema), options) + .collectList() + .block(TEST_TIMEOUT); + + assertNotNull(responses, "Gemini should return a response stream"); + assertTrue( + responses.stream() + .flatMap(response -> response.getContent().stream()) + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .anyMatch(toolUse -> TOOL_NAME.equals(toolUse.getName())), + "Gemini should return a tool-use block for the forced nullable tool"); + } + + @Test + @DisplayName("Gemini accepts a nullable anyOf JSON Schema tool parameter") + void acceptsNullableAnyOfToolSchema() { + String apiKey = System.getenv(GOOGLE_API_KEY); + assumeTrue(apiKey != null && !apiKey.isBlank(), "GOOGLE_API_KEY is required"); + String modelName = System.getenv(GOOGLE_GEMINI_MODEL); + if (modelName == null || modelName.isBlank()) { + modelName = DEFAULT_MODEL; + } + + GeminiChatModel model = + GeminiChatModel.builder() + .apiKey(apiKey) + .modelName(modelName) + .formatter(new GeminiChatFormatter()) + .build(); + + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of( + "value", + Map.of( + "anyOf", + List.of(Map.of("type", "string"), Map.of("type", "null")))), + "required", + List.of("value")); + ToolSchema toolSchema = + ToolSchema.builder() + .name(TOOL_NAME) + .description("Echo an optional value") + .parameters(parameters) + .build(); + + Msg request = + TestUtils.createUserMessage( + "User", "Call nullable_echo with the value field set to JSON null."); + GenerateOptions options = + GenerateOptions.builder().toolChoice(new ToolChoice.Specific(TOOL_NAME)).build(); + + List responses = + model.stream(List.of(request), List.of(toolSchema), options) + .collectList() + .block(TEST_TIMEOUT); + + assertNotNull(responses, "Gemini should return a response stream"); + ToolUseBlock toolUse = + responses.stream() + .flatMap(response -> response.getContent().stream()) + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .filter(tool -> TOOL_NAME.equals(tool.getName())) + .findFirst() + .orElse(null); + + assertNotNull(toolUse, "Gemini should return the nullable anyOf tool call"); + assertTrue(toolUse.getInput().containsKey("value")); + assertNull(toolUse.getInput().get("value")); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelper.java index b8db8b4285..ec3baf3a10 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelper.java @@ -51,6 +51,8 @@ public class GeminiToolsHelper { private static final Logger log = LoggerFactory.getLogger(GeminiToolsHelper.class); + private static final String MULTI_TYPE_ANY_OF_ERROR = + "JSON Schema cannot combine a multi-type array with anyOf"; /** * Creates a new GeminiToolsHelper. @@ -116,17 +118,22 @@ public Tool convertToGeminiTool(List tools) { * @return Gemini Schema object */ protected Schema convertParametersToSchema(Map parameters) { + // Normalize nullable unions before converting nested schema branches. + parameters = normalizeNullableAnyOf(parameters); Schema.Builder schemaBuilder = Schema.builder(); // Set type (default to OBJECT) + boolean typeUsesAnyOf = false; if (parameters.containsKey("type")) { - String typeStr = (String) parameters.get("type"); - Type type = convertJsonTypeToGeminiType(typeStr); - schemaBuilder.type(type); - } else { + typeUsesAnyOf = applyJsonType(schemaBuilder, parameters.get("type")); + } else if (!parameters.containsKey("anyOf")) { schemaBuilder.type(new Type(Type.Known.OBJECT)); } + if (Boolean.TRUE.equals(parameters.get("nullable"))) { + schemaBuilder.nullable(true); + } + // Set description if (parameters.containsKey("description")) { schemaBuilder.description((String) parameters.get("description")); @@ -160,6 +167,20 @@ protected Schema convertParametersToSchema(Map parameters) { schemaBuilder.items(convertParametersToSchema(itemsSchema)); } + // Set anyOf schemas + if (parameters.containsKey("anyOf")) { + if (typeUsesAnyOf) { + throw new IllegalArgumentException(MULTI_TYPE_ANY_OF_ERROR); + } + List anyOfSchemas = new ArrayList<>(); + for (Object anyOfSchema : (List) parameters.get("anyOf")) { + @SuppressWarnings("unchecked") + Map anyOfSchemaMap = (Map) anyOfSchema; + anyOfSchemas.add(convertParametersToSchema(anyOfSchemaMap)); + } + schemaBuilder.anyOf(anyOfSchemas); + } + // Set enum values if (parameters.containsKey("enum")) { @SuppressWarnings("unchecked") @@ -170,6 +191,132 @@ protected Schema convertParametersToSchema(Map parameters) { return schemaBuilder.build(); } + private Map normalizeNullableAnyOf(Map schema) { + Object rawAnyOf = schema.get("anyOf"); + if (!(rawAnyOf instanceof List anyOfSchemas)) { + return schema; + } + + if (hasMultipleNonNullTypes(schema.get("type"))) { + throw new IllegalArgumentException(MULTI_TYPE_ANY_OF_ERROR); + } + + List> nonNullSchemas = new ArrayList<>(); + for (Object anyOfSchema : anyOfSchemas) { + if (!(anyOfSchema instanceof Map schemaMap)) { + return schema; + } + if (!isNullSchema(schemaMap)) { + Map typedSchema = new HashMap<>(); + for (Map.Entry entry : schemaMap.entrySet()) { + if (entry.getKey() instanceof String key) { + typedSchema.put(key, entry.getValue()); + } + } + nonNullSchemas.add(typedSchema); + } + } + + if (nonNullSchemas.size() == anyOfSchemas.size()) { + return schema; + } + + Map normalized = new HashMap<>(schema); + if (nonNullSchemas.isEmpty()) { + normalized.remove("anyOf"); + } else if (nonNullSchemas.size() == 1) { + // Keep the non-null branch authoritative and fill in missing outer metadata. + Map merged = new HashMap<>(nonNullSchemas.get(0)); + for (Map.Entry entry : schema.entrySet()) { + if (!"anyOf".equals(entry.getKey())) { + merged.putIfAbsent(entry.getKey(), entry.getValue()); + } + } + normalized = merged; + } else { + normalized.put("anyOf", nonNullSchemas); + } + normalized.put("nullable", true); + return normalized; + } + + private boolean hasMultipleNonNullTypes(Object jsonType) { + if (!(jsonType instanceof List typeValues)) { + return false; + } + + int nonNullTypeCount = 0; + for (Object typeValue : typeValues) { + if (typeValue instanceof String typeString && !"null".equals(typeString)) { + nonNullTypeCount++; + } + } + return nonNullTypeCount > 1; + } + + private boolean isNullSchema(Map schema) { + Object jsonType = schema.get("type"); + if ("null".equals(jsonType)) { + return true; + } + return jsonType instanceof List typeValues + && typeValues.size() == 1 + && "null".equals(typeValues.get(0)); + } + + private boolean applyJsonType(Schema.Builder schemaBuilder, Object jsonType) { + if (jsonType instanceof String typeString) { + if ("null".equals(typeString)) { + // Gemini rejects standalone null function parameter types. + schemaBuilder.type(new Type(Type.Known.OBJECT)); + return false; + } + schemaBuilder.type(convertJsonTypeToGeminiType(typeString)); + return false; + } + + if (!(jsonType instanceof List typeValues)) { + throw new IllegalArgumentException("JSON Schema type must be a string or an array"); + } + + List nonNullTypes = new ArrayList<>(); + boolean nullable = false; + for (Object typeValue : typeValues) { + if (!(typeValue instanceof String typeString)) { + throw new IllegalArgumentException("JSON Schema type array must contain strings"); + } + if ("null".equalsIgnoreCase(typeString)) { + nullable = true; + } else { + nonNullTypes.add(typeString); + } + } + + if (nonNullTypes.isEmpty()) { + // Preserve the compatibility fallback for a null-only type array. + schemaBuilder.type(new Type(Type.Known.OBJECT)); + if (nullable) { + schemaBuilder.nullable(true); + } + return false; + } + + if (nonNullTypes.size() == 1) { + schemaBuilder.type(convertJsonTypeToGeminiType(nonNullTypes.get(0))); + } else { + List schemas = new ArrayList<>(); + for (String type : nonNullTypes) { + schemas.add(Schema.builder().type(convertJsonTypeToGeminiType(type)).build()); + } + schemaBuilder.anyOf(schemas); + } + + if (nullable) { + schemaBuilder.nullable(true); + } + return nonNullTypes.size() > 1; + } + /** * Convert JSON Schema type string to Gemini Type. * diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelperTest.java index 946e68f16f..c1f115cb00 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelperTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/formatter/GeminiToolsHelperTest.java @@ -15,9 +15,11 @@ */ package io.agentscope.extensions.model.gemini.formatter; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.genai.types.FunctionCallingConfig; @@ -113,6 +115,217 @@ void testConvertParametersWithVariousTypes() { assertEquals(Type.Known.ARRAY, props.get("tags").type().get().knownEnum()); } + @Test + void testConvertNullableStringTypeArray() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of("name", Map.of("type", List.of("string", "null")))); + + ToolSchema toolSchema = + ToolSchema.builder() + .name("lookup") + .description("Lookup a name") + .parameters(parameters) + .build(); + + Tool tool = helper.convertToGeminiTool(List.of(toolSchema)); + + assertNotNull(tool); + Schema nameSchema = + tool.functionDeclarations() + .get() + .get(0) + .parameters() + .get() + .properties() + .get() + .get("name"); + assertEquals(Type.Known.STRING, nameSchema.type().get().knownEnum()); + assertTrue(nameSchema.nullable().get()); + } + + @Test + void testConvertNullableIntegerTypeArray() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of("age", Map.of("type", List.of("integer", "null")))); + + Schema ageSchema = + helper.convertParametersToSchema(parameters).properties().get().get("age"); + + assertEquals(Type.Known.INTEGER, ageSchema.type().get().knownEnum()); + assertTrue(ageSchema.nullable().get()); + } + + @Test + void testConvertMultiTypeNullableArrayToAnyOf() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of("value", Map.of("type", List.of("string", "integer", "null")))); + + Schema valueSchema = + helper.convertParametersToSchema(parameters).properties().get().get("value"); + + assertTrue(valueSchema.nullable().orElse(false)); + assertEquals(2, valueSchema.anyOf().get().size()); + assertEquals(Type.Known.STRING, valueSchema.anyOf().get().get(0).type().get().knownEnum()); + assertEquals(Type.Known.INTEGER, valueSchema.anyOf().get().get(1).type().get().knownEnum()); + } + + @Test + void testTypeArrayNullabilityCannotBeOverriddenByFalseMetadata() { + Map parameters = + Map.of("type", List.of("string", "null"), "nullable", false); + + Schema schema = helper.convertParametersToSchema(parameters); + + assertTrue(schema.nullable().orElse(false)); + } + + @Test + void testPreservesExistingAnyOf() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of( + "value", + Map.of( + "anyOf", + List.of( + Map.of("type", "string"), + Map.of("type", "integer"))))); + + Schema valueSchema = + helper.convertParametersToSchema(parameters).properties().get().get("value"); + + assertTrue(valueSchema.type().isEmpty()); + assertEquals(2, valueSchema.anyOf().get().size()); + assertEquals(Type.Known.STRING, valueSchema.anyOf().get().get(0).type().get().knownEnum()); + assertEquals(Type.Known.INTEGER, valueSchema.anyOf().get().get(1).type().get().knownEnum()); + } + + @Test + void testRemovesAnnotatedNullFromMultiBranchAnyOf() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of( + "value", + Map.of( + "anyOf", + List.of( + Map.of("type", "string"), + Map.of("type", "null", "description", "No value"), + Map.of("type", "integer"))))); + + Schema valueSchema = + helper.convertParametersToSchema(parameters).properties().get().get("value"); + + assertTrue(valueSchema.type().isEmpty()); + assertEquals(2, valueSchema.anyOf().get().size()); + assertTrue(valueSchema.nullable().orElse(false)); + assertEquals(Type.Known.STRING, valueSchema.anyOf().get().get(0).type().get().knownEnum()); + assertEquals(Type.Known.INTEGER, valueSchema.anyOf().get().get(1).type().get().knownEnum()); + } + + @Test + void testRemovesAllNullBranchesFromAnyOf() { + Map parameters = + Map.of( + "anyOf", + List.of( + Map.of("type", "null", "description", "No value"), + Map.of("type", List.of("null")))); + + Schema schema = helper.convertParametersToSchema(parameters); + + assertEquals(Type.Known.OBJECT, schema.type().get().knownEnum()); + assertTrue(schema.anyOf().isEmpty()); + assertTrue(schema.nullable().orElse(false)); + } + + @Test + void testInlinesAnnotatedNullAnyOf() { + Map parameters = + Map.of( + "type", + "object", + "properties", + Map.of( + "value", + Map.of( + "description", + "Optional value", + "anyOf", + List.of( + Map.of("type", "string"), + Map.of( + "type", + "null", + "description", + "No value"))))); + + Schema valueSchema = + helper.convertParametersToSchema(parameters).properties().get().get("value"); + + assertEquals(Type.Known.STRING, valueSchema.type().get().knownEnum()); + assertTrue(valueSchema.anyOf().isEmpty()); + assertTrue(valueSchema.nullable().orElse(false)); + assertEquals("Optional value", valueSchema.description().get()); + } + + @Test + void testConvertsNullOnlyTypeArrayToObject() { + Schema schema = + assertDoesNotThrow( + () -> helper.convertParametersToSchema(Map.of("type", List.of("null")))); + + assertEquals(Type.Known.OBJECT, schema.type().get().knownEnum()); + assertTrue(schema.nullable().orElse(false)); + } + + @Test + void testConvertsDirectNullTypeToObject() { + Schema schema = + assertDoesNotThrow(() -> helper.convertParametersToSchema(Map.of("type", "null"))); + + assertEquals(Type.Known.OBJECT, schema.type().get().knownEnum()); + } + + @Test + void testRejectsMultiTypeArrayCombinedWithAnyOf() { + Map parameters = + Map.of( + "type", + List.of("string", "integer", "null"), + "anyOf", + List.of( + Map.of("type", "string", "enum", List.of("text")), + Map.of("type", "integer", "enum", List.of("1")), + Map.of("type", "null", "description", "No value"))); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> helper.convertParametersToSchema(parameters)); + + assertEquals( + "JSON Schema cannot combine a multi-type array with anyOf", exception.getMessage()); + } + @Test void testToolChoiceAuto() { // Auto or null should return null (use default)