Files
odidere/internal/tool/tool.go

236 lines
6.4 KiB
Go
Raw Normal View History

2026-02-13 15:02:07 +00:00
// Package tool provides a registry for external tools that can be invoked by
// LLMs.
//
// The package bridges YAML configuration to exec.CommandContext, allowing
// tools to be defined declaratively without writing Go code. Each tool
// specifies a command, argument templates using Go's text/template syntax,
// JSON Schema parameters for LLM input, and an optional execution timeout.
//
// The registry validates all tool definitions at construction time,
// failing fast on configuration errors. At execution time, it expands
// argument templates with LLM-provided JSON, runs the subprocess, and
// returns stdout.
package tool
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"text/template"
"time"
"github.com/sashabaranov/go-openai"
)
// fn provides template functions available to argument templates.
var fn = template.FuncMap{
"json": func(v any) string {
b, _ := json.Marshal(v)
return string(b)
},
}
// Tool represents an external tool that can be invoked by LLMs.
// Tools are executed as subprocesses with templated arguments.
type Tool struct {
// Name uniquely identifies the tool within a registry.
Name string `yaml:"name"`
// Description explains the tool's purpose for the LLM.
Description string `yaml:"description"`
// Command is the executable path or name.
Command string `yaml:"command"`
// Arguments are Go templates expanded with LLM-provided parameters.
// Empty results after expansion are filtered out.
Arguments []string `yaml:"arguments"`
// Parameters is a JSON Schema describing expected input from the LLM.
Parameters map[string]any `yaml:"parameters"`
// Timeout limits execution time (e.g., "30s", "5m").
// Empty means no timeout.
Timeout string `yaml:"timeout"`
// timeout is the parsed duration, set during registry construction.
timeout time.Duration `yaml:"-"`
}
// OpenAI converts the tool to an OpenAI function definition for API calls.
func (t Tool) OpenAI() openai.Tool {
return openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
}
}
// ParseArguments expands argument templates with the provided JSON data.
// The args parameter should be a JSON object string; empty string or "{}"
// results in an empty data map. Templates producing empty strings are
// filtered from the result, allowing conditional arguments.
func (t Tool) ParseArguments(args string) ([]string, error) {
var data = map[string]any{}
if args != "" && args != "{}" {
if err := json.Unmarshal([]byte(args), &data); err != nil {
return nil, fmt.Errorf(
"invalid arguments JSON: %w", err,
)
}
}
var result []string
for _, v := range t.Arguments {
tmpl, err := template.New("").Funcs(fn).Parse(v)
if err != nil {
return nil, fmt.Errorf(
"invalid template %q: %w", v, err,
)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf(
"execute template %q: %w", v, err,
)
}
// Filter out empty strings (unused conditional arguments).
if s := buf.String(); s != "" {
result = append(result, s)
}
}
return result, nil
}
// Validate checks that the tool definition is complete and valid.
// It verifies required fields are present, the timeout (if specified)
// is parseable, and all argument templates are syntactically valid.
func (t Tool) Validate() error {
if t.Name == "" {
return fmt.Errorf("missing name")
}
if t.Description == "" {
return fmt.Errorf("missing description")
}
if t.Command == "" {
return fmt.Errorf("missing command")
}
if t.Timeout != "" {
if _, err := time.ParseDuration(t.Timeout); err != nil {
return fmt.Errorf("invalid timeout: %v", err)
}
}
for _, arg := range t.Arguments {
if _, err := template.New("").Funcs(fn).Parse(arg); err != nil {
return fmt.Errorf("invalid argument template")
}
}
return nil
}
// Registry holds tools indexed by name and handles their execution.
// It validates all tools at construction time to fail fast on
// configuration errors.
type Registry struct {
tools map[string]*Tool
}
// NewRegistry creates a registry from the provided tool definitions.
// Returns an error if any tool fails validation or if duplicate names exist.
func NewRegistry(tools []Tool) (*Registry, error) {
var r = &Registry{
tools: make(map[string]*Tool),
}
for _, t := range tools {
if err := t.Validate(); err != nil {
return nil, fmt.Errorf("invalid tool: %v", err)
}
if t.Timeout != "" {
d, err := time.ParseDuration(t.Timeout)
if err != nil {
return nil, fmt.Errorf(
"parse timeout: %v", err,
)
}
t.timeout = d
}
if _, exists := r.tools[t.Name]; exists {
return nil, fmt.Errorf(
"duplicate tool name: %s", t.Name,
)
}
r.tools[t.Name] = &t
}
return r, nil
}
// Get returns a tool by name and a boolean indicating if it was found.
func (r *Registry) Get(name string) (*Tool, bool) {
tool, ok := r.tools[name]
return tool, ok
}
// List returns all registered tool names in arbitrary order.
func (r *Registry) List() []string {
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
return names
}
// Execute runs a tool by name with the provided JSON arguments.
// It expands argument templates, executes the command as a subprocess,
// and returns stdout on success. The context can be used for cancellation;
// tool-specific timeouts are applied on top of any context deadline.
func (r *Registry) Execute(
ctx context.Context, name string, args string,
) (string, error) {
tool, ok := r.tools[name]
if !ok {
return "", fmt.Errorf("unknown tool: %s", name)
}
// Evaluate argument templates.
cmdArgs, err := tool.ParseArguments(args)
if err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
// If defined, use the timeout.
if tool.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, tool.timeout)
defer cancel()
}
// Setup and run the command.
var (
stdout, stderr bytes.Buffer
cmd = exec.CommandContext(
ctx, tool.Command, cmdArgs...,
)
)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded && tool.timeout > 0 {
return "", fmt.Errorf(
"tool %s timed out after %v",
name, tool.timeout,
)
}
return "", fmt.Errorf(
"tool %s: %w\nstderr: %s",
name, err, stderr.String(),
)
}
return stdout.String(), nil
}