bcd246bf85
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval). RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag. Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction. Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner). Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
245 lines
6.2 KiB
Go
245 lines
6.2 KiB
Go
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]<sub-schema>
|
|
// - items: <sub-schema> 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
|
|
}
|