// Package tools defines the tool interface the agent loop dispatches against.
//
// Tools return (string, error). The error is text for the model to read, not an
// exception for Codey to swallow: a failed edit is a normal event in the
// transcript.
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"codey/internal/metrics"
"github.com/invopop/jsonschema"
)
// Tool is everything the loop needs to advertise and run one tool.
type Tool interface {
Name() string
Description() string
InputSchema() json.RawMessage
Execute(ctx context.Context, raw json.RawMessage) (string, error)
}
// typed adapts a func taking a concrete argument struct to the Tool interface,
// so individual tools never touch map[string]any.
type typed[T any] struct {
name, desc string
schema json.RawMessage
fn func(context.Context, T) (string, error)
}
func (t typed[T]) Name() string { return t.name }
func (t typed[T]) Description() string { return t.desc }
func (t typed[T]) InputSchema() json.RawMessage { return t.schema }
func (t typed[T]) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
if len(raw) == 0 {
raw = json.RawMessage("{}")
}
var in T
if err := json.Unmarshal(raw, &in); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
return t.fn(ctx, in)
}
// New builds a Tool whose input schema is derived from T's struct tags. It
// panics if T cannot be reflected, which is a programming error at wiring time.
func New[T any](name, desc string, fn func(context.Context, T) (string, error)) Tool {
return typed[T]{name: name, desc: desc, schema: SchemaFor[T](), fn: fn}
}
// reflector inlines everything: the Messages API wants a self-contained schema
// object, not one stitched together from $defs.
var reflector = &jsonschema.Reflector{
ExpandedStruct: true,
DoNotReference: true,
}
// SchemaFor derives a JSON Schema from T. Fields tagged omitempty are optional;
// everything else is required. Descriptions come from `jsonschema_description:"..."`.
func SchemaFor[T any]() json.RawMessage {
var zero T
b, err := json.Marshal(reflector.Reflect(zero))
if err != nil {
panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
}
// Metadata the API has no use for, and which would only add bytes to the
// cached prompt prefix.
delete(m, "$schema")
delete(m, "$id")
delete(m, "version")
if _, ok := m["type"]; !ok {
m["type"] = "object"
}
if _, ok := m["properties"]; !ok {
m["properties"] = map[string]any{}
}
out, err := json.Marshal(m)
if err != nil {
panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
}
return out
}
// Registry holds the tools available to one session and is the single place
// tool dispatch gets logged, so no individual tool has to remember to do it.
type Registry struct {
mu sync.RWMutex
byName map[string]Tool
order []string
log *metrics.Logger
}
// NewRegistry returns an empty registry. log may be nil.
func NewRegistry(log *metrics.Logger) *Registry {
return &Registry{byName: map[string]Tool{}, log: log}
}
// Register adds a tool, panicking on a duplicate name.
func (r *Registry) Register(ts ...Tool) *Registry {
r.mu.Lock()
defer r.mu.Unlock()
for _, t := range ts {
if _, dup := r.byName[t.Name()]; dup {
panic(fmt.Sprintf("tools: duplicate registration of %q", t.Name()))
}
r.byName[t.Name()] = t
r.order = append(r.order, t.Name())
}
return r
}
// Get returns the named tool.
func (r *Registry) Get(name string) (Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.byName[name]
return t, ok
}
// List returns the tools in a stable order, so the tool-definition block of the
// prompt stays byte-identical between turns and keeps the cache prefix warm.
func (r *Registry) List() []Tool {
r.mu.RLock()
defer r.mu.RUnlock()
names := append([]string(nil), r.order...)
sort.Strings(names)
out := make([]Tool, 0, len(names))
for _, n := range names {
out = append(out, r.byName[n])
}
return out
}
// Execute dispatches by name and logs the outcome. An unknown tool is reported
// back to the model as an error rather than failing the turn.
func (r *Registry) Execute(ctx context.Context, name string, raw json.RawMessage) (string, error) {
t, ok := r.Get(name)
if !ok {
err := fmt.Errorf("unknown tool %q", name)
r.log.ToolCall(name, 0, err, nil)
return "", err
}
start := time.Now()
out, err := t.Execute(ctx, raw)
r.log.ToolCall(name, time.Since(start), err, map[string]any{"output_bytes": len(out)})
return out, err
}