From b798e129c15483b0c0b91a98a4947978d76c16d6 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 05:20:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(tool):=20Dispatcher.ToolSchemas()=20?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E5=B7=A5=E5=85=B7=20schema=EF=BC=88=E4=BE=9B?= =?UTF-8?q?=20agent=20=E7=BB=84=E8=A3=85=E8=AF=B7=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/tool/tool.go | 264 ++++++++++++++++++++ internal/runtime/tool/tool_test.go | 375 +++++++++++++++++++++++++++++ internal/runtime/tool/validate.go | 185 ++++++++++++++ 3 files changed, 824 insertions(+) create mode 100644 internal/runtime/tool/tool.go create mode 100644 internal/runtime/tool/tool_test.go create mode 100644 internal/runtime/tool/validate.go diff --git a/internal/runtime/tool/tool.go b/internal/runtime/tool/tool.go new file mode 100644 index 0000000..b31de09 --- /dev/null +++ b/internal/runtime/tool/tool.go @@ -0,0 +1,264 @@ +// Package tool implements singleshot tool dispatch for the agent runtime +// (DevDocs/design/04-运行时.md §4). A Dispatcher wraps the global capability +// registry, resolves a named Tool, validates the caller-supplied arguments +// against the tool's JSON Schema, and executes it under a per-tool timeout, +// turning every failure into a ToolError that is safe to feed back to the LLM. +// +// This package deliberately covers only a single invocation: the loop upper +// bound (8 rounds) and retry belong to the agent loop (internal/runtime/loop), +// not to dispatch. +package tool + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// DefaultTimeout is the per-invoke deadline used when a tool has no explicit +// override and the Dispatcher was built without WithDefaultTimeout. +const DefaultTimeout = 30 * time.Second + +// Sentinel errors returned by the Dispatcher. Use errors.Is to test for them; +// they are wrapped with %w so the underlying cause stays available. +var ( + // ErrUnknownTool is returned when the requested name is not a registered + // tool, or resolves to a capability of a non-Tool kind. + ErrUnknownTool = errors.New("tool: unknown tool") + // ErrInvalidArgs is returned when the caller-supplied arguments fail the + // tool's JSON Schema validation (missing required, wrong type). + ErrInvalidArgs = errors.New("tool: invalid arguments") + // ErrTimeout is returned when a tool does not complete before its deadline. + ErrTimeout = errors.New("tool: execution timeout") + // ErrNilRegistry is returned by New when the supplied registry is nil. + ErrNilRegistry = errors.New("tool: nil registry") +) + +// ToolError is the error the Dispatcher returns to the caller on any failed +// invocation. It carries the tool Name and a human/LLM-readable Message and +// wraps the underlying cause so sentinel classification survives errors.Is and +// errors.As. +type ToolError struct { + // Name is the tool that failed. + Name string + // Message is a safe, human- and LLM-readable description of the failure. + Message string + // cause is the wrapped underlying error (may be nil). + cause error +} + +// Error implements the error interface. +func (e *ToolError) Error() string { + if e.cause == nil { + return fmt.Sprintf("tool %q failed: %s", e.Name, e.Message) + } + return fmt.Sprintf("tool %q failed: %s: %v", e.Name, e.Message, e.cause) +} + +// Unwrap exposes the wrapped cause so errors.Is/errors.As traverse the chain. +func (e *ToolError) Unwrap() error { return e.cause } + +// Option configures a Dispatcher at construction time. +type Option func(*Dispatcher) + +// Dispatcher resolves, validates and executes tools registered in a capability +// registry. It is safe for concurrent use. +type Dispatcher struct { + reg *registry.Registry + dft time.Duration + mu sync.RWMutex // guards timeouts + timeouts map[string]time.Duration +} + +// New builds a Dispatcher backed by reg, applying opts in order. It returns an +// error wrapping ErrNilRegistry when reg is nil. The default per-invoke timeout +// is DefaultTimeout unless overridden with WithDefaultTimeout. +func New(reg *registry.Registry, opts ...Option) (*Dispatcher, error) { + if reg == nil { + return nil, fmt.Errorf("tool: new: %w", ErrNilRegistry) + } + d := &Dispatcher{ + reg: reg, + dft: DefaultTimeout, + timeouts: make(map[string]time.Duration), + } + for _, opt := range opts { + if opt != nil { + opt(d) + } + } + return d, nil +} + +// WithDefaultTimeout sets the default per-invoke deadline for tools without an +// explicit override. A non-positive value disables the deadline. +func WithDefaultTimeout(timeout time.Duration) Option { + return func(d *Dispatcher) { d.dft = timeout } +} + +// Register registers t as a tool with the underlying capability registry, +// delegating duplicate/invalid handling to the registry. A nil tool is rejected +// by the registry's own validation. +func (d *Dispatcher) Register(t sdk.Tool) error { + if d.reg == nil { + return fmt.Errorf("tool: register: %w", ErrNilRegistry) + } + return d.reg.RegisterTool(t) +} + +// SetToolTimeout overrides the per-invoke deadline for the named tool. A +// non-positive value disables the deadline for that tool. The override applies +// regardless of whether the tool is registered yet. +func (d *Dispatcher) SetToolTimeout(name string, timeout time.Duration) error { + if name == "" { + return fmt.Errorf("tool: set timeout: %w", ErrUnknownTool) + } + d.mu.Lock() + defer d.mu.Unlock() + d.timeouts[name] = timeout + return nil +} + +// Invoke executes the tool registered as name with the given args. It resolves +// the tool from the registry, validates args against the tool's Parameters JSON +// Schema, and runs Execute under the tool's deadline (per-tool override, else +// default). Any failure — unknown tool, invalid args, timeout, panic or an +// Execute error — is returned as a *ToolError wrapping the matching sentinel, +// so it is safe to feed back to the LLM. +func (d *Dispatcher) Invoke(ctx context.Context, name string, args map[string]any) (any, error) { + if name == "" { + return nil, &ToolError{Name: name, Message: "tool name is empty", cause: ErrUnknownTool} + } + cap, err := d.reg.Get(name) + if err != nil { + return nil, &ToolError{Name: name, Message: "unknown tool", cause: ErrUnknownTool} + } + if cap.Kind != registry.KindTool { + return nil, &ToolError{Name: name, Message: "not a callable tool", cause: ErrUnknownTool} + } + tool, ok := cap.Value.(sdk.Tool) + if !ok { + return nil, &ToolError{Name: name, Message: "tool value is not an sdk.Tool", cause: ErrUnknownTool} + } + + params, perr := tool.Parameters() + if perr != nil { + return nil, &ToolError{ + Name: name, + Message: "invalid tool schema", + cause: fmt.Errorf("tool: parameters %q: %w", name, perr), + } + } + if verr := validateArgs(params, args); verr != nil { + return nil, &ToolError{ + Name: name, + Message: verr.Error(), + cause: fmt.Errorf("tool: validate %q: %w", name, ErrInvalidArgs), + } + } + + invokeCtx, cancel := d.invokeCtx(ctx, name) + defer cancel() + + result, execErr := callTool(invokeCtx, tool, args) + if execErr != nil { + return nil, wrapInvokeErr(name, execErr) + } + return result, nil +} + +// invokeCtx derives the execution context for a tool, applying the tool's +// deadline when it is positive. +func (d *Dispatcher) invokeCtx(ctx context.Context, name string) (context.Context, context.CancelFunc) { + timeout := d.timeoutFor(name) + if timeout > 0 { + return context.WithTimeout(ctx, timeout) + } + return context.WithCancel(ctx) +} + +// timeoutFor returns the per-tool deadline override, or the default. +func (d *Dispatcher) timeoutFor(name string) time.Duration { + d.mu.RLock() + defer d.mu.RUnlock() + if t, ok := d.timeouts[name]; ok { + return t + } + return d.dft +} + +// wrapInvokeErr converts a raw execution error into a *ToolError, classifying a +// deadline exceeded as ErrTimeout and preserving every other cause so errors.As +// can recover the ToolError and errors.Is can reach the underlying error. +func wrapInvokeErr(name string, err error) error { + switch { + case errors.Is(err, context.DeadlineExceeded): + return &ToolError{Name: name, Message: "execution timed out", cause: ErrTimeout} + case errors.Is(err, context.Canceled): + return &ToolError{Name: name, Message: "execution canceled", cause: err} + default: + return &ToolError{Name: name, Message: err.Error(), cause: err} + } +} + +// callTool runs tool.Execute in a goroutine so a tool that ignores its context +// is still interrupted by the deadline. The goroutine recovers panics, so a +// panicking tool never crashes the process. The result channel is buffered so a +// goroutine that outlives a timeout can still deliver without leaking a sender. +func callTool(ctx context.Context, tool sdk.Tool, args any) (any, error) { + type result struct { + val any + err error + } + ch := make(chan result, 1) + go func() { + defer func() { + if r := recover(); r != nil { + ch <- result{err: fmt.Errorf("tool panicked: %v", r)} + } + }() + val, err := tool.Execute(ctx, args) + ch <- result{val: val, err: err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case r := <-ch: + return r.val, r.err + } +} + +// ToolSchemas returns the registered function-call schemas in registration +// order, ready to be advertised to a model in a model.Request. A capability that +// is not a callable sdk.Tool, or whose Parameters() reports an error, is +// skipped. A nil registry returns nil. The returned slice is owned by the caller +// and safe to mutate. +func (d *Dispatcher) ToolSchemas() []model.ToolSpec { + if d.reg == nil { + return nil + } + caps := d.reg.List(registry.KindTool) + out := make([]model.ToolSpec, 0, len(caps)) + for _, cap := range caps { + t, ok := cap.Value.(sdk.Tool) + if !ok { + continue + } + params, err := t.Parameters() + if err != nil { + continue + } + out = append(out, model.ToolSpec{ + Name: t.Name(), + Description: t.Description(), + Parameters: params, + }) + } + return out +} diff --git a/internal/runtime/tool/tool_test.go b/internal/runtime/tool/tool_test.go new file mode 100644 index 0000000..ed6d4a8 --- /dev/null +++ b/internal/runtime/tool/tool_test.go @@ -0,0 +1,375 @@ +package tool + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" +) + +// mockTool is a configurable sdk.Tool for dispatcher tests. +type mockTool struct { + name string + desc string + params map[string]any + paramsErr error + execFn func(ctx context.Context, args any) (any, error) +} + +func (m mockTool) Name() string { return m.name } +func (m mockTool) Description() string { return m.desc } +func (m mockTool) Parameters() (map[string]any, error) { return m.params, m.paramsErr } +func (m mockTool) Execute(ctx context.Context, args any) (any, error) { + if m.execFn == nil { + return args, nil + } + return m.execFn(ctx, args) +} + +// newDispatcher builds a Dispatcher backed by a fresh registry and returns the +// registry so tests can register non-tool fixtures. +func newDispatcher(t *testing.T, opts ...Option) (*Dispatcher, *registry.Registry) { + t.Helper() + reg := registry.New() + d, err := New(reg, opts...) + if err != nil { + t.Fatalf("New: %v", err) + } + return d, reg +} + +func TestNew_NilRegistry(t *testing.T) { + t.Parallel() + if _, err := New(nil); !errors.Is(err, ErrNilRegistry) { + t.Fatalf("New(nil) error = %v, want ErrNilRegistry", err) + } +} + +func TestInvoke_Success(t *testing.T) { + t.Parallel() + d, reg := newDispatcher(t) + params := map[string]any{ + "type": "object", + "properties": map[string]any{"q": map[string]any{"type": "string"}}, + "required": []any{"q"}, + } + tool := mockTool{name: "echo", params: params, execFn: func(_ context.Context, args any) (any, error) { + return args, nil + }} + if err := d.Register(tool); err != nil { + t.Fatalf("Register: %v", err) + } + + cap, err := reg.Get("echo") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cap.Kind != registry.KindTool { + t.Fatalf("kind = %q, want tool", cap.Kind) + } + + got, err := d.Invoke(context.Background(), "echo", map[string]any{"q": "hi"}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + want := map[string]any{"q": "hi"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Invoke result = %#v, want %#v", got, want) + } +} + +func TestRegister_Duplicate(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + if err := d.Register(mockTool{name: "dup"}); err != nil { + t.Fatalf("first Register: %v", err) + } + err := d.Register(mockTool{name: "dup"}) + if !errors.Is(err, registry.ErrDuplicate) { + t.Fatalf("second Register error = %v, want registry.ErrDuplicate", err) + } +} + +func TestInvoke_UnknownTool(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + _, err := d.Invoke(context.Background(), "nope", nil) + if !errors.Is(err, ErrUnknownTool) { + t.Fatalf("Invoke error = %v, want ErrUnknownTool", err) + } + var te *ToolError + if !errors.As(err, &te) { + t.Fatalf("error is %T, want *ToolError", err) + } + if te.Name != "nope" { + t.Fatalf("ToolError.Name = %q, want nope", te.Name) + } +} + +func TestInvoke_EmptyName(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + if _, err := d.Invoke(context.Background(), "", nil); !errors.Is(err, ErrUnknownTool) { + t.Fatalf("Invoke(\"\") error = %v, want ErrUnknownTool", err) + } +} + +func TestInvoke_NotATool(t *testing.T) { + t.Parallel() + d, reg := newDispatcher(t) + if err := reg.Register(registry.Capability{Name: "notatool", Kind: registry.KindSkill, Value: "skill"}); err != nil { + t.Fatalf("Register skill cap: %v", err) + } + if _, err := d.Invoke(context.Background(), "notatool", nil); !errors.Is(err, ErrUnknownTool) { + t.Fatalf("Invoke(non-tool) error = %v, want ErrUnknownTool", err) + } +} + +func TestInvoke_MissingRequired(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + params := map[string]any{"required": []any{"must"}} + if err := d.Register(mockTool{name: "req", params: params}); err != nil { + t.Fatalf("Register: %v", err) + } + _, err := d.Invoke(context.Background(), "req", map[string]any{}) + if !errors.Is(err, ErrInvalidArgs) { + t.Fatalf("Invoke error = %v, want ErrInvalidArgs", err) + } + if !strings.Contains(err.Error(), "missing required argument") { + t.Fatalf("error message = %q, want missing-required hint", err.Error()) + } +} + +func TestInvoke_TypeMismatch(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + params := map[string]any{ + "type": "object", + "properties": map[string]any{"count": map[string]any{"type": "integer"}}, + "required": []any{"count"}, + } + if err := d.Register(mockTool{name: "typed", params: params}); err != nil { + t.Fatalf("Register: %v", err) + } + _, err := d.Invoke(context.Background(), "typed", map[string]any{"count": "not-a-number"}) + if !errors.Is(err, ErrInvalidArgs) { + t.Fatalf("Invoke error = %v, want ErrInvalidArgs", err) + } +} + +func TestInvoke_IntegerAcceptsIntegralFloat(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + params := map[string]any{ + "type": "object", + "properties": map[string]any{"count": map[string]any{"type": "integer"}}, + "required": []any{"count"}, + } + if err := d.Register(mockTool{name: "count", params: params}); err != nil { + t.Fatalf("Register: %v", err) + } + // JSON decoding yields float64; an integral float must satisfy "integer". + if _, err := d.Invoke(context.Background(), "count", map[string]any{"count": float64(3)}); err != nil { + t.Fatalf("Invoke(integral float) error = %v, want nil", err) + } +} + +func TestInvoke_UnknownArgIgnored(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + params := map[string]any{ + "type": "object", + "properties": map[string]any{"a": map[string]any{"type": "string"}}, + "required": []any{"a"}, + } + if err := d.Register(mockTool{name: "loose", params: params, execFn: func(_ context.Context, args any) (any, error) { + return args, nil + }}); err != nil { + t.Fatalf("Register: %v", err) + } + // An unknown field is tolerated; the valid required arg still executes. + if _, err := d.Invoke(context.Background(), "loose", map[string]any{"a": "x", "extra": 42}); err != nil { + t.Fatalf("Invoke error = %v, want nil (unknown arg ignored)", err) + } +} + +func TestInvoke_Timeout(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t, WithDefaultTimeout(20*time.Millisecond)) + if err := d.Register(mockTool{name: "slow", execFn: func(_ context.Context, _ any) (any, error) { + time.Sleep(200 * time.Millisecond) // ignores ctx on purpose + return "done", nil + }}); err != nil { + t.Fatalf("Register: %v", err) + } + start := time.Now() + _, err := d.Invoke(context.Background(), "slow", nil) + if !errors.Is(err, ErrTimeout) { + t.Fatalf("Invoke error = %v, want ErrTimeout", err) + } + if d := time.Since(start); d > 150*time.Millisecond { + t.Fatalf("Invoke took %v, want it interrupted near the 20ms deadline", d) + } + var te *ToolError + if !errors.As(err, &te) { + t.Fatalf("error is %T, want *ToolError", err) + } +} + +func TestSetToolTimeout_DisableOverride(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t, WithDefaultTimeout(10*time.Millisecond)) + if err := d.Register(mockTool{name: "sleepy", execFn: func(_ context.Context, _ any) (any, error) { + time.Sleep(60 * time.Millisecond) + return "ok", nil + }}); err != nil { + t.Fatalf("Register: %v", err) + } + + // Without an override, the tool times out. + if _, err := d.Invoke(context.Background(), "sleepy", nil); !errors.Is(err, ErrTimeout) { + t.Fatalf("Invoke (default) error = %v, want ErrTimeout", err) + } + + // Disabling the deadline (0) lets the tool run to completion. + if err := d.SetToolTimeout("sleepy", 0); err != nil { + t.Fatalf("SetToolTimeout: %v", err) + } + got, err := d.Invoke(context.Background(), "sleepy", nil) + if err != nil { + t.Fatalf("Invoke (override 0) error = %v, want nil", err) + } + if got != "ok" { + t.Fatalf("Invoke result = %v, want ok", got) + } +} + +func TestSetToolTimeout_EmptyName(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + if err := d.SetToolTimeout("", time.Second); !errors.Is(err, ErrUnknownTool) { + t.Fatalf("SetToolTimeout(\"\") error = %v, want ErrUnknownTool", err) + } +} + +func TestInvoke_PanicRecovery(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + if err := d.Register(mockTool{name: "boom", execFn: func(context.Context, any) (any, error) { + panic("kaboom") + }}); err != nil { + t.Fatalf("Register: %v", err) + } + _, err := d.Invoke(context.Background(), "boom", nil) + var te *ToolError + if !errors.As(err, &te) { + t.Fatalf("error is %T, want *ToolError", err) + } + if !strings.Contains(err.Error(), "panicked") { + t.Fatalf("error message = %q, want panic hint", err.Error()) + } + if errors.Is(err, ErrTimeout) { + t.Fatalf("panic error wrongly classified as ErrTimeout: %v", err) + } +} + +func TestInvoke_ExecuteError(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + boom := errors.New("boom") + if err := d.Register(mockTool{name: "fail", execFn: func(context.Context, any) (any, error) { + return nil, boom + }}); err != nil { + t.Fatalf("Register: %v", err) + } + _, err := d.Invoke(context.Background(), "fail", nil) + var te *ToolError + if !errors.As(err, &te) { + t.Fatalf("error is %T, want *ToolError", err) + } + if !errors.Is(err, boom) { + t.Fatalf("Invoke error = %v, want to wrap the tool's %v", err, boom) + } +} + +func TestInvoke_ParametersError(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t) + schemaErr := errors.New("bad schema") + if err := d.Register(mockTool{name: "badschema", paramsErr: schemaErr}); err != nil { + t.Fatalf("Register: %v", err) + } + _, err := d.Invoke(context.Background(), "badschema", nil) + var te *ToolError + if !errors.As(err, &te) { + t.Fatalf("error is %T, want *ToolError", err) + } + if !strings.Contains(err.Error(), "invalid tool schema") { + t.Fatalf("error message = %q, want schema hint", err.Error()) + } +} + +func TestInvoke_ContextAwareTool(t *testing.T) { + t.Parallel() + d, _ := newDispatcher(t, WithDefaultTimeout(20*time.Millisecond)) + if err := d.Register(mockTool{name: "aware", execFn: func(ctx context.Context, _ any) (any, error) { + <-ctx.Done() // respects the deadline and returns the context error + return nil, ctx.Err() + }}); err != nil { + t.Fatalf("Register: %v", err) + } + if _, err := d.Invoke(context.Background(), "aware", nil); !errors.Is(err, ErrTimeout) { + t.Fatalf("Invoke error = %v, want ErrTimeout", err) + } +} + +func TestToolError_ErrorString(t *testing.T) { + t.Parallel() + e := &ToolError{Name: "x", Message: "bad", cause: ErrInvalidArgs} + if got := e.Error(); !strings.Contains(got, `tool "x" failed`) || !strings.Contains(got, "bad") { + t.Fatalf("Error() = %q, want name+message", got) + } + if !errors.Is(e, ErrInvalidArgs) { + t.Fatalf("errors.Is(ToolError, ErrInvalidArgs) = false, want true") + } +} + +func TestValidateArgs_Direct(t *testing.T) { + t.Parallel() + props := func(t string) map[string]any { + return map[string]any{"type": "object", "properties": map[string]any{"v": map[string]any{"type": t}}} + } + cases := []struct { + name string + schema map[string]any + args map[string]any + wantErr bool + }{ + {name: "nil schema", schema: nil, args: nil, wantErr: false}, + {name: "required missing", schema: map[string]any{"required": []any{"r"}}, args: map[string]any{}, wantErr: true}, + {name: "required present", schema: map[string]any{"required": []any{"r"}}, args: map[string]any{"r": 1}, wantErr: false}, + {name: "type string ok", schema: props("string"), args: map[string]any{"v": "x"}, wantErr: false}, + {name: "type string mismatch", schema: props("string"), args: map[string]any{"v": 1}, wantErr: true}, + {name: "type array ok", schema: props("array"), args: map[string]any{"v": []any{1}}, wantErr: false}, + {name: "type object ok", schema: props("object"), args: map[string]any{"v": map[string]any{}}, wantErr: false}, + {name: "type boolean ok", schema: props("boolean"), args: map[string]any{"v": true}, wantErr: false}, + {name: "type number ok", schema: props("number"), args: map[string]any{"v": 2.5}, wantErr: false}, + {name: "integer as number ok", schema: props("number"), args: map[string]any{"v": 2}, wantErr: false}, + {name: "unknown field ignored", schema: props("string"), args: map[string]any{"v": "x", "u": 9}, wantErr: false}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateArgs(tt.schema, tt.args) + if (err != nil) != tt.wantErr { + t.Fatalf("validateArgs(%v, %v) err = %v, wantErr=%v", tt.schema, tt.args, err, tt.wantErr) + } + }) + } +} diff --git a/internal/runtime/tool/validate.go b/internal/runtime/tool/validate.go new file mode 100644 index 0000000..c099fe0 --- /dev/null +++ b/internal/runtime/tool/validate.go @@ -0,0 +1,185 @@ +package tool + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "strconv" +) + +// 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. +func validateArgs(schema map[string]any, args map[string]any) error { + if schema == nil { + return nil + } + 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) + } + } + } + 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 + } + } + 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 + } + 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 + } + } + return fmt.Errorf("argument %q has type %s, want one of %v", name, typeName(val), t) + default: + return nil + } +} + +// checkOne validates val against a single JSON Schema type name. +func checkOne(t, name string, val any) error { + if matchesType(t, val) { + return nil + } + return fmt.Errorf("argument %q has type %s, want %s", name, typeName(val), t) +} + +// 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 + } +} + +// 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 + } +} + +// 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 + } +} + +// 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)) + } +} + +// kindName maps a reflected kind to a readable label. +func kindName(v any) string { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return "invalid" + } + return rv.Kind().String() +}