Vectrum
Sign In
158 lines · 4.6 KB · Go
// 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
}