Files
Cyrene_v2/internal/runtime/tool/validate.go
T
Aska 464ed2cad3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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。
2026-09-06 17:11:52 +08:00

152 lines
4.8 KiB
Go

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
}