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:
loveuer
2026-06-22 17:28:22 +08:00
commit a431d1ec95
23 changed files with 3526 additions and 0 deletions
+331
View File
@@ -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
}
+15
View File
@@ -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))
}
+80
View File
@@ -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
}
+122
View File
@@ -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()
}
+110
View File
@@ -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)
}
}
}