demo
This commit is contained in:
981
tools/tools.go
Normal file
981
tools/tools.go
Normal file
@@ -0,0 +1,981 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tool represents a function-calling tool exposed to an agent workflow.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters Schema `json:"parameters"`
|
||||
}
|
||||
|
||||
// Schema defines the JSON schema for tool parameters.
|
||||
type Schema struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]Schema `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Items *Schema `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the output from executing a tool.
|
||||
type Result struct {
|
||||
Content string `json:"content"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceValidator ensures operations stay within the allowed workspace.
|
||||
type WorkspaceValidator struct {
|
||||
workspacePath string
|
||||
}
|
||||
|
||||
func cancelledResult() (*Result, error) {
|
||||
return &Result{Success: false, Error: "operation cancelled"}, nil
|
||||
}
|
||||
|
||||
func preferredShellPath() string {
|
||||
shell := strings.TrimSpace(os.Getenv("SHELL"))
|
||||
if shell != "" {
|
||||
if strings.Contains(shell, string(os.PathSeparator)) {
|
||||
return shell
|
||||
}
|
||||
if resolved, err := exec.LookPath(shell); err == nil {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
func shellQuote(arg string) string {
|
||||
if arg == "" {
|
||||
return "''"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(arg, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func buildShellCommand(command string, args []string) string {
|
||||
parts := make([]string, 0, 1+len(args))
|
||||
if trimmed := strings.TrimSpace(command); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
for _, arg := range args {
|
||||
parts = append(parts, shellQuote(arg))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// NewWorkspaceValidator creates a validator for the given workspace.
|
||||
func NewWorkspaceValidator(workspacePath string) *WorkspaceValidator {
|
||||
absPath, err := filepath.Abs(workspacePath)
|
||||
if err != nil {
|
||||
absPath = workspacePath
|
||||
}
|
||||
return &WorkspaceValidator{workspacePath: absPath}
|
||||
}
|
||||
|
||||
func (v *WorkspaceValidator) resolvePath(path string) (string, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" || trimmed == "." {
|
||||
return v.workspacePath, nil
|
||||
}
|
||||
|
||||
candidate := trimmed
|
||||
if !filepath.IsAbs(candidate) {
|
||||
candidate = filepath.Join(v.workspacePath, candidate)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(absPath)
|
||||
if !strings.HasPrefix(cleaned, v.workspacePath) && cleaned != v.workspacePath {
|
||||
return "", fmt.Errorf("access denied: path '%s' is outside workspace '%s'", path, v.workspacePath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// Validate checks if a path is within the workspace and returns the resolved absolute path.
|
||||
func (v *WorkspaceValidator) Validate(path string) (string, error) {
|
||||
return v.resolvePath(path)
|
||||
}
|
||||
|
||||
// IsWithinWorkspace checks if a path is within the workspace.
|
||||
func (v *WorkspaceValidator) IsWithinWorkspace(path string) bool {
|
||||
_, err := v.resolvePath(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizeLineEndings(text string) string {
|
||||
return strings.ReplaceAll(text, "\r\n", "\n")
|
||||
}
|
||||
|
||||
func splitLines(text string) []string {
|
||||
text = normalizeLineEndings(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatNumberedLines(lines []string, startLine int) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
fmt.Fprintf(&builder, "%d: %s", startLine+i, line)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func spliceLineRange(existing []string, startLine, endLine int, replacement []string) ([]string, error) {
|
||||
if startLine < 1 {
|
||||
return nil, fmt.Errorf("start_line must be >= 1")
|
||||
}
|
||||
if endLine < -1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or >= 0")
|
||||
}
|
||||
|
||||
totalLines := len(existing)
|
||||
if endLine == -1 {
|
||||
endLine = totalLines
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or at least start_line-1")
|
||||
}
|
||||
if startLine > totalLines+1 {
|
||||
return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines)
|
||||
}
|
||||
if endLine > totalLines {
|
||||
return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines)
|
||||
}
|
||||
|
||||
prefix := append([]string(nil), existing[:startLine-1]...)
|
||||
suffix := append([]string(nil), existing[endLine:]...)
|
||||
|
||||
nextLines := make([]string, 0, len(prefix)+len(replacement)+len(suffix))
|
||||
nextLines = append(nextLines, prefix...)
|
||||
nextLines = append(nextLines, replacement...)
|
||||
nextLines = append(nextLines, suffix...)
|
||||
|
||||
return nextLines, nil
|
||||
}
|
||||
|
||||
func lineRangeLines(lines []string, startLine, endLine int) ([]string, error) {
|
||||
if startLine < 1 {
|
||||
return nil, fmt.Errorf("start_line must be >= 1")
|
||||
}
|
||||
if endLine < -1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or >= 0")
|
||||
}
|
||||
|
||||
totalLines := len(lines)
|
||||
if endLine == -1 {
|
||||
endLine = totalLines
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or at least start_line-1")
|
||||
}
|
||||
if startLine > totalLines+1 {
|
||||
return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines)
|
||||
}
|
||||
if endLine > totalLines {
|
||||
return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines)
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return []string{}, nil
|
||||
}
|
||||
if totalLines == 0 || endLine < startLine {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return append([]string(nil), lines[startLine-1:endLine]...), nil
|
||||
}
|
||||
|
||||
func findUniqueLineSequence(haystack, needle []string) (int, error) {
|
||||
if len(needle) == 0 {
|
||||
return 0, fmt.Errorf("expected_text cannot be empty")
|
||||
}
|
||||
if len(needle) > len(haystack) {
|
||||
return 0, fmt.Errorf("expected text has %d lines, but the requested range only has %d lines", len(needle), len(haystack))
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := 0; i <= len(haystack)-len(needle); i++ {
|
||||
matched := true
|
||||
for j := range needle {
|
||||
if haystack[i+j] != needle[j] {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case matchCount == 0:
|
||||
return 0, fmt.Errorf("expected text was not found within the requested range")
|
||||
case matchCount > 1:
|
||||
return 0, fmt.Errorf("expected text matched %d locations within the requested range", matchCount)
|
||||
default:
|
||||
return matchIndex, nil
|
||||
}
|
||||
}
|
||||
|
||||
func stripReadFileLinePrefix(line string) (string, bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(line) && line[i] >= '0' && line[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(line) || line[i] != ':' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
rest := line[i+1:]
|
||||
if strings.HasPrefix(rest, " ") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func normalizePatchExpectedLines(expectedText string) []string {
|
||||
lines := splitLines(expectedText)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(lines) >= 2 && strings.HasPrefix(lines[0], "File: ") && strings.HasPrefix(lines[1], "Lines ") && strings.Contains(lines[1], " of ") {
|
||||
lines = lines[2:]
|
||||
normalized := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
if stripped, ok := stripReadFileLinePrefix(line); ok {
|
||||
normalized[i] = stripped
|
||||
} else {
|
||||
normalized[i] = line
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
normalized := make([]string, len(lines))
|
||||
allNumbered := true
|
||||
for i, line := range lines {
|
||||
stripped, ok := stripReadFileLinePrefix(line)
|
||||
if !ok {
|
||||
allNumbered = false
|
||||
break
|
||||
}
|
||||
normalized[i] = stripped
|
||||
}
|
||||
if allNumbered {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// --- File Tools ---
|
||||
|
||||
// ReadFile reads a line range from a file within the workspace.
|
||||
func (v *WorkspaceValidator) ReadFile(ctx context.Context, path string, startLine, endLine int) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read file: %v", err)}, nil
|
||||
}
|
||||
|
||||
if startLine < 1 {
|
||||
return &Result{Success: false, Error: "start_line must be >= 1"}, nil
|
||||
}
|
||||
|
||||
if endLine != -1 && endLine < 1 {
|
||||
return &Result{Success: false, Error: "end_line must be -1 or >= 1"}, nil
|
||||
}
|
||||
if endLine != -1 && endLine < startLine {
|
||||
return &Result{Success: false, Error: "end_line must be -1 or greater than or equal to start_line"}, nil
|
||||
}
|
||||
|
||||
lines := splitLines(string(data))
|
||||
totalLines := len(lines)
|
||||
if totalLines == 0 {
|
||||
return &Result{
|
||||
Content: fmt.Sprintf("File: %s\nFile is empty.", absPath),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
actualEnd := totalLines
|
||||
if endLine != -1 {
|
||||
if endLine < totalLines {
|
||||
actualEnd = endLine
|
||||
}
|
||||
}
|
||||
|
||||
if startLine > totalLines {
|
||||
displayEnd := "EOF"
|
||||
if endLine != -1 {
|
||||
displayEnd = fmt.Sprintf("%d", endLine)
|
||||
}
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nRequested lines %d-%s are beyond EOF. File has %d lines.",
|
||||
absPath,
|
||||
startLine,
|
||||
displayEnd,
|
||||
totalLines,
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if actualEnd < startLine {
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nRequested lines %d-%d are empty. File has %d lines.",
|
||||
absPath,
|
||||
startLine,
|
||||
actualEnd,
|
||||
totalLines,
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
selected := lines[startLine-1 : actualEnd]
|
||||
displayEnd := fmt.Sprintf("%d", actualEnd)
|
||||
if endLine == -1 && actualEnd == totalLines {
|
||||
displayEnd = "EOF"
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nLines %d-%s of %d:\n%s",
|
||||
absPath,
|
||||
startLine,
|
||||
displayEnd,
|
||||
totalLines,
|
||||
formatNumberedLines(selected, startLine),
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteFile replaces a line range in a file within the workspace, creating parent directories if needed.
|
||||
func (v *WorkspaceValidator) WriteFile(ctx context.Context, path string, startLine, endLine int, content string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil
|
||||
}
|
||||
|
||||
existingData, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil
|
||||
}
|
||||
|
||||
existingLines := splitLines(string(existingData))
|
||||
replacementLines := splitLines(content)
|
||||
originalEndLine := endLine
|
||||
|
||||
nextLines, err := spliceLineRange(existingLines, startLine, endLine, replacementLines)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
nextContent := strings.Join(nextLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil
|
||||
}
|
||||
|
||||
action := "replaced"
|
||||
if os.IsNotExist(readErr) || (len(existingLines) == 0 && startLine == 1 && originalEndLine <= 0) {
|
||||
action = "created"
|
||||
} else if originalEndLine < startLine {
|
||||
action = "inserted"
|
||||
}
|
||||
|
||||
replacedCount := 0
|
||||
if endLine == -1 {
|
||||
replacedCount = len(existingLines) - startLine + 1
|
||||
if replacedCount < 0 {
|
||||
replacedCount = 0
|
||||
}
|
||||
} else if endLine >= startLine {
|
||||
replacedCount = endLine - startLine + 1
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Successfully %s %s", action, absPath)
|
||||
if action == "inserted" {
|
||||
message = fmt.Sprintf("Successfully inserted %d lines before line %d in %s", len(replacementLines), startLine, absPath)
|
||||
} else if action == "created" {
|
||||
message = fmt.Sprintf("Successfully created %s with %d lines", absPath, len(nextLines))
|
||||
} else {
|
||||
message = fmt.Sprintf("Successfully replaced %d lines starting at line %d in %s", replacedCount, startLine, absPath)
|
||||
}
|
||||
|
||||
return &Result{Content: message, Success: true}, nil
|
||||
}
|
||||
|
||||
// PatchFile replaces the unique snippet matching the expected text within a requested line window.
|
||||
func (v *WorkspaceValidator) PatchFile(ctx context.Context, path string, startLine, endLine int, expectedText, content string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil
|
||||
}
|
||||
|
||||
existingData, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil
|
||||
}
|
||||
|
||||
existingLines := splitLines(string(existingData))
|
||||
windowLines, err := lineRangeLines(existingLines, startLine, endLine)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
expectedLines := normalizePatchExpectedLines(expectedText)
|
||||
matchOffset, err := findUniqueLineSequence(windowLines, expectedLines)
|
||||
if err != nil {
|
||||
displayEnd := "EOF"
|
||||
if endLine != -1 {
|
||||
displayEnd = fmt.Sprintf("%d", endLine)
|
||||
}
|
||||
return &Result{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf(
|
||||
"patch guard failed for requested range %d-%s: %v. Re-read the exact snippet and include a little more context if needed.",
|
||||
startLine,
|
||||
displayEnd,
|
||||
err,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
actualStartLine := startLine + matchOffset
|
||||
actualEndLine := actualStartLine + len(expectedLines) - 1
|
||||
|
||||
replacementLines := splitLines(content)
|
||||
nextLines, err := spliceLineRange(existingLines, actualStartLine, actualEndLine, replacementLines)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
nextContent := strings.Join(nextLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Content: fmt.Sprintf("Successfully patched lines %d-%d in %s", actualStartLine, actualEndLine, absPath),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateDirectory creates a directory within the workspace.
|
||||
func (v *WorkspaceValidator) CreateDirectory(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absPath, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directory: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: fmt.Sprintf("Successfully created directory: %s", absPath), Success: true}, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes a file within the workspace.
|
||||
func (v *WorkspaceValidator) DeleteFile(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if err := os.Remove(absPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("file not found: %s", path)}, nil
|
||||
}
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to delete file: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: fmt.Sprintf("Successfully deleted: %s", absPath), Success: true}, nil
|
||||
}
|
||||
|
||||
// ListDirectory lists files and directories within the workspace.
|
||||
func (v *WorkspaceValidator) ListDirectory(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)}, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Contents of %s:", absPath))
|
||||
|
||||
for _, entry := range entries {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
mode := entry.Type()
|
||||
var prefix string
|
||||
if mode.IsDir() {
|
||||
prefix = "D "
|
||||
} else {
|
||||
prefix = "F "
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
size := "0"
|
||||
if err == nil && info != nil {
|
||||
size = fmt.Sprintf("%d", info.Size())
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if mode.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %8s %s", prefix, size, name))
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
// --- Search Tools ---
|
||||
|
||||
// SearchFiles searches for files matching a pattern within the workspace.
|
||||
func (v *WorkspaceValidator) SearchFiles(ctx context.Context, pathPattern string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
// Validate the base path
|
||||
baseDir := filepath.Dir(pathPattern)
|
||||
if baseDir != "." && baseDir != "/" {
|
||||
if _, err := v.Validate(baseDir); err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var matches []string
|
||||
pattern := pathPattern
|
||||
|
||||
err := filepath.Walk(v.workspacePath, func(currentPath string, info os.FileInfo, err error) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, _ := filepath.Rel(v.workspacePath, currentPath)
|
||||
if matched, _ := filepath.Match(pattern, relPath); matched {
|
||||
matches = append(matches, relPath)
|
||||
return nil
|
||||
}
|
||||
if matched, _ := filepath.Match(pattern, filepath.Base(relPath)); matched {
|
||||
matches = append(matches, relPath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return cancelledResult()
|
||||
}
|
||||
return &Result{Success: false, Error: fmt.Sprintf("search failed: %v", err)}, nil
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return &Result{Content: "No files found matching pattern.", Success: true}, nil
|
||||
}
|
||||
|
||||
var output []string
|
||||
output = append(output, fmt.Sprintf("Found %d files matching '%s':", len(matches), pattern))
|
||||
for _, m := range matches {
|
||||
output = append(output, " "+m)
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(output, "\n")}, nil
|
||||
}
|
||||
|
||||
// Grep searches for text content within files in the workspace.
|
||||
func (v *WorkspaceValidator) Grep(ctx context.Context, searchTerm string, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
var results []string
|
||||
count := 0
|
||||
|
||||
err = filepath.Walk(absPath, func(currentPath string, info os.FileInfo, err error) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if count >= 100 {
|
||||
return fmt.Errorf("max results reached")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(currentPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(v.workspacePath, currentPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, searchTerm) {
|
||||
results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, strings.TrimSpace(line)))
|
||||
count++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
if count >= 100 {
|
||||
results = append(results, "... (truncated at 100 results)")
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return &Result{Content: fmt.Sprintf("No matches for '%s' in %s", searchTerm, path)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(results, "\n")}, nil
|
||||
}
|
||||
|
||||
// --- Shell Tools ---
|
||||
|
||||
// RunCommand executes a shell command within the workspace.
|
||||
func (v *WorkspaceValidator) RunCommand(ctx context.Context, command string, args []string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
shellCommand := buildShellCommand(command, args)
|
||||
if shellCommand == "" {
|
||||
return &Result{Success: false, Error: "command is required"}, nil
|
||||
}
|
||||
|
||||
// Check for dangerous commands
|
||||
dangerousCmds := []string{"rm -rf /", "rm -rf *", "dd if=", "mkfs", "fdisk", "> /dev/"}
|
||||
for _, d := range dangerousCmds {
|
||||
if strings.Contains(strings.ToLower(shellCommand), d) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("dangerous command blocked: %s", shellCommand)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
shellPath := preferredShellPath()
|
||||
shellArgs := []string{"-lc", shellCommand}
|
||||
switch filepath.Base(shellPath) {
|
||||
case "zsh", "bash":
|
||||
shellArgs[0] = "-lic"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, shellPath, shellArgs...)
|
||||
cmd.Dir = v.workspacePath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
exitCode := -1
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
return &Result{
|
||||
Content: outputStr,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("command failed (exit %d): %v", exitCode, err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Truncate output
|
||||
maxLen := 50000
|
||||
if len(outputStr) > maxLen {
|
||||
outputStr = outputStr[:maxLen] + "\n... [output truncated]"
|
||||
}
|
||||
|
||||
return &Result{Content: outputStr, Success: true}, nil
|
||||
}
|
||||
|
||||
// GetWorkspacePath returns the workspace path.
|
||||
func (v *WorkspaceValidator) GetWorkspacePath() string {
|
||||
return v.workspacePath
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
|
||||
// AvailableTools returns the list of workspace tool definitions for function calling.
|
||||
func AvailableTools(workspacePath string) []Tool {
|
||||
tools := []Tool{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a line range from a file in the workspace. Use start_line=1 and end_line=-1 to read the full file. The output includes line numbers and a header, and it can be pasted directly into patch_file as expected_text for the same snippet or range.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to read (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line to read, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line to read, inclusive. Use -1 to read through EOF. Defaults to -1.",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "patch_file",
|
||||
Description: "Guarded snippet replacement within a line window. Like write_file, but only applies if expected_text matches one unique contiguous snippet inside the requested range. expected_text can be raw file text or the line-numbered output from read_file. Use this for surgical edits to avoid clobbering concurrent changes.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to patch (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line of the search window, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line of the search window, inclusive. Use -1 to search through EOF.",
|
||||
},
|
||||
"expected_text": {
|
||||
Type: "string",
|
||||
Description: "The current text to match. This can be raw file text or the output from read_file for a matching snippet within the requested window.",
|
||||
},
|
||||
"content": {
|
||||
Type: "string",
|
||||
Description: "The replacement text for the requested line range",
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "expected_text", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "Replace a line range in a file. Use start_line=1 and end_line=-1 to replace the full file or create a new file. Use end_line=start_line-1 to insert content before a line, such as start_line=1 and end_line=0 to prepend to an existing file. Prefer patch_file when you want to guard against stale content.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to write (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line to replace, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line to replace, inclusive. Use -1 to replace through EOF. Use start_line-1 to insert before start_line.",
|
||||
},
|
||||
"content": {
|
||||
Type: "string",
|
||||
Description: "The replacement text for the requested line range",
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_directory",
|
||||
Description: "Create a new directory or get an existing one within the workspace.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the directory to create (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_file",
|
||||
Description: "Delete a file from the workspace.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to delete (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_directory",
|
||||
Description: "List the contents of a directory within the workspace. Shows file sizes and types.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the directory to list (relative to workspace, defaults to workspace root if '.' or empty)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_files",
|
||||
Description: "Search for files by name pattern within the workspace directory tree.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path_pattern": {
|
||||
Type: "string",
|
||||
Description: "The glob pattern to match (e.g., '*.go', 'src/**/*.ts')",
|
||||
},
|
||||
},
|
||||
Required: []string{"path_pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grep",
|
||||
Description: "Search for text content within files in the workspace. Limited to 100 results.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"search_term": {
|
||||
Type: "string",
|
||||
Description: "The text pattern to search for",
|
||||
},
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The directory to search in (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"search_term", "path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run_command",
|
||||
Description: "Execute a shell command through the user's login shell within the workspace directory. Shell syntax, pipes, redirects, and installed tools available on PATH are supported.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"command": {
|
||||
Type: "string",
|
||||
Description: "The command to execute (e.g., 'ls', 'cat', 'go build', 'npm test')",
|
||||
},
|
||||
"args": {
|
||||
Type: "array",
|
||||
Items: &Schema{
|
||||
Type: "string",
|
||||
},
|
||||
Description: "Arguments for the command",
|
||||
},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_ = workspacePath
|
||||
return tools
|
||||
}
|
||||
Reference in New Issue
Block a user