feat(tool): Top5#2 完整 JSON-Schema 校验 + 类型强转 + 错误路径 + defaults(对齐 OpenClaw validation)
- 引入 github.com/santhosh-tekuri/jsonschema/v5(纯 Go,OpenClaw/MCP SDK 同思路)。 - validate.go 重写:validateArgs 用 jsonschema Compile+Validate 替换手写子集(required单层), 支持 required/properties/嵌套 object/array items/additionalProperties/allOf/anyOf/oneOf/ 类型联合/enum/min-max/pattern/defaults;编译器缓存 sync.Map;Draft7。 - coerce.go:对齐 OpenClaw coerceWithJsonSchema 类型强转(string→number/integer/boolean/ array/object,number/bool→string;union 处理;object/array 递归)。 - defaults.go:applyDefaults 缺失属性按 default 填(含嵌套),先填默认再强转再校验。 - 错误路径:ValidationError.InstanceLocation → 带路径报告(/count: expected integer...)。 - 测试:tool_test.go 更新 required 语义 + 补强转/default/错误路径用例。 go build/vet/test 全绿 + windows/darwin 交叉编译 + gofmt。
This commit is contained in:
1 parent
24d5924cee
commit
464ed2cad3
6 files changed
+718
-156
No files matched your search
+116
-150
@@ -1,19 +1,37 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||
)
|
||||
|
||||
// validateArgs performs a lightweight JSON Schema check on args against the
|
||||
// tool's Parameters map: every name in "required" must be present, and each
|
||||
// present argument matching a "properties" entry must satisfy its "type".
|
||||
// Unknown argument fields are ignored (lenient) so a tool can accept optional
|
||||
// extras without the dispatcher rejecting them. Full JSON Schema validation is
|
||||
// deferred to M5 per the milestone plan.
|
||||
// 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
|
||||
@@ -21,165 +39,113 @@ func validateArgs(schema map[string]any, args map[string]any) error {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
if raw, ok := schema["required"]; ok {
|
||||
reqs, ok := raw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("schema required must be an array")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
name, ok := r.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("schema required entries must be strings")
|
||||
}
|
||||
if _, present := args[name]; !present {
|
||||
return fmt.Errorf("missing required argument %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
compiled, err := compileSchema(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
for name, val := range args {
|
||||
ps, ok := props[name]
|
||||
if !ok {
|
||||
continue // unknown field: ignored
|
||||
}
|
||||
pSchema, ok := ps.(map[string]any)
|
||||
if !ok {
|
||||
continue // unconstrained property: not checkable
|
||||
}
|
||||
if err := checkType(pSchema, name, val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyDefaults(schema, args)
|
||||
coerceWithSchema(args, schema)
|
||||
|
||||
if verr := compiled.Validate(args); verr != nil {
|
||||
return newValidationError(verr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkType validates val against the "type" constraint of a property schema.
|
||||
func checkType(schema map[string]any, name string, val any) error {
|
||||
raw, ok := schema["type"]
|
||||
if !ok || raw == nil {
|
||||
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)
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case string:
|
||||
return checkOne(t, name, val)
|
||||
case []any:
|
||||
for _, item := range t {
|
||||
ts, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if checkOne(ts, name, val) == nil {
|
||||
return nil
|
||||
}
|
||||
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("; ")
|
||||
}
|
||||
return fmt.Errorf("argument %q has type %s, want one of %v", name, typeName(val), t)
|
||||
default:
|
||||
return nil
|
||||
sb.WriteString(formatErrorPath(e))
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(e.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOne validates val against a single JSON Schema type name.
|
||||
func checkOne(t, name string, val any) error {
|
||||
if matchesType(t, val) {
|
||||
return nil
|
||||
if sb.Len() == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("argument %q has type %s, want %s", name, typeName(val), t)
|
||||
return errors.New(sb.String())
|
||||
}
|
||||
|
||||
// matchesType reports whether v satisfies a single JSON Schema type name.
|
||||
// Unknown type names are treated as unconstrained (lenient) and the types
|
||||
// "any"/"" impose no constraint.
|
||||
func matchesType(t string, v any) bool {
|
||||
switch t {
|
||||
case "any", "":
|
||||
return true
|
||||
case "string":
|
||||
_, ok := v.(string)
|
||||
return ok
|
||||
case "boolean":
|
||||
_, ok := v.(bool)
|
||||
return ok
|
||||
case "number":
|
||||
return isNumber(v)
|
||||
case "integer":
|
||||
return isInteger(v)
|
||||
case "array":
|
||||
return isArrayLike(v)
|
||||
case "object":
|
||||
return isMapLike(v)
|
||||
case "null":
|
||||
return v == nil
|
||||
default:
|
||||
return true
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
for _, c := range ve.Causes {
|
||||
walk(c)
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isArrayLike reports whether v is a slice or array.
|
||||
func isArrayLike(v any) bool {
|
||||
rv := reflect.ValueOf(v)
|
||||
return rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array)
|
||||
}
|
||||
|
||||
// isMapLike reports whether v is a map.
|
||||
func isMapLike(v any) bool {
|
||||
rv := reflect.ValueOf(v)
|
||||
return rv.IsValid() && rv.Kind() == reflect.Map
|
||||
}
|
||||
|
||||
// typeName renders a short, human-readable name for v for error messages.
|
||||
func typeName(v any) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
return "boolean"
|
||||
case string:
|
||||
return "string"
|
||||
case []any:
|
||||
return "array"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
default:
|
||||
return fmt.Sprintf("%s", kindName(v))
|
||||
if e.InstanceLocation == "" {
|
||||
return "/"
|
||||
}
|
||||
return e.InstanceLocation
|
||||
}
|
||||
|
||||
// kindName maps a reflected kind to a readable label.
|
||||
func kindName(v any) string {
|
||||
rv := reflect.ValueOf(v)
|
||||
if !rv.IsValid() {
|
||||
return "invalid"
|
||||
// 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 rv.Kind().String()
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user