From ffe6ad3c5fcf0a5eedd2198cd2e04b0249dc5063 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 21 Sep 2026 22:38:13 +0800 Subject: [PATCH] fix(gemini): enforce array type for schema nodes declaring items - Infer `array` type or remove `items` on non-array nodes to satisfy Gemini schema validation - Prioritize `array` type when flattening union types for properties declaring `items` - Infer `type: array` during tool schema normalization when `items` is defined without an explicit type Closes: #6011 --- internal/util/gemini_schema.go | 54 +++++++- internal/util/gemini_schema_test.go | 190 ++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 7 deletions(-) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 458580716..4bdd8abbf 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -152,6 +152,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { jsonStr = removeKeywords(jsonStr, []string{"title"}) } jsonStr = cleanupRequiredFields(jsonStr) + jsonStr = sanitizeArrayItems(jsonStr) // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) if options.addPlaceholder { jsonStr = addEmptySchemaPlaceholder(jsonStr) @@ -160,6 +161,29 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { return jsonStr } +// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array". +// Gemini's protobuf validator enforces a strict field predicate on items ($type == Type.ARRAY); +// if type is missing, it is inferred as array; if type is explicitly a non-array, items is removed. +func sanitizeArrayItems(jsonStr string) string { + paths := findPaths(jsonStr, "items") + sortByDepth(paths) + for _, p := range paths { + parentPath := trimSuffix(p, ".items") + if isPropertyDefinition(parentPath) { + continue + } + typePath := joinPath(parentPath, "type") + t := gjson.Get(jsonStr, typePath).String() + if t == "" { + updated, _ := sjson.SetBytes([]byte(jsonStr), typePath, "array") + jsonStr = string(updated) + } else if t != "array" { + jsonStr, _ = sjson.Delete(jsonStr, p) + } + } + return jsonStr +} + // removeKeywords removes all occurrences of specified keywords from the JSON schema. func removeKeywords(jsonStr string, keywords []string) string { deletePaths := make([]string, 0) @@ -459,11 +483,19 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } } - // Gemini and Antigravity reject tool array schemas without an items definition. - if addMissingArrayItems && isArrayDeclaredType(clone["type"]) { - if _, hasItems := clone["items"]; !hasItems { - clone["items"] = map[string]any{"type": "string"} - modified = true + // Gemini and Antigravity reject tool array schemas without an items definition, + // and reject tool schemas with items whose type is not ARRAY. + if addMissingArrayItems { + if isArrayDeclaredType(clone["type"]) { + if _, hasItems := clone["items"]; !hasItems { + clone["items"] = map[string]any{"type": "string"} + modified = true + } + } else if _, hasItems := clone["items"]; hasItems { + if clone["type"] == nil || clone["type"] == "" { + clone["type"] = "array" + modified = true + } } } @@ -1185,15 +1217,23 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { } } + parentPath := trimSuffix(p, ".type") + firstType := "string" if len(nonNullTypes) > 0 { - firstType = nonNullTypes[0] + if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") { + firstType = "array" + } else { + firstType = nonNullTypes[0] + } } updated, _ := sjson.SetBytes([]byte(jsonStr), p, firstType) jsonStr = string(updated) - parentPath := trimSuffix(p, ".type") + if firstType != "array" && gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() { + jsonStr, _ = sjson.Delete(jsonStr, joinPath(parentPath, "items")) + } if len(nonNullTypes) > 1 { hint := "Accepts: " + strings.Join(nonNullTypes, " | ") jsonStr = appendHint(jsonStr, parentPath, hint) diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index b6afdfab3..40b9dc040 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -2729,3 +2729,193 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesSchemaValuedAdditionalPrope t.Fatalf("legacy CleanJSONSchemaForGemini should strip additionalProperties schema: %s", legacyCleaned) } } + +// TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011 covers Issue #6011: +// Gemini rejects tool schemas where a property declares "items" but its type is not "array" +// (e.g. type is omitted or flattened to a non-array type), returning: +// "field predicate failed: $type == Type.ARRAY". +func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) { + // Case 1: property declares "items" but omits "type": "array" (relying on JSON Schema inference) + // Reproduces the error path reported in Issue #6011: properties.revision_reasons.items.properties.evidence_reference + inputMissingType := `{ + "type": "object", + "properties": { + "revision_reasons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "evidence_reference": { + "description": "references to evidence", + "items": { + "type": "string" + } + } + } + } + } + } + }` + + cleaners := map[string]func(string) string{ + "gemini": CleanJSONSchemaForGemini, + "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, + "antigravity": CleanJSONSchemaForAntigravity, + "antigravityLegacy": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, + } + + for name, clean := range cleaners { + t.Run(name+"_missing_type", func(t *testing.T) { + cleaned := clean(inputMissingType) + parsed := gjson.Parse(cleaned) + target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference") + if !target.Exists() { + t.Fatalf("evidence_reference property missing in %s: %s", name, cleaned) + } + if gotType := target.Get("type").String(); gotType != "array" { + t.Fatalf("%s: evidence_reference.type = %q, want 'array'; cleaned schema: %s", name, gotType, cleaned) + } + if gotItemsType := target.Get("items.type").String(); gotItemsType != "string" { + t.Fatalf("%s: evidence_reference.items.type = %q, want 'string'; cleaned schema: %s", name, gotItemsType, cleaned) + } + }) + } + + // Case 2: property declares union type: ["string", "array"] with items + inputUnionType := `{ + "type": "object", + "properties": { + "revision_reasons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "evidence_reference": { + "type": ["string", "array"], + "items": { + "type": "string" + } + } + } + } + } + } + }` + + for name, clean := range cleaners { + t.Run(name+"_union_type_with_items", func(t *testing.T) { + cleaned := clean(inputUnionType) + parsed := gjson.Parse(cleaned) + target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference") + if gotType := target.Get("type").String(); gotType != "array" { + t.Fatalf("%s: evidence_reference.type = %q, want 'array' when items is defined; cleaned schema: %s", name, gotType, cleaned) + } + }) + } + + // Case 3: property has explicit non-array type (e.g. "string") with extraneous items + inputNonArrayWithItems := `{ + "type": "object", + "properties": { + "label": { + "type": "string", + "items": { "type": "string" } + }, + "config": { + "type": "object", + "properties": { "key": { "type": "string" } }, + "items": { "type": "string" } + } + } + }` + + for name, clean := range cleaners { + t.Run(name+"_non_array_strips_items", func(t *testing.T) { + cleaned := clean(inputNonArrayWithItems) + parsed := gjson.Parse(cleaned) + if parsed.Get("properties.label.items").Exists() { + t.Fatalf("%s: properties.label should not retain items on string type: %s", name, cleaned) + } + if parsed.Get("properties.config.items").Exists() { + t.Fatalf("%s: properties.config should not retain items on object type: %s", name, cleaned) + } + if parsed.Get("properties.label.type").String() != "string" { + t.Fatalf("%s: properties.label.type should remain string: %s", name, cleaned) + } + }) + } + + // Case 4: property literally named "items" should be preserved and not treated as array items + inputPropertyNamedItems := `{ + "type": "object", + "properties": { + "items": { + "type": "string", + "description": "a field named items" + } + } + }` + + for name, clean := range cleaners { + t.Run(name+"_property_named_items_preserved", func(t *testing.T) { + cleaned := clean(inputPropertyNamedItems) + parsed := gjson.Parse(cleaned) + itemProp := parsed.Get("properties.items") + if !itemProp.Exists() { + t.Fatalf("%s: property named 'items' was removed: %s", name, cleaned) + } + if itemProp.Get("type").String() != "string" { + t.Fatalf("%s: property named 'items' type changed: %s", name, cleaned) + } + }) + } + + // Case 5: Root array with items missing type + inputRootArrayMissingType := `{ + "items": { + "type": "string" + } + }` + + for name, clean := range cleaners { + t.Run(name+"_root_array_missing_type", func(t *testing.T) { + cleaned := clean(inputRootArrayMissingType) + parsed := gjson.Parse(cleaned) + if parsed.Get("type").String() != "array" { + t.Fatalf("%s: root schema type should be inferred as 'array': %s", name, cleaned) + } + if parsed.Get("items.type").String() != "string" { + t.Fatalf("%s: root schema items.type should remain 'string': %s", name, cleaned) + } + }) + } + + // Case 6: Cleaning is idempotent across all cleaners + for name, clean := range cleaners { + t.Run(name+"_idempotent", func(t *testing.T) { + cleaned1 := clean(inputMissingType) + cleaned2 := clean(cleaned1) + if cleaned1 != cleaned2 { + t.Fatalf("%s: cleaner is not idempotent.\nFirst:\n%s\nSecond:\n%s", name, cleaned1, cleaned2) + } + }) + } + + // Case 7: Response schema regression check + t.Run("antigravity_response_schema", func(t *testing.T) { + // Response schema declaring items without type infers array type + respInput := `{"type":"object","properties":{"names":{"items":{"type":"string"}}}}` + cleaned := CleanJSONSchemaForAntigravityResponse(respInput) + parsed := gjson.Parse(cleaned) + if parsed.Get("properties.names.type").String() != "array" { + t.Fatalf("response schema should infer type 'array' when items is defined: %s", cleaned) + } + // Response array missing items still remains untouched + respNoItems := `{"type":"object","properties":{"values":{"type":"array"}}}` + cleanedNoItems := CleanJSONSchemaForAntigravityResponse(respNoItems) + parsedNoItems := gjson.Parse(cleanedNoItems) + if parsedNoItems.Get("properties.values.items").Exists() { + t.Fatalf("response array missing items should not gain placeholder items: %s", cleanedNoItems) + } + }) +}