feat(tool): Dispatcher.ToolSchemas() 暴露工具 schema(供 agent 组装请求)
This commit is contained in:
1 parent
3da189e856
commit
b798e129c1
3 files changed
+824
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user