package main import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" ) type Tool struct { Type string `json:"type"` Function ToolFunction `json:"function"` } type ToolFunction struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]any `json:"parameters"` } // programmerTools returns the full file toolset for the Programmer agent. func programmerTools() []Tool { return allFileTools() } // qaTools returns read-only file tools plus a shell command runner so QA can // execute test suites / linters / builds for any language or framework. func qaTools() []Tool { keep := map[string]bool{"read_file": true, "list_directory": true} all := allFileTools() out := make([]Tool, 0, len(keep)+1) for _, t := range all { if keep[t.Function.Name] { out = append(out, t) } } out = append(out, commandTool()) return out } func commandTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "run_command", Description: "Run a shell command via 'sh -c' in the project root and return combined stdout+stderr plus exit code. " + "Use for running test suites, linters, type-checkers, or builds — any language or framework. " + "Examples: 'go test ./...', 'bun test', 'npm run lint', 'php artisan test', 'composer test', 'pytest -q', 'cargo test'. " + "Inherits the caller's PATH and environment. The working directory starts at the project root; note that the shell itself is not path-sandboxed, so prefer commands that stay within the project.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "Full shell command to execute (pipes, redirects, env-prefixes supported).", }, "timeout_seconds": map[string]any{ "type": "integer", "description": "Max seconds to wait. Default 60, capped at 600.", }, }, "required": []string{"command"}, }, }, } } func allFileTools() []Tool { return []Tool{ { Type: "function", Function: ToolFunction{ Name: "read_file", Description: "Read a UTF-8 text file relative to the project root. Returns the file contents.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Relative path from the project root.", }, }, "required": []string{"path"}, }, }, }, { Type: "function", Function: ToolFunction{ Name: "create_file", Description: "Create a new file with the given content. Fails if the file already exists. Parent directories are created as needed.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "Relative path from the project root."}, "content": map[string]any{"type": "string", "description": "Full file contents to write."}, }, "required": []string{"path", "content"}, }, }, }, { Type: "function", Function: ToolFunction{ Name: "edit_file", Description: "Overwrite an existing file with new content. Fails if the file does not exist.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "Relative path from the project root."}, "content": map[string]any{"type": "string", "description": "Full replacement contents."}, }, "required": []string{"path", "content"}, }, }, }, { Type: "function", Function: ToolFunction{ Name: "delete_file", Description: "Delete a file. Fails if the path is a directory or does not exist.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "Relative path from the project root."}, }, "required": []string{"path"}, }, }, }, { Type: "function", Function: ToolFunction{ Name: "list_directory", Description: "List the entries of a directory, relative to the project root. Use \".\" for the root.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "Relative directory path. Use \".\" for the project root."}, }, "required": []string{"path"}, }, }, }, } } // runTool executes a tool call and returns a string payload for the model. // Errors are returned as strings too so the model can react, not fatal. func runTool(root, name, argsJSON string) string { var a struct { Path string `json:"path"` Content string `json:"content"` Command string `json:"command"` TimeoutSeconds int `json:"timeout_seconds"` } if argsJSON != "" { if err := json.Unmarshal([]byte(argsJSON), &a); err != nil { return fmt.Sprintf("error: invalid arguments: %v", err) } } switch name { case "read_file": return doRead(root, a.Path) case "create_file": return doCreate(root, a.Path, a.Content) case "edit_file": return doEdit(root, a.Path, a.Content) case "delete_file": return doDelete(root, a.Path) case "list_directory": return doList(root, a.Path) case "run_command": return doRunCommand(root, a.Command, a.TimeoutSeconds) default: return fmt.Sprintf("error: unknown tool %q", name) } } func doRead(root, p string) string { abs, err := resolveSafe(root, p) if err != nil { return "error: " + err.Error() } b, err := os.ReadFile(abs) if err != nil { return "error: " + err.Error() } return string(b) } func doCreate(root, p, content string) string { abs, err := resolveSafe(root, p) if err != nil { return "error: " + err.Error() } if _, err := os.Stat(abs); err == nil { return fmt.Sprintf("error: file already exists: %s", p) } if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return "error: " + err.Error() } if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { return "error: " + err.Error() } return fmt.Sprintf("created %s (%d bytes)", p, len(content)) } func doEdit(root, p, content string) string { abs, err := resolveSafe(root, p) if err != nil { return "error: " + err.Error() } info, err := os.Stat(abs) if err != nil { return "error: " + err.Error() } if info.IsDir() { return fmt.Sprintf("error: path is a directory: %s", p) } if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { return "error: " + err.Error() } return fmt.Sprintf("updated %s (%d bytes)", p, len(content)) } func doDelete(root, p string) string { abs, err := resolveSafe(root, p) if err != nil { return "error: " + err.Error() } if abs == root { return "error: refusing to delete project root" } info, err := os.Stat(abs) if err != nil { return "error: " + err.Error() } if info.IsDir() { return fmt.Sprintf("error: path is a directory: %s", p) } if err := os.Remove(abs); err != nil { return "error: " + err.Error() } return fmt.Sprintf("deleted %s", p) } const ( defaultCommandTimeout = 60 maxCommandTimeout = 600 maxCommandOutput = 16 * 1024 ) func doRunCommand(root, command string, timeoutSec int) string { if strings.TrimSpace(command) == "" { return "error: empty command" } if timeoutSec <= 0 { timeoutSec = defaultCommandTimeout } if timeoutSec > maxCommandTimeout { timeoutSec = maxCommandTimeout } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "sh", "-c", command) cmd.Dir = root cmd.Env = os.Environ() start := time.Now() out, runErr := cmd.CombinedOutput() elapsed := time.Since(start).Round(time.Millisecond) truncated := false if len(out) > maxCommandOutput { out = out[:maxCommandOutput] truncated = true } var header string switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): header = fmt.Sprintf("status=timeout after %ds elapsed=%s", timeoutSec, elapsed) case runErr != nil: var ee *exec.ExitError if errors.As(runErr, &ee) { header = fmt.Sprintf("exit_code=%d elapsed=%s", ee.ExitCode(), elapsed) } else { header = fmt.Sprintf("status=error: %v elapsed=%s", runErr, elapsed) } default: header = fmt.Sprintf("exit_code=0 elapsed=%s", elapsed) } suffix := "" if truncated { suffix = fmt.Sprintf("\n[output truncated at %d bytes]", maxCommandOutput) } return fmt.Sprintf("$ %s\n%s\n--- output ---\n%s%s", command, header, string(out), suffix) } func doList(root, p string) string { if p == "" { p = "." } abs, err := resolveSafe(root, p) if err != nil { return "error: " + err.Error() } entries, err := os.ReadDir(abs) if err != nil { return "error: " + err.Error() } lines := make([]string, 0, len(entries)) for _, e := range entries { name := e.Name() if e.IsDir() { name += "/" } lines = append(lines, name) } sort.Strings(lines) if len(lines) == 0 { return "(empty)" } return strings.Join(lines, "\n") }