Files
agents_and_robots/pkg/tools/devicemesh/schema.go
T
egutierrez bcd246bf85 feat(0144a): tool registry framework para device-mesh
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.
2026-05-24 14:07:13 +02:00

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
}