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
|
||||
}
|
||||
Reference in new issue
Block a user