package tool import ( "encoding/json" "math" "regexp" "strconv" "strings" ) // maxJSONCoerceLen caps how long a string we will attempt to JSON-parse when // coercing a model-emitted string into an array or object. It mirrors the cap // OpenClaw uses so a huge string argument fails fast instead of being parsed. const maxJSONCoerceLen = 64 * 1024 // jsonNumberRe matches a JSON number token; used to decide whether a model // string such as "3.5" may be safely coerced to a number. var jsonNumberRe = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$`) // coerceWithSchema mirrors OpenClaw's coerceWithJsonSchema: it walks the // schema's allOf/anyOf/oneOf, coerces a value whose type does not yet match, // then recurses into object properties and array items. It returns the value it // was given (mutating maps/slices in place) so validation sees coerced types. func coerceWithSchema(value any, schema map[string]any) any { next := value if arr, ok := schema["allOf"].([]any); ok { for _, raw := range arr { if s, ok := raw.(map[string]any); ok { next = coerceWithSchema(next, s) } } } if arr, ok := schema["anyOf"].([]any); ok { next = coerceWithUnion(next, arr) } if arr, ok := schema["oneOf"].([]any); ok { next = coerceWithUnion(next, arr) } types := schemaTypes(schema) if len(types) > 0 && !matchesAnyType(next, types) { for _, t := range types { if coerced, ok := coercePrimitiveByType(next, t); ok { next = coerced break } } } if isObject(next) { applyObjectCoercion(next.(map[string]any), schema) } if isArray(next) { applyArrayCoercion(next.([]any), schema) } return next } // coerceWithUnion tries each union member in turn, cloning the value and // coercing against that member, returning the first candidate that validates. // A null value is returned as-is when some member accepts null so it is not // mangled into the empty string by a string branch. func coerceWithUnion(value any, schemas []any) any { if value == nil { for _, raw := range schemas { s, ok := raw.(map[string]any) if !ok || !contains(schemaTypes(s), "null") { continue } if compiled, err := compileSchema(s); err == nil && compiled.Validate(value) == nil { return value } } } for _, raw := range schemas { s, ok := raw.(map[string]any) if !ok { continue } candidate := coerceWithSchema(deepCopy(value), s) if compiled, err := compileSchema(s); err == nil && compiled.Validate(candidate) == nil { return candidate } } return value } // applyObjectCoercion recurses into declared properties and, when // additionalProperties is itself a schema, into undeclared keys. func applyObjectCoercion(value map[string]any, schema map[string]any) { props, _ := schema["properties"].(map[string]any) for name, raw := range props { ps, ok := raw.(map[string]any) if !ok { continue } if val, present := value[name]; present { value[name] = coerceWithSchema(val, ps) } } if ap, ok := schema["additionalProperties"].(map[string]any); ok { for name, val := range value { if _, defined := props[name]; !defined { value[name] = coerceWithSchema(val, ap) } } } } // applyArrayCoercion recurses into array items, handling the schematic, // tuple-in-items and 2020-12 prefixItems forms. func applyArrayCoercion(value []any, schema map[string]any) { switch items := schema["items"].(type) { case map[string]any: for i := range value { value[i] = coerceWithSchema(value[i], items) } case []any: for i, item := range items { if i >= len(value) { break } if is, ok := item.(map[string]any); ok { value[i] = coerceWithSchema(value[i], is) } } } if prefix, ok := schema["prefixItems"].([]any); ok { for i, item := range prefix { if i >= len(value) { break } if is, ok := item.(map[string]any); ok { value[i] = coerceWithSchema(value[i], is) } } } } // coercePrimitiveByType maps a requested JSON type to its coercion helper; when // no helper can apply it returns the value unchanged. func coercePrimitiveByType(value any, typ string) (any, bool) { switch typ { case "number": return coerceNumber(value) case "integer": return coerceInteger(value) case "boolean": return coerceBoolean(value) case "string": return coerceString(value) case "array": return coerceArray(value) case "object": return coerceObject(value) case "null": return coerceNull(value) default: return value, false } } func coerceNumber(value any) (any, bool) { switch v := value.(type) { case nil: return 0, true case string: if trimmed := strings.TrimSpace(v); trimmed != "" { if n, ok := parseNumber(trimmed); ok { return n, true } } case bool: if v { return 1, true } return 0, true } return value, false } func coerceInteger(value any) (any, bool) { switch v := value.(type) { case nil: return 0, true case string: if trimmed := strings.TrimSpace(v); trimmed != "" { if n, ok := parseNumber(trimmed); ok && math.Trunc(n) == n { return n, true } } case bool: if v { return 1, true } return 0, true } return value, false } func coerceBoolean(value any) (any, bool) { switch v := value.(type) { case nil: return false, true case string: if v == "true" { return true, true } if v == "false" { return false, true } case float64: if v == 1 { return true, true } if v == 0 { return false, true } } return value, false } func coerceString(value any) (any, bool) { switch v := value.(type) { case nil: return "", true case bool: return strconv.FormatBool(v), true case float64: return strconv.FormatFloat(v, 'f', -1, 64), true case float32: return strconv.FormatFloat(float64(v), 'f', -1, 32), true case int: return strconv.Itoa(v), true case int64: return strconv.FormatInt(v, 10), true case json.Number: return v.String(), true } return value, false } func coerceArray(value any) (any, bool) { s, ok := value.(string) if !ok || len(s) > maxJSONCoerceLen || strings.TrimSpace(s) == "" { return value, false } var parsed any if err := json.Unmarshal([]byte(s), &parsed); err != nil { return value, false } if arr, ok := parsed.([]any); ok { return arr, true } return value, false } func coerceObject(value any) (any, bool) { s, ok := value.(string) if !ok || len(s) > maxJSONCoerceLen || strings.TrimSpace(s) == "" { return value, false } var parsed any if err := json.Unmarshal([]byte(s), &parsed); err != nil { return value, false } if obj, ok := parsed.(map[string]any); ok { return obj, true } return value, false } func coerceNull(value any) (any, bool) { switch v := value.(type) { case string: if strings.TrimSpace(v) == "" { return nil, true } case float64: if v == 0 { return nil, true } case bool: if !v { return nil, true } } return value, false } // parseNumber parses a strictly JSON-shaped numeric token (no hex, Inf, NaN). func parseNumber(s string) (float64, bool) { if !jsonNumberRe.MatchString(s) { return 0, false } f, err := strconv.ParseFloat(s, 64) if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { return 0, false } return f, true } // schemaTypes returns the type names declared by a schema (string or union). func schemaTypes(schema map[string]any) []string { switch t := schema["type"].(type) { case string: return []string{t} case []any: out := make([]string, 0, len(t)) for _, item := range t { if s, ok := item.(string); ok { out = append(out, s) } } return out default: return nil } } // matchesAnyType reports whether value already satisfies one of the type names. func matchesAnyType(value any, types []string) bool { for _, t := range types { if matchesJSONType(value, t) { return true } } return false } // matchesJSONType reports whether value satisfies a single JSON type name. func matchesJSONType(value any, typ string) bool { switch typ { case "number": return isNumber(value) case "integer": return isInteger(value) case "boolean": _, ok := value.(bool) return ok case "string": _, ok := value.(string) return ok case "null": return value == nil case "array": return isArray(value) case "object": return isObject(value) default: return false } } // isObject reports whether v is a JSON object (map[string]any). func isObject(v any) bool { _, ok := v.(map[string]any) return ok } // isArray reports whether v is a JSON array ([]any). func isArray(v any) bool { _, ok := v.([]any) return ok } // contains reports whether slice has s. func contains(slice []string, s string) bool { for _, item := range slice { if item == s { return true } } return false } // isNumber reports whether v is any integer or floating-point numeric type. func isNumber(v any) bool { switch v.(type) { case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true default: return false } } // isInteger reports whether v is an integer type or a floating-point / json // Number that holds an integral value. func isInteger(v any) bool { switch x := v.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true case float32: return math.Trunc(float64(x)) == float64(x) case float64: return math.Trunc(x) == x case json.Number: _, err := strconv.ParseInt(x.String(), 10, 64) return err == nil default: return false } }