410 lines
13 KiB
Go
410 lines
13 KiB
Go
// 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
|
|
// 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{
|
|
Name: cfg.Name,
|
|
Description: cfg.Description,
|
|
Command: cfg.Command,
|
|
Arguments: cfg.Arguments,
|
|
Parameters: cfg.Parameters,
|
|
tmpl: templates,
|
|
timeout: cfg.Timeout,
|
|
}, 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
|
|
}
|