// Package util provides utility functions for the CLI Proxy API server. package util import ( "bytes" "encoding/json" "fmt" "reflect" "sort" "strconv" "strings" "github.com/MoeForge/moego/ojson" ) var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?") const placeholderReasonDescription = "Brief explanation of why you are calling this tool" // Pass a single JSON schema to the functions below — never a whole request document. // // Cleaning walks every node and rewrites keys by name, and schema keywords such as "title", // "format", "default" and "const" are also ordinary data keys. Handing these functions a request // silently rewrites tool-call arguments inside the conversation history: the guard that protects // a key under ".properties" does not apply to argument values, so the keys are deleted outright // and replacements such as "enum" and "type" are fabricated. That regression reached production // once already; scope every call site to the schema itself. type jsonSchemaCleanOptions struct { addPlaceholder bool addMissingArrayItems bool antigravitySemantics bool removeToolTitle bool removeGeminiMetadata bool flattenUnions bool // flattenMultiBranchUnions forces multi-branch anyOf/oneOf unions down to a single // strongest branch instead of preserving them. It is used on routes whose upstream // rejects nested multi-branch unions even though they are valid JSON Schema. flattenMultiBranchUnions bool forceEnumStringType bool dropAllEnums bool dropBooleanEnums bool preserveAdditionalPropertiesFalse bool preserveAllAdditionalProperties bool preserveStandardConstraints bool } // CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API. // 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 cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, false) } // CleanJSONSchemaForAntigravityClaudeTool is the Antigravity tool cleaner for Claude // models. It applies the same Antigravity schema cleanup, but flattens multi-branch // anyOf/oneOf unions to a single strongest branch: the Antigravity Claude backend // converts Gemini function declarations into Claude custom.input_schema and rejects // nested multi-branch unions with "JSON schema is invalid", while the Gemini // backends accept preserved unions (see Issue 3662 and its regression follow-up). func CleanJSONSchemaForAntigravityClaudeTool(jsonStr string, requirePlaceholder bool) string { return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, true) } func cleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder, flattenMultiBranchUnions bool) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ addPlaceholder: requirePlaceholder, addMissingArrayItems: true, antigravitySemantics: true, removeToolTitle: !requirePlaceholder, flattenUnions: true, flattenMultiBranchUnions: flattenMultiBranchUnions, dropAllEnums: true, }) } // CleanJSONSchemaForAntigravityResponse transforms a response schema without applying tool-only // compatibility rewrites that would alter the client's structured output contract. // // Sanitization policy: // - 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. // - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with 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, }) } // CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling. // It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders. func CleanJSONSchemaForGemini(jsonStr string) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ addMissingArrayItems: true, removeGeminiMetadata: true, flattenUnions: true, forceEnumStringType: true, }) } // CleanJSONSchemaForGeminiJSONSchema transforms a JSON schema for Gemini tool calling // when using the parametersJsonSchema carrier. It preserves standard JSON Schema constraints // (such as pattern, minLength, maxLength) and additionalProperties (both boolean and schema-valued), // while removing Gemini-incompatible metadata fields and cleaning required properties. func CleanJSONSchemaForGeminiJSONSchema(jsonStr string) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ addMissingArrayItems: true, removeGeminiMetadata: true, flattenUnions: true, forceEnumStringType: true, preserveAllAdditionalProperties: true, preserveStandardConstraints: true, }) } // cleanJSONSchema performs the core cleaning operations on the JSON schema. func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { // Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools). // Array placeholders and array type inference are deferred to Phase 3 (sanitizeArrayItems) after // local references are inlined and allOf/union structures are resolved, so synthetic placeholders // do not clobber items supplied by $ref targets or allOf branches. jsonStr = normalizeMalformedSchemaObjects(jsonStr) jsonStr = NormalizeJSONSchemaNumericConstraints(jsonStr) // Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local // references must be expanded here for every route; otherwise nested fields are lost. jsonStr = inlineLocalRefsForClean(jsonStr) jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) jsonStr = convertConstToEnum(jsonStr) jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) jsonStr = addEnumHints(jsonStr) jsonStr = dropIgnoredEnumsToHints(jsonStr, options) if !options.preserveAdditionalPropertiesFalse && !options.preserveAllAdditionalProperties { jsonStr = addAdditionalPropertiesHints(jsonStr) } jsonStr = moveConstraintsToDescription(jsonStr, options) if options.antigravitySemantics { jsonStr = moveNotToDescription(jsonStr) } // Phase 2: Flatten complex structures. Once a JSON Schema contains a conjunction, // local shape assumptions are unsafe in ancestors, siblings, and descendants alike. preserveConjunctions := options.preserveStandardConstraints && hasSchemaConjunction(jsonStr) jsonStr = mergeConditionals(jsonStr) if !options.preserveStandardConstraints { // The JSON Schema carrier must keep conjunctions, including ref sibling conflicts. jsonStr = mergeAllOf(jsonStr) } if !preserveConjunctions { if options.flattenUnions { jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints, options.flattenMultiBranchUnions) } jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) } // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr, options) if options.removeGeminiMetadata { // 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"}) } if !preserveConjunctions { jsonStr = cleanupRequiredFields(jsonStr) jsonStr = sanitizeObjectProperties(jsonStr) jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems) } // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) if options.addPlaceholder { jsonStr = addEmptySchemaPlaceholder(jsonStr) } return jsonStr } // sanitizeObjectProperties normalizes native Schema object declarations. The caller skips this // pass for JSON Schema conjunctions, where local properties do not imply an object type. func sanitizeObjectProperties(jsonStr string) string { paths := findPaths(jsonStr, "properties") sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { parentPath := trimSuffix(p, ".properties") if isPropertyDefinition(parentPath) { continue } props := doc.Get(p) if !props.IsObject() { continue } typePath := joinPath(parentPath, "type") t := doc.Get(typePath).String() if !strings.EqualFold(t, "object") { if doc.Set(typePath, "object") == nil { modified = true } } } if modified { return doc.String() } return jsonStr } // sanitizeArrayItems normalizes native Schema arrays and supplies missing items placeholders. // The caller skips this pass for JSON Schema conjunctions, where element and instance types // may be constrained elsewhere and synthesizing local fields can make the intersection impossible. func sanitizeArrayItems(jsonStr string, addMissing bool) string { paths := findPaths(jsonStr, "items") sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { parentPath := trimSuffix(p, ".items") if isPropertyDefinition(parentPath) { continue } typePath := joinPath(parentPath, "type") t := doc.Get(typePath).String() if t == "" { if doc.Set(typePath, "array") == nil { modified = true } } else if !strings.EqualFold(t, "array") { if doc.Delete(p) == nil { modified = true } } } if modified { jsonStr = doc.String() } if !addMissing { return jsonStr } typePaths := findPaths(jsonStr, "type") sortByDepth(typePaths) doc = ojson.NewDocString(jsonStr) modified = false for _, p := range typePaths { parentPath := trimSuffix(p, ".type") if isPropertyDefinition(parentPath) || !isArrayDeclaredType(doc.Get(p).Value()) { continue } if doc.Get(joinPath(parentPath, "items")).Exists() || doc.Get(joinPath(parentPath, "$ref")).Exists() { continue } if doc.SetRaw(joinPath(parentPath, "items"), `{"type":"string"}`) == nil { modified = true } } if modified { return doc.String() } return jsonStr } // removeKeywords removes all occurrences of specified keywords from the JSON schema. func removeKeywords(jsonStr string, keywords []string) string { deletePaths := make([]string, 0) pathsByField := findPathsByFields(jsonStr, keywords) for _, key := range keywords { for _, p := range pathsByField[key] { if isPropertyDefinition(trimSuffix(p, "."+key)) { continue } deletePaths = append(deletePaths, p) } } if len(deletePaths) == 0 { return jsonStr } sortByDepth(deletePaths) doc := ojson.NewDocString(jsonStr) for _, p := range deletePaths { _ = doc.Delete(p) } return doc.String() } // removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries. func removePlaceholderFields(jsonStr string) string { // Remove "_" placeholder properties. paths := findPaths(jsonStr, "_") sortByDepth(paths) if len(paths) > 0 { doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { if !strings.HasSuffix(p, ".properties._") { continue } if doc.Delete(p) == nil { modified = true } parentPath := trimSuffix(p, ".properties._") reqPath := joinPath(parentPath, "required") req := doc.Get(reqPath) if req.IsArray() { var filtered []string for _, r := range req.Array() { if r.String() != "_" { filtered = append(filtered, r.String()) } } if len(filtered) == 0 { if doc.Delete(reqPath) == nil { modified = true } } else { if doc.Set(reqPath, filtered) == nil { modified = true } } } } if modified { jsonStr = doc.String() } } // Remove placeholder-only "reason" objects. reasonPaths := findPaths(jsonStr, "reason") sortByDepth(reasonPaths) if len(reasonPaths) > 0 { doc := ojson.NewDocString(jsonStr) modified := false for _, p := range reasonPaths { if !strings.HasSuffix(p, ".properties.reason") { continue } parentPath := trimSuffix(p, ".properties.reason") props := doc.Get(joinPath(parentPath, "properties")) if !props.IsObject() || len(props.Map()) != 1 { continue } desc := doc.Get(p + ".description").String() if desc != placeholderReasonDescription { continue } if doc.Delete(p) == nil { modified = true } reqPath := joinPath(parentPath, "required") req := doc.Get(reqPath) if req.IsArray() { var filtered []string for _, r := range req.Array() { if r.String() != "reason" { filtered = append(filtered, r.String()) } } if len(filtered) == 0 { if doc.Delete(reqPath) == nil { modified = true } } else { if doc.Set(reqPath, filtered) == nil { modified = true } } } } if modified { jsonStr = doc.String() } } return jsonStr } // normalizeMalformedSchemaObjects normalizes malformed JSON schema nodes commonly produced by // certain MCP tool definitions (e.g. Asana MCP server): // 1. Bare property maps missing the "type": "object" and "properties": {...} wrappers are wrapped. // 2. Boolean "required": true on property definitions are stripped and promoted to the parent's "required" array. // 3. Boolean true subschemas are normalized to empty schema objects. func normalizeMalformedSchemaObjects(jsonStr string) string { if jsonStr == "" { return jsonStr } decoder := json.NewDecoder(strings.NewReader(jsonStr)) decoder.UseNumber() var root any if err := decoder.Decode(&root); err != nil { return jsonStr } if rootBool, ok := root.(bool); ok && rootBool { return "{}" } rootMap, ok := root.(map[string]any) if !ok || isAPIRequestDocument(rootMap) { return jsonStr } // If wrapped in single-key {"schema": ...} by cleanNestedSchema, unwrap, repair, and re-wrap. if len(rootMap) == 1 { if innerSchema, ok := rootMap["schema"].(map[string]any); ok { repairedInner, modified := repairSchemaNode(innerSchema) if !modified { return jsonStr } out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": repairedInner}) if err != nil { return jsonStr } return string(out) } else if innerBool, ok := rootMap["schema"].(bool); ok && innerBool { out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": map[string]any{}}) if err != nil { return jsonStr } return string(out) } } repaired, modified := repairSchemaNode(rootMap) if !modified { return jsonStr } out, err := marshalJSONNoHTMLEscape(repaired) if err != nil { return jsonStr } return string(out) } func marshalJSONNoHTMLEscape(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, err } b := buf.Bytes() if len(b) > 0 && b[len(b)-1] == '\n' { b = b[:len(b)-1] } return b, nil } func isKnownSchemaKeywordOrExtension(key string) bool { if strings.HasPrefix(key, "x-") { return true } switch key { case "properties", "patternProperties", "additionalProperties", "items", "prefixItems", "$defs", "definitions", "dependentSchemas", "dependentRequired", "dependencies", "if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems", "default", "const", "example", "examples", "discriminator", "xml", "externalDocs", "enumDescriptions", "enumTitles": return true } return false } func isNonObjectDeclaredType(t any) bool { if s, ok := t.(string); ok { return s != "" && !strings.EqualFold(s, "object") } if arr, ok := t.([]any); ok { for _, item := range arr { if s, ok := item.(string); ok && strings.EqualFold(s, "object") { return false } } return len(arr) > 0 } return false } func isArrayDeclaredType(t any) bool { switch typeValue := t.(type) { case string: return strings.EqualFold(typeValue, "array") case []any: for _, item := range typeValue { if itemType, ok := item.(string); ok && strings.EqualFold(itemType, "array") { return true } } } return false } func isAPIRequestDocument(m map[string]any) bool { if _, ok := m["tools"].([]any); ok { return true } if _, ok := m["contents"].([]any); ok { return true } if _, ok := m["messages"].([]any); ok { return true } if _, ok := m["functionDeclarations"].([]any); ok { return true } if _, ok := m["function_declarations"].([]any); ok { return true } if reqMap, ok := m["request"].(map[string]any); ok { if isAPIRequestDocument(reqMap) { return true } } return false } func repairSchemaNode(node map[string]any) (map[string]any, bool) { if node == nil { return nil, false } modified := false clone := make(map[string]any, len(node)) for k, v := range node { clone[k] = v } // 1. If not declared as a primitive/array type, collect bare property definition maps if !isNonObjectDeclaredType(clone["type"]) { var bareProps map[string]any for k, v := range clone { if childMap, isMap := v.(map[string]any); isMap { if !isKnownSchemaKeywordOrExtension(k) { if bareProps == nil { bareProps = make(map[string]any) } bareProps[k] = childMap } } } if len(bareProps) > 0 { repairedProps, promotedReqs, _ := repairPropertyMap(bareProps) for k := range bareProps { delete(clone, k) } if existingProps, ok := clone["properties"].(map[string]any); ok { newProps := make(map[string]any, len(existingProps)+len(repairedProps)) for k, v := range existingProps { newProps[k] = v } for k, v := range repairedProps { newProps[k] = v } clone["properties"] = newProps } else { clone["properties"] = repairedProps if _, hasType := clone["type"]; !hasType { clone["type"] = "object" } } if len(promotedReqs) > 0 { existingReqs := extractStringArray(clone["required"]) merged := mergeStringSlices(existingReqs, promotedReqs) clone["required"] = merged } modified = true } } // 2. Repair declared properties without inferring a type before refs are expanded. // Final sanitization handles native object and array schemas once refs and conjunctions are resolved. if propsVal, ok := clone["properties"].(map[string]any); ok { repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal) if propsMod { clone["properties"] = repairedProps modified = true } if len(promotedReqs) > 0 { existingReqs := extractStringArray(clone["required"]) merged := mergeStringSlices(existingReqs, promotedReqs) clone["required"] = merged modified = true } } // 3. Recurse into all other standard schema containers if itemsVal, ok := clone["items"].(map[string]any); ok { repairedItems, itemsMod := repairSchemaNode(itemsVal) if itemsMod { clone["items"] = repairedItems modified = true } } else if itemsList, ok := clone["items"].([]any); ok { repairedList, listMod := repairSchemaList(itemsList) if listMod { clone["items"] = repairedList modified = true } } else if itemsBool, ok := clone["items"].(bool); ok && itemsBool { clone["items"] = map[string]any{} modified = true } if addProps, ok := clone["additionalProperties"].(map[string]any); ok { repairedAddProps, addPropsMod := repairSchemaNode(addProps) if addPropsMod { clone["additionalProperties"] = repairedAddProps modified = true } } if patProps, ok := clone["patternProperties"].(map[string]any); ok { repairedPatProps, _, patMod := repairPropertyMap(patProps) if patMod { clone["patternProperties"] = repairedPatProps modified = true } } for _, key := range []string{"if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems"} { if subVal, ok := clone[key].(map[string]any); ok { repairedSub, subMod := repairSchemaNode(subVal) if subMod { clone[key] = repairedSub modified = true } } else if subBool, ok := clone[key].(bool); ok && subBool { clone[key] = map[string]any{} modified = true } } for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} { if listVal, ok := clone[key].([]any); ok { repairedList, listMod := repairSchemaList(listVal) if listMod { clone[key] = repairedList modified = true } } } for _, key := range []string{"$defs", "definitions", "dependentSchemas", "dependencies"} { if defsVal, ok := clone[key].(map[string]any); ok { repairedDefs := make(map[string]any, len(defsVal)) defsModified := false for dk, dv := range defsVal { if defMap, ok := dv.(map[string]any); ok { repairedDef, defMod := repairSchemaNode(defMap) repairedDefs[dk] = repairedDef if defMod { defsModified = true modified = true } } else if defBool, ok := dv.(bool); ok && defBool { repairedDefs[dk] = map[string]any{} defsModified = true modified = true } else { repairedDefs[dk] = dv } } if defsModified { clone[key] = repairedDefs } } } return clone, modified } func repairSchemaList(list []any) ([]any, bool) { var repairedList []any listModified := false for _, item := range list { if itemMap, ok := item.(map[string]any); ok { repairedItem, itemMod := repairSchemaNode(itemMap) repairedList = append(repairedList, repairedItem) if itemMod { listModified = true } } else if itemBool, ok := item.(bool); ok && itemBool { repairedList = append(repairedList, map[string]any{}) listModified = true } else { repairedList = append(repairedList, item) } } return repairedList, listModified } func repairPropertyMap(props map[string]any) (map[string]any, []string, bool) { out := make(map[string]any, len(props)) var promotedReqs []string modified := false for k, v := range props { if b, ok := v.(bool); ok && b { out[k] = map[string]any{} modified = true continue } childMap, isMap := v.(map[string]any) if !isMap { out[k] = v continue } childClone := make(map[string]any, len(childMap)) for ck, cv := range childMap { childClone[ck] = cv } if reqBool, isBool := childClone["required"].(bool); isBool { delete(childClone, "required") modified = true if reqBool { promotedReqs = append(promotedReqs, k) } } repairedChild, childMod := repairSchemaNode(childClone) if childMod { modified = true } out[k] = repairedChild } sort.Strings(promotedReqs) return out, promotedReqs, modified } func extractStringArray(val any) []string { if val == nil { return nil } arr, ok := val.([]any) if !ok { if strArr, ok := val.([]string); ok { return strArr } return nil } var res []string for _, item := range arr { if s, ok := item.(string); ok { res = append(res, s) } } return res } func mergeStringSlices(existing, promoted []string) []string { seen := make(map[string]bool) var res []string for _, s := range existing { if !seen[s] && s != "" { seen[s] = true res = append(res, s) } } for _, s := range promoted { if !seen[s] && s != "" { seen[s] = true res = append(res, s) } } return res } // InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference // receives its own copy as long as the expansion budget lasts, conflicting sibling assertions // remain conjunctive, and cycles terminate as a typed hint instead of recursing forever. func InlineLocalRefs(jsonStr string) string { return inlineLocalRefs(jsonStr) } // maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local // reference expansion. Shared definitions fan out once per reference, so a small input can expand // exponentially; expansions past the budget degrade to a typed hint like cycles do. const maxInlinedSchemaBytes = 256 * 1024 // maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a // large shared description cannot be repeated per reference and defeat the expansion budget. const maxFallbackDescriptionBytes = 512 // inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference // receives its own copy as long as the expansion budget lasts, conflicting sibling assertions // remain conjunctive, and cycles terminate as a typed hint instead of recursing forever. func inlineLocalRefs(jsonStr string) string { return inlineLocalRefsRooted(jsonStr, nil, false) } // inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes // receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase // 0); references inside it must then resolve against the inner schema, not the carrier, or every // lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte. func inlineLocalRefsForClean(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 } resolveRoot := root wrapped := false if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 { if inner, okInner := rootMap["schema"].(map[string]any); okInner { resolveRoot = inner wrapped = true } } if !wrapped { return inlineLocalRefsRooted(jsonStr, root, true) } state := newRefResolveState() resolved := resolveSchemaRoot(resolveRoot, state, true) out, err := json.Marshal(map[string]any{"schema": resolved}) if err != nil { return jsonStr } return string(out) } func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string { if !strings.Contains(jsonStr, `"$ref"`) { return jsonStr } root := parsedRoot if root == nil { decoder := json.NewDecoder(strings.NewReader(jsonStr)) decoder.UseNumber() if err := decoder.Decode(&root); err != nil { return jsonStr } } state := newRefResolveState() resolved := resolveSchemaRoot(root, state, forClean) out, err := json.Marshal(resolved) if err != nil { return jsonStr } return string(out) } // refResolveState carries the remaining expansion budget of one inlining pass. Every reference // charges the size of the target it copies (nested references charge their own targets), so // shared-definition fan-out stops once the budget is spent while ordinary reuse still expands. type refResolveState struct { budget int } func newRefResolveState() *refResolveState { return &refResolveState{budget: maxInlinedSchemaBytes} } // sortedKeys returns map keys in a deterministic order, so budget allocation never depends on // Go map iteration order. func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) } sort.Strings(keys) return keys } // resolveSchemaRoot resolves references in the schema body before resolving definition // containers. Definitions only matter through references, so walking them first would spend the // expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while // the fields that are actually kept degraded to hints. For the public InlineLocalRefs the // containers are kept, so they are still resolved afterwards with whatever budget remains; when // cleaning they are dropped here altogether (references still resolve against the original // root), so their bytes never reach the intermediate JSON at all. func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any { rootMap, ok := root.(map[string]any) if !ok { return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean) } out := make(map[string]any, len(rootMap)) for _, key := range sortedKeys(rootMap) { item := rootMap[key] switch key { case "$defs", "definitions": if !forClean { out[key] = nil // placeholder, resolved after the schema body below } case "$ref": out[key] = item default: out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean) } } // A root-level reference and its already resolved siblings use the same conjunction rules // as nested references. Keep document declarations outside any generated allOf branches. if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") { if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget { if size := targetExpansionSize(target, forClean); size <= state.budget { state.budget -= size active := map[string]bool{ref: true} if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap { delete(out, "$ref") delete(out, "$defs") delete(out, "definitions") delete(out, "$schema") out = mergeInlinedRefSchemas(targetMap, out) if dialect, exists := rootMap["$schema"]; exists { out["$schema"] = dialect } } else { out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean) } } else { out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean) } delete(out, "$ref") } } if !forClean { for _, key := range []string{"$defs", "definitions"} { if item, exists := rootMap[key]; exists { out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false) } } } return out } // targetExpansionSize estimates what copying a reference target into the output costs. When // cleaning, definitions nested inside the target are deleted later and never copied, so they are // excluded; otherwise a small target carrying a large unused definitions block would be degraded // purely because of material that is about to be thrown away. Name-map and literal context is // threaded so properties named like a container keyword, and data inside literal values, still // count in full. func targetExpansionSize(value any, forClean bool) int { if !forClean { return jsonValueSize(value) } return cleanExpansionSize(value, false) } func cleanExpansionSize(value any, inNameMap bool) int { switch node := value.(type) { case nil: return 4 case bool: return 5 case json.Number: return len(node.String()) case string: return jsonStringEncodedSize(node) case []any: size := 2 for _, item := range node { size += cleanExpansionSize(item, false) + 1 if size > maxInlinedSchemaBytes+1 { return maxInlinedSchemaBytes + 1 } } return size case map[string]any: size := 2 for key, item := range node { if !inNameMap && (key == "$defs" || key == "definitions") { continue } child := item childInNameMap := false if !inNameMap { if isLiteralValueKeyword(key) { size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1 if size > maxInlinedSchemaBytes+1 { return maxInlinedSchemaBytes + 1 } continue } if _, isNameMap := schemaNameMapKeywords[key]; isNameMap { childInNameMap = true } } size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1 if size > maxInlinedSchemaBytes+1 { return maxInlinedSchemaBytes + 1 } } return size default: return 16 } } // jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit // it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control // characters). It is used to bound ref expansion, so it must not under-count what serialization // will actually produce. func jsonValueSize(value any) int { switch node := value.(type) { case nil: return 4 case bool: return 5 case json.Number: return len(node.String()) case string: return jsonStringEncodedSize(node) case []any: size := 2 for _, item := range node { size += jsonValueSize(item) + 1 } return size case map[string]any: size := 2 for key, item := range node { size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1 } return size default: return 16 } } // jsonStringEncodedSize returns the encoded length of one JSON string including quotes, // mirroring encoding/json's default HTML escaping. func jsonStringEncodedSize(s string) int { size := 2 for i := 0; i < len(s); i++ { c := s[i] switch { case c < 0x20: if c == '\n' || c == '\r' || c == '\t' { size += 2 } else { size += 6 } case c == '"' || c == '\\': size += 2 case c == '<' || c == '>' || c == '&': size += 6 case c < 0x80: size++ case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9): size += 6 i += 2 default: size++ } } return size } // literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must // copy their values verbatim: keys inside them that happen to look like schema keywords are data. var literalValueKeywords = map[string]struct{}{ "default": {}, "example": {}, "examples": {}, "const": {}, "enum": {}, } func isLiteralValueKeyword(key string) bool { _, ok := literalValueKeywords[key] return ok } func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any { switch node := value.(type) { case []any: out := make([]any, len(node)) for i, item := range node { out[i] = resolveLocalRefs(root, item, active, state, false, forClean) } return out case map[string]any: if !inNameMap { ref, hasRef := node["$ref"].(string) if hasRef && strings.HasPrefix(ref, "#/") { if target, ok := resolveJSONPointer(root, ref); ok { if active[ref] { return resolveRefFallback(root, node, target, ref, active, state, forClean) } // Charge the stored target size and let nested references charge their own // costs during recursion. The total copied material stays bounded, while an // over-budget chain still expands its affordable outer levels and only the // deepest references degrade to hints. if size := targetExpansionSize(target, forClean); size > state.budget { return resolveRefFallback(root, node, target, ref, active, state, forClean) } else { state.budget -= size } active[ref] = true resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean) delete(active, ref) if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { siblings := make(map[string]any, len(node)) for _, key := range sortedKeys(node) { if key == "$ref" { continue } if forClean && (key == "$defs" || key == "definitions") { continue } siblings[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) } return mergeInlinedRefSchemas(targetMap, siblings) } } } } out := make(map[string]any, len(node)) for _, key := range sortedKeys(node) { if forClean && !inNameMap && (key == "$defs" || key == "definitions") { continue // dropped with every other definitions container when cleaning } out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) } return out default: return value } } // mergeInlinedRefSchemas keeps both complete schemas when assertions collide. Merging only // the conflicting keyword would change the scope of properties/additionalProperties and other // interacting keywords. Annotation siblings may still override the target's annotations. func mergeInlinedRefSchemas(target, siblings map[string]any) map[string]any { if len(siblings) > 0 && (target["unevaluatedProperties"] != nil || target["unevaluatedItems"] != nil) { return conjoinInlinedRefSchemas(target, siblings) } // These assertions depend on adjacent keywords in their own schema. Even equal values // must not inherit property declarations, tuple prefixes, or match counts across a ref. for _, pair := range [][2]string{ {"additionalProperties", "properties"}, {"additionalProperties", "patternProperties"}, {"additionalProperties", "additionalProperties"}, {"items", "prefixItems"}, {"contains", "contains"}, {"contains", "minContains"}, {"contains", "maxContains"}, } { if (target[pair[0]] != nil && siblings[pair[1]] != nil) || (siblings[pair[0]] != nil && target[pair[1]] != nil) { return conjoinInlinedRefSchemas(target, siblings) } } out := make(map[string]any, len(target)+len(siblings)) for key, value := range target { out[key] = value } for key, value := range siblings { switch key { case "title", "description", "default", "example", "examples", "$comment", "deprecated", "readOnly", "writeOnly": // Annotations do not relax validation constraints. default: if previous, exists := target[key]; exists && !reflect.DeepEqual(previous, value) { return conjoinInlinedRefSchemas(target, siblings) } } out[key] = value } return out } // conjoinInlinedRefSchemas replaces $ref with an allOf applicator without moving its siblings. // In particular, sibling unevaluatedProperties/unevaluatedItems must still see annotations // produced by the reference target, while the target's own evaluation scope stays isolated. func conjoinInlinedRefSchemas(target, siblings map[string]any) map[string]any { out := make(map[string]any, len(siblings)+1) for key, value := range siblings { out[key] = value } branches := []any{target} if existing, ok := siblings["allOf"].([]any); ok { branches = append(branches, existing...) } out["allOf"] = branches return out } // resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to // a typed hint, but still resolves the sibling subschemas on the same node so their own references // are not left dangling behind the degraded one. Literal values stay verbatim through // resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so // resolved siblings cannot clobber the hint. func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any { out := cyclicRefFallback(node, target, ref) // The target description copied into the fallback is expansion output like any other; charge // it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large // number of degraded references cannot multiply copied descriptions without bound. Cyclic // fallbacks omit over-long descriptions entirely, and omitted content is not charged. if targetMap, okMap := target.(map[string]any); okMap { if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes { if _, hasOwn := node["description"]; !hasOwn { cost := jsonStringEncodedSize(description) if cost > state.budget { out["description"] = "See: " + refName(ref) } else { state.budget -= cost } } } } for _, key := range sortedKeys(node) { if key == "$ref" || key == "description" { continue } if forClean && (key == "$defs" || key == "definitions") { delete(out, key) continue } out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) } return out } // resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value // is client data and is deep-copied without resolving refs inside it. Under a name map // ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen // property name whose value is a subschema; name-map entries are therefore resolved directly // here so such property names keep their schema semantics. Nested definition containers outside // the root still resolve through this path; when cleaning, callers skip them entirely instead. func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any { if isLiteralValueKeyword(key) { return deepCopyJSONValue(item) } if _, isNameMap := schemaNameMapKeywords[key]; isNameMap { if nameMap, ok := item.(map[string]any); ok { out := make(map[string]any, len(nameMap)) for _, name := range sortedKeys(nameMap) { out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean) } return out } } return resolveLocalRefs(root, item, active, state, false, forClean) } func deepCopyJSONValue(value any) any { switch node := value.(type) { case []any: out := make([]any, len(node)) for i, item := range node { out[i] = deepCopyJSONValue(item) } return out case map[string]any: out := make(map[string]any, len(node)) for key, item := range node { out[key] = deepCopyJSONValue(item) } 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 { if key == "description" { if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes { continue } } 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") if len(paths) == 0 { return jsonStr } sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { parentPath := trimSuffix(p, ".$ref") if isPropertyDefinition(parentPath) { continue // a property literally named "$ref", not a reference keyword } refVal := doc.Get(p).String() defName := refName(refVal) hint := fmt.Sprintf("See: %s", defName) if !preserveSiblings { if existing := doc.Get(descriptionPath(parentPath)).String(); existing != "" { hint = fmt.Sprintf("%s (%s)", existing, hint) } replacementDoc := ojson.NewDocString(`{"type":"object","description":""}`) _ = replacementDoc.Set("description", hint) setRawAtDoc(doc, parentPath, replacementDoc.String()) modified = true continue } _ = doc.Delete(p) appendHintDoc(doc, parentPath, hint) modified = true } if modified { return doc.String() } return jsonStr } func convertConstToEnum(jsonStr string) string { paths := findPaths(jsonStr, "const") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { val := doc.Get(p) if !val.Exists() { continue } enumPath := trimSuffix(p, ".const") + ".enum" if !doc.Get(enumPath).Exists() { if doc.Set(enumPath, []interface{}{val.Value()}) == nil { modified = true } } } if modified { return doc.String() } return jsonStr } // convertEnumValuesToStrings ensures all enum values use the string representation required by // 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 { paths := findPaths(jsonStr, "enum") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { arr := doc.Get(p) if !arr.IsArray() { continue } var stringVals []string for _, item := range arr.Array() { stringVals = append(stringVals, item.String()) } if doc.Set(p, stringVals) == nil { modified = true } if forceStringType { parentPath := trimSuffix(p, ".enum") if doc.Set(joinPath(parentPath, "type"), "string") == nil { modified = true } } } if modified { return doc.String() } return jsonStr } func addEnumHints(jsonStr string) string { paths := findPaths(jsonStr, "enum") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { arr := doc.Get(p) if !arr.IsArray() { continue } items := arr.Array() if len(items) <= 1 || len(items) > 10 { continue } var vals []string for _, item := range items { vals = append(vals, item.String()) } appendHintDoc(doc, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", ")) modified = true } if modified { return doc.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 { paths := findPaths(jsonStr, "enum") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, path := range paths { parentPath := trimSuffix(path, ".enum") shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && doc.Get(joinPath(parentPath, "type")).String() == "boolean") if !shouldDrop { continue } enum := doc.Get(path) if enum.IsArray() && len(enum.Array()) == 1 { appendHintDoc(doc, parentPath, "Allowed: "+enum.Array()[0].String()) } _ = doc.Delete(path) modified = true } if modified { return doc.String() } return jsonStr } func addAdditionalPropertiesHints(jsonStr string) string { paths := findPaths(jsonStr, "additionalProperties") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { if doc.Get(p).Type == ojson.False { appendHintDoc(doc, trimSuffix(p, ".additionalProperties"), "No extra properties allowed") modified = true } } if modified { return doc.String() } return jsonStr } var numericSchemaConstraintKeywords = []string{ "minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties", } type numericConstraintEdit struct { offset int end int replacement string } // NormalizeJSONSchemaNumericConstraints converts string-encoded integer values on JSON Schema // numeric constraint keywords (minLength, maxLength, minItems, maxItems, minProperties, // maxProperties) to JSON numbers. Google's Protobuf Schema format represents these int64 // fields as strings, whereas standard JSON Schema carriers (such as parametersJsonSchema) // require numeric values. func NormalizeJSONSchemaNumericConstraints(jsonStr string) string { if !hasNumericSchemaConstraintKeyword(jsonStr) && !strings.Contains(jsonStr, `\u`) && !strings.Contains(jsonStr, `\U`) { return jsonStr } root := ojson.Parse(jsonStr) if !root.IsObject() { return jsonStr } doc := root.Raw root.Index = 0 var edits []numericConstraintEdit collectNumericConstraintEdits(doc, root, &edits) if len(edits) == 0 { return jsonStr } var sb strings.Builder sb.Grow(len(doc)) cursor := 0 for _, edit := range edits { if edit.offset < cursor || edit.end > len(doc) { return jsonStr } sb.WriteString(doc[cursor:edit.offset]) sb.WriteString(edit.replacement) cursor = edit.end } sb.WriteString(doc[cursor:]) return sb.String() } func collectNumericConstraintEdits(doc string, node ojson.Result, edits *[]numericConstraintEdit) { if !node.IsObject() { return } node.ForEach(func(key, val ojson.Result) bool { keyStr := key.String() switch keyStr { case "minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties": if val.Type == ojson.String { if n, ok := parseSchemaNonNegativeInt(val.String()); ok { end := val.Index + len(val.Raw) if val.Index >= 0 && end <= len(doc) && doc[val.Index:end] == val.Raw { *edits = append(*edits, numericConstraintEdit{ offset: val.Index, end: end, replacement: strconv.FormatInt(n, 10), }) } } } case "properties", "patternProperties", "dependentSchemas", "dependencies", "$defs", "definitions": if val.IsObject() { val.ForEach(func(_, propVal ojson.Result) bool { if propVal.IsObject() { collectNumericConstraintEdits(doc, propVal, edits) } return true }) } case "items": if val.IsObject() { collectNumericConstraintEdits(doc, val, edits) } else if val.IsArray() { val.ForEach(func(_, item ojson.Result) bool { if item.IsObject() { collectNumericConstraintEdits(doc, item, edits) } return true }) } case "additionalProperties", "additionalItems", "unevaluatedProperties", "unevaluatedItems", "if", "then", "else", "not", "contains", "propertyNames", "contentSchema": if val.IsObject() { collectNumericConstraintEdits(doc, val, edits) } case "prefixItems", "anyOf", "oneOf", "allOf": if val.IsArray() { val.ForEach(func(_, item ojson.Result) bool { if item.IsObject() { collectNumericConstraintEdits(doc, item, edits) } return true }) } } return true }) } func hasNumericSchemaConstraintKeyword(jsonStr string) bool { for _, key := range numericSchemaConstraintKeywords { if strings.Contains(jsonStr, key) { return true } } return false } func parseSchemaNonNegativeInt(raw string) (int64, bool) { if raw == "" { return 0, false } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n < 0 || strconv.FormatInt(n, 10) != raw { return 0, false } return n, true } var unsupportedConstraints = []string{ "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", "pattern", "minItems", "maxItems", "uniqueItems", "contains", "format", "default", "examples", // Claude rejects these in VALIDATED mode } func constraintKeywords(options jsonSchemaCleanOptions) []string { if options.preserveStandardConstraints { return nil } 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) if len(constraints) == 0 { return jsonStr } pathsByField := findPathsByFields(jsonStr, constraints) doc := ojson.NewDocString(jsonStr) modified := false for _, key := range constraints { for _, p := range pathsByField[key] { val := doc.Get(p) if !val.Exists() { continue } parentPath := trimSuffix(p, "."+key) if isPropertyDefinition(parentPath) { continue } if val.IsObject() || val.IsArray() { appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.Raw)) modified = true continue } appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.String())) modified = true } } if modified { return doc.String() } return jsonStr } func moveNotToDescription(jsonStr string) string { paths := findPaths(jsonStr, "not") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, path := range paths { value := doc.Get(path) if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) { continue } appendHintDoc(doc, trimSuffix(path, ".not"), "not: "+value.Raw) modified = true } if modified { return doc.String() } return jsonStr } func mergeConditionals(jsonStr string) string { pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) var paths []string for _, key := range []string{"then", "else"} { for _, p := range pathsByField[key] { parentPath := trimSuffix(p, "."+key) if isPropertyDefinition(parentPath) { continue } paths = append(paths, p) } } if len(paths) == 0 { return jsonStr } sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { props := doc.Get(joinPath(p, "properties")) if !props.IsObject() { continue } var parentPath string if strings.HasSuffix(p, ".then") { parentPath = trimSuffix(p, ".then") } else if strings.HasSuffix(p, ".else") { parentPath = trimSuffix(p, ".else") } else if p == "then" || p == "else" { parentPath = "" } else { continue } props.ForEach(func(key, value ojson.Result) bool { destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String())) if !doc.Get(destPath).Exists() { if doc.SetRaw(destPath, value.Raw) == nil { modified = true } } return true }) } if modified { return doc.String() } return jsonStr } func mergeAllOf(jsonStr string) string { paths := findPaths(jsonStr, "allOf") if len(paths) == 0 { return jsonStr } sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { allOf := doc.Get(p) if !allOf.IsArray() { continue } parentPath := trimSuffix(p, ".allOf") for _, item := range allOf.Array() { if !item.IsObject() { continue } item.ForEach(func(key, value ojson.Result) bool { field := key.String() switch field { case "required": if !value.IsArray() { return true } reqPath := joinPath(parentPath, "required") current := getStringsDoc(doc, reqPath) for _, required := range value.Array() { if name := required.String(); !contains(current, name) { current = append(current, name) } } _ = doc.Set(reqPath, current) case "if", "then", "else", "allOf": // Conditional applicability cannot be represented by the upstream schema. default: destination := joinPath(parentPath, escapeGJSONPathKey(field)) mergeMissingSchemaAtPathDoc(doc, destination, value) } return true }) } _ = doc.Delete(p) modified = true } if modified { return doc.String() } return jsonStr } // mergeMissingSchemaAtPathDoc 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 mergeMissingSchemaAtPathDoc(doc *ojson.Doc, destination string, incoming ojson.Result) { existing := doc.Get(destination) if !existing.Exists() { _ = doc.SetRaw(destination, incoming.Raw) return } if !existing.IsObject() || !incoming.IsObject() { return } incoming.ForEach(func(key, value ojson.Result) bool { child := joinPath(destination, escapeGJSONPathKey(key.String())) mergeMissingSchemaAtPathDoc(doc, child, value) return true }) } func isRequiredOnlyUnionBranch(item ojson.Result) bool { if !item.IsObject() || !item.Get("required").Exists() { return false } for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} { if item.Get(key).Exists() { return false } } return true } func materializeRequiredOnlyBranch(item, parentProps ojson.Result) (ojson.Result, bool) { if !parentProps.IsObject() { return ojson.Result{}, false } reqs := getStrings(item.Raw, "required") if len(reqs) == 0 { return ojson.Result{}, false } doc := ojson.NewDocString(item.Raw) for _, reqName := range reqs { escaped := escapeGJSONPathKey(reqName) propVal := parentProps.Get(escaped) if !propVal.Exists() { return ojson.Result{}, false } _ = doc.SetRaw("properties."+escaped, propVal.Raw) } _ = doc.Set("type", "object") return ojson.Parse(doc.String()), true } func isNullOnlyBranch(item ojson.Result) bool { if item.Get("properties").Exists() || item.Get("items").Exists() { return false } t := item.Get("type") if t.Type == ojson.String { return t.String() == "null" } if t.IsArray() && len(t.Array()) > 0 { for _, elem := range t.Array() { if elem.String() != "null" { return false } } return true } return false } func partitionUnionBranches(items []ojson.Result, parentProps ojson.Result) (nonNullItems []ojson.Result, hasNull, hasFilteredBranch bool) { for _, item := range items { if item.Type == ojson.True { nonNullItems = append(nonNullItems, ojson.Parse("{}")) hasFilteredBranch = true continue } if !item.IsObject() { hasFilteredBranch = true continue } if isNullOnlyBranch(item) { hasNull = true continue } if isRequiredOnlyUnionBranch(item) { if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok { nonNullItems = append(nonNullItems, materialized) hasFilteredBranch = true continue } hasFilteredBranch = true continue } nonNullItems = append(nonNullItems, item) } return } func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) string { pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"}) var paths []string for _, key := range []string{"oneOf", "anyOf"} { paths = append(paths, pathsByField[key]...) } if len(paths) == 0 { return jsonStr } sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { key := "anyOf" if p == "oneOf" || strings.HasSuffix(p, ".oneOf") { key = "oneOf" } arr := doc.Get(p) if !arr.IsArray() || len(arr.Array()) == 0 { continue } parentPath := trimSuffix(p, "."+key) if isPropertyDefinition(parentPath) { continue } parent := doc.Get(parentPath) if parentPath == "" { if modified { jsonStr = doc.String() modified = false } parent = ojson.Parse(jsonStr) } // When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf), // protobuf Schema has only a single anyOf field and cannot represent a second union // conjunction structurally. Preserve anyOf as the primary union and move the sibling // oneOf constraint to a description hint instead of concatenating it into anyOf. // Routes that flatten multi-branch unions skip this shortcut: the primary union is // about to be flattened away too, and hinting the raw oneOf JSON into the // description would only add a giant, misleading hint. if key == "oneOf" && !flattenMultiBranch && !preserveOneOf && parent.Get("anyOf").IsArray() { appendHintDoc(doc, parentPath, "oneOf: "+arr.Raw) _ = doc.Delete(p) modified = true continue } items := arr.Array() parentProps := parent.Get("properties") nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps) // An earlier union-preserving pass may already have stripped the null branch and // recorded it as nullable:true on this node (translator -> executor chain). That // marker is part of the schema contract, so the flattening below must inherit it. nullable := hasNull || parent.Get("nullable").Bool() // Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost. // On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because // the protobuf Schema message supports anyOf but rejects oneOf. // Routes that reject multi-branch unions upstream (flattenMultiBranch) skip this // preservation and fall through to the single-strongest-branch flattening below. if len(nonNullItems) >= 2 && !flattenMultiBranch { targetKey := key if key == "oneOf" && !preserveOneOf { targetKey = "anyOf" } targetPath := joinPath(parentPath, targetKey) if targetKey != key || hasNull || hasFilteredBranch { rawBranches := make([]json.RawMessage, 0, len(nonNullItems)) for _, item := range nonNullItems { rawBranches = append(rawBranches, json.RawMessage(item.Raw)) } if doc.Set(targetPath, rawBranches) == nil { modified = true } if targetKey != key { _ = doc.Delete(p) modified = true } } if hasNull { _ = doc.Set(joinPath(parentPath, "nullable"), true) appendHintDoc(doc, parentPath, "(nullable)") modified = true } continue } // If the parent already defines properties (e.g. an object schema with unmaterializable // union constraints or a single-branch union), do not replace the parent with a single branch. // Instead, merge any branch properties into the parent and delete the union keyword. if parentProps.IsObject() { for _, item := range nonNullItems { if branchProps := item.Get("properties"); branchProps.IsObject() { branchProps.ForEach(func(propKey, propVal ojson.Result) bool { destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String())) mergeMissingSchemaAtPathDoc(doc, destPath, propVal) return true }) } } if nullable { _ = doc.Set(joinPath(parentPath, "nullable"), true) } if parent.Get("type").String() == "" { _ = doc.Set(joinPath(parentPath, "type"), "object") } _ = doc.Delete(p) modified = true continue } if len(nonNullItems) == 0 { if !hasNull { _ = doc.Delete(p) modified = true continue } selectedDoc := ojson.NewDocString(`{"type":"null"}`) if parentDesc := doc.Get(descriptionPath(parentPath)).String(); parentDesc != "" { mergeDescriptionDoc(selectedDoc, parentDesc) } setRawAtDoc(doc, parentPath, selectedDoc.String()) modified = true continue } candidateItems := items if hasFilteredBranch || len(nonNullItems) >= 2 { candidateItems = nonNullItems if hasNull { candidateItems = append(append([]ojson.Result(nil), nonNullItems...), ojson.Parse(`{"type":"null"}`)) } } parentDesc := doc.Get(descriptionPath(parentPath)).String() _, allTypes := selectBest(candidateItems) // Select the strongest non-null branch (ties keep the first one), matching the // pre-preservation flattening behavior. selectBest already treats union-bearing // branches as score-0, so a nested union never wins over a null-free scalar here. selected := nonNullItems[0].Raw if bestIdx, _ := selectBest(nonNullItems); bestIdx >= 0 && bestIdx < len(nonNullItems) { selected = nonNullItems[bestIdx].Raw } selectedDoc := ojson.NewDocString(selected) selectedModified := false if nullable { _ = selectedDoc.Set("nullable", true) selectedModified = true } if parentDesc != "" { if mergeDescriptionDoc(selectedDoc, parentDesc) { selectedModified = true } } if len(allTypes) > 1 { hint := "Accepts: " + strings.Join(allTypes, " | ") appendHintDoc(selectedDoc, "", hint) selectedModified = true } else if nullable { appendHintDoc(selectedDoc, "", "(nullable)") selectedModified = true } if selectedModified { selected = selectedDoc.String() } setRawAtDoc(doc, parentPath, selected) modified = true } if modified { return doc.String() } return jsonStr } func selectBest(items []ojson.Result) (bestIdx int, types []string) { bestScore := -1 for i, item := range items { t := item.Get("type").String() score := 0 switch { case t == "object" || item.Get("properties").Exists(): score, t = 3, orDefault(t, "object") case t == "array" || item.Get("items").Exists(): score, t = 2, orDefault(t, "array") case t != "" && t != "null": score = 1 case t == "null": score, t = 0, "null" default: score, t = 0, "" } if t != "" { types = append(types, t) } if score > bestScore { bestScore, bestIdx = score, i } } return } func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { paths := findPaths(jsonStr, "type") if len(paths) == 0 { return jsonStr } sortByDepth(paths) nullableFields := make(map[string][]string) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { res := doc.Get(p) if !res.IsArray() || len(res.Array()) == 0 { continue } hasNull := false var nonNullTypes []string for _, item := range res.Array() { s := item.String() if s == "null" { hasNull = true } else if s != "" { nonNullTypes = append(nonNullTypes, s) } } parentPath := trimSuffix(p, ".type") firstType := "string" if len(nonNullTypes) > 0 { if doc.Get(joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") { firstType = "array" } else if doc.Get(joinPath(parentPath, "properties")).Exists() && contains(nonNullTypes, "object") { firstType = "object" } else { firstType = nonNullTypes[0] } } _ = doc.Set(p, firstType) modified = true if firstType != "array" && doc.Get(joinPath(parentPath, "items")).Exists() { _ = doc.Delete(joinPath(parentPath, "items")) } if len(nonNullTypes) > 1 { hint := "Accepts: " + strings.Join(nonNullTypes, " | ") appendHintDoc(doc, parentPath, hint) } if hasNull { if preserveNativeNullable { _ = doc.Set(joinPath(parentPath, "nullable"), true) appendHintDoc(doc, 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) appendHintDoc(doc, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)") } } } for objectPath, fields := range nullableFields { reqPath := joinPath(objectPath, "required") req := doc.Get(reqPath) if !req.IsArray() { continue } var filtered []string for _, required := range req.Array() { if !contains(fields, required.String()) { filtered = append(filtered, required.String()) } } if len(filtered) == 0 { _ = doc.Delete(reqPath) } else { _ = doc.Set(reqPath, filtered) } modified = true } if modified { return doc.String() } return jsonStr } func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { keywords := append(constraintKeywords(options), "$schema", "$defs", "definitions", "const", "$ref", "$id", "id", "additionalProperties", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor", "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords "dependentRequired", "dependentSchemas", "dependencies", "if", "then", "else", "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", "discriminator", "readOnly", "writeOnly", // Schema metadata fields unsupported by Gemini "additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "contentMediaType", "contentEncoding", ) if options.antigravitySemantics { keywords = append(keywords, "not") } deletePaths := make([]string, 0) pathsByField := findPathsByFields(jsonStr, keywords) for _, key := range keywords { for _, p := range pathsByField[key] { if isPropertyDefinition(trimSuffix(p, "."+key)) { continue } if key == "additionalProperties" { if options.preserveAllAdditionalProperties { continue } if options.preserveAdditionalPropertiesFalse && ojson.Get(jsonStr, p).Type == ojson.False { continue } } deletePaths = append(deletePaths, p) } } if len(deletePaths) > 0 { sortByDepth(deletePaths) doc := ojson.NewDocString(jsonStr) for _, p := range deletePaths { _ = doc.Delete(p) } jsonStr = doc.String() } // Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API jsonStr = removeExtensionFields(jsonStr) return jsonStr } // removeExtensionFields removes all x-* extension fields from the JSON schema. // These are OpenAPI/JSON Schema extension fields that Google APIs don't recognize. func removeExtensionFields(jsonStr string) string { var paths []string walkForExtensions(ojson.Parse(jsonStr), "", &paths) // walkForExtensions returns paths in a way that deeper paths are added before their ancestors // when they are not deleted wholesale, but since we skip children of deleted x-* nodes, // any collected path is safe to delete. if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) for _, p := range paths { _ = doc.Delete(p) } return doc.String() } func walkForExtensions(value ojson.Result, path string, paths *[]string) { if value.IsArray() { arr := value.Array() for i := len(arr) - 1; i >= 0; i-- { walkForExtensions(arr[i], joinPath(path, strconv.Itoa(i)), paths) } return } if value.IsObject() { value.ForEach(func(key, val ojson.Result) bool { keyStr := key.String() safeKey := escapeGJSONPathKey(keyStr) childPath := joinPath(path, safeKey) // If it's an extension field, we delete it and don't need to look at its children. if strings.HasPrefix(keyStr, "x-") && !isPropertyDefinition(path) { *paths = append(*paths, childPath) return true } // Literal values are client data; x-* keys inside them are not schema extensions. if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { return true } walkForExtensions(val, childPath, paths) return true }) } } func hasSchemaConjunction(jsonStr string) bool { for _, path := range findPaths(jsonStr, "allOf") { if ojson.Get(jsonStr, path).IsArray() { return true } } return false } func cleanupRequiredFields(jsonStr string) string { paths := findPaths(jsonStr, "required") if len(paths) == 0 { return jsonStr } doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { parentPath := trimSuffix(p, ".required") propsPath := joinPath(parentPath, "properties") req := doc.Get(p) props := doc.Get(propsPath) if !req.IsArray() { continue } if !props.IsObject() { _ = doc.Delete(p) modified = true continue } var valid []string for _, r := range req.Array() { key := r.String() if props.Get(escapeGJSONPathKey(key)).Exists() { valid = append(valid, key) } } if len(valid) != len(req.Array()) { if len(valid) == 0 { _ = doc.Delete(p) } else { _ = doc.Set(p, valid) } modified = true } } if modified { return doc.String() } return jsonStr } // addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas. // Claude VALIDATED mode requires at least one required property in tool schemas. func addEmptySchemaPlaceholder(jsonStr string) string { // Find all "type" fields paths := findPaths(jsonStr, "type") if len(paths) == 0 { return jsonStr } // Process from deepest to shallowest (to handle nested objects properly) sortByDepth(paths) doc := ojson.NewDocString(jsonStr) modified := false for _, p := range paths { typeVal := doc.Get(p) if typeVal.String() != "object" { continue } // Get the parent path (the object containing "type") parentPath := trimSuffix(p, ".type") // Check if properties exists and is empty or missing propsPath := joinPath(parentPath, "properties") propsVal := doc.Get(propsPath) reqPath := joinPath(parentPath, "required") reqVal := doc.Get(reqPath) hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0 if doc.Get(joinPath(parentPath, "anyOf")).IsArray() || doc.Get(joinPath(parentPath, "oneOf")).IsArray() { continue } needsPlaceholder := false if !propsVal.Exists() { // No properties field at all needsPlaceholder = true } else if propsVal.IsObject() && len(propsVal.Map()) == 0 { // Empty properties object needsPlaceholder = true } if needsPlaceholder { // Add placeholder "reason" property reasonPath := joinPath(propsPath, "reason") _ = doc.Set(reasonPath+".type", "string") _ = doc.Set(reasonPath+".description", placeholderReasonDescription) _ = doc.Set(reqPath, []string{"reason"}) modified = true continue } // If schema has properties but none are required, add a minimal placeholder. if propsVal.IsObject() && !hasRequiredProperties { // DO NOT add placeholder if it's a top-level schema (parentPath is empty) // or if we've already added a placeholder reason above. if parentPath == "" { continue } placeholderPath := joinPath(propsPath, "_") if !doc.Get(placeholderPath).Exists() { _ = doc.Set(placeholderPath+".type", "boolean") } _ = doc.Set(reqPath, []string{"_"}) modified = true } } if modified { return doc.String() } return jsonStr } // --- Helpers --- func findPaths(jsonStr, field string) []string { var paths []string WalkSchema(ojson.Parse(jsonStr), "", field, &paths) return paths } func findPathsByFields(jsonStr string, fields []string) map[string][]string { set := make(map[string]struct{}, len(fields)) for _, field := range fields { set[field] = struct{}{} } paths := make(map[string][]string, len(set)) walkForFields(ojson.Parse(jsonStr), "", set, paths) return paths } func walkForFields(value ojson.Result, path string, fields map[string]struct{}, paths map[string][]string) { switch value.Type { case ojson.JSON: value.ForEach(func(key, val ojson.Result) bool { keyStr := key.String() safeKey := escapeGJSONPathKey(keyStr) var childPath string if path == "" { childPath = safeKey } else { childPath = path + "." + safeKey } if _, ok := fields[keyStr]; ok { paths[keyStr] = append(paths[keyStr], childPath) } // Literal values are client data; schema-looking keys inside them are not keywords. // Under a name map the key is a property name instead, so keep descending there. if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { return true } walkForFields(val, childPath, fields, paths) return true }) case ojson.String, ojson.Number, ojson.True, ojson.False, ojson.Null: // Terminal types - no further traversal needed } } func sortByDepth(paths []string) { sort.SliceStable(paths, func(i, j int) bool { return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j])) }) } func trimSuffix(path, suffix string) string { if path == strings.TrimPrefix(suffix, ".") { return "" } return strings.TrimSuffix(path, suffix) } func joinPath(base, suffix string) string { if base == "" { return suffix } return base + "." + suffix } func setRawAtDoc(doc *ojson.Doc, path, value string) { if path == "" { *doc = *ojson.NewDocString(value) return } _ = doc.SetRaw(path, value) } // schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to // subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07 // "dependencies" container qualifies for object (schema) values; array values are property-name // lists and are never walked as maps by the checks that use this set. var schemaNameMapKeywords = map[string]struct{}{ "properties": {}, "patternProperties": {}, "dependentSchemas": {}, "dependencies": {}, "$defs": {}, "definitions": {}, } // isPropertyDefinition reports whether path points at a map whose keys are names chosen by the // tool author, so a key spelled like a schema keyword there must be preserved. // // A trailing ".properties" is not enough to tell: a tool may declare a property named // "properties", and the schema for that property then sits at a path ending in ".properties" while // being an ordinary schema node. Classifying it as a name map skipped every cleaning pass inside // it, so unsupported keywords such as "propertyNames" reached the private Gemini backend, which // rejects unknown fields with a 400. // // Each name-map keyword at the end of the path therefore flips the answer, because the node it // names is a map only when its own parent is a schema: "properties" is a map, // "properties.properties" the schema of a property named "properties", and // "properties.properties.properties" that schema's own map. Only the trailing run matters, so any // prefix the caller nests the schema under is ignored. func isPropertyDefinition(path string) bool { segments := splitGJSONPath(path) trailing := 0 for i := len(segments) - 1; i >= 0; i-- { if _, ok := schemaNameMapKeywords[unescapeGJSONPathKey(segments[i])]; !ok { break } trailing++ } return trailing%2 == 1 } func descriptionPath(parentPath string) string { if parentPath == "" || parentPath == "@this" { return "description" } return parentPath + ".description" } // mergeHint combines an existing description with a hint. Cleaning is not always a single pass: // a schema may be cleaned by a translator and again by an executor, so an already-present hint is // kept as-is instead of being appended a second time. func mergeHint(existing, hint string) string { if existing == "" { return hint } // A hint added to an empty description is stored bare and later hints are appended after it, so // the bare form may sit alone, lead the description, or appear parenthesised further along. if existing == hint || strings.HasPrefix(existing, hint+" (") || strings.Contains(existing, fmt.Sprintf("(%s)", hint)) { return existing } return fmt.Sprintf("%s (%s)", existing, hint) } func appendHintDoc(doc *ojson.Doc, parentPath, hint string) { descPath := parentPath + ".description" if parentPath == "" || parentPath == "@this" { descPath = "description" } merged := mergeHint(doc.Get(descPath).String(), hint) _ = doc.Set(descPath, merged) } func getStrings(jsonStr, path string) []string { var result []string if arr := ojson.Get(jsonStr, path); arr.IsArray() { for _, r := range arr.Array() { result = append(result, r.String()) } } return result } func getStringsDoc(doc *ojson.Doc, path string) []string { var result []string if arr := doc.Get(path); arr.IsArray() { for _, r := range arr.Array() { result = append(result, r.String()) } } return result } func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } func orDefault(val, def string) string { if val == "" { return def } return val } func escapeGJSONPathKey(key string) string { if strings.IndexAny(key, ".*?") == -1 { return key } return gjsonPathKeyReplacer.Replace(key) } func unescapeGJSONPathKey(key string) string { if !strings.Contains(key, "\\") { return key } var b strings.Builder b.Grow(len(key)) for i := 0; i < len(key); i++ { if key[i] == '\\' && i+1 < len(key) { i++ b.WriteByte(key[i]) continue } b.WriteByte(key[i]) } return b.String() } func splitGJSONPath(path string) []string { if path == "" { return nil } parts := make([]string, 0, strings.Count(path, ".")+1) var b strings.Builder b.Grow(len(path)) for i := 0; i < len(path); i++ { c := path[i] if c == '\\' && i+1 < len(path) { b.WriteByte('\\') i++ b.WriteByte(path[i]) continue } if c == '.' { parts = append(parts, b.String()) b.Reset() continue } b.WriteByte(c) } parts = append(parts, b.String()) return parts } func mergeDescriptionDoc(doc *ojson.Doc, parentDesc string) bool { childDesc := doc.Get("description").String() switch { case childDesc == "": _ = doc.Set("description", parentDesc) return true case childDesc == parentDesc: return false default: combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc) _ = doc.Set("description", combined) return true } }