Files
odidere/internal/tool/tool.go
T

414 lines
13 KiB
Go
Raw Normal View History

2026-09-25 13:45:15 +00:00
// Package tool provides configured subprocess tools.
package tool
import (
"bytes"
"context"
jsonv2 "encoding/json/v2"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"text/template"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
"code.chimeric.al/chimerical/odidere/internal/llm"
)
// errToolTimeout marks a timeout caused by the tool's configured limit.
var errToolTimeout = errors.New("tool timeout")
// fn provides template functions available to argument templates.
var fn = template.FuncMap{
"json": func(v any) string {
b, _ := jsonv2.Marshal(v)
return string(b)
},
}
// Tool represents an external tool that can be invoked by LLMs.
// Tools are executed as subprocesses with templated arguments,
// and described to the LLM via their JSON Schema parameters.
type Tool struct {
// Name uniquely identifies the tool within a registry.
Name string
// Description explains the tool's purpose for the LLM.
Description string
// Command is the executable path or name.
Command string
// Arguments contains the templates used to construct the subprocess
// arguments. See config.ToolConfig.Arguments for examples, behavior, and
// limitations.
Arguments []string
// tmpl holds Arguments compiled to executable templates, one per
// element, in the same order. Only NewTool populates it; on a Tool
// built any other way it is nil, and ParseArguments returns no
// arguments rather than an error.
tmpl []*template.Template
// Parameters describes the tool's accepted input to the LLM. It is not
// used to validate a tool call before its arguments are rendered. See
// config.ToolConfig.Parameters.
Parameters map[string]any
2026-10-06 12:08:12 +00:00
// RequiresApproval reports whether calls to this tool must be approved
// by a user before the subprocess launches.
RequiresApproval bool
2026-09-25 13:45:15 +00:00
// timeout is the parsed execution duration limit.
timeout time.Duration
}
// NewTool creates a Tool from a ToolConfig. It compiles the argument
// templates so that a template syntax error is reported here, at
// construction, rather than on the tool's first execution.
func NewTool(cfg config.ToolConfig) (*Tool, error) {
templates := make([]*template.Template, len(cfg.Arguments))
for i, arg := range cfg.Arguments {
tmpl, err := template.New("").Funcs(fn).Parse(arg)
if err != nil {
return nil, fmt.Errorf(
"invalid template %q: %w", arg, err,
)
}
templates[i] = tmpl
}
return &Tool{
2026-10-06 12:08:12 +00:00
Name: cfg.Name,
Description: cfg.Description,
Command: cfg.Command,
Arguments: cfg.Arguments,
Parameters: cfg.Parameters,
RequiresApproval: cfg.RequiresApproval,
tmpl: templates,
timeout: cfg.Timeout,
2026-09-25 13:45:15 +00:00
}, nil
}
// ParseArguments converts tool-call arguments into program arguments. The
// input is the JSON object supplied by the LLM when it calls the tool. The
// returned slice is passed to the external program after the tool's Command;
// it does not include the command name itself.
//
// An empty string or JSON null is treated as an empty object. ParseArguments
// returns an error for any other input that is not a single valid JSON
// object, or if an argument template cannot be evaluated. A template that
// fails to parse is an error of NewTool, reported when the tool is
// constructed.
//
// JSON numbers are decoded as float64, which is encoding/json/v2's default
// for values decoded into an interface. Integers outside the IEEE 754 safe
// range (±2^53) do not survive this round trip: they are rounded and
// rendered in floating-point form (9007199254740993 becomes
// 9.007199254740992e+15). Tool parameters whose values may exceed that range
// should be declared as strings in the tool's Parameters.
//
// See config.ToolConfig.Arguments for template behavior and limitations.
func (t *Tool) ParseArguments(args string) ([]string, error) {
var data = map[string]any{}
if args != "" && args != "{}" {
if err := jsonv2.Unmarshal([]byte(args), &data); err != nil {
return nil, fmt.Errorf(
"invalid arguments JSON: %w", err,
)
}
}
var result []string
for i, tmpl := range t.tmpl {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf(
"execute template %q: %w", t.Arguments[i], err,
)
}
// Filter out empty strings (unused conditional arguments).
if s := buf.String(); s != "" {
result = append(result, s)
}
}
return result, nil
}
// ToAPI converts the tool definition into the API format suitable for
// inclusion in a ChatRequest.
func (t *Tool) ToAPI() llm.APITool {
return llm.APITool{
Type: llm.ToolTypeFunction,
Function: &llm.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
}
}
// Registry holds tools, indexed by name, and handles their execution.
type Registry struct {
tools map[string]*Tool
}
// NewRegistry creates a registry from the provided tool definitions.
// It returns an error if a tool cannot be constructed or a name is duplicated.
func NewRegistry(tools []config.ToolConfig) (*Registry, error) {
var r = &Registry{
tools: make(map[string]*Tool),
}
for _, tc := range tools {
t, err := NewTool(tc)
if err != nil {
return nil, fmt.Errorf(
"invalid tool %q: %w", tc.Name, err,
)
}
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
}
// ToAPI converts all registered tools into API format suitable for
// inclusion in a ChatRequest.
func (r *Registry) ToAPI() []llm.APITool {
if r == nil {
return nil
}
tools := make([]llm.APITool, 0, len(r.tools))
for _, t := range r.tools {
tools = append(tools, t.ToAPI())
}
return tools
}
// binaryOutputPrefix is the message shown when tool output is detected as binary.
const binaryOutputPrefix = "error: binary output detected: "
// ToolResult holds the structured output from a tool execution.
type ToolResult struct {
// FinishedAt is the wall-clock time at which the command stopped
// running, including the wait for its output pipes to drain after it
// exits.
FinishedAt time.Time
// ProcessState describes how the process ended: its exit code, or the
// signal that killed it.
ProcessState *os.ProcessState
// StartedAt is the wall-clock time at which the command began running.
StartedAt time.Time
// Stderr holds the bytes the command wrote to standard error.
Stderr []byte
// Stdout holds the bytes the command wrote to standard output.
Stdout []byte
}
// FormatResult formats the tool result into separate sections for stdout and
// stderr, and other metadata.
//
// Output sections appear even when empty. A missing trailing newline is
// added after captured output. The exit section contains the process exit
// code; if it is -1, the section also includes the process-state
// description.
//
// A section contains the captured bytes only when FormatResult classifies
// them as text; otherwise, it contains a single notice line naming the
// detected type. Classification uses a MIME type, inferred from the
// stream's leading bytes: any type beginning with "text/" counts as text.
// This classification can be wrong in both directions; see
// [http.DetectContentType] for the rules it follows and
// https://mimesniff.spec.whatwg.org/ for the algorithm it implements.
func (r *ToolResult) FormatResult() string {
var buf strings.Builder
fmt.Fprintln(&buf, "▸ stdout:")
if len(r.Stdout) > 0 {
ctype := http.DetectContentType(r.Stdout)
if !strings.HasPrefix(ctype, "text/") {
fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype)
} else {
buf.Write(r.Stdout)
if r.Stdout[len(r.Stdout)-1] != '\n' {
fmt.Fprint(&buf, "\n")
}
}
}
fmt.Fprintln(&buf, "▸ stderr:")
if len(r.Stderr) > 0 {
ctype := http.DetectContentType(r.Stderr)
if !strings.HasPrefix(ctype, "text/") {
fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype)
} else {
buf.Write(r.Stderr)
if r.Stderr[len(r.Stderr)-1] != '\n' {
fmt.Fprint(&buf, "\n")
}
}
}
fmt.Fprintf(&buf, "┗ start: %s\n", r.StartedAt.Format(time.RFC3339Nano))
fmt.Fprintf(&buf, "┗ end: %s\n", r.FinishedAt.Format(time.RFC3339Nano))
fmt.Fprintf(&buf, "┗ duration: %s\n",
r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond))
exitCode := r.ProcessState.ExitCode()
fmt.Fprintf(&buf, "┗ exit: %d", exitCode)
// Include signal info when the process was killed by a signal.
if exitCode == -1 {
fmt.Fprintf(&buf, " (%s)", r.ProcessState.String())
}
fmt.Fprintln(&buf)
return buf.String()
}
// Execute runs a tool by name with the provided JSON arguments.
// It expands argument templates, executes the command as a subprocess, and
// returns a structured result with stdout, stderr, and exit code.
// The context can be used for cancellation; tool-specific timeouts are
// applied on top of any context deadline.
//
// On timeout, context cancellation, or [exec.ErrWaitDelay], Execute may
// return both a non-nil ToolResult with partial output and a non-nil error.
// The ToolResult is nil only for start failures where the process never ran.
func (r *Registry) Execute(
ctx context.Context, name string, args string,
) (*ToolResult, error) {
tool, ok := r.tools[name]
if !ok {
return nil, fmt.Errorf("unknown tool: %q", name)
}
// Evaluate argument templates.
cmdArgs, err := tool.ParseArguments(args)
if err != nil {
return nil, fmt.Errorf(
"tool %q: parse arguments: %w", name, err,
)
}
// If defined, use the timeout. WithTimeoutCause attaches errToolTimeout
// as the context cause so we can distinguish it from a parent deadline.
if tool.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeoutCause(
ctx, tool.timeout, errToolTimeout,
)
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
// Bound pipe draining after cancellation or process exit; descendants
// may retain output descriptors. This does not terminate descendants.
// https://pkg.go.dev/os/exec#Cmd.WaitDelay
cmd.WaitDelay = time.Second
// After cmd.Run() returns, the possible error cases are:
//
// 1. Context error (ctx.Err() != nil): the context was cancelled or
// timed out. If the process had started, it was killed and
// ProcessState is set. If it had not, ProcessState is nil.
//
// 2. Start failure (cmd.ProcessState == nil): the executable was not
// found, the fork failed, or the context was already done before
// Start. No stdout/stderr to capture.
//
// 3. Process exited with error (err is *exec.ExitError): the process
// ran but exited with a non-zero status or was killed by a signal.
// ProcessState is set and contains the exit code.
//
// In all cases where the process started, ProcessState is guaranteed
// to be set because Start() succeeded.
started := time.Now()
err = cmd.Run()
finished := time.Now()
if err != nil {
// Build the result only if the process started. A non-nil result
// always has a non-nil ProcessState.
var result *ToolResult
if cmd.ProcessState != nil {
result = &ToolResult{
FinishedAt: finished,
ProcessState: cmd.ProcessState,
StartedAt: started,
Stderr: stderr.Bytes(),
Stdout: stdout.Bytes(),
}
}
switch {
// Context was cancelled or timed out. If the process started,
// result contains partial output. If it never started, result
// is nil. Distinguish tool timeout from other context errors
// so we can return an appropriate message.
case ctx.Err() != nil:
if errors.Is(context.Cause(ctx), errToolTimeout) {
return result, fmt.Errorf(
"tool %q: timed out after %v: %w",
name, tool.timeout, errToolTimeout,
)
}
return result, fmt.Errorf("tool %q: %w", name, ctx.Err())
// Process never started, and it wasn't a context error.
// The error is the raw start failure (e.g., *PathError).
case result == nil:
return nil, fmt.Errorf("tool %q: %w", name, err)
// Output may be incomplete even when the process exited
// successfully, if the output pipe drained slowly.
case errors.Is(err, exec.ErrWaitDelay):
return result, fmt.Errorf(
"tool %q: output incomplete: %w", name, err,
)
// Process ran and exited with non-zero status or was killed
// by a signal. ProcessState is set; return it with output.
default:
if _, ok := errors.AsType[*exec.ExitError](err); !ok {
return nil, fmt.Errorf("tool %q: %w", name, err)
}
return result, nil
}
}
// No error. Process exited with status 0.
return &ToolResult{
FinishedAt: finished,
ProcessState: cmd.ProcessState,
StartedAt: started,
Stderr: stderr.Bytes(),
Stdout: stdout.Bytes(),
}, nil
}