// 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/llm" "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) } // Output is what a tool call produced. Text is always set: it is what the // transcript shows, the log measures and — for most tools — what the model // reads. Blocks is set only when text cannot carry the result, an image above // all; then the model is sent the blocks and Text is their caption. type Output struct { Text string Blocks []llm.ContentBlock } // BlockTool is a Tool whose result may be content blocks rather than a string. // The registry prefers ExecuteBlocks when a tool offers it; everything else // keeps returning text and never knows the difference. type BlockTool interface { Tool ExecuteBlocks(ctx context.Context, raw json.RawMessage) (Output, 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) (Output, error) { t, ok := r.Get(name) if !ok { err := fmt.Errorf("unknown tool %q", name) r.log.ToolCall(name, 0, err, nil) return Output{}, err } start := time.Now() var out Output var err error if bt, ok := t.(BlockTool); ok { out, err = bt.ExecuteBlocks(ctx, raw) } else { out.Text, err = t.Execute(ctx, raw) } extra := map[string]any{"output_bytes": len(out.Text)} if len(out.Blocks) > 0 { extra["blocks"] = len(out.Blocks) } r.log.ToolCall(name, time.Since(start), err, extra) return out, err }