History

Merge pull request #5008 from shengyy/fix/antigravity-schema-semantics

fix(antigravity): preserve response and tool schema semantics

Luis Pater committed 2 months ago

Commit 361fe952 parents 7eefab9 7d55d0d4 files · +487 −143Browse files

Unified · Split

4 files
  1. internal/runtime/executor/antigravity_executor_request.go +2 −3
  2. internal/runtime/executor/antigravity_schema_sanitize_test.go +34 −10
  3. internal/util/gemini_schema.go +297 −58
  4. internal/util/gemini_schema_test.go +154 −72
internal/runtime/executor/antigravity_executor_request.go+2−3
@@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche · 204 unmodified lines
205205 payloadStr = renamed
206206 }
207207
208− toolSchemaCleaner := util.CleanJSONSchemaForGemini
209− if useAntigravitySchema {
210− toolSchemaCleaner = util.CleanJSONSchemaForAntigravity
208+ toolSchemaCleaner := func(schema string) string {
209+ return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
211210 }
212211 cleanNestedToolSchema := func(schemaRaw string) string {
213212 return cleanNestedSchema(toolSchemaCleaner, schemaRaw)
internal/runtime/executor/antigravity_schema_sanitize_test.go+34−10
@@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing · 221 unmodified lines
222222 for _, useAntigravitySchema := range []bool{false, true} {
223223 for name, schema := range shapes {
224224 doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`
225− whole := util.CleanJSONSchemaForGemini(doc)
226− if useAntigravitySchema {
227− whole = util.CleanJSONSchemaForAntigravity(doc)
228− }
225+ whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema)
229226 want := gjson.Get(whole, schemaPath).Raw
230227 got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
231228 if want != got {
@@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t · 42 unmodified lines
274271 }
275272 }
276273
277−func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) {
274+func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) {
278275 payload := `{"request":{
279276 "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
280277 "choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
@@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 10 unmodified lines
291288
292289 got := sanitizeAntigravityRequestSchemas(payload, true)
293290 responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
294− union := responseSchema.Get("properties.action.anyOf")
295− if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" {
296− t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw)
291+ action := responseSchema.Get("properties.action")
292+ if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() {
293+ t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw)
297294 }
298295 conviction := responseSchema.Get("properties.conviction")
299296 if gotType := conviction.Get("type").String(); gotType != "number" {
@@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 9 unmodified lines
309306 if toolSchema.Get("properties.choice.anyOf").Exists() {
310307 t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)
311308 }
312− if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" {
313− t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw)
309+ if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
310+ t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
311+ }
312+}
313+
314+func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *testing.T) {
315+ payload := `{"request":{"tools":[{"functionDeclarations":[{"name":"tool","parameters":{
316+ "type":"object",
317+ "properties":{
318+ "level":{"type":"number","enum":[1,2]},
319+ "note":{"type":["string","null"]}
320+ },
321+ "required":["level","note"]
322+ }}]}]}}`
323+
324+ for _, requirePlaceholder := range []bool{false, true} {
325+ got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder)
326+ schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
327+ if schema.Get("properties.level.type").String() != "number" {
328+ t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw)
329+ }
330+ for _, member := range schema.Get("properties.level.enum").Array() {
331+ if member.Type != gjson.String {
332+ t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw)
333+ }
334+ }
335+ if !schema.Get("properties.note.nullable").Bool() || schema.Get("required.1").String() != "note" {
336+ t.Fatalf("placeholder=%v lost native nullable/required semantics: %s", requirePlaceholder, schema.Raw)
337+ }
314338 }
315339 }
316340
internal/util/gemini_schema.go+297−58
@@ -2,6 +2,7 @@ · 1 unmodified line
22 package util
33
44 import (
5+ "encoding/json"
56 "fmt"
67 "sort"
78 "strconv"
@@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t · 18 unmodified lines
2627
2728 type jsonSchemaCleanOptions struct {
2829 addPlaceholder bool
30+ antigravitySemantics bool
31+ removeToolTitle bool
2932 removeGeminiMetadata bool
3033 flattenUnions bool
3134 forceEnumStringType bool
35+ dropAllEnums bool
36+ dropBooleanEnums bool
3237 preserveAdditionalPropertiesFalse bool
3338 }
3439
@@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct { · 1 unmodified line
3641 // It handles unsupported keywords, type flattening, and schema simplification while preserving
3742 // semantic information as description hints and adding placeholders required by VALIDATED mode.
3843 func CleanJSONSchemaForAntigravity(jsonStr string) string {
44+ return CleanJSONSchemaForAntigravityTool(jsonStr, true)
45+}
46+
47+// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private
48+// backend accepts enum members only as strings, but the declared type still controls the JSON
49+// type of generated function arguments, so numeric and boolean types must not be rewritten.
50+// requirePlaceholder is used only for Claude VALIDATED mode.
51+func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
3952 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
40− addPlaceholder: true,
41− flattenUnions: true,
42− forceEnumStringType: true,
53+ addPlaceholder: requirePlaceholder,
54+ antigravitySemantics: true,
55+ removeToolTitle: !requirePlaceholder,
56+ flattenUnions: true,
57+ dropAllEnums: true,
4358 })
4459 }
4560
@@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { · 1 unmodified line
4762 // compatibility rewrites that would alter the client's structured output contract.
4863 //
4964 // Sanitization policy:
50−// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf),
51−// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint).
52−// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems,
53−// maxItems, uniqueItems, format, default, examples).
65+// - Passthrough: type, properties, items, required, description, enum, nullable, and
66+// additionalProperties: false (which Antigravity natively enforces for response schemas).
67+// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
5468 // - Flattened: allOf merged into properties/required.
55−// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint),
56−// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties,
57−// and x-* extensions.
69+// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
70+// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
71+// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
72+// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
5873 func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
5974 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
75+ antigravitySemantics: true,
76+ flattenUnions: true,
77+ dropBooleanEnums: true,
6078 preserveAdditionalPropertiesFalse: true,
6179 })
6280 }
@@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string { · 11 unmodified lines
7492 // cleanJSONSchema performs the core cleaning operations on the JSON schema.
7593 func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
7694 // Phase 1: Convert and add hints
77− jsonStr = convertRefsToHints(jsonStr)
95+ if options.antigravitySemantics {
96+ jsonStr = inlineLocalRefs(jsonStr)
97+ }
98+ jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
7899 jsonStr = convertConstToEnum(jsonStr)
79100 jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
80101 jsonStr = addEnumHints(jsonStr)
102+ jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
81103 if !options.preserveAdditionalPropertiesFalse {
82104 jsonStr = addAdditionalPropertiesHints(jsonStr)
83105 }
84− jsonStr = moveConstraintsToDescription(jsonStr)
106+ jsonStr = moveConstraintsToDescription(jsonStr, options)
107+ if options.antigravitySemantics {
108+ jsonStr = moveNotToDescription(jsonStr)
109+ }
85110
86111 // Phase 2: Flatten complex structures
87112 jsonStr = mergeConditionals(jsonStr)
@@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line
89114 if options.flattenUnions {
90115 jsonStr = flattenAnyOfOneOf(jsonStr)
91116 }
92− jsonStr = flattenTypeArrays(jsonStr)
117+ jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
93118
94119 // Phase 3: Cleanup
95120 jsonStr = removeUnsupportedKeywords(jsonStr, options)
@@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line
97122 // Gemini schema cleanup: remove nullable/title and placeholder-only fields.
98123 jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
99124 jsonStr = removePlaceholderFields(jsonStr)
125+ } else if options.removeToolTitle {
126+ // Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title.
127+ // Keep that harmless metadata policy without losing Antigravity's native nullable support.
128+ jsonStr = removeKeywords(jsonStr, []string{"title"})
100129 }
101130 jsonStr = cleanupRequiredFields(jsonStr)
102131 // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
@@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string { · 90 unmodified lines
193222 return jsonStr
194223 }
195224
196−// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).
197−func convertRefsToHints(jsonStr string) string {
225+// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
226+// containers are stripped. Each expansion receives its own copy, sibling keywords override the
227+// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
228+func inlineLocalRefs(jsonStr string) string {
229+ if !strings.Contains(jsonStr, `"$ref"`) {
230+ return jsonStr
231+ }
232+
233+ decoder := json.NewDecoder(strings.NewReader(jsonStr))
234+ decoder.UseNumber()
235+ var root any
236+ if err := decoder.Decode(&root); err != nil {
237+ return jsonStr
238+ }
239+
240+ resolved := resolveLocalRefs(root, root, make(map[string]bool))
241+ out, err := json.Marshal(resolved)
242+ if err != nil {
243+ return jsonStr
244+ }
245+ return string(out)
246+}
247+
248+func resolveLocalRefs(root, value any, active map[string]bool) any {
249+ switch node := value.(type) {
250+ case []any:
251+ out := make([]any, len(node))
252+ for i, item := range node {
253+ out[i] = resolveLocalRefs(root, item, active)
254+ }
255+ return out
256+ case map[string]any:
257+ ref, hasRef := node["$ref"].(string)
258+ if hasRef && strings.HasPrefix(ref, "#/") {
259+ if target, ok := resolveJSONPointer(root, ref); ok {
260+ if active[ref] {
261+ return cyclicRefFallback(node, target, ref)
262+ }
263+ active[ref] = true
264+ resolvedTarget := resolveLocalRefs(root, target, active)
265+ delete(active, ref)
266+ if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
267+ out := make(map[string]any, len(targetMap)+len(node))
268+ for key, item := range targetMap {
269+ out[key] = item
270+ }
271+ for key, item := range node {
272+ if key == "$ref" {
273+ continue
274+ }
275+ out[key] = resolveLocalRefs(root, item, active)
276+ }
277+ return out
278+ }
279+ }
280+ }
281+
282+ out := make(map[string]any, len(node))
283+ for key, item := range node {
284+ out[key] = resolveLocalRefs(root, item, active)
285+ }
286+ return out
287+ default:
288+ return value
289+ }
290+}
291+
292+func resolveJSONPointer(root any, ref string) (any, bool) {
293+ current := root
294+ for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
295+ part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~")
296+ switch node := current.(type) {
297+ case map[string]any:
298+ var ok bool
299+ current, ok = node[part]
300+ if !ok {
301+ return nil, false
302+ }
303+ case []any:
304+ index, err := strconv.Atoi(part)
305+ if err != nil || index < 0 || index >= len(node) {
306+ return nil, false
307+ }
308+ current = node[index]
309+ default:
310+ return nil, false
311+ }
312+ }
313+ return current, true
314+}
315+
316+func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any {
317+ out := make(map[string]any, len(node)+2)
318+ if targetMap, ok := target.(map[string]any); ok {
319+ for _, key := range []string{"type", "nullable", "description"} {
320+ if value, exists := targetMap[key]; exists {
321+ out[key] = value
322+ }
323+ }
324+ }
325+ for key, value := range node {
326+ if key != "$ref" {
327+ out[key] = value
328+ }
329+ }
330+ name := refName(ref)
331+ hint := "See: " + name
332+ if description, _ := out["description"].(string); description != "" {
333+ out["description"] = mergeHint(description, hint)
334+ } else {
335+ out["description"] = hint
336+ }
337+ return out
338+}
339+
340+func refName(ref string) string {
341+ if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) {
342+ return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~")
343+ }
344+ return ref
345+}
346+
347+// convertRefsToHints retains sibling keywords and converts only unresolved or external references
348+// to descriptions. Local references have already been expanded by inlineLocalRefs.
349+func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
198350 paths := findPaths(jsonStr, "$ref")
199351 sortByDepth(paths)
200352
201353 for _, p := range paths {
202354 refVal := gjson.Get(jsonStr, p).String()
203− defName := refVal
204− if idx := strings.LastIndex(refVal, "/"); idx >= 0 {
205− defName = refVal[idx+1:]
206− }
355+ defName := refName(refVal)
207356
208357 parentPath := trimSuffix(p, ".$ref")
209358 hint := fmt.Sprintf("See: %s", defName)
210− if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
211− hint = fmt.Sprintf("%s (%s)", existing, hint)
359+ if !preserveSiblings {
360+ if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
361+ hint = fmt.Sprintf("%s (%s)", existing, hint)
362+ }
363+ replacement := `{"type":"object","description":""}`
364+ replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
365+ jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes))
366+ continue
212367 }
213−
214− replacement := `{"type":"object","description":""}`
215− replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
216− replacement = string(replacementBytes)
217− jsonStr = setRawAt(jsonStr, parentPath, replacement)
368+ jsonStr, _ = sjson.Delete(jsonStr, p)
369+ jsonStr = appendHint(jsonStr, parentPath, hint)
218370 }
219371 return jsonStr
220372 }
@@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string { · 14 unmodified lines
235387 }
236388
237389 // convertEnumValuesToStrings ensures all enum values use the string representation required by
238−// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve
239−// their declared type because the upstream decoder uses it to select the emitted JSON value type.
390+// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
391+// emitted JSON type on both response and function-argument paths.
240392 func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
241393 for _, p := range findPaths(jsonStr, "enum") {
242394 arr := gjson.Get(jsonStr, p)
@@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string { · 37 unmodified lines
280432 return jsonStr
281433 }
282434
435+// Antigravity does not enforce enum on function arguments and ignores boolean response enums.
436+// Preserve the advisory values in description, but do not leave an unenforced constraint in the
437+// schema contract. Response enums for string, number, and integer remain native constraints.
438+func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
439+ for _, path := range findPaths(jsonStr, "enum") {
440+ parentPath := trimSuffix(path, ".enum")
441+ shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean")
442+ if !shouldDrop {
443+ continue
444+ }
445+ enum := gjson.Get(jsonStr, path)
446+ if enum.IsArray() && len(enum.Array()) == 1 {
447+ jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String())
448+ }
449+ jsonStr, _ = sjson.Delete(jsonStr, path)
450+ }
451+ return jsonStr
452+}
453+
283454 func addAdditionalPropertiesHints(jsonStr string) string {
284455 for _, p := range findPaths(jsonStr, "additionalProperties") {
285456 if gjson.Get(jsonStr, p).Type == gjson.False {
@@ -295,9 +466,18 @@ var unsupportedConstraints = []string{ · 9 unmodified lines
295466 "default", "examples", // Claude rejects these in VALIDATED mode
296467 }
297468
298−func moveConstraintsToDescription(jsonStr string) string {
299− pathsByField := findPathsByFields(jsonStr, unsupportedConstraints)
300− for _, key := range unsupportedConstraints {
469+func constraintKeywords(options jsonSchemaCleanOptions) []string {
470+ keywords := append([]string(nil), unsupportedConstraints...)
471+ if options.antigravitySemantics {
472+ keywords = append(keywords, "minimum", "maximum", "multipleOf")
473+ }
474+ return keywords
475+}
476+
477+func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
478+ constraints := constraintKeywords(options)
479+ pathsByField := findPathsByFields(jsonStr, constraints)
480+ for _, key := range constraints {
301481 for _, p := range pathsByField[key] {
302482 val := gjson.Get(jsonStr, p)
303483 if !val.Exists() || val.IsObject() || val.IsArray() {
@@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string { · 9 unmodified lines
313493 return jsonStr
314494 }
315495
496+func moveNotToDescription(jsonStr string) string {
497+ for _, path := range findPaths(jsonStr, "not") {
498+ value := gjson.Get(jsonStr, path)
499+ if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
500+ continue
501+ }
502+ jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw)
503+ }
504+ return jsonStr
505+}
506+
316507 func mergeConditionals(jsonStr string) string {
317508 pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
318509 var paths []string
@@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string { · 48 unmodified lines
367558 parentPath := trimSuffix(p, ".allOf")
368559
369560 for _, item := range allOf.Array() {
370− if props := item.Get("properties"); props.IsObject() {
371− props.ForEach(func(key, value gjson.Result) bool {
372− destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
373− updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))
374− jsonStr = string(updated)
375− return true
376− })
377− }
378− if req := item.Get("required"); req.IsArray() {
379− reqPath := joinPath(parentPath, "required")
380− current := getStrings(jsonStr, reqPath)
381− for _, r := range req.Array() {
382− if s := r.String(); !contains(current, s) {
383− current = append(current, s)
561+ if !item.IsObject() {
562+ continue
563+ }
564+ item.ForEach(func(key, value gjson.Result) bool {
565+ field := key.String()
566+ switch field {
567+ case "required":
568+ if !value.IsArray() {
569+ return true
570+ }
571+ reqPath := joinPath(parentPath, "required")
572+ current := getStrings(jsonStr, reqPath)
573+ for _, required := range value.Array() {
574+ if name := required.String(); !contains(current, name) {
575+ current = append(current, name)
576+ }
384577 }
578+ updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
579+ jsonStr = string(updated)
580+ case "if", "then", "else", "allOf":
581+ // Conditional applicability cannot be represented by the upstream schema.
582+ default:
583+ destination := joinPath(parentPath, escapeGJSONPathKey(field))
584+ jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value)
385585 }
386− updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
387− jsonStr = string(updated)
388− }
586+ return true
587+ })
389588 }
390589 jsonStr, _ = sjson.Delete(jsonStr, p)
391590 }
392591 return jsonStr
393592 }
394593
594+// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing
595+// definition. A parent schema is the canonical definition; allOf and conditional branches may
596+// enrich gaps in it, but can never replace it with a narrower branch shell.
597+func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string {
598+ existing := gjson.Get(jsonStr, destination)
599+ if !existing.Exists() {
600+ updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw))
601+ return string(updated)
602+ }
603+ if !existing.IsObject() || !incoming.IsObject() {
604+ return jsonStr
605+ }
606+ incoming.ForEach(func(key, value gjson.Result) bool {
607+ child := joinPath(destination, escapeGJSONPathKey(key.String()))
608+ jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value)
609+ return true
610+ })
611+ return jsonStr
612+}
613+
395614 func flattenAnyOfOneOf(jsonStr string) string {
396615 for _, key := range []string{"anyOf", "oneOf"} {
397616 paths := findPaths(jsonStr, key)
@@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string { · 11 unmodified lines
409628 items := arr.Array()
410629 bestIdx, allTypes := selectBest(items)
411630 selected := items[bestIdx].Raw
631+ hasNull := false
632+ for _, item := range items {
633+ if item.Get("type").String() == "null" {
634+ hasNull = true
635+ break
636+ }
637+ }
638+ if hasNull && items[bestIdx].Get("type").String() != "null" {
639+ updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
640+ selected = string(updated)
641+ }
412642
413643 if parentDesc != "" {
414644 selected = mergeDescriptionRaw(selected, parentDesc)
@@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) { · 37 unmodified lines
452682 return
453683 }
454684
455−func flattenTypeArrays(jsonStr string) string {
685+func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
456686 paths := findPaths(jsonStr, "type")
457687 sortByDepth(paths)
458688
@@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string { · 31 unmodified lines
490720 }
491721
492722 if hasNull {
723+ if preserveNativeNullable {
724+ updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
725+ jsonStr = string(updated)
726+ jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
727+ continue
728+ }
729+
493730 parts := splitGJSONPath(p)
494731 if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
495732 fieldNameEscaped := parts[len(parts)-2]
496733 fieldName := unescapeGJSONPathKey(fieldNameEscaped)
497734 objectPath := strings.Join(parts[:len(parts)-3], ".")
498735 nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
499−
500− propPath := joinPath(objectPath, "properties."+fieldNameEscaped)
501− jsonStr = appendHint(jsonStr, propPath, "(nullable)")
736+ jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
502737 }
503738 }
504739 }
@@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string { · 6 unmodified lines
511746 }
512747
513748 var filtered []string
514− for _, r := range req.Array() {
515− if !contains(fields, r.String()) {
516− filtered = append(filtered, r.String())
749+ for _, required := range req.Array() {
750+ if !contains(fields, required.String()) {
751+ filtered = append(filtered, required.String())
517752 }
518753 }
519−
520754 if len(filtered) == 0 {
521755 jsonStr, _ = sjson.Delete(jsonStr, reqPath)
522756 } else {
@@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string { · 5 unmodified lines
528762 }
529763
530764 func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
531− keywords := append(unsupportedConstraints,
765+ keywords := append(constraintKeywords(options),
532766 "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
533767 "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
534768 "if", "then", "else",
535769 "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini
536770 )
771+ if options.antigravitySemantics {
772+ keywords = append(keywords, "not")
773+ }
537774
538775 deletePaths := make([]string, 0)
539776 pathsByField := findPathsByFields(jsonStr, keywords)
@@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{}, · 206 unmodified lines
746983 }
747984
748985 func sortByDepth(paths []string) {
749− sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
986+ sort.SliceStable(paths, func(i, j int) bool {
987+ return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j]))
988+ })
750989 }
751990
752991 func trimSuffix(path, suffix string) string {
internal/util/gemini_schema_test.go+154−72
@@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { · 24 unmodified lines
2525 "properties": {
2626 "kind": {
2727 "type": "string",
28− "enum": ["InsightVizNode"]
28+ "description": "Allowed: InsightVizNode"
2929 }
3030 }
3131 }`
@@ -53,13 +53,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) { · 21 unmodified lines
5353 "properties": {
5454 "name": {
5555 "type": "string",
56+ "nullable": true,
5657 "description": "(nullable)"
5758 },
5859 "other": {
5960 "type": "string"
6061 }
6162 },
62− "required": ["other"]
63+ "required": ["name", "other"]
6364 }`
6465
6566 result := CleanJSONSchemaForAntigravity(input)
@@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing · 59 unmodified lines
125126 "properties": {
126127 "query": {
127128 "type": "object",
129+ "nullable": true,
128130 "description": "Accepts: null | object",
129131 "properties": {
130132 "_": { "type": "boolean" },
@@ -214,20 +216,18 @@ func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) { · 83 unmodified lines
214216 }
215217 }`
216218
217− // After $ref is converted to placeholder object, empty schema placeholder is also added
219+ // The local reference is expanded before definitions are removed. Claude VALIDATED mode adds
220+ // only its optional-object placeholder; the referenced property definition remains intact.
218221 expected := `{
219222 "type": "object",
220223 "properties": {
221224 "customer": {
222225 "type": "object",
223− "description": "See: User",
224226 "properties": {
225− "reason": {
226− "type": "string",
227− "description": "Brief explanation of why you are calling this tool"
228− }
227+ "name": { "type": "string" },
228+ "_": { "type": "boolean" }
229229 },
230− "required": ["reason"]
230+ "required": ["_"]
231231 }
232232 }
233233 }`
@@ -255,20 +255,17 @@ func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testin · 21 unmodified lines
255255 }
256256 }`
257257
258− // After $ref is converted, empty schema placeholder is also added
259258 expected := `{
260259 "type": "object",
261260 "properties": {
262261 "customer": {
263262 "type": "object",
264− "description": "He said \"hi\"\\nsecond line (See: User)",
263+ "description": "He said \"hi\"\\nsecond line",
265264 "properties": {
266− "reason": {
267− "type": "string",
268− "description": "Brief explanation of why you are calling this tool"
269− }
265+ "name": { "type": "string" },
266+ "_": { "type": "boolean" }
270267 },
271− "required": ["reason"]
268+ "required": ["_"]
272269 }
273270 }
274271 }`
@@ -299,9 +296,9 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) { · 24 unmodified lines
299296 t.Errorf("Expected type: object, got: %v", resMap["type"])
300297 }
301298
302− desc, ok := resMap["description"].(string)
303− if !ok || !strings.Contains(desc, "Node") {
304− t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"])
299+ child := gjson.Get(result, "properties.child")
300+ if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") {
301+ t.Errorf("Expected typed cycle hint containing Node, got: %s", result)
305302 }
306303 }
307304
@@ -499,13 +496,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing · 191 unmodified lines
499496 "properties": {
500497 "my.param": {
501498 "type": "string",
499+ "nullable": true,
502500 "description": "(nullable)"
503501 },
504502 "other": {
505503 "type": "string"
506504 }
507505 },
508− "required": ["other"]
506+ "required": ["my.param", "other"]
509507 }`
510508
511509 result := CleanJSONSchemaForAntigravity(input)
@@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t · 66 unmodified lines
578576 compareJSON(t, expected, result)
579577 }
580578
581−func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
579+func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
582580 input := `{
583581 "type": "object",
584582 "properties": {
@@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { · 6 unmodified lines
591589
592590 result := CleanJSONSchemaForAntigravity(input)
593591
594− if strings.Contains(result, "Allowed:") {
595− t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
592+ if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() {
593+ t.Errorf("Ignored tool enum should become a hint, got: %s", result)
596594 }
597595 }
598596
@@ -764,7 +762,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test · 165 unmodified lines
764762 }
765763 }
766764
767−func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {
765+func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) {
768766 input := `{
769767 "type":"object",
770768 "properties":{
@@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { · 6 unmodified lines
777775 }`
778776
779777 result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
780− for _, testCase := range []struct {
781− path string
782− wantTypes []string
783− }{
784− {path: "properties.action.anyOf", wantTypes: []string{"object", "null"}},
785− {path: "properties.label.oneOf", wantTypes: []string{"string", "null"}},
786− } {
787− union := result.Get(testCase.path)
788− if !union.IsArray() {
789− t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw)
790− continue
791− }
792− var gotTypes []string
793− for _, branch := range union.Array() {
794− gotTypes = append(gotTypes, branch.Get("type").String())
778+ for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} {
779+ if result.Get(path).Exists() {
780+ t.Errorf("ignored response union %s survived: %s", path, result.Raw)
795781 }
796− if !reflect.DeepEqual(gotTypes, testCase.wantTypes) {
797− t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw)
782+ }
783+ for _, testCase := range []struct{ path, wantType string }{
784+ {path: "properties.action", wantType: "object"},
785+ {path: "properties.label", wantType: "string"},
786+ } {
787+ schema := result.Get(testCase.path)
788+ if schema.Get("type").String() != testCase.wantType || !schema.Get("nullable").Bool() {
789+ t.Errorf("%s was not projected to nullable %s: %s", testCase.path, testCase.wantType, result.Raw)
798790 }
799791 }
800792 }
@@ -978,8 +970,7 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { · 177 unmodified lines
978970 }
979971 }
980972
981−func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {
982− // Gemini API requires enum values to be strings, not numbers
973+func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
983974 input := `{
984975 "type": "object",
985976 "properties": {
@@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { · 6 unmodified lines
992983 result := CleanJSONSchemaForAntigravity(input)
993984 parsed := gjson.Parse(result)
994985
995− // Tool enum schemas require both string values and a string type.
996− for _, path := range []string{"properties.priority", "properties.level"} {
997− if gotType := parsed.Get(path + ".type").String(); gotType != "string" {
998− t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result)
986+ // Antigravity ignores function-argument enum but still uses the declared type to choose the
987+ // emitted JSON type. Preserve types and convert enum values to advisory hints.
988+ for path, wantType := range map[string]string{
989+ "properties.priority": "integer",
990+ "properties.level": "number",
991+ "properties.status": "string",
992+ } {
993+ if gotType := parsed.Get(path + ".type").String(); gotType != wantType {
994+ t.Errorf("Tool enum type at %s = %q, want %s: %s", path, gotType, wantType, result)
995+ }
996+ if parsed.Get(path+".enum").Exists() || !strings.Contains(parsed.Get(path+".description").String(), "Allowed:") {
997+ t.Errorf("Tool enum at %s was not projected to a hint: %s", path, result)
999998 }
1000− }
1001−
1002− // Numeric enum values should be converted to strings
1003− if strings.Contains(result, `"enum":[0,1,2]`) {
1004− t.Errorf("Integer enum values should be converted to strings, got: %s", result)
1005− }
1006− if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) {
1007− t.Errorf("Float enum values should be converted to strings, got: %s", result)
1008− }
1009− // Should contain string versions
1010− if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) {
1011− t.Errorf("Integer enum values should be converted to string format, got: %s", result)
1012− }
1013− // String enum values should remain unchanged
1014− if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) {
1015− t.Errorf("String enum values should remain unchanged, got: %s", result)
1016999 }
10171000 }
10181001
1019−func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
1020− // Boolean enum values should also be converted to strings
1002+func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) {
10211003 input := `{
10221004 "type": "object",
10231005 "properties": {
@@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { · 3 unmodified lines
10271009
10281010 result := CleanJSONSchemaForAntigravity(input)
10291011
1030− // Boolean enum values should be converted to strings
1031− if strings.Contains(result, `"enum":[true,false]`) {
1032− t.Errorf("Boolean enum values should be converted to strings, got: %s", result)
1033− }
1034− // Should contain string versions "true" and "false"
1035− if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) {
1036− t.Errorf("Boolean enum values should be converted to string format, got: %s", result)
1012+ value := gjson.Get(result, "properties.enabled")
1013+ if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") {
1014+ t.Errorf("Boolean tool enum should become a typed hint, got: %s", result)
10371015 }
10381016 }
10391017
@@ -1450,3 +1428,107 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) { · 410 unmodified lines
14501428 }
14511429 }
14521430 }
1431+
1432+func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t *testing.T) {
1433+ input := `{
1434+ "type":"object",
1435+ "properties":{
1436+ "kind":{"type":"string"},
1437+ "action":{"type":"object","properties":{"full":{"type":"string"}},"required":["full"]}
1438+ },
1439+ "required":["kind","action"],
1440+ "allOf":[{
1441+ "if":{"properties":{"kind":{"const":"skip"}}},
1442+ "then":{"properties":{
1443+ "action":{"type":"null"},
1444+ "branch_only":{"type":"integer"}
1445+ }}
1446+ }]
1447+ }`
1448+
1449+ result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
1450+ action := result.Get("properties.action")
1451+ if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() {
1452+ t.Fatalf("conditional branch replaced canonical action: %s", result.Raw)
1453+ }
1454+ if action.Get("required.0").String() != "full" || !result.Get("properties.branch_only").Exists() {
1455+ t.Fatalf("conditional merge lost parent or branch-only information: %s", result.Raw)
1456+ }
1457+ if result.Get("allOf").Exists() || strings.Contains(result.Raw, `"if"`) || strings.Contains(result.Raw, `"then"`) {
1458+ t.Fatalf("unsupported conditional keywords survived: %s", result.Raw)
1459+ }
1460+}
1461+
1462+func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) {
1463+ input := `{
1464+ "$defs":{"Payload":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}},
1465+ "type":"object",
1466+ "properties":{"payload":{"$ref":"#/$defs/Payload"}},
1467+ "required":["payload"]
1468+ }`
1469+
1470+ result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
1471+ if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) {
1472+ t.Fatalf("local reference metadata survived: %s", result.Raw)
1473+ }
1474+ payload := result.Get("properties.payload")
1475+ if payload.Get("type").String() != "object" || payload.Get("properties.id.type").String() != "integer" || payload.Get("required.0").String() != "id" {
1476+ t.Fatalf("local reference definition was not inlined: %s", result.Raw)
1477+ }
1478+}
1479+
1480+func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) {
1481+ input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}`
1482+ result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
1483+ value := result.Get("properties.value")
1484+ if value.Get("type").String() != "number" || !value.Get("nullable").Bool() {
1485+ t.Fatalf("type array was not projected to native nullable: %s", result.Raw)
1486+ }
1487+ if result.Get("required.0").String() != "value" {
1488+ t.Fatalf("nullable required property became optional: %s", result.Raw)
1489+ }
1490+}
1491+
1492+func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) {
1493+ input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}`
1494+ result := gjson.Parse(CleanJSONSchemaForAntigravityTool(input, false))
1495+ value := result.Get("properties.value")
1496+ if value.Get("type").String() != "number" {
1497+ t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw)
1498+ }
1499+ if value.Get("enum").Exists() || !strings.Contains(value.Get("description").String(), "Allowed: 1, 2") {
1500+ t.Fatalf("ignored tool enum was not projected to a hint: %s", result.Raw)
1501+ }
1502+}
1503+
1504+func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) {
1505+ input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}`
1506+ result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
1507+ value := result.Get("properties.value")
1508+ if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") {
1509+ t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw)
1510+ }
1511+}
1512+
1513+func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) {
1514+ input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}`
1515+ result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
1516+ value := result.Get("properties.value")
1517+ for _, keyword := range []string{"minimum", "maximum", "not"} {
1518+ if value.Get(keyword).Exists() {
1519+ t.Fatalf("ignored constraint %s survived: %s", keyword, result.Raw)
1520+ }
1521+ if !strings.Contains(value.Get("description").String(), keyword+":") {
1522+ t.Fatalf("ignored constraint %s lost its hint: %s", keyword, result.Raw)
1523+ }
1524+ }
1525+}
1526+
1527+func TestSortByDepthUsesSegmentsAndIsStable(t *testing.T) {
1528+ paths := []string{"root.verylong", "root.x.y", "first.same", "later.same"}
1529+ sortByDepth(paths)
1530+ want := []string{"root.x.y", "root.verylong", "first.same", "later.same"}
1531+ if !reflect.DeepEqual(paths, want) {
1532+ t.Fatalf("sortByDepth() = %v, want %v", paths, want)
1533+ }
1534+}