0147a79d18
Phase 9a (ADR-0014): Registry.RegisterResolver for dynamic tiers; DefineTool[Args] typed tools; Usage cache/reasoning detail fields wired through anthropic/openai/google; WithPromptCaching (Anthropic cache_control); agent supervision hooks (WithMaxStepsFunc, WithSteer, WithCompactor, WithToolErrorLimits + ErrToolLoop); health Bench/Unbench/Snapshot; ChainConfig.Observer failover events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
5.6 KiB
Go
200 lines
5.6 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Tool is a callable capability exposed to a model: a name, a description,
|
|
// JSON-Schema parameters, and a Go handler. Providers map this one canonical
|
|
// shape onto their native function-calling formats.
|
|
type Tool struct {
|
|
Name string
|
|
Description string
|
|
|
|
// Parameters is a JSON Schema object describing the tool's arguments.
|
|
// nil means the tool takes no arguments.
|
|
Parameters json.RawMessage
|
|
|
|
// Handler executes the tool. args is the raw JSON arguments object the
|
|
// model supplied. The returned value is JSON-encoded into the ToolResult.
|
|
Handler func(ctx context.Context, args json.RawMessage) (any, error)
|
|
}
|
|
|
|
// DefineTool builds a typed tool: the parameter schema is derived from
|
|
// Args (see SchemaFor) and the raw JSON arguments are unmarshaled into an
|
|
// Args value before the handler runs.
|
|
//
|
|
// weather := llm.DefineTool("get_weather", "Current weather for a city",
|
|
// func(ctx context.Context, args struct {
|
|
// City string `json:"city" description:"city name"`
|
|
// }) (any, error) {
|
|
// return lookup(args.City)
|
|
// })
|
|
//
|
|
// Schema derivation failures panic: tools are defined at startup and an
|
|
// unschematizable Args type is a programming error worth failing loudly on.
|
|
func DefineTool[Args any](name, description string, fn func(ctx context.Context, args Args) (any, error)) Tool {
|
|
schema, err := SchemaFor[Args]()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("llm: DefineTool(%q): %v", name, err))
|
|
}
|
|
return Tool{
|
|
Name: name,
|
|
Description: description,
|
|
Parameters: schema,
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var args Args
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments for %s: %w", name, err)
|
|
}
|
|
}
|
|
return fn(ctx, args)
|
|
},
|
|
}
|
|
}
|
|
|
|
// ToolCall is a model's request to invoke a tool.
|
|
type ToolCall struct {
|
|
// ID is the provider-assigned call id; majordomo synthesizes one for
|
|
// providers that do not supply ids. ToolResult.ID must echo it.
|
|
ID string
|
|
Name string
|
|
|
|
// Arguments is the raw JSON arguments object.
|
|
Arguments json.RawMessage
|
|
}
|
|
|
|
// ToolResult is the outcome of executing a ToolCall, sent back to the model.
|
|
type ToolResult struct {
|
|
// ID matches the originating ToolCall.ID.
|
|
ID string
|
|
Name string
|
|
|
|
// Content is the result serialized as text (JSON for structured values).
|
|
Content string
|
|
|
|
// IsError marks the result as a failure; the content then describes the
|
|
// error so the model can react (retry, apologize, try another tool).
|
|
IsError bool
|
|
}
|
|
|
|
// Toolbox is a named, ordered set of tools.
|
|
//
|
|
// Why: agents compose their available tools from several sources (multiple
|
|
// toolboxes plus skills); a small named container with duplicate detection
|
|
// keeps that merge explicit and debuggable.
|
|
type Toolbox struct {
|
|
name string
|
|
order []string
|
|
tools map[string]Tool
|
|
}
|
|
|
|
// NewToolbox creates a toolbox with the given name and initial tools.
|
|
// Duplicate tool names panic: toolboxes are assembled at startup, and a
|
|
// silently shadowed tool is a programming error worth failing loudly on.
|
|
func NewToolbox(name string, tools ...Tool) *Toolbox {
|
|
b := &Toolbox{name: name, tools: make(map[string]Tool, len(tools))}
|
|
for _, t := range tools {
|
|
if err := b.Add(t); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Name returns the toolbox name.
|
|
func (b *Toolbox) Name() string { return b.name }
|
|
|
|
// Add registers a tool, rejecting empty or duplicate names.
|
|
func (b *Toolbox) Add(t Tool) error {
|
|
if t.Name == "" {
|
|
return fmt.Errorf("toolbox %q: tool with empty name", b.name)
|
|
}
|
|
if _, exists := b.tools[t.Name]; exists {
|
|
return fmt.Errorf("toolbox %q: duplicate tool %q", b.name, t.Name)
|
|
}
|
|
b.tools[t.Name] = t
|
|
b.order = append(b.order, t.Name)
|
|
return nil
|
|
}
|
|
|
|
// Tools returns the tools in insertion order.
|
|
func (b *Toolbox) Tools() []Tool {
|
|
out := make([]Tool, 0, len(b.order))
|
|
for _, name := range b.order {
|
|
out = append(out, b.tools[name])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Get returns the named tool.
|
|
func (b *Toolbox) Get(name string) (Tool, bool) {
|
|
t, ok := b.tools[name]
|
|
return t, ok
|
|
}
|
|
|
|
// Execute runs the named tool for the given call and packages the outcome as
|
|
// a ToolResult. It never panics and never returns an error: handler errors
|
|
// and panics become IsError results so an agent loop can always continue.
|
|
func (b *Toolbox) Execute(ctx context.Context, call ToolCall) ToolResult {
|
|
t, ok := b.tools[call.Name]
|
|
if !ok {
|
|
return ToolResult{
|
|
ID: call.ID, Name: call.Name,
|
|
Content: fmt.Sprintf("unknown tool %q", call.Name),
|
|
IsError: true,
|
|
}
|
|
}
|
|
return ExecuteTool(ctx, t, call)
|
|
}
|
|
|
|
// ExecuteTool runs a single tool for the given call, recovering panics and
|
|
// converting errors into IsError results.
|
|
func ExecuteTool(ctx context.Context, t Tool, call ToolCall) (res ToolResult) {
|
|
res = ToolResult{ID: call.ID, Name: call.Name}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
res.Content = fmt.Sprintf("tool %q panicked: %v", call.Name, r)
|
|
res.IsError = true
|
|
}
|
|
}()
|
|
|
|
if t.Handler == nil {
|
|
res.Content = fmt.Sprintf("tool %q has no handler", call.Name)
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
|
|
args := call.Arguments
|
|
if len(args) == 0 {
|
|
args = json.RawMessage("{}")
|
|
}
|
|
out, err := t.Handler(ctx, args)
|
|
if err != nil {
|
|
res.Content = err.Error()
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
|
|
switch v := out.(type) {
|
|
case nil:
|
|
res.Content = "null"
|
|
case string:
|
|
res.Content = v
|
|
case json.RawMessage:
|
|
res.Content = string(v)
|
|
default:
|
|
enc, err := json.Marshal(v)
|
|
if err != nil {
|
|
res.Content = fmt.Sprintf("tool %q returned unencodable value: %v", call.Name, err)
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
res.Content = string(enc)
|
|
}
|
|
return res
|
|
}
|