From e23395a92b45bc6666147602b84d7ba6e5eef1b8 Mon Sep 17 00:00:00 2001 From: shengyy Date: Sun, 16 Aug 2026 13:00:50 +0800 Subject: [PATCH 1/2] test(antigravity): cover schema sanitizer semantics --- .../antigravity_schema_sanitize_test.go | 44 +++- internal/util/gemini_schema_test.go | 226 ++++++++++++------ 2 files changed, 188 insertions(+), 82 deletions(-) diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index 479207fae..1a368b44f 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing for _, useAntigravitySchema := range []bool{false, true} { for name, schema := range shapes { doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}` - whole := util.CleanJSONSchemaForGemini(doc) - if useAntigravitySchema { - whole = util.CleanJSONSchemaForAntigravity(doc) - } + whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema) want := gjson.Get(whole, schemaPath).Raw got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw if want != got { @@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t } } -func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) { +func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) { payload := `{"request":{ "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{ "choice":{"anyOf":[{"type":"string"},{"type":"null"}]}, @@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t got := sanitizeAntigravityRequestSchemas(payload, true) responseSchema := gjson.Get(got, "request.generationConfig.responseSchema") - union := responseSchema.Get("properties.action.anyOf") - if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" { - t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw) + action := responseSchema.Get("properties.action") + if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() { + t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw) } conviction := responseSchema.Get("properties.conviction") if gotType := conviction.Get("type").String(); gotType != "number" { @@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t if toolSchema.Get("properties.choice.anyOf").Exists() { t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw) } - if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" { - t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw) + if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" { + t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw) + } +} + +func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *testing.T) { + payload := `{"request":{"tools":[{"functionDeclarations":[{"name":"tool","parameters":{ + "type":"object", + "properties":{ + "level":{"type":"number","enum":[1,2]}, + "note":{"type":["string","null"]} + }, + "required":["level","note"] + }}]}]}}` + + for _, requirePlaceholder := range []bool{false, true} { + got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder) + schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") + if schema.Get("properties.level.type").String() != "number" { + t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw) + } + for _, member := range schema.Get("properties.level.enum").Array() { + if member.Type != gjson.String { + t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw) + } + } + if !schema.Get("properties.note.nullable").Bool() || schema.Get("required.1").String() != "note" { + t.Fatalf("placeholder=%v lost native nullable/required semantics: %s", requirePlaceholder, schema.Raw) + } } } diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index ce766b4a1..4cea33977 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { "properties": { "kind": { "type": "string", - "enum": ["InsightVizNode"] + "description": "Allowed: InsightVizNode" } } }` @@ -53,13 +53,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) { "properties": { "name": { "type": "string", + "nullable": true, "description": "(nullable)" }, "other": { "type": "string" } }, - "required": ["other"] + "required": ["name", "other"] }` result := CleanJSONSchemaForAntigravity(input) @@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing "properties": { "query": { "type": "object", + "nullable": true, "description": "Accepts: null | object", "properties": { "_": { "type": "boolean" }, @@ -214,20 +216,18 @@ func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) { } }` - // After $ref is converted to placeholder object, empty schema placeholder is also added + // The local reference is expanded before definitions are removed. Claude VALIDATED mode adds + // only its optional-object placeholder; the referenced property definition remains intact. expected := `{ "type": "object", "properties": { "customer": { "type": "object", - "description": "See: User", "properties": { - "reason": { - "type": "string", - "description": "Brief explanation of why you are calling this tool" - } + "name": { "type": "string" }, + "_": { "type": "boolean" } }, - "required": ["reason"] + "required": ["_"] } } }` @@ -255,20 +255,17 @@ func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testin } }` - // After $ref is converted, empty schema placeholder is also added expected := `{ "type": "object", "properties": { "customer": { "type": "object", - "description": "He said \"hi\"\\nsecond line (See: User)", + "description": "He said \"hi\"\\nsecond line", "properties": { - "reason": { - "type": "string", - "description": "Brief explanation of why you are calling this tool" - } + "name": { "type": "string" }, + "_": { "type": "boolean" } }, - "required": ["reason"] + "required": ["_"] } } }` @@ -299,9 +296,9 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) { t.Errorf("Expected type: object, got: %v", resMap["type"]) } - desc, ok := resMap["description"].(string) - if !ok || !strings.Contains(desc, "Node") { - t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"]) + child := gjson.Get(result, "properties.child") + if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") { + t.Errorf("Expected typed cycle hint containing Node, got: %s", result) } } @@ -499,13 +496,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing "properties": { "my.param": { "type": "string", + "nullable": true, "description": "(nullable)" }, "other": { "type": "string" } }, - "required": ["other"] + "required": ["my.param", "other"] }` result := CleanJSONSchemaForAntigravity(input) @@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t compareJSON(t, expected, result) } -func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { result := CleanJSONSchemaForAntigravity(input) - if strings.Contains(result, "Allowed:") { - t.Errorf("Single value enum should not add Allowed hint, got: %s", result) + if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() { + t.Errorf("Ignored tool enum should become a hint, got: %s", result) } } @@ -764,7 +762,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test } } -func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { +func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) { input := `{ "type":"object", "properties":{ @@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { }` result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) - for _, testCase := range []struct { - path string - wantTypes []string - }{ - {path: "properties.action.anyOf", wantTypes: []string{"object", "null"}}, - {path: "properties.label.oneOf", wantTypes: []string{"string", "null"}}, + for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} { + if result.Get(path).Exists() { + t.Errorf("ignored response union %s survived: %s", path, result.Raw) + } + } + for _, testCase := range []struct{ path, wantType string }{ + {path: "properties.action", wantType: "object"}, + {path: "properties.label", wantType: "string"}, } { - union := result.Get(testCase.path) - if !union.IsArray() { - t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw) - continue - } - var gotTypes []string - for _, branch := range union.Array() { - gotTypes = append(gotTypes, branch.Get("type").String()) - } - if !reflect.DeepEqual(gotTypes, testCase.wantTypes) { - t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw) + schema := result.Get(testCase.path) + if schema.Get("type").String() != testCase.wantType || !schema.Get("nullable").Bool() { + t.Errorf("%s was not projected to nullable %s: %s", testCase.path, testCase.wantType, result.Raw) } } } @@ -978,8 +970,7 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { } } -func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { - // Gemini API requires enum values to be strings, not numbers +func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) { input := `{ "type": "object", "properties": { @@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { result := CleanJSONSchemaForAntigravity(input) parsed := gjson.Parse(result) - // Tool enum schemas require both string values and a string type. - for _, path := range []string{"properties.priority", "properties.level"} { - if gotType := parsed.Get(path + ".type").String(); gotType != "string" { - t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result) + // Antigravity ignores function-argument enum but still uses the declared type to choose the + // emitted JSON type. Preserve types and convert enum values to advisory hints. + for path, wantType := range map[string]string{ + "properties.priority": "integer", + "properties.level": "number", + "properties.status": "string", + } { + if gotType := parsed.Get(path + ".type").String(); gotType != wantType { + t.Errorf("Tool enum type at %s = %q, want %s: %s", path, gotType, wantType, result) + } + if parsed.Get(path+".enum").Exists() || !strings.Contains(parsed.Get(path+".description").String(), "Allowed:") { + t.Errorf("Tool enum at %s was not projected to a hint: %s", path, result) } - } - - // Numeric enum values should be converted to strings - if strings.Contains(result, `"enum":[0,1,2]`) { - t.Errorf("Integer enum values should be converted to strings, got: %s", result) - } - if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) { - t.Errorf("Float enum values should be converted to strings, got: %s", result) - } - // Should contain string versions - if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) { - t.Errorf("Integer enum values should be converted to string format, got: %s", result) - } - // String enum values should remain unchanged - if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) { - t.Errorf("String enum values should remain unchanged, got: %s", result) } } -func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { - // Boolean enum values should also be converted to strings +func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { result := CleanJSONSchemaForAntigravity(input) - // Boolean enum values should be converted to strings - if strings.Contains(result, `"enum":[true,false]`) { - t.Errorf("Boolean enum values should be converted to strings, got: %s", result) - } - // Should contain string versions "true" and "false" - if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) { - t.Errorf("Boolean enum values should be converted to string format, got: %s", result) + value := gjson.Get(result, "properties.enabled") + if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") { + t.Errorf("Boolean tool enum should become a typed hint, got: %s", result) } } @@ -1450,3 +1428,107 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) { } } } + +func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t *testing.T) { + input := `{ + "type":"object", + "properties":{ + "kind":{"type":"string"}, + "action":{"type":"object","properties":{"full":{"type":"string"}},"required":["full"]} + }, + "required":["kind","action"], + "allOf":[{ + "if":{"properties":{"kind":{"const":"skip"}}}, + "then":{"properties":{ + "action":{"type":"null"}, + "branch_only":{"type":"integer"} + }} + }] + }` + + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + action := result.Get("properties.action") + if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() { + t.Fatalf("conditional branch replaced canonical action: %s", result.Raw) + } + if action.Get("required.0").String() != "full" || !result.Get("properties.branch_only").Exists() { + t.Fatalf("conditional merge lost parent or branch-only information: %s", result.Raw) + } + if result.Get("allOf").Exists() || strings.Contains(result.Raw, `"if"`) || strings.Contains(result.Raw, `"then"`) { + t.Fatalf("unsupported conditional keywords survived: %s", result.Raw) + } +} + +func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) { + input := `{ + "$defs":{"Payload":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}}, + "type":"object", + "properties":{"payload":{"$ref":"#/$defs/Payload"}}, + "required":["payload"] + }` + + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) { + t.Fatalf("local reference metadata survived: %s", result.Raw) + } + payload := result.Get("properties.payload") + if payload.Get("type").String() != "object" || payload.Get("properties.id.type").String() != "integer" || payload.Get("required.0").String() != "id" { + t.Fatalf("local reference definition was not inlined: %s", result.Raw) + } +} + +func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) { + input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}` + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + value := result.Get("properties.value") + if value.Get("type").String() != "number" || !value.Get("nullable").Bool() { + t.Fatalf("type array was not projected to native nullable: %s", result.Raw) + } + if result.Get("required.0").String() != "value" { + t.Fatalf("nullable required property became optional: %s", result.Raw) + } +} + +func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) { + input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}` + result := gjson.Parse(CleanJSONSchemaForAntigravityTool(input, false)) + value := result.Get("properties.value") + if value.Get("type").String() != "number" { + t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw) + } + if value.Get("enum").Exists() || !strings.Contains(value.Get("description").String(), "Allowed: 1, 2") { + t.Fatalf("ignored tool enum was not projected to a hint: %s", result.Raw) + } +} + +func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) { + input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}` + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + value := result.Get("properties.value") + if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") { + t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw) + } +} + +func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) { + input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}` + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + value := result.Get("properties.value") + for _, keyword := range []string{"minimum", "maximum", "not"} { + if value.Get(keyword).Exists() { + t.Fatalf("ignored constraint %s survived: %s", keyword, result.Raw) + } + if !strings.Contains(value.Get("description").String(), keyword+":") { + t.Fatalf("ignored constraint %s lost its hint: %s", keyword, result.Raw) + } + } +} + +func TestSortByDepthUsesSegmentsAndIsStable(t *testing.T) { + paths := []string{"root.verylong", "root.x.y", "first.same", "later.same"} + sortByDepth(paths) + want := []string{"root.x.y", "root.verylong", "first.same", "later.same"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("sortByDepth() = %v, want %v", paths, want) + } +} From 7d55d0da0dd270ed45b31a696748878f03fac440 Mon Sep 17 00:00:00 2001 From: shengyy Date: Sun, 16 Aug 2026 13:00:59 +0800 Subject: [PATCH 2/2] fix(antigravity): preserve schema semantics --- .../executor/antigravity_executor_request.go | 5 +- internal/util/gemini_schema.go | 353 +++++++++++++++--- 2 files changed, 298 insertions(+), 60 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go index c8d3141a6..d4c7a790c 100644 --- a/internal/runtime/executor/antigravity_executor_request.go +++ b/internal/runtime/executor/antigravity_executor_request.go @@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche payloadStr = renamed } - toolSchemaCleaner := util.CleanJSONSchemaForGemini - if useAntigravitySchema { - toolSchemaCleaner = util.CleanJSONSchemaForAntigravity + toolSchemaCleaner := func(schema string) string { + return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema) } cleanNestedToolSchema := func(schemaRaw string) string { return cleanNestedSchema(toolSchemaCleaner, schemaRaw) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index f0333c157..48b6615ea 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -2,6 +2,7 @@ package util import ( + "encoding/json" "fmt" "sort" "strconv" @@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t type jsonSchemaCleanOptions struct { addPlaceholder bool + antigravitySemantics bool + removeToolTitle bool removeGeminiMetadata bool flattenUnions bool forceEnumStringType bool + dropAllEnums bool + dropBooleanEnums bool preserveAdditionalPropertiesFalse bool } @@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct { // It handles unsupported keywords, type flattening, and schema simplification while preserving // semantic information as description hints and adding placeholders required by VALIDATED mode. func CleanJSONSchemaForAntigravity(jsonStr string) string { + return CleanJSONSchemaForAntigravityTool(jsonStr, true) +} + +// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private +// backend accepts enum members only as strings, but the declared type still controls the JSON +// type of generated function arguments, so numeric and boolean types must not be rewritten. +// requirePlaceholder is used only for Claude VALIDATED mode. +func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ - addPlaceholder: true, - flattenUnions: true, - forceEnumStringType: true, + addPlaceholder: requirePlaceholder, + antigravitySemantics: true, + removeToolTitle: !requirePlaceholder, + flattenUnions: true, + dropAllEnums: true, }) } @@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { // compatibility rewrites that would alter the client's structured output contract. // // Sanitization policy: -// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf), -// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint). -// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems, -// maxItems, uniqueItems, format, default, examples). +// - Passthrough: type, properties, items, required, description, enum, nullable, and +// additionalProperties: false (which Antigravity natively enforces for response schemas). +// - Description hints + deletion: unsupported or accepted-but-ignored constraints. // - Flattened: allOf merged into properties/required. -// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint), -// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties, -// and x-* extensions. +// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. +// - Resolved: local $ref targets are inlined before $defs/definitions are removed. +// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, +// conditional keywords (after non-conflicting properties are retained), and x-* extensions. func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ + antigravitySemantics: true, + flattenUnions: true, + dropBooleanEnums: true, preserveAdditionalPropertiesFalse: true, }) } @@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string { // cleanJSONSchema performs the core cleaning operations on the JSON schema. func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { // Phase 1: Convert and add hints - jsonStr = convertRefsToHints(jsonStr) + if options.antigravitySemantics { + jsonStr = inlineLocalRefs(jsonStr) + } + jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) jsonStr = convertConstToEnum(jsonStr) jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) jsonStr = addEnumHints(jsonStr) + jsonStr = dropIgnoredEnumsToHints(jsonStr, options) if !options.preserveAdditionalPropertiesFalse { jsonStr = addAdditionalPropertiesHints(jsonStr) } - jsonStr = moveConstraintsToDescription(jsonStr) + jsonStr = moveConstraintsToDescription(jsonStr, options) + if options.antigravitySemantics { + jsonStr = moveNotToDescription(jsonStr) + } // Phase 2: Flatten complex structures jsonStr = mergeConditionals(jsonStr) @@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { if options.flattenUnions { jsonStr = flattenAnyOfOneOf(jsonStr) } - jsonStr = flattenTypeArrays(jsonStr) + jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr, options) @@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { // Gemini schema cleanup: remove nullable/title and placeholder-only fields. jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) jsonStr = removePlaceholderFields(jsonStr) + } else if options.removeToolTitle { + // Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title. + // Keep that harmless metadata policy without losing Antigravity's native nullable support. + jsonStr = removeKeywords(jsonStr, []string{"title"}) } jsonStr = cleanupRequiredFields(jsonStr) // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) @@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string { return jsonStr } -// convertRefsToHints converts $ref to description hints (Lazy Hint strategy). -func convertRefsToHints(jsonStr string) string { +// inlineLocalRefs resolves JSON Pointer references against the original schema before definition +// containers are stripped. Each expansion receives its own copy, sibling keywords override the +// referenced definition, and cycles terminate as a typed hint instead of recursing forever. +func inlineLocalRefs(jsonStr string) string { + if !strings.Contains(jsonStr, `"$ref"`) { + return jsonStr + } + + decoder := json.NewDecoder(strings.NewReader(jsonStr)) + decoder.UseNumber() + var root any + if err := decoder.Decode(&root); err != nil { + return jsonStr + } + + resolved := resolveLocalRefs(root, root, make(map[string]bool)) + out, err := json.Marshal(resolved) + if err != nil { + return jsonStr + } + return string(out) +} + +func resolveLocalRefs(root, value any, active map[string]bool) any { + switch node := value.(type) { + case []any: + out := make([]any, len(node)) + for i, item := range node { + out[i] = resolveLocalRefs(root, item, active) + } + return out + case map[string]any: + ref, hasRef := node["$ref"].(string) + if hasRef && strings.HasPrefix(ref, "#/") { + if target, ok := resolveJSONPointer(root, ref); ok { + if active[ref] { + return cyclicRefFallback(node, target, ref) + } + active[ref] = true + resolvedTarget := resolveLocalRefs(root, target, active) + delete(active, ref) + if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { + out := make(map[string]any, len(targetMap)+len(node)) + for key, item := range targetMap { + out[key] = item + } + for key, item := range node { + if key == "$ref" { + continue + } + out[key] = resolveLocalRefs(root, item, active) + } + return out + } + } + } + + out := make(map[string]any, len(node)) + for key, item := range node { + out[key] = resolveLocalRefs(root, item, active) + } + return out + default: + return value + } +} + +func resolveJSONPointer(root any, ref string) (any, bool) { + current := root + for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~") + switch node := current.(type) { + case map[string]any: + var ok bool + current, ok = node[part] + if !ok { + return nil, false + } + case []any: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= len(node) { + return nil, false + } + current = node[index] + default: + return nil, false + } + } + return current, true +} + +func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any { + out := make(map[string]any, len(node)+2) + if targetMap, ok := target.(map[string]any); ok { + for _, key := range []string{"type", "nullable", "description"} { + if value, exists := targetMap[key]; exists { + out[key] = value + } + } + } + for key, value := range node { + if key != "$ref" { + out[key] = value + } + } + name := refName(ref) + hint := "See: " + name + if description, _ := out["description"].(string); description != "" { + out["description"] = mergeHint(description, hint) + } else { + out["description"] = hint + } + return out +} + +func refName(ref string) string { + if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) { + return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~") + } + return ref +} + +// convertRefsToHints retains sibling keywords and converts only unresolved or external references +// to descriptions. Local references have already been expanded by inlineLocalRefs. +func convertRefsToHints(jsonStr string, preserveSiblings bool) string { paths := findPaths(jsonStr, "$ref") sortByDepth(paths) for _, p := range paths { refVal := gjson.Get(jsonStr, p).String() - defName := refVal - if idx := strings.LastIndex(refVal, "/"); idx >= 0 { - defName = refVal[idx+1:] - } + defName := refName(refVal) parentPath := trimSuffix(p, ".$ref") hint := fmt.Sprintf("See: %s", defName) - if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { - hint = fmt.Sprintf("%s (%s)", existing, hint) + if !preserveSiblings { + if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { + hint = fmt.Sprintf("%s (%s)", existing, hint) + } + replacement := `{"type":"object","description":""}` + replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) + jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes)) + continue } - - replacement := `{"type":"object","description":""}` - replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) - replacement = string(replacementBytes) - jsonStr = setRawAt(jsonStr, parentPath, replacement) + jsonStr, _ = sjson.Delete(jsonStr, p) + jsonStr = appendHint(jsonStr, parentPath, hint) } return jsonStr } @@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string { } // convertEnumValuesToStrings ensures all enum values use the string representation required by -// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve -// their declared type because the upstream decoder uses it to select the emitted JSON value type. +// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the +// emitted JSON type on both response and function-argument paths. func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string { for _, p := range findPaths(jsonStr, "enum") { arr := gjson.Get(jsonStr, p) @@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string { return jsonStr } +// Antigravity does not enforce enum on function arguments and ignores boolean response enums. +// Preserve the advisory values in description, but do not leave an unenforced constraint in the +// schema contract. Response enums for string, number, and integer remain native constraints. +func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string { + for _, path := range findPaths(jsonStr, "enum") { + parentPath := trimSuffix(path, ".enum") + shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean") + if !shouldDrop { + continue + } + enum := gjson.Get(jsonStr, path) + if enum.IsArray() && len(enum.Array()) == 1 { + jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String()) + } + jsonStr, _ = sjson.Delete(jsonStr, path) + } + return jsonStr +} + func addAdditionalPropertiesHints(jsonStr string) string { for _, p := range findPaths(jsonStr, "additionalProperties") { if gjson.Get(jsonStr, p).Type == gjson.False { @@ -295,9 +466,18 @@ var unsupportedConstraints = []string{ "default", "examples", // Claude rejects these in VALIDATED mode } -func moveConstraintsToDescription(jsonStr string) string { - pathsByField := findPathsByFields(jsonStr, unsupportedConstraints) - for _, key := range unsupportedConstraints { +func constraintKeywords(options jsonSchemaCleanOptions) []string { + keywords := append([]string(nil), unsupportedConstraints...) + if options.antigravitySemantics { + keywords = append(keywords, "minimum", "maximum", "multipleOf") + } + return keywords +} + +func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string { + constraints := constraintKeywords(options) + pathsByField := findPathsByFields(jsonStr, constraints) + for _, key := range constraints { for _, p := range pathsByField[key] { val := gjson.Get(jsonStr, p) if !val.Exists() || val.IsObject() || val.IsArray() { @@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string { return jsonStr } +func moveNotToDescription(jsonStr string) string { + for _, path := range findPaths(jsonStr, "not") { + value := gjson.Get(jsonStr, path) + if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) { + continue + } + jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw) + } + return jsonStr +} + func mergeConditionals(jsonStr string) string { pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) var paths []string @@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string { parentPath := trimSuffix(p, ".allOf") for _, item := range allOf.Array() { - if props := item.Get("properties"); props.IsObject() { - props.ForEach(func(key, value gjson.Result) bool { - destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String())) - updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw)) - jsonStr = string(updated) - return true - }) + if !item.IsObject() { + continue } - if req := item.Get("required"); req.IsArray() { - reqPath := joinPath(parentPath, "required") - current := getStrings(jsonStr, reqPath) - for _, r := range req.Array() { - if s := r.String(); !contains(current, s) { - current = append(current, s) + item.ForEach(func(key, value gjson.Result) bool { + field := key.String() + switch field { + case "required": + if !value.IsArray() { + return true } + reqPath := joinPath(parentPath, "required") + current := getStrings(jsonStr, reqPath) + for _, required := range value.Array() { + if name := required.String(); !contains(current, name) { + current = append(current, name) + } + } + updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) + jsonStr = string(updated) + case "if", "then", "else", "allOf": + // Conditional applicability cannot be represented by the upstream schema. + default: + destination := joinPath(parentPath, escapeGJSONPathKey(field)) + jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value) } - updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) - jsonStr = string(updated) - } + return true + }) } jsonStr, _ = sjson.Delete(jsonStr, p) } return jsonStr } +// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing +// definition. A parent schema is the canonical definition; allOf and conditional branches may +// enrich gaps in it, but can never replace it with a narrower branch shell. +func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string { + existing := gjson.Get(jsonStr, destination) + if !existing.Exists() { + updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw)) + return string(updated) + } + if !existing.IsObject() || !incoming.IsObject() { + return jsonStr + } + incoming.ForEach(func(key, value gjson.Result) bool { + child := joinPath(destination, escapeGJSONPathKey(key.String())) + jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value) + return true + }) + return jsonStr +} + func flattenAnyOfOneOf(jsonStr string) string { for _, key := range []string{"anyOf", "oneOf"} { paths := findPaths(jsonStr, key) @@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string { items := arr.Array() bestIdx, allTypes := selectBest(items) selected := items[bestIdx].Raw + hasNull := false + for _, item := range items { + if item.Get("type").String() == "null" { + hasNull = true + break + } + } + if hasNull && items[bestIdx].Get("type").String() != "null" { + updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) + selected = string(updated) + } if parentDesc != "" { selected = mergeDescriptionRaw(selected, parentDesc) @@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) { return } -func flattenTypeArrays(jsonStr string) string { +func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { paths := findPaths(jsonStr, "type") sortByDepth(paths) @@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string { } if hasNull { + if preserveNativeNullable { + updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + jsonStr = string(updated) + jsonStr = appendHint(jsonStr, parentPath, "(nullable)") + continue + } + parts := splitGJSONPath(p) if len(parts) >= 3 && parts[len(parts)-3] == "properties" { fieldNameEscaped := parts[len(parts)-2] fieldName := unescapeGJSONPathKey(fieldNameEscaped) objectPath := strings.Join(parts[:len(parts)-3], ".") nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) - - propPath := joinPath(objectPath, "properties."+fieldNameEscaped) - jsonStr = appendHint(jsonStr, propPath, "(nullable)") + jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)") } } } @@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string { } var filtered []string - for _, r := range req.Array() { - if !contains(fields, r.String()) { - filtered = append(filtered, r.String()) + for _, required := range req.Array() { + if !contains(fields, required.String()) { + filtered = append(filtered, required.String()) } } - if len(filtered) == 0 { jsonStr, _ = sjson.Delete(jsonStr, reqPath) } else { @@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string { } func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { - keywords := append(unsupportedConstraints, + keywords := append(constraintKeywords(options), "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords "if", "then", "else", "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini ) + if options.antigravitySemantics { + keywords = append(keywords, "not") + } deletePaths := make([]string, 0) pathsByField := findPathsByFields(jsonStr, keywords) @@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{}, } func sortByDepth(paths []string) { - sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + sort.SliceStable(paths, func(i, j int) bool { + return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j])) + }) } func trimSuffix(path, suffix string) string {