v0.0.1: agentu mvp
Add OpenAI-compatible providers, TUI, slash commands, and model switching. Add local file tools plus optional yolo shell execution. Document config, usage, and development workflow.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
type FileReadTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewFileReadTool(workingDir string) *FileReadTool {
|
||||
return &FileReadTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *FileReadTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "file_read",
|
||||
Description: "Read a local file as UTF-8 text.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read. Relative paths are resolved from the configured agent working directory."},
|
||||
"max_bytes": {"type": "integer", "description": "Optional maximum bytes to return."}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fileReadArgs struct {
|
||||
Path string `json:"path"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
func (t *FileReadTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args fileReadArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return "", errors.New("path is required")
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
limit := MaxToolOutputBytes
|
||||
if args.MaxBytes > 0 && args.MaxBytes < limit {
|
||||
limit = args.MaxBytes
|
||||
}
|
||||
if len(data) > limit {
|
||||
data = append(data[:limit], []byte(fmt.Sprintf("\n\n[truncated after %d bytes]", limit))...)
|
||||
}
|
||||
return string(data), ctx.Err()
|
||||
}
|
||||
|
||||
type FileWriteTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewFileWriteTool(workingDir string) *FileWriteTool {
|
||||
return &FileWriteTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *FileWriteTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "file_write",
|
||||
Description: "Write UTF-8 text to a local file. Creates parent directories when needed.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to write. Relative paths are resolved from the configured agent working directory."},
|
||||
"content": {"type": "string", "description": "Text content to write."},
|
||||
"append": {"type": "boolean", "description": "Append instead of overwriting."}
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fileWriteArgs struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Append bool `json:"append"`
|
||||
}
|
||||
|
||||
func (t *FileWriteTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args fileWriteArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return "", errors.New("path is required")
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
flag := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
|
||||
if args.Append {
|
||||
flag = os.O_CREATE | os.O_WRONLY | os.O_APPEND
|
||||
}
|
||||
file, err := os.OpenFile(path, flag, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.WriteString(args.Content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
action := "wrote"
|
||||
if args.Append {
|
||||
action = "appended"
|
||||
}
|
||||
return fmt.Sprintf("%s %d bytes to %s", action, len(args.Content), path), nil
|
||||
}
|
||||
|
||||
type FileListTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewFileListTool(workingDir string) *FileListTool {
|
||||
return &FileListTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *FileListTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "file_list",
|
||||
Description: "List files and directories at a local path.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Directory path to list. Defaults to the configured agent working directory."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fileListArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (t *FileListTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args fileListArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lines := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
lines = append(lines, name)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatResult(strings.Join(lines, "\n")), nil
|
||||
}
|
||||
|
||||
type FileSearchTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewFileSearchTool(workingDir string) *FileSearchTool {
|
||||
return &FileSearchTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *FileSearchTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "file_search",
|
||||
Description: "Search local text files for a pattern. Uses ripgrep when available.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Text or regular expression to search for."},
|
||||
"path": {"type": "string", "description": "Optional directory or file to search. Defaults to the configured agent working directory."},
|
||||
"glob": {"type": "string", "description": "Optional file glob, for example '*.go'."}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fileSearchArgs struct {
|
||||
Query string `json:"query"`
|
||||
Path string `json:"path"`
|
||||
Glob string `json:"glob"`
|
||||
}
|
||||
|
||||
func (t *FileSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args fileSearchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Query) == "" {
|
||||
return "", errors.New("query is required")
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := exec.LookPath("rg"); err == nil {
|
||||
return t.ripgrep(ctx, args, path)
|
||||
}
|
||||
return t.walkSearch(ctx, args, path)
|
||||
}
|
||||
|
||||
func (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path string) (string, error) {
|
||||
rgArgs := []string{"--line-number", "--color", "never"}
|
||||
if args.Glob != "" {
|
||||
rgArgs = append(rgArgs, "--glob", args.Glob)
|
||||
}
|
||||
rgArgs = append(rgArgs, args.Query, path)
|
||||
cmd := exec.CommandContext(ctx, "rg", rgArgs...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
text := FormatResult(string(output))
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return text, fmt.Errorf("search timed out: %w", ctx.Err())
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
return "no matches", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, root string) (string, error) {
|
||||
var out bytes.Buffer
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
if name == ".git" || name == "vendor" || name == "node_modules" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if args.Glob != "" {
|
||||
match, err := filepath.Match(args.Glob, filepath.Base(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || bytes.IndexByte(data, 0) >= 0 {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, args.Query) {
|
||||
fmt.Fprintf(&out, "%s:%d:%s\n", path, i+1, line)
|
||||
if out.Len() > MaxToolOutputBytes {
|
||||
return errors.New("output limit reached")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err.Error() != "output limit reached" {
|
||||
return "", err
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "no matches", nil
|
||||
}
|
||||
return FormatResult(out.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func resolvePath(baseDir, path string) (string, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
return filepath.Abs(filepath.Join(baseDir, path))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
type ShellRunTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewShellRunTool(workingDir string) *ShellRunTool {
|
||||
return &ShellRunTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *ShellRunTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "shell_run",
|
||||
Description: "Run a non-interactive shell command on the local machine. Use for builds, tests, local status inspection, and local automation. Avoid commands that require an interactive TTY, credentials, or open long-running sessions.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Command to execute."},
|
||||
"workdir": {"type": "string", "description": "Optional working directory. Relative paths are resolved from the configured agent working directory."}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type shellRunArgs struct {
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir"`
|
||||
}
|
||||
|
||||
func (t *ShellRunTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args shellRunArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Command) == "" {
|
||||
return "", errors.New("command is required")
|
||||
}
|
||||
|
||||
workdir, err := resolvePath(t.workingDir, args.Workdir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, shell, "-lc", args.Command)
|
||||
cmd.Dir = workdir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
text := FormatResult(string(output))
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return text, fmt.Errorf("command timed out: %w", ctx.Err())
|
||||
}
|
||||
if err != nil {
|
||||
if text != "" {
|
||||
return text, fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
const MaxToolOutputBytes = 64 * 1024
|
||||
|
||||
type Tool interface {
|
||||
Definition() llm.Tool
|
||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
primary []string
|
||||
}
|
||||
|
||||
func NewRegistry(items ...Tool) *Registry {
|
||||
r := &Registry{tools: make(map[string]Tool, len(items))}
|
||||
for _, item := range items {
|
||||
r.Register(item)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Registry) Register(tool Tool) {
|
||||
name := tool.Definition().Function.Name
|
||||
if _, exists := r.tools[name]; !exists {
|
||||
r.primary = append(r.primary, name)
|
||||
}
|
||||
r.tools[name] = tool
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterAlias(alias string, tool Tool) {
|
||||
r.tools[alias] = tool
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Tool, bool) {
|
||||
tool, ok := r.tools[name]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Definitions() []llm.Tool {
|
||||
defs := make([]llm.Tool, 0, len(r.primary))
|
||||
for _, name := range r.primary {
|
||||
tool := r.tools[name]
|
||||
defs = append(defs, tool.Definition())
|
||||
}
|
||||
sort.Slice(defs, func(i, j int) bool {
|
||||
return defs[i].Function.Name < defs[j].Function.Name
|
||||
})
|
||||
return defs
|
||||
}
|
||||
|
||||
func ReadOnlyBuiltins(workingDir string) *Registry {
|
||||
fileRead := NewFileReadTool(workingDir)
|
||||
fileList := NewFileListTool(workingDir)
|
||||
fileSearch := NewFileSearchTool(workingDir)
|
||||
|
||||
registry := NewRegistry(fileRead, fileList, fileSearch)
|
||||
registry.RegisterAlias("file.read", fileRead)
|
||||
registry.RegisterAlias("file.list", fileList)
|
||||
registry.RegisterAlias("file.search", fileSearch)
|
||||
return registry
|
||||
}
|
||||
|
||||
func Builtins(workingDir string) *Registry {
|
||||
registry := ReadOnlyBuiltins(workingDir)
|
||||
fileWrite := NewFileWriteTool(workingDir)
|
||||
shellRun := NewShellRunTool(workingDir)
|
||||
registry.Register(fileWrite)
|
||||
registry.Register(shellRun)
|
||||
registry.RegisterAlias("file.write", fileWrite)
|
||||
registry.RegisterAlias("shell.run", shellRun)
|
||||
return registry
|
||||
}
|
||||
|
||||
func AgentInstructions(workingDir string, yolo bool) string {
|
||||
if yolo {
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
- Tools are available, but use them only when needed to satisfy the user's request.
|
||||
- Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project or command context.
|
||||
- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files.
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands.
|
||||
- Do not use file tools as a substitute for a command execution request.
|
||||
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search, file_write, shell_run.`, workingDir)
|
||||
}
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
- Read-only project file tools are available, but use them only when needed to satisfy the user's request.
|
||||
- Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project context.
|
||||
- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files.
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run.
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search.`, workingDir)
|
||||
}
|
||||
|
||||
func JSONSchema(schema string) json.RawMessage {
|
||||
return json.RawMessage(schema)
|
||||
}
|
||||
|
||||
func FormatResult(output string) string {
|
||||
if len(output) <= MaxToolOutputBytes {
|
||||
return output
|
||||
}
|
||||
return output[:MaxToolOutputBytes] + fmt.Sprintf("\n\n[truncated after %d bytes]", MaxToolOutputBytes)
|
||||
}
|
||||
|
||||
func FormatError(err error) string {
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileTools(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
write := NewFileWriteTool(dir)
|
||||
if out, err := write.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt","content":"hello world"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if !strings.Contains(out, "wrote 11 bytes") {
|
||||
t.Fatalf("write output = %q", out)
|
||||
}
|
||||
|
||||
read := NewFileReadTool(dir)
|
||||
if out, err := read.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if out != "hello world" {
|
||||
t.Fatalf("read output = %q", out)
|
||||
}
|
||||
|
||||
list := NewFileListTool(dir)
|
||||
if out, err := list.Execute(ctx, json.RawMessage(`{"path":"notes"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if out != "a.txt" {
|
||||
t.Fatalf("list output = %q", out)
|
||||
}
|
||||
|
||||
search := NewFileSearchTool(dir)
|
||||
if out, err := search.Execute(ctx, json.RawMessage(`{"query":"hello","path":"notes"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if !strings.Contains(out, "hello world") {
|
||||
t.Fatalf("search output = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunTool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tool := NewShellRunTool(dir)
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"pwd && ls"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, dir) || !strings.Contains(out, "x.txt") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
registry := ReadOnlyBuiltins(t.TempDir())
|
||||
var names []string
|
||||
for _, def := range registry.Definitions() {
|
||||
names = append(names, def.Function.Name)
|
||||
if strings.Contains(def.Function.Name, ".") {
|
||||
t.Fatalf("tool name contains dot: %s", def.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"file_list", "file_read", "file_search"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, alias := range []string{"file.list", "file.read", "file.search"} {
|
||||
if _, ok := registry.Get(alias); !ok {
|
||||
t.Fatalf("alias missing: %s", alias)
|
||||
}
|
||||
}
|
||||
if _, ok := registry.Get("file_write"); ok {
|
||||
t.Fatal("read-only registry should not expose file_write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
registry := Builtins(t.TempDir())
|
||||
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
|
||||
if _, ok := registry.Get(name); !ok {
|
||||
t.Fatalf("tool missing: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
readOnly := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} {
|
||||
if !strings.Contains(readOnly, want) {
|
||||
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
|
||||
}
|
||||
}
|
||||
|
||||
yolo := AgentInstructions("/tmp/project", true)
|
||||
for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} {
|
||||
if !strings.Contains(yolo, want) {
|
||||
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user