Add tool pkg

This commit is contained in:
dwrz
2026-09-25 13:45:15 +00:00
parent 7224fea99f
commit 7ac72cef6a
2 changed files with 952 additions and 0 deletions
+409
View File
@@ -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
}
+543
View File
@@ -0,0 +1,543 @@
package tool
import (
"bytes"
"context"
"os/exec"
"strings"
"testing"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
)
func TestTool_ParseArguments(t *testing.T) {
tool, err := NewTool(config.ToolConfig{
Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"},
})
if err != nil {
t.Fatalf("NewTool() error = %v", err)
}
tests := []struct {
name string
args string
expected []string
wantErr bool
errContains string
}{
{
name: "basic",
args: `{"name": "test"}`,
expected: []string{"--name test"},
},
{
// Documented limitation (see the ParseArguments doc): numbers
// decode as float64, so integers past 2^53 render in
// floating-point form.
name: "large integer (float64 limitation)",
args: `{"name": 9007199254740993}`,
expected: []string{"--name 9.007199254740992e+15"},
},
{
name: "with flag",
args: `{"name": "test", "flag": true}`,
expected: []string{"--name test", "--enabled"},
},
{
name: "null",
args: "null",
expected: []string{"--name <no value>"},
},
{
name: "invalid JSON",
args: `{invalid`,
wantErr: true,
errContains: "invalid arguments JSON",
},
{
name: "non-object JSON",
args: `[1, 2, 3]`,
wantErr: true,
errContains: "invalid arguments JSON",
},
{
name: "trailing data",
args: `{"name": "x"} trailing`,
wantErr: true,
errContains: "invalid arguments JSON",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tool.ParseArguments(tt.args)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseArguments() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("err = %q, want to contain %q", err, tt.errContains)
}
return
}
if len(got) != len(tt.expected) {
t.Fatalf("expected len %d, got %d", len(tt.expected), len(got))
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("expected %q, got %q", tt.expected[i], got[i])
}
}
})
}
}
func TestTool_ParseArguments_ExecutionError(t *testing.T) {
// "{{printf}}" parses cleanly but fails at execution: printf
// requires at least one argument.
tool, err := NewTool(config.ToolConfig{
Arguments: []string{"{{printf}}"},
})
if err != nil {
t.Fatalf("NewTool() error = %v", err)
}
_, err = tool.ParseArguments(`{}`)
if err == nil {
t.Fatal("ParseArguments() error = nil, want execution error")
}
if !strings.Contains(err.Error(), "execute template") {
t.Errorf("err = %v, want execute template error", err)
}
}
func TestNewTool(t *testing.T) {
tool, err := NewTool(config.ToolConfig{
Name: "test",
Description: "desc",
Command: "ls",
Timeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("NewTool() error = %v", err)
}
if tool.Name != "test" {
t.Errorf("Name = %q, want %q", tool.Name, "test")
}
if tool.timeout != 10*time.Second {
t.Errorf("timeout = %v, want 10s", tool.timeout)
}
}
func TestNewTool_InvalidTemplate(t *testing.T) {
tests := []struct {
name string
arg string
}{
{name: "unclosed action", arg: "{{.name"},
{name: "undefined function", arg: "{{nosuch .name}}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewTool(config.ToolConfig{
Name: "t", Arguments: []string{tt.arg},
})
if err == nil {
t.Fatal("NewTool() error = nil, want template error")
}
if !strings.Contains(err.Error(), "invalid template") {
t.Errorf("err = %v, want invalid template error", err)
}
})
}
}
func TestRegistry_NewRegistry(t *testing.T) {
tests := []struct {
name string
tools []config.ToolConfig
wantErr bool
}{
{
name: "valid",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t2", Description: "d2", Command: "c2"},
},
wantErr: false,
},
{
name: "duplicate",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t1", Description: "d2", Command: "c2"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewRegistry(tt.tools)
if (err != nil) != tt.wantErr {
t.Errorf("NewRegistry() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRegistry_ToAPI_Nil(t *testing.T) {
var reg *Registry
got := reg.ToAPI()
if got != nil {
t.Errorf("Registry.ToAPI(nil) = %v, want nil", got)
}
}
func TestRegistry_ToAPI_Empty(t *testing.T) {
reg, err := NewRegistry(nil)
if err != nil {
t.Fatalf("NewRegistry(nil) error = %v", err)
}
got := reg.ToAPI()
if len(got) != 0 {
t.Errorf("Registry.ToAPI(empty) len = %d, want 0", len(got))
}
}
func TestRegistry_ToAPI_Single(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{
Name: "weather",
Description: "Get the weather",
Parameters: map[string]any{"type": "object"},
},
})
if err != nil {
t.Fatalf("NewRegistry error = %v", err)
}
got := reg.ToAPI()
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].Type != "function" {
t.Errorf("type = %q, want %q", got[0].Type, "function")
}
if got[0].Function.Name != "weather" {
t.Errorf("name = %q, want %q", got[0].Function.Name, "weather")
}
if got[0].Function.Description != "Get the weather" {
t.Errorf("description = %q, want %q", got[0].Function.Description, "Get the weather")
}
}
func TestTool_ToAPI(t *testing.T) {
tool := &Tool{
Name: "test",
Description: "A test tool",
Parameters: map[string]any{"type": "object"},
Command: "echo",
Arguments: []string{"hello"},
}
api := tool.ToAPI()
if api.Type != "function" {
t.Errorf("type = %q, want %q", api.Type, "function")
}
if api.Function.Name != "test" {
t.Errorf("name = %q, want %q", api.Function.Name, "test")
}
if api.Function.Description != "A test tool" {
t.Errorf("description = %q, want %q", api.Function.Description, "A test tool")
}
if api.Function.Parameters == nil {
t.Error("parameters is nil")
}
}
func TestToolResult_FormatResult(t *testing.T) {
// Get real ProcessState values from actual commands.
cmdOK := exec.Command("sh", "-c", "exit 0")
if err := cmdOK.Run(); err != nil {
t.Fatalf("cmdOK: %v", err)
}
cmdFail := exec.Command("sh", "-c", "exit 3")
cmdFail.Run() // expect non-nil error
cmdKill := exec.Command("sh", "-c", "kill -9 $$")
cmdKill.Run() // expect non-nil error
// Fixed times, so the rendered timing lines are deterministic.
started := time.Date(2026, 9, 23, 20, 31, 0, 123_000_000, time.UTC)
finished := started.Add(1500 * time.Millisecond)
// Every case renders the same timing lines, because every case uses
// the same times.
wantStart := "┗ start: " + started.Format(time.RFC3339Nano)
wantEnd := "┗ end: " + finished.Format(time.RFC3339Nano)
wantDuration := "┗ duration: 1.5s"
tests := []struct {
name string
result ToolResult
wantContains []string
}{
{
name: "success with stdout",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte("hello\n"),
},
wantContains: []string{
"▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "failure with stderr",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdFail.ProcessState,
StartedAt: started,
Stderr: []byte("error occurred\n"),
},
wantContains: []string{
"▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3",
wantStart, wantEnd, wantDuration,
},
},
{
name: "both stdout and stderr",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stderr: []byte("warning"),
Stdout: []byte("output"),
},
wantContains: []string{
"▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "empty output",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
},
wantContains: []string{
"▸ stdout:", "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "binary stdout (PNG)",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte("\x89PNG\r\n\x1a\n"),
},
wantContains: []string{
"▸ stdout:", binaryOutputPrefix, "image/png", "▸ stderr:",
"┗ exit: 0", wantStart, wantEnd, wantDuration,
},
},
{
name: "binary stderr",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stderr: []byte("\x89PNG\r\n\x1a\n"),
},
wantContains: []string{
"▸ stdout:", "▸ stderr:", binaryOutputPrefix, "image/png",
"┗ exit: 0", wantStart, wantEnd, wantDuration,
},
},
{
name: "HTML",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte("<HTML><BODY>Hello</BODY></HTML>\n"),
},
wantContains: []string{
"▸ stdout:", "<HTML>", "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "XML",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte("<?xml version=\"1.0\"?><root/>\n"),
},
wantContains: []string{
"▸ stdout:", "<?xml", "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "Go source",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte("package main\n\nfunc main() {}\n"),
},
wantContains: []string{
"▸ stdout:", "package main", "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "JSON",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdOK.ProcessState,
StartedAt: started,
Stdout: []byte(`{"key":"value"}`),
},
wantContains: []string{
"▸ stdout:", `{"key":"value"}`, "▸ stderr:", "┗ exit: 0",
wantStart, wantEnd, wantDuration,
},
},
{
name: "killed by signal",
result: ToolResult{
FinishedAt: finished,
ProcessState: cmdKill.ProcessState,
StartedAt: started,
},
wantContains: []string{
"▸ stdout:", "▸ stderr:", "┗ exit: -1 (signal: killed)",
wantStart, wantEnd, wantDuration,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.result.FormatResult()
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("FormatResult() = %q, want to contain %q", got, want)
}
}
})
}
}
func TestRegistry_Execute_Success(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "echo", Description: "echo", Command: "echo", Arguments: []string{"hello"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "echo", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.ProcessState.ExitCode() != 0 {
t.Errorf("ExitCode = %d, want 0", result.ProcessState.ExitCode())
}
if !bytes.Contains(result.Stdout, []byte("hello")) {
t.Errorf("Stdout = %q, want to contain hello", result.Stdout)
}
}
func TestRegistry_Execute_NonZeroExit(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "fail", Description: "fail", Command: "sh", Arguments: []string{"-c", "exit 42"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "fail", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.ProcessState.ExitCode() != 42 {
t.Errorf("ExitCode = %d, want 42", result.ProcessState.ExitCode())
}
}
func TestRegistry_Execute_UnknownCommand(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "nope", Description: "nope", Command: "nonexistent_binary_xyz", Arguments: []string{}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "nope", "{}")
if err == nil {
t.Fatal("Execute: want error, got nil")
}
}
func TestRegistry_Execute_UnknownTool(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "missing", "{}")
if err == nil {
t.Fatal("Execute: want error, got nil")
}
if !strings.Contains(err.Error(), "unknown tool") {
t.Errorf("err = %v, want unknown tool error", err)
}
}
func TestRegistry_Execute_Timeout(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "sleep", Description: "sleep", Command: "sleep", Arguments: []string{"10"}, Timeout: 50 * time.Millisecond},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "sleep", "{}")
if err == nil {
t.Fatal("Execute: want timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("err = %v, want timed out error", err)
}
}
func TestRegistry_Execute_SignalKilled(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "kill", Description: "kill", Command: "sh", Arguments: []string{"-c", "kill -9 $$"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "kill", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
// The shell kills itself ($$ is its own PID) so that the direct
// child dies by signal and ExitCode reports -1.
if result.ProcessState.ExitCode() != -1 {
t.Errorf("ExitCode = %d, want -1 (killed by signal)", result.ProcessState.ExitCode())
}
if !strings.Contains(result.ProcessState.String(), "signal:") {
t.Errorf("ProcessState.String() = %q, want signal info", result.ProcessState.String())
}
}