fix(antigravity): handle prefixItems and ensure array schema has valid items fallback

- Handle JSON Schema Draft 2020-12 prefixItems (tuple arrays) by converting to a best-match items schema
- Ensure array schema always defines a valid items schema to prevent Gemini 400 'missing field: items.items' error
- Fix recursive traversal when a schema defines both properties and items
- Add comprehensive unit test cases for tuple and array schemas
This commit is contained in:
郭兰鑫
2026-09-12 23:25:57 +08:00
parent bdb42e22f8
commit 5d781ee6b7
2 changed files with 189 additions and 45 deletions
@@ -119,14 +119,27 @@ func cleanJSONSchemaRecursive(value any) any {
// 0. [NEW] 合并 allOf
mergeAllOf(schemaMap)
// 0.5 [FIX] 处理 Draft 2020-12 的 prefixItems (元组定义)
if prefixItems, ok := schemaMap["prefixItems"].([]any); ok && len(prefixItems) > 0 {
itemsVal, hasItems := schemaMap["items"]
_, itemsIsBool := itemsVal.(bool)
if !hasItems || itemsVal == nil || itemsIsBool {
best := extractBestSchemaFromUnion(prefixItems)
if best == nil {
best = map[string]any{"type": "string"}
}
schemaMap["items"] = best
}
delete(schemaMap, "prefixItems")
}
// 1. [CRITICAL] 深度递归处理子项
if props, ok := schemaMap["properties"].(map[string]any); ok {
for _, v := range props {
cleanJSONSchemaRecursive(v)
}
// Go 中不需要像 Rust 那样显式处理 nullable_keys remove required,
// 因为我们在子项处理中会正确设置 type 和 description
} else if items, ok := schemaMap["items"]; ok {
}
if items, ok := schemaMap["items"]; ok {
// [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。
if itemsArr, ok := items.([]any); ok {
// 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。
@@ -141,8 +154,9 @@ func cleanJSONSchemaRecursive(value any) any {
} else {
cleanJSONSchemaRecursive(items)
}
} else {
// 遍历所有值递归
}
// 如果既无 properties 也无 items,遍历其他 map/slice 子项递归
if !hasKey(schemaMap, "properties") && !hasKey(schemaMap, "items") {
for _, v := range schemaMap {
if _, isMap := v.(map[string]any); isMap {
cleanJSONSchemaRecursive(v)
@@ -238,43 +252,7 @@ func cleanJSONSchemaRecursive(value any) any {
}
}
// 6. [SAFETY] 处理空 Object
if t, _ := schemaMap["type"].(string); t == "object" {
hasProps := false
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
hasProps = true
}
if !hasProps {
schemaMap["properties"] = map[string]any{
"reason": map[string]any{
"type": "string",
"description": "Reason for calling this tool",
},
}
schemaMap["required"] = []any{"reason"}
}
}
// 7. [SAFETY] Required 字段对齐
if props, ok := schemaMap["properties"].(map[string]any); ok {
if req, ok := schemaMap["required"].([]any); ok {
var validReq []any
for _, r := range req {
if rStr, ok := r.(string); ok {
if _, exists := props[rStr]; exists {
validReq = append(validReq, r)
}
}
}
if len(validReq) > 0 {
schemaMap["required"] = validReq
} else {
delete(schemaMap, "required")
}
}
}
// 8. 处理 type 字段 (Lowercase + Nullable 提取)
// 6. 处理 type 字段 (Lowercase + Nullable 提取)
isEffectivelyNullable := false
if typeVal, exists := schemaMap["type"]; exists {
var selectedType string
@@ -305,16 +283,72 @@ func cleanJSONSchemaRecursive(value any) any {
}
schemaMap["type"] = selectedType
} else {
// 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist)
// 如果没有 type,但有 properties,补一个
// 如果没有 type,但有 properties/items,推断并补全
if hasKey(schemaMap, "properties") {
schemaMap["type"] = "object"
} else if hasKey(schemaMap, "items") {
schemaMap["type"] = "array"
} else {
// 默认为 string ? or object? Gemini 通常需要明确 type
schemaMap["type"] = "object"
}
}
// 7. [SAFETY] 处理空 Object
if t, _ := schemaMap["type"].(string); t == "object" {
hasProps := false
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
hasProps = true
}
if !hasProps {
schemaMap["properties"] = map[string]any{
"reason": map[string]any{
"type": "string",
"description": "Reason for calling this tool",
},
}
schemaMap["required"] = []any{"reason"}
}
}
// 8. [SAFETY] 处理缺失 items 的 Array (Gemini 严苛要求 array 必须声明 items Schema)
if t, _ := schemaMap["type"].(string); t == "array" {
itemsVal, hasItems := schemaMap["items"]
needDefaultItems := false
if !hasItems || itemsVal == nil {
needDefaultItems = true
} else if itemsMap, ok := itemsVal.(map[string]any); ok {
if len(itemsMap) == 0 {
needDefaultItems = true
}
} else if _, isBool := itemsVal.(bool); isBool {
needDefaultItems = true
}
if needDefaultItems {
schemaMap["items"] = map[string]any{
"type": "string",
}
}
}
// 9. [SAFETY] Required 字段对齐
if props, ok := schemaMap["properties"].(map[string]any); ok {
if req, ok := schemaMap["required"].([]any); ok {
var validReq []any
for _, r := range req {
if rStr, ok := r.(string); ok {
if _, exists := props[rStr]; exists {
validReq = append(validReq, r)
}
}
}
if len(validReq) > 0 {
schemaMap["required"] = validReq
} else {
delete(schemaMap, "required")
}
}
}
if isEffectivelyNullable {
desc, _ := schemaMap["description"].(string)
if !strings.Contains(desc, "nullable") {
@@ -0,0 +1,110 @@
package antigravity
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCleanJSONSchema_ArrayPrefixItems(t *testing.T) {
// 模拟 Claude Code 2.1 Artifact 工具的 query.where 参数 Schema (Draft 2020-12 prefixItems 元组)
input := map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "object",
"properties": map[string]any{
"where": map[string]any{
"type": "array",
"items": map[string]any{
"type": "array",
"prefixItems": []any{
map[string]any{"type": "string"},
map[string]any{"type": "string", "enum": []any{"==", "!=", ">", "<"}},
map[string]any{},
},
},
"maxItems": float64(10),
},
},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
query, ok := cleaned["properties"].(map[string]any)["query"].(map[string]any)
require.True(t, ok)
where, ok := query["properties"].(map[string]any)["where"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "array", where["type"])
// prefixItems 应被移除或替换
assert.Nil(t, where["prefixItems"])
whereItems, ok := where["items"].(map[string]any)
require.True(t, ok, "where.items must be an object")
assert.Equal(t, "array", whereItems["type"])
// 关键验证:where.items.items 必须存在且有效,不能缺失导致 Gemini 400
innerItems, ok := whereItems["items"].(map[string]any)
require.True(t, ok, "where.items.items must be an object")
assert.Equal(t, "string", innerItems["type"])
}
func TestCleanJSONSchema_ArrayMissingItemsFallback(t *testing.T) {
// 针对任何缺少 items 的 array,必须兜底注入 items: {type: string}
input := map[string]any{
"type": "object",
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
},
"nested_empty_array": map[string]any{
"type": "array",
"items": map[string]any{
"type": "array",
},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
props := cleaned["properties"].(map[string]any)
tags := props["tags"].(map[string]any)
require.NotNil(t, tags["items"])
assert.Equal(t, "string", tags["items"].(map[string]any)["type"])
nested := props["nested_empty_array"].(map[string]any)
nestedItems := nested["items"].(map[string]any)
assert.Equal(t, "array", nestedItems["type"])
require.NotNil(t, nestedItems["items"])
assert.Equal(t, "string", nestedItems["items"].(map[string]any)["type"])
}
func TestCleanJSONSchema_ArrayExistingItemsPreserved(t *testing.T) {
// 正常的 array items 不受影响
input := map[string]any{
"type": "object",
"properties": map[string]any{
"numbers": map[string]any{
"type": "array",
"items": map[string]any{
"type": "integer",
},
},
},
}
cleaned := CleanJSONSchema(input)
require.NotNil(t, cleaned)
numbers := cleaned["properties"].(map[string]any)["numbers"].(map[string]any)
assert.Equal(t, "array", numbers["type"])
items := numbers["items"].(map[string]any)
assert.Equal(t, "integer", items["type"])
}