// Package util provides utility functions for the CLI Proxy API server. package util import ( "bytes" "encoding/json" "fmt" "sort" "strconv" "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) 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 antigravitySemantics bool removeToolTitle bool removeGeminiMetadata bool flattenUnions bool forceEnumStringType bool dropAllEnums bool dropBooleanEnums bool preserveAdditionalPropertiesFalse 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 cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ addPlaceholder: requirePlaceholder, antigravitySemantics: true, removeToolTitle: !requirePlaceholder, flattenUnions: true, 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. // - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. // - Resolved: local $ref targets are inlined before $defs/definitions are removed. // - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, // conditional keywords (after non-conflicting properties are retained), and x-* extensions. func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ antigravitySemantics: true, flattenUnions: true, dropBooleanEnums: true, preserveAdditionalPropertiesFalse: true, }) } // 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{ removeGeminiMetadata: true, flattenUnions: true, forceEnumStringType: 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) jsonStr = normalizeMalformedSchemaObjects(jsonStr) // Phase 1: Convert and add hints if options.antigravitySemantics { jsonStr = inlineLocalRefs(jsonStr) } jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) jsonStr = convertConstToEnum(jsonStr) jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) jsonStr = addEnumHints(jsonStr) jsonStr = dropIgnoredEnumsToHints(jsonStr, options) if !options.preserveAdditionalPropertiesFalse { jsonStr = addAdditionalPropertiesHints(jsonStr) } jsonStr = moveConstraintsToDescription(jsonStr, options) if options.antigravitySemantics { jsonStr = moveNotToDescription(jsonStr) } // Phase 2: Flatten complex structures jsonStr = mergeConditionals(jsonStr) jsonStr = mergeAllOf(jsonStr) if options.flattenUnions { jsonStr = flattenAnyOfOneOf(jsonStr) } 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"}) } jsonStr = cleanupRequiredFields(jsonStr) // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) if options.addPlaceholder { jsonStr = addEmptySchemaPlaceholder(jsonStr) } 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) } } sortByDepth(deletePaths) for _, p := range deletePaths { jsonStr, _ = sjson.Delete(jsonStr, p) } return jsonStr } // removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries. func removePlaceholderFields(jsonStr string) string { // Remove "_" placeholder properties. paths := findPaths(jsonStr, "_") sortByDepth(paths) for _, p := range paths { if !strings.HasSuffix(p, ".properties._") { continue } jsonStr, _ = sjson.Delete(jsonStr, p) parentPath := trimSuffix(p, ".properties._") reqPath := joinPath(parentPath, "required") req := gjson.Get(jsonStr, reqPath) if req.IsArray() { var filtered []string for _, r := range req.Array() { if r.String() != "_" { filtered = append(filtered, r.String()) } } if len(filtered) == 0 { jsonStr, _ = sjson.Delete(jsonStr, reqPath) } else { updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered) jsonStr = string(updated) } } } // Remove placeholder-only "reason" objects. reasonPaths := findPaths(jsonStr, "reason") sortByDepth(reasonPaths) for _, p := range reasonPaths { if !strings.HasSuffix(p, ".properties.reason") { continue } parentPath := trimSuffix(p, ".properties.reason") props := gjson.Get(jsonStr, joinPath(parentPath, "properties")) if !props.IsObject() || len(props.Map()) != 1 { continue } desc := gjson.Get(jsonStr, p+".description").String() if desc != placeholderReasonDescription { continue } jsonStr, _ = sjson.Delete(jsonStr, p) reqPath := joinPath(parentPath, "required") req := gjson.Get(jsonStr, 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 { jsonStr, _ = sjson.Delete(jsonStr, reqPath) } else { updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered) jsonStr = string(updated) } } } 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. 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 } 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) } } 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 != "" && s != "object" } if arr, ok := t.([]any); ok { for _, item := range arr { if s, ok := item.(string); ok && s == "object" { return false } } return len(arr) > 0 } 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. If node has a "properties" map, recursively repair all properties inside it 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 } } 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 } } } 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"} { 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 { 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 { 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 { 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 before definition // containers are stripped. Each expansion receives its own copy, sibling keywords override the // referenced definition, and cycles terminate as a typed hint instead of recursing forever. func inlineLocalRefs(jsonStr string) string { if !strings.Contains(jsonStr, `"$ref"`) { return jsonStr } decoder := json.NewDecoder(strings.NewReader(jsonStr)) decoder.UseNumber() var root any if err := decoder.Decode(&root); err != nil { return jsonStr } resolved := resolveLocalRefs(root, root, make(map[string]bool)) out, err := json.Marshal(resolved) if err != nil { return jsonStr } return string(out) } func resolveLocalRefs(root, value any, active map[string]bool) any { switch node := value.(type) { case []any: out := make([]any, len(node)) for i, item := range node { out[i] = resolveLocalRefs(root, item, active) } return out case map[string]any: ref, hasRef := node["$ref"].(string) if hasRef && strings.HasPrefix(ref, "#/") { if target, ok := resolveJSONPointer(root, ref); ok { if active[ref] { return cyclicRefFallback(node, target, ref) } active[ref] = true resolvedTarget := resolveLocalRefs(root, target, active) delete(active, ref) if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { out := make(map[string]any, len(targetMap)+len(node)) for key, item := range targetMap { out[key] = item } for key, item := range node { if key == "$ref" { continue } out[key] = resolveLocalRefs(root, item, active) } return out } } } out := make(map[string]any, len(node)) for key, item := range node { out[key] = resolveLocalRefs(root, item, active) } return out default: return value } } func resolveJSONPointer(root any, ref string) (any, bool) { current := root for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~") switch node := current.(type) { case map[string]any: var ok bool current, ok = node[part] if !ok { return nil, false } case []any: index, err := strconv.Atoi(part) if err != nil || index < 0 || index >= len(node) { return nil, false } current = node[index] default: return nil, false } } return current, true } func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any { out := make(map[string]any, len(node)+2) if targetMap, ok := target.(map[string]any); ok { for _, key := range []string{"type", "nullable", "description"} { if value, exists := targetMap[key]; exists { out[key] = value } } } for key, value := range node { if key != "$ref" { out[key] = value } } name := refName(ref) hint := "See: " + name if description, _ := out["description"].(string); description != "" { out["description"] = mergeHint(description, hint) } else { out["description"] = hint } return out } func refName(ref string) string { if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) { return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~") } return ref } // convertRefsToHints retains sibling keywords and converts only unresolved or external references // to descriptions. Local references have already been expanded by inlineLocalRefs. func convertRefsToHints(jsonStr string, preserveSiblings bool) string { paths := findPaths(jsonStr, "$ref") sortByDepth(paths) for _, p := range paths { refVal := gjson.Get(jsonStr, p).String() defName := refName(refVal) parentPath := trimSuffix(p, ".$ref") hint := fmt.Sprintf("See: %s", defName) if !preserveSiblings { if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { hint = fmt.Sprintf("%s (%s)", existing, hint) } replacement := `{"type":"object","description":""}` replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes)) continue } jsonStr, _ = sjson.Delete(jsonStr, p) jsonStr = appendHint(jsonStr, parentPath, hint) } return jsonStr } func convertConstToEnum(jsonStr string) string { for _, p := range findPaths(jsonStr, "const") { val := gjson.Get(jsonStr, p) if !val.Exists() { continue } enumPath := trimSuffix(p, ".const") + ".enum" if !gjson.Get(jsonStr, enumPath).Exists() { updated, _ := sjson.SetBytes([]byte(jsonStr), enumPath, []interface{}{val.Value()}) jsonStr = string(updated) } } 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 { for _, p := range findPaths(jsonStr, "enum") { arr := gjson.Get(jsonStr, p) if !arr.IsArray() { continue } var stringVals []string for _, item := range arr.Array() { stringVals = append(stringVals, item.String()) } updated, _ := sjson.SetBytes([]byte(jsonStr), p, stringVals) jsonStr = string(updated) if forceStringType { parentPath := trimSuffix(p, ".enum") updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "string") jsonStr = string(updated) } } return jsonStr } func addEnumHints(jsonStr string) string { for _, p := range findPaths(jsonStr, "enum") { arr := gjson.Get(jsonStr, 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()) } jsonStr = appendHint(jsonStr, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", ")) } return jsonStr } // Antigravity does not enforce enum on function arguments and ignores boolean response enums. // Preserve the advisory values in description, but do not leave an unenforced constraint in the // schema contract. Response enums for string, number, and integer remain native constraints. func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string { for _, path := range findPaths(jsonStr, "enum") { parentPath := trimSuffix(path, ".enum") shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean") if !shouldDrop { continue } enum := gjson.Get(jsonStr, path) if enum.IsArray() && len(enum.Array()) == 1 { jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String()) } jsonStr, _ = sjson.Delete(jsonStr, path) } return jsonStr } func addAdditionalPropertiesHints(jsonStr string) string { for _, p := range findPaths(jsonStr, "additionalProperties") { if gjson.Get(jsonStr, p).Type == gjson.False { jsonStr = appendHint(jsonStr, trimSuffix(p, ".additionalProperties"), "No extra properties allowed") } } return jsonStr } var unsupportedConstraints = []string{ "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", "pattern", "minItems", "maxItems", "uniqueItems", "format", "default", "examples", // Claude rejects these in VALIDATED mode } func constraintKeywords(options jsonSchemaCleanOptions) []string { keywords := append([]string(nil), unsupportedConstraints...) if options.antigravitySemantics { keywords = append(keywords, "minimum", "maximum", "multipleOf") } return keywords } func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string { constraints := constraintKeywords(options) pathsByField := findPathsByFields(jsonStr, constraints) for _, key := range constraints { for _, p := range pathsByField[key] { val := gjson.Get(jsonStr, p) if !val.Exists() || val.IsObject() || val.IsArray() { continue } parentPath := trimSuffix(p, "."+key) if isPropertyDefinition(parentPath) { continue } jsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf("%s: %s", key, val.String())) } } return jsonStr } func moveNotToDescription(jsonStr string) string { for _, path := range findPaths(jsonStr, "not") { value := gjson.Get(jsonStr, path) if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) { continue } jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw) } return jsonStr } func mergeConditionals(jsonStr string) string { pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) var paths []string for _, key := range []string{"then", "else"} { for _, p := range pathsByField[key] { parentPath := trimSuffix(p, "."+key) if isPropertyDefinition(parentPath) { continue } paths = append(paths, p) } } sortByDepth(paths) for _, p := range paths { props := gjson.Get(jsonStr, 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 gjson.Result) bool { destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String())) if !gjson.Get(jsonStr, destPath).Exists() { updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw)) jsonStr = string(updated) } return true }) } return jsonStr } func mergeAllOf(jsonStr string) string { paths := findPaths(jsonStr, "allOf") sortByDepth(paths) for _, p := range paths { allOf := gjson.Get(jsonStr, p) if !allOf.IsArray() { continue } parentPath := trimSuffix(p, ".allOf") for _, item := range allOf.Array() { if !item.IsObject() { continue } item.ForEach(func(key, value gjson.Result) bool { field := key.String() switch field { case "required": if !value.IsArray() { return true } reqPath := joinPath(parentPath, "required") current := getStrings(jsonStr, reqPath) for _, required := range value.Array() { if name := required.String(); !contains(current, name) { current = append(current, name) } } updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) jsonStr = string(updated) case "if", "then", "else", "allOf": // Conditional applicability cannot be represented by the upstream schema. default: destination := joinPath(parentPath, escapeGJSONPathKey(field)) jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value) } return true }) } jsonStr, _ = sjson.Delete(jsonStr, p) } return jsonStr } // mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing // definition. A parent schema is the canonical definition; allOf and conditional branches may // enrich gaps in it, but can never replace it with a narrower branch shell. func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string { existing := gjson.Get(jsonStr, destination) if !existing.Exists() { updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw)) return string(updated) } if !existing.IsObject() || !incoming.IsObject() { return jsonStr } incoming.ForEach(func(key, value gjson.Result) bool { child := joinPath(destination, escapeGJSONPathKey(key.String())) jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value) return true }) return jsonStr } func flattenAnyOfOneOf(jsonStr string) string { for _, key := range []string{"anyOf", "oneOf"} { paths := findPaths(jsonStr, key) sortByDepth(paths) for _, p := range paths { arr := gjson.Get(jsonStr, p) if !arr.IsArray() || len(arr.Array()) == 0 { continue } parentPath := trimSuffix(p, "."+key) parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() items := arr.Array() bestIdx, allTypes := selectBest(items) selected := items[bestIdx].Raw hasNull := false for _, item := range items { if item.Get("type").String() == "null" { hasNull = true break } } if hasNull && items[bestIdx].Get("type").String() != "null" { updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) selected = string(updated) } if parentDesc != "" { selected = mergeDescriptionRaw(selected, parentDesc) } if len(allTypes) > 1 { hint := "Accepts: " + strings.Join(allTypes, " | ") selected = appendHintRaw(selected, hint) } jsonStr = setRawAt(jsonStr, parentPath, selected) } } return jsonStr } func selectBest(items []gjson.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 default: t = orDefault(t, "null") } 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") sortByDepth(paths) nullableFields := make(map[string][]string) for _, p := range paths { res := gjson.Get(jsonStr, 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) } } firstType := "string" if len(nonNullTypes) > 0 { firstType = nonNullTypes[0] } updated, _ := sjson.SetBytes([]byte(jsonStr), p, firstType) jsonStr = string(updated) parentPath := trimSuffix(p, ".type") if len(nonNullTypes) > 1 { hint := "Accepts: " + strings.Join(nonNullTypes, " | ") jsonStr = appendHint(jsonStr, parentPath, hint) } if hasNull { if preserveNativeNullable { updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) jsonStr = string(updated) jsonStr = appendHint(jsonStr, parentPath, "(nullable)") continue } parts := splitGJSONPath(p) if len(parts) >= 3 && parts[len(parts)-3] == "properties" { fieldNameEscaped := parts[len(parts)-2] fieldName := unescapeGJSONPathKey(fieldNameEscaped) objectPath := strings.Join(parts[:len(parts)-3], ".") nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)") } } } for objectPath, fields := range nullableFields { reqPath := joinPath(objectPath, "required") req := gjson.Get(jsonStr, 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 { jsonStr, _ = sjson.Delete(jsonStr, reqPath) } else { updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered) jsonStr = string(updated) } } return jsonStr } func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { keywords := append(constraintKeywords(options), "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords "if", "then", "else", "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", // Schema metadata fields unsupported by Gemini ) 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 options.preserveAdditionalPropertiesFalse && key == "additionalProperties" { if gjson.Get(jsonStr, p).Type == gjson.False { continue } } deletePaths = append(deletePaths, p) } } sortByDepth(deletePaths) for _, p := range deletePaths { jsonStr, _ = sjson.Delete(jsonStr, p) } // 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(gjson.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. We still use DeleteBytes for efficiency. b := []byte(jsonStr) for _, p := range paths { b, _ = sjson.DeleteBytes(b, p) } return string(b) } func walkForExtensions(value gjson.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 gjson.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 } walkForExtensions(val, childPath, paths) return true }) } } func cleanupRequiredFields(jsonStr string) string { for _, p := range findPaths(jsonStr, "required") { parentPath := trimSuffix(p, ".required") propsPath := joinPath(parentPath, "properties") req := gjson.Get(jsonStr, p) props := gjson.Get(jsonStr, propsPath) if !req.IsArray() || !props.IsObject() { 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 { jsonStr, _ = sjson.Delete(jsonStr, p) } else { updated, _ := sjson.SetBytes([]byte(jsonStr), p, valid) jsonStr = string(updated) } } } 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") // Process from deepest to shallowest (to handle nested objects properly) sortByDepth(paths) for _, p := range paths { typeVal := gjson.Get(jsonStr, 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 := gjson.Get(jsonStr, propsPath) reqPath := joinPath(parentPath, "required") reqVal := gjson.Get(jsonStr, reqPath) hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0 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") updated, _ := sjson.SetBytes([]byte(jsonStr), reasonPath+".type", "string") jsonStr = string(updated) updated, _ = sjson.SetBytes([]byte(jsonStr), reasonPath+".description", placeholderReasonDescription) jsonStr = string(updated) // Add to required array updated, _ = sjson.SetBytes([]byte(jsonStr), reqPath, []string{"reason"}) jsonStr = string(updated) 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 !gjson.Get(jsonStr, placeholderPath).Exists() { updated, _ := sjson.SetBytes([]byte(jsonStr), placeholderPath+".type", "boolean") jsonStr = string(updated) } updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, []string{"_"}) jsonStr = string(updated) } } return jsonStr } // --- Helpers --- func findPaths(jsonStr, field string) []string { var paths []string Walk(gjson.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(gjson.Parse(jsonStr), "", set, paths) return paths } func walkForFields(value gjson.Result, path string, fields map[string]struct{}, paths map[string][]string) { switch value.Type { case gjson.JSON: value.ForEach(func(key, val gjson.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) } walkForFields(val, childPath, fields, paths) return true }) case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.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 setRawAt(jsonStr, path, value string) string { if path == "" { return value } result, _ := sjson.SetRawBytes([]byte(jsonStr), path, []byte(value)) return string(result) } // 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. var schemaNameMapKeywords = map[string]struct{}{ "properties": {}, "patternProperties": {}, "dependentSchemas": {}, "$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 appendHint(jsonStr, parentPath, hint string) string { descPath := parentPath + ".description" if parentPath == "" || parentPath == "@this" { descPath = "description" } merged := mergeHint(gjson.Get(jsonStr, descPath).String(), hint) updated, _ := sjson.SetBytes([]byte(jsonStr), descPath, merged) jsonStr = string(updated) return jsonStr } func appendHintRaw(jsonRaw, hint string) string { merged := mergeHint(gjson.Get(jsonRaw, "description").String(), hint) updated, _ := sjson.SetBytes([]byte(jsonRaw), "description", merged) jsonRaw = string(updated) return jsonRaw } func getStrings(jsonStr, path string) []string { var result []string if arr := gjson.Get(jsonStr, 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 mergeDescriptionRaw(schemaRaw, parentDesc string) string { childDesc := gjson.Get(schemaRaw, "description").String() switch { case childDesc == "": updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", parentDesc) return string(updated) case childDesc == parentDesc: return schemaRaw default: combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc) updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", combined) return string(updated) } }