Add tool pkg
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user