Skip to content

Commit 22e1d59

Browse files
jWos1copybara-github
authored andcommitted
feat: preserve nested null values when converting to the GenAI SDK
A null nested inside a tool argument or tool result - for example `{"data": {"x": null}}` - was dropped when converting to the GenAI SDK and never reached the model. Nulls are now sent as JSON `null` at any depth, so a field explicitly set to null is distinguishable from an omitted one. This affects `FunctionCall.args`, `FunctionResponse.response`, `Part.partMetadata` and `Schema.default`. PiperOrigin-RevId: 963927466
1 parent b3cfe8f commit 22e1d59

3 files changed

Lines changed: 18 additions & 18 deletions

File tree

‎core/src/commonMain/kotlin/com/google/adk/kt/types/GenaiConverters.kt‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ import kotlinx.serialization.json.longOrNull
8888
/**
8989
* Recursively converts an arbitrary value into a [JsonElement] for the GenAI SDK.
9090
*
91-
* A `null` becomes [JsonNull]. Within a nested [Map] value, null-valued entries are dropped; the
92-
* top-level `FunctionResponse.response` / `FunctionCall.args` maps are converted entry-by-entry by
93-
* their callers, so their `null` values are preserved as [JsonNull].
91+
* A `null` becomes [JsonNull] at any depth, so a map entry explicitly set to `null` reaches the
92+
* model as JSON `null` rather than disappearing. Only non-string keys are normalized, never
93+
* dropped.
9494
*/
9595
internal fun Any?.toJsonElement(): JsonElement =
9696
when (this) {
@@ -100,11 +100,7 @@ internal fun Any?.toJsonElement(): JsonElement =
100100
is Number -> JsonPrimitive(this)
101101
is String -> JsonPrimitive(this)
102102
is Map<*, *> ->
103-
JsonObject(
104-
this.entries
105-
.filter { (_, v) -> v != null }
106-
.associate { (k, v) -> k.toString() to v.toJsonElement() }
107-
)
103+
JsonObject(this.entries.associate { (k, v) -> k.toString() to v.toJsonElement() })
108104
is Iterable<*> -> JsonArray(this.map { it.toJsonElement() })
109105
is Array<*> -> JsonArray(this.map { it.toJsonElement() })
110106
// Edge-case fallback for a custom object arriving as erased `Any`; KSP `@Tool`s don't reach

‎core/src/commonTest/kotlin/com/google/adk/kt/types/GenaiConvertersTest.kt‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class GenaiConvertersTest {
8888

8989
@Test
9090
fun jsonElementConversions_roundTripNestedValues() {
91-
// Exercises Any?.toJsonElement() (Map null-stripping, Iterable, Array, primitives) and
91+
// Exercises Any?.toJsonElement() (null preservation, Iterable, Array, primitives) and
9292
// JsonElement.toAny() (JsonObject, JsonArray, primitive coercions) directly.
9393
val original: Map<String, Any?> =
9494
mapOf(
@@ -99,13 +99,15 @@ class GenaiConvertersTest {
9999
"double" to 1.5,
100100
"list" to listOf("a", 1),
101101
"array" to arrayOf<Any>("b", 2),
102-
"nested" to mapOf("inner" to "v"),
103-
"dropped" to null,
102+
"nested" to mapOf("inner" to "v", "keptNull" to null),
103+
"kept" to null,
104104
)
105105

106106
val roundTripped = original.toJsonElement().toAny()
107107

108-
// `dropped` is omitted (null map entries are stripped) and arrays come back as lists.
108+
// Nulls survive at both depths; only arrays come back as lists. The nested one is the case
109+
// that matters: callers convert the top-level map entry by entry, so only a nested map
110+
// reaches the `Map` branch that used to drop them.
109111
assertEquals(
110112
mapOf(
111113
"string" to "text",
@@ -115,7 +117,8 @@ class GenaiConvertersTest {
115117
"double" to 1.5,
116118
"list" to listOf("a", 1),
117119
"array" to listOf("b", 2),
118-
"nested" to mapOf("inner" to "v"),
120+
"nested" to mapOf("inner" to "v", "keptNull" to null),
121+
"kept" to null,
119122
),
120123
roundTripped,
121124
)

‎core/src/jvmAndroidKspTest/kotlin/com/google/adk/kt/models/FunctionToolWireFormatTest.kt‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ import kotlinx.serialization.json.JsonObject
4242
* payload.
4343
*
4444
* The checked payload is the converted `response` map (`Map<String, JsonElement>`) -- exactly what
45-
* the SDK puts on the wire -- so primitives keep their JSON type and `null` entries are dropped,
46-
* verified without a live model call.
45+
* the SDK puts on the wire -- so primitives keep their JSON type and `null` entries survive as JSON
46+
* `null`, verified without a live model call.
4747
*/
4848
class FunctionToolWireFormatTest {
4949

@@ -173,9 +173,9 @@ class FunctionToolWireFormatTest {
173173
}
174174

175175
@Test
176-
fun toolReturningNestedMapWithNulls_emitsRecursivelyFlattenedJsonWithNullsStripped() = runTest {
177-
// `null` entries (here: `label`) are dropped by the converter, mirroring the SDK's
178-
// `explicitNulls = false` wire behavior, so Gemini never sees a `label: null` entry.
176+
fun toolReturningNestedMapWithNulls_emitsRecursivelyFlattenedJsonWithNullsPreserved() = runTest {
177+
// A `null` entry (here: `label`) reaches the wire as JSON `null`, so Gemini can tell it apart
178+
// from a field the tool omitted entirely.
179179
assertWireResponse(
180180
ReturnsKitchenSinkTool(),
181181
expected =
@@ -186,6 +186,7 @@ class FunctionToolWireFormatTest {
186186
"value": 42,
187187
"ratio": 0.5,
188188
"active": true,
189+
"label": null,
189190
"status": "READY",
190191
"tags": ["alpha", "beta"],
191192
"items": [

0 commit comments

Comments
 (0)