package devicemesh import ( "fmt" "sort" ) // schema.go: minimal JSON-Schema-like validator. We do NOT depend on a full // JSON Schema implementation — the surface we use is small and stable: // // - type: "object" | "string" | "number" | "integer" | "boolean" | "array" // - required: []string (names of fields that must be present and non-nil) // - properties: map[string] // - items: for arrays // - enum: []any — allowed scalar values // - additionalProperties: false (strict; default true) // // This is enough to catch LLM-induced typos (extra fields, wrong types) and // gives the runtime a place to grow if we need oneOf/pattern later. // ValidateInput checks the spec.InputSchema against the provided input map. // Returns nil on success, a descriptive error otherwise. The error path is // surfaced back to the LLM so it can self-correct. func ValidateInput(spec ToolSpec, input map[string]any) error { if spec.InputSchema == nil { // No schema means "anything goes". Tools without a schema are rare // (mostly internal ones like memory.recall in 0144d). return nil } return validateValue("input", input, spec.InputSchema) } func validateValue(path string, value any, schema map[string]any) error { typ, _ := schema["type"].(string) if typ == "" { // No type declared: accept as-is. return nil } // nil handling: only allowed if the field is not required (handled by parent). if value == nil { return fmt.Errorf("%s: expected %s, got null", path, typ) } switch typ { case "object": obj, ok := value.(map[string]any) if !ok { return fmt.Errorf("%s: expected object, got %T", path, value) } return validateObject(path, obj, schema) case "array": arr, ok := coerceToAnySlice(value) if !ok { return fmt.Errorf("%s: expected array, got %T", path, value) } return validateArray(path, arr, schema) case "string": if _, ok := value.(string); !ok { return fmt.Errorf("%s: expected string, got %T", path, value) } return validateEnum(path, value, schema) case "integer": if !isInteger(value) { return fmt.Errorf("%s: expected integer, got %T (%v)", path, value, value) } return validateEnum(path, value, schema) case "number": if !isNumber(value) { return fmt.Errorf("%s: expected number, got %T", path, value) } return validateEnum(path, value, schema) case "boolean": if _, ok := value.(bool); !ok { return fmt.Errorf("%s: expected boolean, got %T", path, value) } default: return fmt.Errorf("%s: unknown schema type %q", path, typ) } return nil } func validateObject(path string, obj map[string]any, schema map[string]any) error { // Required fields must be present and non-nil. if reqRaw, ok := schema["required"]; ok { req, _ := asStringSlice(reqRaw) // Deterministic ordering of errors helps tests and LLM correction. sort.Strings(req) for _, name := range req { v, present := obj[name] if !present || v == nil { return fmt.Errorf("%s.%s: required field missing", path, name) } } } props, _ := schema["properties"].(map[string]any) // Strict additionalProperties: reject unknown keys when explicitly false. additional := true if ap, ok := schema["additionalProperties"]; ok { if b, isBool := ap.(bool); isBool { additional = b } } if !additional && props != nil { keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if _, known := props[k]; !known { return fmt.Errorf("%s.%s: unknown field (additionalProperties=false)", path, k) } } } if props == nil { return nil } // Walk known properties. names := make([]string, 0, len(props)) for k := range props { names = append(names, k) } sort.Strings(names) for _, name := range names { sub, _ := props[name].(map[string]any) if sub == nil { continue } v, present := obj[name] if !present { continue // absent + not required ⇒ ok } if v == nil { continue // nil + not required ⇒ ok } if err := validateValue(path+"."+name, v, sub); err != nil { return err } } return nil } func validateArray(path string, arr []any, schema map[string]any) error { itemSchema, _ := schema["items"].(map[string]any) if itemSchema == nil { return nil } for i, v := range arr { if err := validateValue(fmt.Sprintf("%s[%d]", path, i), v, itemSchema); err != nil { return err } } return nil } func validateEnum(path string, value any, schema map[string]any) error { enumRaw, ok := schema["enum"] if !ok { return nil } enum, _ := enumRaw.([]any) if len(enum) == 0 { return nil } for _, allowed := range enum { if fmt.Sprint(allowed) == fmt.Sprint(value) { return nil } } return fmt.Errorf("%s: value %v not in enum %v", path, value, enum) } func isInteger(v any) bool { switch n := v.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true case float32: return float64(n) == float64(int64(n)) case float64: return n == float64(int64(n)) } return false } func isNumber(v any) bool { switch v.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: return true } return false } // coerceToAnySlice accepts []any or any typed slice ([]string, []int, ...) // and returns it as []any. This keeps the schema validator forgiving when // callers pass native Go slices directly (common in tests and ArgMapping // outputs) instead of JSON-decoded []any. func coerceToAnySlice(v any) ([]any, bool) { switch s := v.(type) { case []any: return s, true case []string: out := make([]any, len(s)) for i, e := range s { out[i] = e } return out, true case []int: out := make([]any, len(s)) for i, e := range s { out[i] = e } return out, true case []float64: out := make([]any, len(s)) for i, e := range s { out[i] = e } return out, true } return nil, false } func asStringSlice(v any) ([]string, bool) { switch s := v.(type) { case []string: out := make([]string, len(s)) copy(out, s) return out, true case []any: out := make([]string, 0, len(s)) for _, e := range s { str, ok := e.(string) if !ok { return nil, false } out = append(out, str) } return out, true } return nil, false }