package tool import ( "bytes" "encoding/json" "errors" "fmt" "regexp" "strings" "sync" "github.com/santhosh-tekuri/jsonschema/v5" ) // quotedRe extracts single-quoted tokens, e.g. the property names inside the // library's required error message "missing properties: 'a', 'b'". var quotedRe = regexp.MustCompile(`'([^']+)'`) // schemaCache reuses compiled schemas keyed by their canonical JSON bytes. // Tool.Parameters is called on every dispatch, so recompiling each round is // wasteful; the map is concurrency-safe so concurrent dispatches share entries. var schemaCache sync.Map // key: string (schema JSON), value: *jsonschema.Schema // validateArgs validates args against the tool's JSON Schema. It first applies // schema "default" values for absent properties (mirroring MCP applySchema and // OpenClaw's Convert), then coerces model-emitted scalar strings into the types // the schema asks for (OpenClaw coerceWithJsonSchema), then runs a full // santhosh-tekuri/jsonschema validation supporting required, properties, nested // objects, array items, additionalProperties, allOf/anyOf/oneOf, type unions, // enum, min/max and pattern. Validation failures carry the offending instance // location (e.g. /count) so the caller and front-end can pinpoint the argument. // // The args map is mutated in place: defaults are inserted and values are coerced // so the tool subsequently receives the arguments it declared. func validateArgs(schema map[string]any, args map[string]any) error { if schema == nil { return nil } if args == nil { args = map[string]any{} } compiled, err := compileSchema(schema) if err != nil { return fmt.Errorf("schema: %w", err) } applyDefaults(schema, args) coerceWithSchema(args, schema) if verr := compiled.Validate(args); verr != nil { return newValidationError(verr) } return nil } // compileSchema compiles schema (or returns a cached compiled schema) using // draft-7 semantics for schemas that do not declare "$schema", since tool input // schemas are overwhelmingly OpenAI-function-shaped (tuple "items" arrays // included). Schemas that declare their own "$schema" are honored as-is. func compileSchema(schema map[string]any) (*jsonschema.Schema, error) { raw, err := json.Marshal(schema) if err != nil { return nil, fmt.Errorf("schema is not json-marshalable: %w", err) } key := string(raw) if cached, ok := schemaCache.Load(key); ok { return cached.(*jsonschema.Schema), nil } c := jsonschema.NewCompiler() c.Draft = jsonschema.Draft7 if err := c.AddResource("tool-schema.json", bytes.NewReader(raw)); err != nil { return nil, err } sch, err := c.Compile("tool-schema.json") if err != nil { return nil, err } actual, _ := schemaCache.LoadOrStore(key, sch) return actual.(*jsonschema.Schema), nil } // newValidationError renders a *jsonschema.ValidationError into a compact, // human-readable report of the leaf failures, each carrying the instance // location (e.g. /count or /user/age) so callers can locate the exact property. func newValidationError(err error) error { var ve *jsonschema.ValidationError if !errors.As(err, &ve) { return err } var sb strings.Builder for _, e := range flattenLeaves(ve) { if sb.Len() > 0 { sb.WriteString("; ") } sb.WriteString(formatErrorPath(e)) sb.WriteString(": ") sb.WriteString(e.Message) } if sb.Len() == 0 { return err } return errors.New(sb.String()) } // flattenLeaves walks the ValidationError tree skipping the root wrapper // ("doesn't validate with ...") and returning every sub-error that carries its // own message. func flattenLeaves(ve *jsonschema.ValidationError) []*jsonschema.ValidationError { var leaves []*jsonschema.ValidationError var walk func(*jsonschema.ValidationError) walk = func(e *jsonschema.ValidationError) { if e.Message != "" && !strings.HasPrefix(e.Message, "doesn't validate with") { leaves = append(leaves, e) } for _, c := range e.Causes { walk(c) } } for _, c := range ve.Causes { walk(c) } return leaves } // formatErrorPath renders the instance location for a leaf error as a JSON // pointer (e.g. /count). For a missing-required error the instance location // points at the enclosing object, so the first missing property name is // appended to give a precise path. func formatErrorPath(e *jsonschema.ValidationError) string { if e.KeywordLocation == "required" || strings.HasSuffix(e.KeywordLocation, "/required") { if names := quotedTokens(e.Message); len(names) > 0 { return strings.TrimRight(e.InstanceLocation, "/") + "/" + names[0] } } if e.InstanceLocation == "" { return "/" } return e.InstanceLocation } // quotedTokens extracts every single-quoted token from s. func quotedTokens(s string) []string { matches := quotedRe.FindAllStringSubmatch(s, -1) out := make([]string, 0, len(matches)) for _, m := range matches { out = append(out, m[1]) } return out }