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
This commit is contained in:
Luis Pater
2026-09-21 22:38:13 +08:00
parent ac3849e5d9
commit ffe6ad3c5f
2 changed files with 237 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)
}
})
}