Files
majordomo/llm/tool.go
T
steve 0147a79d18
CI / Tidy (push) Successful in 9m31s
CI / Build & Test (push) Successful in 10m13s
feat: conversion-driven extensions — resolvers, DefineTool, hooks, ops controls
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>
2026-06-10 13:30:06 +02:00

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
}