From f6542ecddc090045988be668db3f6bacc61be30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sobczyk?= Date: Wed, 4 Nov 2026 03:13:51 -0800 Subject: [PATCH] fix(tools): keep the null of a required nullable property in `set_model_response` PiperOrigin-RevId: 1013707587 --- .../adk/tools/SetModelResponseTool.java | 28 ++++-- .../adk/tools/SetModelResponseToolTest.java | 91 +++++++++++++++++++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/com/google/adk/tools/SetModelResponseTool.java b/core/src/main/java/com/google/adk/tools/SetModelResponseTool.java index 4f7022914..88f52cf02 100644 --- a/core/src/main/java/com/google/adk/tools/SetModelResponseTool.java +++ b/core/src/main/java/com/google/adk/tools/SetModelResponseTool.java @@ -17,6 +17,7 @@ package com.google.adk.tools; import com.google.adk.SchemaUtils; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.Schema; @@ -77,34 +78,43 @@ public Single> runAsync(Map args, ToolContex + "\nRecall the set_model_response function correctly, fix the errors, and" + " call it again with all required fields using the correct types."); } - // Match Python's model_dump(exclude_none=True) for Java's map-shaped response. - Map validatedResponse = excludeNullFields(args); + Map validatedResponse = excludeNullFields(args, outputSchema); toolContext.actions().setSetModelResponse(validatedResponse); return validatedResponse; }); } - private static Map excludeNullFields(Map values) { + /** + * Drops nulls like Python's {@code model_dump(exclude_none=True)}, but keeps the null of a + * property that {@code schema} marks required and nullable, which the schema demands. + */ + private static Map excludeNullFields(Map values, Schema schema) { + // Validation passed, so each key has a property schema and each non-empty list an item schema. + Map properties = schema.properties().orElseThrow(); + List required = schema.required().orElse(ImmutableList.of()); Map result = new LinkedHashMap<>(); for (Map.Entry entry : values.entrySet()) { + Schema property = properties.get(entry.getKey()); Object value = entry.getValue(); if (value != null) { - result.put(entry.getKey(), excludeNullsFromValue(value)); + result.put(entry.getKey(), excludeNullsFromValue(value, property)); + } else if (property.nullable().orElse(false) && required.contains(entry.getKey())) { + result.put(entry.getKey(), null); } } return result; } - private static Object excludeNullsFromValue(Object value) { + private static Object excludeNullsFromValue(Object value, Schema schema) { if (value instanceof Map) { @SuppressWarnings("unchecked") // Function-call args always have String keys. Map map = (Map) value; - return excludeNullFields(map); + return excludeNullFields(map, schema); } - if (value instanceof List) { + if (value instanceof List list) { List result = new ArrayList<>(); - for (Object item : (List) value) { - result.add(excludeNullsFromValue(item)); + for (Object item : list) { + result.add(excludeNullsFromValue(item, schema.items().orElseThrow())); } return result; } diff --git a/core/src/test/java/com/google/adk/tools/SetModelResponseToolTest.java b/core/src/test/java/com/google/adk/tools/SetModelResponseToolTest.java index 1964daa98..ea35f2f85 100644 --- a/core/src/test/java/com/google/adk/tools/SetModelResponseToolTest.java +++ b/core/src/test/java/com/google/adk/tools/SetModelResponseToolTest.java @@ -21,6 +21,7 @@ import static com.google.adk.testing.TestUtils.createTestLlm; import static com.google.common.truth.Truth.assertThat; +import com.google.adk.SchemaUtils; import com.google.adk.models.LlmResponse; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -123,6 +124,96 @@ public void runAsync_nullableNull_excludesNullFromReturnedAndRecordedResponse() assertThat(toolContext.actions().setModelResponse()).hasValue(ImmutableMap.of("a", "x")); } + @Test + public void runAsync_requiredNullableNull_keepsTheNull() { + Schema outputSchema = + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of( + "a", + Schema.builder().type("STRING").build(), + "b", + Schema.builder().type("STRING").nullable(true).build())) + .required(ImmutableList.of("a", "b")) + .build(); + Map args = new LinkedHashMap<>(); + args.put("a", "x"); + args.put("b", null); + + ToolContext toolContext = createToolContext(); + Map result = + new SetModelResponseTool(outputSchema).runAsync(args, toolContext).blockingGet(); + + assertThat(result).containsExactly("a", "x", "b", null).inOrder(); + assertThat(toolContext.actions().setModelResponse()).hasValue(result); + // Throws if the result does not match the schema, as dropping the null did. + SchemaUtils.validateMapOnSchema(result, outputSchema, /* isInput= */ false); + } + + @Test + public void runAsync_nestedRequiredNullableNull_keepsTheNull() { + Schema outputSchema = + Schema.builder() + .type("OBJECT") + .properties(ImmutableMap.of("addr", requiredNullableZipAddressSchema())) + .required(ImmutableList.of("addr")) + .build(); + Map address = new LinkedHashMap<>(); + address.put("city", "NYC"); + address.put("zip", null); + + ToolContext toolContext = createToolContext(); + Map result = + new SetModelResponseTool(outputSchema) + .runAsync(ImmutableMap.of("addr", address), toolContext) + .blockingGet(); + + assertThat(result).isEqualTo(ImmutableMap.of("addr", address)); + assertThat(toolContext.actions().setModelResponse()).hasValue(result); + } + + @Test + public void runAsync_arrayItemRequiredNullableNull_keepsTheNull() { + Schema outputSchema = + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of( + "addresses", + Schema.builder() + .type("ARRAY") + .items(requiredNullableZipAddressSchema()) + .build())) + .required(ImmutableList.of("addresses")) + .build(); + Map address = new LinkedHashMap<>(); + address.put("city", "NYC"); + address.put("zip", null); + + ToolContext toolContext = createToolContext(); + Map result = + new SetModelResponseTool(outputSchema) + .runAsync(ImmutableMap.of("addresses", ImmutableList.of(address)), toolContext) + .blockingGet(); + + assertThat(result).isEqualTo(ImmutableMap.of("addresses", ImmutableList.of(address))); + assertThat(toolContext.actions().setModelResponse()).hasValue(result); + } + + private static Schema requiredNullableZipAddressSchema() { + return Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of( + "city", + Schema.builder().type("STRING").build(), + "zip", + Schema.builder().type("STRING").nullable(true).build())) + .required(ImmutableList.of("city", "zip")) + .build(); + } + @Test public void runAsync_nestedNullableNull_excludesNullFromReturnedAndRecordedResponse() { Schema addressSchema =