336 lines
9.1 KiB
Go
336 lines
9.1 KiB
Go
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")
|
|
}
|