feat: complete v0.3.1 agent workflow
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ApplyPatchTool — multi-file patch using V4 format
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ApplyPatchTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewApplyPatchTool(workingDir string) *ApplyPatchTool {
|
||||
return &ApplyPatchTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
// MutatingTool interface.
|
||||
|
||||
func (*ApplyPatchTool) Mutates() bool { return true }
|
||||
|
||||
// CallMutatingTool — dry_run is non-mutating.
|
||||
func (t *ApplyPatchTool) MutatesCall(args json.RawMessage) bool {
|
||||
var a applyPatchArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return true // conservative
|
||||
}
|
||||
return !a.DryRun
|
||||
}
|
||||
|
||||
// PreviewableTool interface.
|
||||
|
||||
func (t *ApplyPatchTool) Preview(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var a applyPatchArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
return summarizePatch(a.Patch)
|
||||
}
|
||||
|
||||
// TargetPathTool interface.
|
||||
|
||||
func (t *ApplyPatchTool) TargetPaths(args json.RawMessage) ([]string, error) {
|
||||
var a applyPatchArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
return extractTargetPaths(t.workingDir, a.Patch)
|
||||
}
|
||||
|
||||
// Definition.
|
||||
|
||||
func (t *ApplyPatchTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "apply_patch",
|
||||
Description: "Apply a multi-file patch to create, update, or delete files. Uses a V4 diff format with *** Begin Patch / *** Add File / *** Update File / *** Delete File markers. Prefer this over multiple file_edit calls when changing 2+ files.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch": {"type": "string", "description": "V4 format patch text. Must start with *** Begin Patch and end with *** End Patch."},
|
||||
"dry_run": {"type": "boolean", "description": "Preview the patch without writing any files."}
|
||||
},
|
||||
"required": ["patch"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type applyPatchArgs struct {
|
||||
Patch string `json:"patch"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// Execute.
|
||||
|
||||
func (t *ApplyPatchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args applyPatchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(args.Patch) == "" {
|
||||
return "", fmt.Errorf("patch is empty")
|
||||
}
|
||||
|
||||
pp, err := parsePatch(args.Patch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if args.DryRun {
|
||||
summary, err := summarizePatch(args.Patch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "dry run — " + summary, nil
|
||||
}
|
||||
|
||||
rollback := NewRollbackStore()
|
||||
var snapshotIDs []string
|
||||
for _, pf := range pp.files {
|
||||
path, err := resolvePathForWrite(t.workingDir, pf.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := rollback.Capture("apply_patch", pf.path, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
snapshotIDs = append(snapshotIDs, id)
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, pf := range pp.files {
|
||||
if err := ctx.Err(); err != nil {
|
||||
if rollbackErr := restoreSnapshots(rollback, snapshotIDs); rollbackErr != nil {
|
||||
return "", fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
result, err := applyPatchFile(t.workingDir, pf)
|
||||
if err != nil {
|
||||
if rollbackErr := restoreSnapshots(rollback, snapshotIDs); rollbackErr != nil {
|
||||
return "", fmt.Errorf("apply patch: %w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return "", fmt.Errorf("apply patch: %w", err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("applied patch: %d file(s) changed\n%s", len(results), strings.Join(results, "\n"))
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func restoreSnapshots(store *RollbackStore, ids []string) error {
|
||||
var errs []error
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if _, err := store.Restore(ids[i]); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
+44
-16
@@ -13,6 +13,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
@@ -83,6 +84,33 @@ func NewFileWriteTool(workingDir string) *FileWriteTool {
|
||||
}
|
||||
func (*FileWriteTool) Mutates() bool { return true }
|
||||
|
||||
func (t *FileWriteTool) Preview(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)
|
||||
}
|
||||
action := "write"
|
||||
if args.Append {
|
||||
action = "append"
|
||||
}
|
||||
return fmt.Sprintf("%s %d bytes to %s", action, len(args.Content), args.Path), ctx.Err()
|
||||
}
|
||||
|
||||
func (t *FileWriteTool) TargetPaths(raw json.RawMessage) ([]string, error) {
|
||||
var args fileWriteArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return nil, errors.New("path is required")
|
||||
}
|
||||
path, err := resolvePathForWrite(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{path}, nil
|
||||
}
|
||||
|
||||
func (t *FileWriteTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
@@ -117,28 +145,28 @@ func (t *FileWriteTool) Execute(ctx context.Context, raw json.RawMessage) (strin
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return "", errors.New("path is required")
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
path, err := resolvePathForWrite(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
|
||||
}
|
||||
content := []byte(args.Content)
|
||||
perm := os.FileMode(0o644)
|
||||
if info, statErr := os.Stat(path); statErr == nil {
|
||||
perm = info.Mode().Perm()
|
||||
}
|
||||
if args.Append {
|
||||
existing, readErr := os.ReadFile(path)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return "", readErr
|
||||
}
|
||||
content = append(existing, content...)
|
||||
}
|
||||
if err := safeio.AtomicWriteFile(path, content, perm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
action := "wrote"
|
||||
if args.Append {
|
||||
action = "appended"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,36 @@ func NewFileEditTool(workingDir string) *FileEditTool {
|
||||
|
||||
func (*FileEditTool) Mutates() bool { return true }
|
||||
|
||||
func (t *FileEditTool) Preview(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args fileEditArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if args.StartLine > 0 {
|
||||
end := args.EndLine
|
||||
if end == 0 {
|
||||
end = args.StartLine
|
||||
}
|
||||
return fmt.Sprintf("edit %s lines %d-%d", args.Path, args.StartLine, end), ctx.Err()
|
||||
}
|
||||
return fmt.Sprintf("replace text in %s", args.Path), ctx.Err()
|
||||
}
|
||||
|
||||
func (t *FileEditTool) TargetPaths(raw json.RawMessage) ([]string, error) {
|
||||
var args fileEditArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Path) == "" {
|
||||
return nil, errors.New("path is required")
|
||||
}
|
||||
path, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{path}, nil
|
||||
}
|
||||
|
||||
func (t *FileEditTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
@@ -111,7 +142,7 @@ func (t *FileEditTool) Execute(ctx context.Context, raw json.RawMessage) (string
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(result), perm); err != nil {
|
||||
if err := safeio.AtomicWriteFile(path, []byte(result), perm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
const defaultOutlineMaxFiles = 200
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ProjectOutlineTool — gitignore-aware file tree + symbol outline
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ProjectOutlineTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewProjectOutlineTool(workingDir string) *ProjectOutlineTool {
|
||||
return &ProjectOutlineTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *ProjectOutlineTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "project_outline",
|
||||
Description: "Get the project file tree and code symbol outline in one call. Respects .gitignore. Use this first to understand project structure before reading individual files.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Subdirectory to outline. Defaults to the project root."},
|
||||
"max_files": {"type": "integer", "description": "Maximum files to include in the outline. Defaults to 200."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type outlineArgs struct {
|
||||
Path string `json:"path"`
|
||||
MaxFiles int `json:"max_files"`
|
||||
}
|
||||
|
||||
func (t *ProjectOutlineTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args outlineArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
|
||||
root, err := resolvePath(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
limit := args.MaxFiles
|
||||
if limit <= 0 {
|
||||
limit = defaultOutlineMaxFiles
|
||||
}
|
||||
|
||||
// Parse .gitignore files from the root upward.
|
||||
ignorer := newGitignoreMatcher(root)
|
||||
|
||||
var out strings.Builder
|
||||
out.WriteString(root)
|
||||
out.WriteString("\n")
|
||||
|
||||
count := 0
|
||||
truncated := false
|
||||
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Skip always-ignored directories.
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
// Always skip VCS and common build-artifact directories.
|
||||
switch name {
|
||||
case ".git", ".hg", ".svn", "vendor", "node_modules",
|
||||
"dist", "build", "__pycache__", "target":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Skip hidden directories other than .github and similar.
|
||||
if strings.HasPrefix(name, ".") && name != ".github" && name != ".vscode" && name != ".devcontainer" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check gitignore.
|
||||
if ignorer.shouldIgnore(rel, d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
// Build indented tree entry.
|
||||
depth := strings.Count(rel, string(filepath.Separator))
|
||||
indent := strings.Repeat(" ", depth)
|
||||
name := d.Name()
|
||||
|
||||
if d.IsDir() {
|
||||
out.WriteString(fmt.Sprintf("%s%s/\n", indent, name))
|
||||
return nil
|
||||
}
|
||||
|
||||
count++
|
||||
out.WriteString(indent + name)
|
||||
|
||||
// Extract symbols for source files.
|
||||
ext := filepath.Ext(name)
|
||||
if sourceFileExtensions[ext] {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr == nil && len(data) <= 256*1024 {
|
||||
symbols, symErr := extractSymbols(path, data)
|
||||
if symErr == nil && len(symbols) > 0 {
|
||||
out.WriteString(" — ")
|
||||
out.WriteString(compactSymbolSummary(symbols))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteString("\n")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil && !errors.Is(err, filepath.SkipAll) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if truncated {
|
||||
out.WriteString(fmt.Sprintf("\n... (%d files shown, more available; use file_list or code_symbols for details)\n", count))
|
||||
}
|
||||
|
||||
return FormatResult(out.String()), nil
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Compact symbol summary for inline display in the file tree
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func compactSymbolSummary(symbols []symbolInfo) string {
|
||||
var parts []string
|
||||
|
||||
// Group by kind.
|
||||
funcs := []string{}
|
||||
methods := []string{}
|
||||
types := []string{}
|
||||
|
||||
for _, s := range symbols {
|
||||
switch s.Kind {
|
||||
case "function":
|
||||
funcs = append(funcs, s.Name)
|
||||
case "method":
|
||||
methods = append(methods, s.Name)
|
||||
case "class", "interface", "struct", "enum", "trait", "alias", "func", "type", "impl":
|
||||
types = append(types, s.Name)
|
||||
}
|
||||
// Skip imports and package for compact view.
|
||||
}
|
||||
|
||||
if len(funcs) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("fn: %s", strings.Join(funcs, ", ")))
|
||||
}
|
||||
if len(methods) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("method: %s", strings.Join(methods, ", ")))
|
||||
}
|
||||
if len(types) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("type: %s", strings.Join(types, ", ")))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Simple .gitignore matcher
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type gitignoreMatcher struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
func newGitignoreMatcher(root string) *gitignoreMatcher {
|
||||
m := &gitignoreMatcher{}
|
||||
// Walk up from root, collecting .gitignore patterns.
|
||||
for dir := root; dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
|
||||
giPath := filepath.Join(dir, ".gitignore")
|
||||
data, err := os.ReadFile(giPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Remove leading / for anchored patterns; we handle all as relative.
|
||||
line = strings.TrimPrefix(line, "/")
|
||||
// Remove trailing / for directory-only patterns.
|
||||
line = strings.TrimSuffix(line, "/")
|
||||
m.patterns = append(m.patterns, line)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *gitignoreMatcher) shouldIgnore(path string, isDir bool) bool {
|
||||
name := filepath.Base(path)
|
||||
for _, pat := range m.patterns {
|
||||
// Simple match against full relative path or just the basename.
|
||||
matched, _ := filepath.Match(pat, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
matched, _ = filepath.Match(pat, name)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
)
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// V4 patch format data structures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type patchAction string
|
||||
|
||||
const (
|
||||
actionAdd patchAction = "add"
|
||||
actionUpdate patchAction = "update"
|
||||
actionDelete patchAction = "delete"
|
||||
)
|
||||
|
||||
type patchFile struct {
|
||||
action patchAction
|
||||
path string
|
||||
newContent string // for "add" — full file content
|
||||
lines []string // for "update" — lines to match/replace; for "delete" — unused
|
||||
}
|
||||
|
||||
type parsedPatch struct {
|
||||
files []patchFile
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Parser
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// parsePatch parses a V4-format patch string.
|
||||
//
|
||||
// Format:
|
||||
//
|
||||
// *** Begin Patch
|
||||
// *** Add File: <path>
|
||||
// <file content>
|
||||
// *** Update File: <path>
|
||||
// @@
|
||||
// <context line>
|
||||
// @@
|
||||
// -<old line>
|
||||
// +<new line>
|
||||
// *** Delete File: <path>
|
||||
// *** End Patch
|
||||
func parsePatch(patch string) (*parsedPatch, error) {
|
||||
patch = strings.TrimSpace(patch)
|
||||
if patch == "" {
|
||||
return nil, fmt.Errorf("patch is empty")
|
||||
}
|
||||
|
||||
lines := strings.Split(patch, "\n")
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("patch is empty")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(lines[0]) != "*** Begin Patch" {
|
||||
return nil, fmt.Errorf("patch must start with *** Begin Patch")
|
||||
}
|
||||
|
||||
var files []patchFile
|
||||
seenPaths := make(map[string]struct{})
|
||||
seenEnd := false
|
||||
i := 1
|
||||
|
||||
for i < len(lines) {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
|
||||
switch {
|
||||
case line == "*** End Patch":
|
||||
seenEnd = true
|
||||
i = len(lines)
|
||||
case strings.HasPrefix(line, "*** Add File:"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Add File:"))
|
||||
if err := validatePatchPath(path, seenPaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i++
|
||||
var contentLines []string
|
||||
for i < len(lines) {
|
||||
next := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(next, "***") {
|
||||
break
|
||||
}
|
||||
content := lines[i]
|
||||
if strings.HasPrefix(content, "+") {
|
||||
content = strings.TrimPrefix(content, "+")
|
||||
}
|
||||
contentLines = append(contentLines, content)
|
||||
i++
|
||||
}
|
||||
files = append(files, patchFile{
|
||||
action: actionAdd,
|
||||
path: path,
|
||||
newContent: strings.Join(contentLines, "\n") + "\n",
|
||||
})
|
||||
|
||||
case strings.HasPrefix(line, "*** Delete File:"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Delete File:"))
|
||||
if err := validatePatchPath(path, seenPaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, patchFile{
|
||||
action: actionDelete,
|
||||
path: path,
|
||||
})
|
||||
i++
|
||||
|
||||
case strings.HasPrefix(line, "*** Update File:"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Update File:"))
|
||||
if err := validatePatchPath(path, seenPaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i++
|
||||
var hunkLines []string
|
||||
for i < len(lines) {
|
||||
next := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(next, "***") {
|
||||
break
|
||||
}
|
||||
hunkLines = append(hunkLines, lines[i])
|
||||
i++
|
||||
}
|
||||
files = append(files, patchFile{
|
||||
action: actionUpdate,
|
||||
path: path,
|
||||
lines: hunkLines,
|
||||
})
|
||||
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("patch contains no file operations")
|
||||
}
|
||||
if !seenEnd {
|
||||
return nil, fmt.Errorf("patch must end with *** End Patch")
|
||||
}
|
||||
|
||||
return &parsedPatch{files: files}, nil
|
||||
}
|
||||
|
||||
func validatePatchPath(path string, seen map[string]struct{}) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("patch file path is required")
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
return fmt.Errorf("patch contains duplicate file path: %s", path)
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Applier
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// applyPatchFile applies a single patch file operation.
|
||||
func applyPatchFile(baseDir string, pf patchFile) (string, error) {
|
||||
switch pf.action {
|
||||
case actionAdd:
|
||||
return applyAdd(baseDir, pf)
|
||||
case actionUpdate:
|
||||
return applyUpdate(baseDir, pf)
|
||||
case actionDelete:
|
||||
return applyDelete(baseDir, pf)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown patch action: %s", pf.action)
|
||||
}
|
||||
}
|
||||
|
||||
func applyAdd(baseDir string, pf patchFile) (string, error) {
|
||||
path, err := resolvePathForWrite(baseDir, pf.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return "", fmt.Errorf("cannot add existing file: %s", pf.path)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("stat new file %s: %w", pf.path, err)
|
||||
}
|
||||
if err := safeio.AtomicWriteFile(path, []byte(pf.newContent), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write new file %s: %w", pf.path, err)
|
||||
}
|
||||
return fmt.Sprintf("+ %s", pf.path), nil
|
||||
}
|
||||
|
||||
func applyDelete(baseDir string, pf patchFile) (string, error) {
|
||||
path, err := resolvePath(baseDir, pf.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("cannot delete non-existent file: %s", pf.path)
|
||||
}
|
||||
return "", err
|
||||
} else if info.IsDir() {
|
||||
return "", fmt.Errorf("cannot delete directory: %s", pf.path)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return "", fmt.Errorf("delete file %s: %w", pf.path, err)
|
||||
}
|
||||
// Try to remove empty parent directories.
|
||||
dir := filepath.Dir(path)
|
||||
for dir != baseDir && dir != "." {
|
||||
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
|
||||
os.Remove(dir)
|
||||
dir = filepath.Dir(dir)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("- %s", pf.path), nil
|
||||
}
|
||||
|
||||
func applyUpdate(baseDir string, pf patchFile) (string, error) {
|
||||
path, err := resolvePath(baseDir, pf.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot update non-existent file: %s: %w", pf.path, err)
|
||||
}
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot update non-existent file: %s", pf.path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat file %s: %w", pf.path, err)
|
||||
}
|
||||
|
||||
fileLines := strings.Split(string(existing), "\n")
|
||||
|
||||
// Parse the hunk lines into: context blocks + old lines + new lines.
|
||||
result, err := applyHunkLines(fileLines, pf.lines, pf.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newContent := strings.Join(result, "\n")
|
||||
if err := safeio.AtomicWriteFile(path, []byte(newContent), info.Mode().Perm()); err != nil {
|
||||
return "", fmt.Errorf("write updated file %s: %w", pf.path, err)
|
||||
}
|
||||
return fmt.Sprintf("~ %s", pf.path), nil
|
||||
}
|
||||
|
||||
// applyHunkLines applies a sequence of hunk lines to the file content.
|
||||
// The hunk lines are a mix of:
|
||||
//
|
||||
// @@ — hunk separator (a context line block starts)
|
||||
// <plain line> — context line (must appear in both old and new)
|
||||
// -<line> — line to remove
|
||||
// +<line> — line to add
|
||||
//
|
||||
// Hunks are matched greedily: for each group of contiguous context + - + + lines,
|
||||
// find the first position in the file where context + - lines match,
|
||||
// replace with context + + lines.
|
||||
func applyHunkLines(fileLines []string, hunkLines []string, path string) ([]string, error) {
|
||||
if len(hunkLines) == 0 {
|
||||
return fileLines, nil
|
||||
}
|
||||
|
||||
// Split the hunk lines into separate hunks at @@ markers.
|
||||
type hunk struct {
|
||||
lines []string
|
||||
}
|
||||
var hunks []hunk
|
||||
var current hunk
|
||||
|
||||
for _, hl := range hunkLines {
|
||||
trimmed := strings.TrimSpace(hl)
|
||||
if strings.HasPrefix(trimmed, "@@") {
|
||||
if len(current.lines) > 0 {
|
||||
hunks = append(hunks, current)
|
||||
current = hunk{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.lines = append(current.lines, hl)
|
||||
}
|
||||
if len(current.lines) > 0 {
|
||||
hunks = append(hunks, current)
|
||||
}
|
||||
|
||||
if len(hunks) == 0 {
|
||||
return fileLines, nil
|
||||
}
|
||||
|
||||
result := make([]string, len(fileLines))
|
||||
copy(result, fileLines)
|
||||
offset := 0 // tracks net line count change from previous hunks
|
||||
|
||||
for hi, h := range hunks {
|
||||
// Separate the hunk lines into a search pattern and replacement.
|
||||
searchLines, replaceLines := buildHunkSearchReplace(h.lines)
|
||||
if len(searchLines) == 0 && len(replaceLines) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
matchStart := findHunkMatch(result, searchLines, offset)
|
||||
if matchStart < 0 {
|
||||
return nil, fmt.Errorf("hunk context not found in %s", path)
|
||||
}
|
||||
|
||||
// Replace: remove searchLines, insert replaceLines.
|
||||
matchEnd := matchStart + len(searchLines)
|
||||
newResult := make([]string, 0, len(result)-len(searchLines)+len(replaceLines))
|
||||
newResult = append(newResult, result[:matchStart]...)
|
||||
newResult = append(newResult, replaceLines...)
|
||||
newResult = append(newResult, result[matchEnd:]...)
|
||||
result = newResult
|
||||
|
||||
offset = matchStart + len(replaceLines)
|
||||
|
||||
// Multiple matches check for this hunk only.
|
||||
secondMatch := findHunkMatch(result, searchLines, offset)
|
||||
if secondMatch >= 0 {
|
||||
return nil, fmt.Errorf("hunk context matched multiple times in %s", path)
|
||||
}
|
||||
|
||||
_ = hi
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildHunkSearchReplace splits a hunk's lines into the search pattern
|
||||
// (context + old lines) and replacement (context + new lines).
|
||||
func buildHunkSearchReplace(lines []string) (search, replace []string) {
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "-") {
|
||||
search = append(search, l[1:])
|
||||
} else if strings.HasPrefix(l, "+") {
|
||||
replace = append(replace, l[1:])
|
||||
} else {
|
||||
contextLine := strings.TrimPrefix(l, " ")
|
||||
search = append(search, contextLine)
|
||||
replace = append(replace, contextLine)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// findHunkMatch finds the first position >= offset in fileLines where
|
||||
// searchLines matches exactly (line-by-line comparison).
|
||||
func findHunkMatch(fileLines []string, searchLines []string, offset int) int {
|
||||
for i := offset; i <= len(fileLines)-len(searchLines); i++ {
|
||||
match := true
|
||||
for j, sl := range searchLines {
|
||||
if fileLines[i+j] != sl {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// summarizePatch returns a human-readable summary of the patch.
|
||||
func summarizePatch(patch string) (string, error) {
|
||||
pp, err := parsePatch(patch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
added, updated, deleted := 0, 0, 0
|
||||
for _, pf := range pp.files {
|
||||
switch pf.action {
|
||||
case actionAdd:
|
||||
added++
|
||||
fmt.Fprintf(&b, "+ add %s\n", pf.path)
|
||||
case actionUpdate:
|
||||
updated++
|
||||
fmt.Fprintf(&b, "~ update %s\n", pf.path)
|
||||
case actionDelete:
|
||||
deleted++
|
||||
fmt.Fprintf(&b, "- delete %s\n", pf.path)
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("patch: %d file(s) changed", len(pp.files))
|
||||
if added > 0 {
|
||||
summary += fmt.Sprintf(" (%d added)", added)
|
||||
}
|
||||
if updated > 0 {
|
||||
summary += fmt.Sprintf(" (%d updated)", updated)
|
||||
}
|
||||
if deleted > 0 {
|
||||
summary += fmt.Sprintf(" (%d deleted)", deleted)
|
||||
}
|
||||
|
||||
return summary + "\n" + b.String(), nil
|
||||
}
|
||||
|
||||
// extractTargetPaths returns all file paths referenced in the patch.
|
||||
func extractTargetPaths(baseDir, patch string) ([]string, error) {
|
||||
pp, err := parsePatch(patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, pf := range pp.files {
|
||||
// Use resolvePathForWrite for all files — TargetPaths needs paths
|
||||
// even for files that don't exist yet (rollback snapshots).
|
||||
resolved, err := resolvePathForWrite(baseDir, pf.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file %s: %w", pf.path, err)
|
||||
}
|
||||
paths = append(paths, resolved)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
+68
-5
@@ -2,22 +2,85 @@ package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func resolvePath(baseDir, path string) (string, error) {
|
||||
resolved, canonicalBase, err := resolveLexicalPath(baseDir, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isWithinDir(canonical, canonicalBase) {
|
||||
return "", fmt.Errorf("path escapes the project directory through a symbolic link: %s", path)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// resolvePathForWrite resolves a path that may not exist yet while preventing
|
||||
// existing parent-directory symlinks from escaping the project directory.
|
||||
func resolvePathForWrite(baseDir, path string) (string, error) {
|
||||
resolved, canonicalBase, err := resolveLexicalPath(baseDir, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
existing := resolved
|
||||
var suffix []string
|
||||
for {
|
||||
_, err := os.Lstat(existing)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return "", err
|
||||
}
|
||||
suffix = append(suffix, filepath.Base(existing))
|
||||
existing = parent
|
||||
}
|
||||
|
||||
canonical, err := filepath.EvalSymlinks(existing)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := len(suffix) - 1; i >= 0; i-- {
|
||||
canonical = filepath.Join(canonical, suffix[i])
|
||||
}
|
||||
if !isWithinDir(canonical, canonicalBase) {
|
||||
return "", fmt.Errorf("path escapes the project directory through a symbolic link: %s", path)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func resolveLexicalPath(baseDir, path string) (string, string, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("absolute paths are not allowed; use a path relative to the project directory")
|
||||
return "", "", fmt.Errorf("absolute paths are not allowed; use a path relative to the project directory")
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(baseDir, path))
|
||||
if !isWithinDir(resolved, baseDir) {
|
||||
return "", fmt.Errorf("path escapes the project directory: %s", path)
|
||||
absBase, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resolved, nil
|
||||
canonicalBase, err := filepath.EvalSymlinks(absBase)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(absBase, path))
|
||||
if !isWithinDir(resolved, absBase) {
|
||||
return "", "", fmt.Errorf("path escapes the project directory: %s", path)
|
||||
}
|
||||
return resolved, canonicalBase, nil
|
||||
}
|
||||
|
||||
func isWithinDir(path, dir string) bool {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
)
|
||||
|
||||
// RollbackSnapshot captures the state of a file before a mutation.
|
||||
type RollbackSnapshot struct {
|
||||
ID string
|
||||
Tool string
|
||||
Path string
|
||||
ResolvedPath string
|
||||
Existed bool
|
||||
Data []byte
|
||||
Mode fs.FileMode
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RollbackStore manages in-memory rollback snapshots for the current process.
|
||||
type RollbackStore struct {
|
||||
mu sync.Mutex
|
||||
snapshots []RollbackSnapshot
|
||||
}
|
||||
|
||||
// NewRollbackStore creates an empty rollback store.
|
||||
func NewRollbackStore() *RollbackStore {
|
||||
return &RollbackStore{}
|
||||
}
|
||||
|
||||
// generateSnapshotID creates a rollback ID: "rb_" + 12 lowercase hex chars.
|
||||
func generateSnapshotID() (string, error) {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate snapshot id: %w", err)
|
||||
}
|
||||
return "rb_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Capture stores the current state of the file at resolvedPath before a tool
|
||||
// modifies it. Returns the snapshot ID.
|
||||
func (s *RollbackStore) Capture(tool string, displayPath string, resolvedPath string) (string, error) {
|
||||
id, err := generateSnapshotID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
snap := RollbackSnapshot{
|
||||
ID: id,
|
||||
Tool: tool,
|
||||
Path: displayPath,
|
||||
ResolvedPath: resolvedPath,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
info, err := os.Stat(resolvedPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
snap.Existed = false
|
||||
} else {
|
||||
return "", fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
} else {
|
||||
snap.Existed = true
|
||||
snap.Mode = info.Mode().Perm()
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read file for snapshot: %w", err)
|
||||
}
|
||||
snap.Data = data
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.snapshots = append(s.snapshots, snap)
|
||||
s.mu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Restore rolls back a file to the state captured in the given snapshot.
|
||||
func (s *RollbackStore) Restore(id string) (RollbackSnapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := len(s.snapshots) - 1; i >= 0; i-- {
|
||||
if s.snapshots[i].ID == id {
|
||||
snap := s.snapshots[i]
|
||||
if snap.Existed {
|
||||
if err := safeio.AtomicWriteFile(snap.ResolvedPath, snap.Data, snap.Mode); err != nil {
|
||||
return RollbackSnapshot{}, fmt.Errorf("restore file: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(snap.ResolvedPath); err != nil && !os.IsNotExist(err) {
|
||||
return RollbackSnapshot{}, fmt.Errorf("remove created file: %w", err)
|
||||
}
|
||||
}
|
||||
s.snapshots = append(s.snapshots[:i], s.snapshots[i+1:]...)
|
||||
return snap, nil
|
||||
}
|
||||
}
|
||||
return RollbackSnapshot{}, fmt.Errorf("rollback snapshot %s not found", id)
|
||||
}
|
||||
|
||||
// LastID returns the most recent snapshot ID, or "" if empty.
|
||||
func (s *RollbackStore) LastID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.snapshots) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.snapshots[len(s.snapshots)-1].ID
|
||||
}
|
||||
@@ -23,6 +23,17 @@ func NewShellRunTool(workingDir string) *ShellRunTool {
|
||||
}
|
||||
func (*ShellRunTool) Mutates() bool { return true }
|
||||
|
||||
func (t *ShellRunTool) Preview(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")
|
||||
}
|
||||
return "$ " + strings.TrimSpace(args.Command), ctx.Err()
|
||||
}
|
||||
|
||||
func (t *ShellRunTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// symbolInfo holds extracted symbol metadata.
|
||||
type symbolInfo struct {
|
||||
Name string
|
||||
Kind string // "import", "function", "method", "class", "type", "interface", "trait", "enum", "struct"
|
||||
StartLine int
|
||||
}
|
||||
|
||||
// sourceFileExtensions lists extensions that we can extract symbols from.
|
||||
var sourceFileExtensions = map[string]bool{
|
||||
".go": true,
|
||||
".ts": true,
|
||||
".tsx": true,
|
||||
".js": true,
|
||||
".jsx": true,
|
||||
".mjs": true,
|
||||
".py": true,
|
||||
".rs": true,
|
||||
}
|
||||
|
||||
// extractSymbols extracts symbols from a source file using language-specific
|
||||
// parsing (go/ast for Go, regex for other languages).
|
||||
func extractSymbols(path string, data []byte) ([]symbolInfo, error) {
|
||||
ext := ""
|
||||
if idx := strings.LastIndex(path, "."); idx >= 0 {
|
||||
ext = path[idx:]
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".go":
|
||||
return extractGoSymbols(path, data)
|
||||
case ".py":
|
||||
return extractPythonsymbols(data)
|
||||
case ".ts", ".tsx", ".js", ".jsx", ".mjs":
|
||||
return extractTSSymbols(data)
|
||||
case ".rs":
|
||||
return extractRustSymbols(data)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Go symbol extraction (using go/ast)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func extractGoSymbols(path string, data []byte) ([]symbolInfo, error) {
|
||||
fset := token.NewFileSet()
|
||||
// Parse imports only first for the import list.
|
||||
importFile, err := parser.ParseFile(fset, path, data, parser.ImportsOnly|parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full, err := parser.ParseFile(fset, path, data, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []symbolInfo
|
||||
|
||||
// Package
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: full.Name.Name,
|
||||
Kind: "package",
|
||||
StartLine: fset.Position(full.Name.Pos()).Line,
|
||||
})
|
||||
|
||||
// Imports
|
||||
for _, imp := range importFile.Imports {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: strings.Trim(imp.Path.Value, "\""),
|
||||
Kind: "import",
|
||||
StartLine: fset.Position(imp.Pos()).Line,
|
||||
})
|
||||
}
|
||||
|
||||
// Declarations
|
||||
for _, decl := range full.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.GenDecl:
|
||||
if d.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range d.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok {
|
||||
kind := goTypeKind(ts.Type)
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: ts.Name.Name,
|
||||
Kind: kind,
|
||||
StartLine: fset.Position(ts.Pos()).Line,
|
||||
})
|
||||
}
|
||||
}
|
||||
case *ast.FuncDecl:
|
||||
line := fset.Position(d.Pos()).Line
|
||||
if d.Recv != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: fmt.Sprintf("%s.%s", goReceiverName(d.Recv), d.Name.Name),
|
||||
Kind: "method",
|
||||
StartLine: line,
|
||||
})
|
||||
} else {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: d.Name.Name,
|
||||
Kind: "function",
|
||||
StartLine: line,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortSymbols(symbols)
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
func goTypeKind(expr ast.Expr) string {
|
||||
switch expr.(type) {
|
||||
case *ast.StructType:
|
||||
return "struct"
|
||||
case *ast.InterfaceType:
|
||||
return "interface"
|
||||
case *ast.FuncType:
|
||||
return "func"
|
||||
default:
|
||||
return "alias"
|
||||
}
|
||||
}
|
||||
|
||||
func goReceiverName(recv *ast.FieldList) string {
|
||||
if recv == nil || len(recv.List) == 0 {
|
||||
return "?"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := printer.Fprint(&b, token.NewFileSet(), recv.List[0].Type); err != nil {
|
||||
return "?"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Python symbol extraction (regex-based)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
pyFuncRe = regexp.MustCompile(`^\s*def\s+(\w+)\s*\(`)
|
||||
pyClassRe = regexp.MustCompile(`^\s*class\s+(\w+)\s*[(:]`)
|
||||
pyImportRe = regexp.MustCompile(`^\s*(?:from\s+\S+\s+)?import\s+`)
|
||||
pyDecoratorRe = regexp.MustCompile(`^\s*@`)
|
||||
)
|
||||
|
||||
func extractPythonsymbols(data []byte) ([]symbolInfo, error) {
|
||||
var symbols []symbolInfo
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
lineNum := 0
|
||||
inDecorator := false
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Track decorator stacks
|
||||
if pyDecoratorRe.MatchString(line) {
|
||||
inDecorator = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Import lines
|
||||
if pyImportRe.MatchString(line) {
|
||||
// Extract the module name for a simple "import foo" or "from foo import bar"
|
||||
importPath := strings.TrimSpace(line)
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: importPath,
|
||||
Kind: "import",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
inDecorator = false
|
||||
continue
|
||||
}
|
||||
|
||||
matches := pyClassRe.FindStringSubmatch(line)
|
||||
if matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "class",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
inDecorator = false
|
||||
continue
|
||||
}
|
||||
|
||||
matches = pyFuncRe.FindStringSubmatch(line)
|
||||
if matches != nil {
|
||||
kind := "function"
|
||||
if inDecorator {
|
||||
// Could be a decorated function or method — still mark as function
|
||||
// (class-method detection requires indentation tracking)
|
||||
}
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: kind,
|
||||
StartLine: lineNum,
|
||||
})
|
||||
inDecorator = false
|
||||
continue
|
||||
}
|
||||
|
||||
inDecorator = false
|
||||
}
|
||||
|
||||
sortSymbols(symbols)
|
||||
return symbols, scanner.Err()
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// TypeScript / JavaScript symbol extraction (regex-based)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
tsFuncRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)\s*[<(]`)
|
||||
tsArrowRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\s*\(`)
|
||||
tsClassRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?class\s+(\w+)`)
|
||||
tsInterfaceRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?interface\s+(\w+)`)
|
||||
tsTypeRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?type\s+(\w+)\s*=`)
|
||||
tsEnumRe = regexp.MustCompile(`(?:export\s+(?:default\s+)?)?enum\s+(\w+)`)
|
||||
tsImportRe = regexp.MustCompile(`^\s*import\s+`)
|
||||
tsMethodRe = regexp.MustCompile(`^\s*(?:public\s+|private\s+|protected\s+|static\s+|async\s+)*(\w+)\s*[<(][^)]*\)\s*(?::\s*\S+\s*)?\{`)
|
||||
)
|
||||
|
||||
func extractTSSymbols(data []byte) ([]symbolInfo, error) {
|
||||
var symbols []symbolInfo
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
lineNum := 0
|
||||
|
||||
// Track brace depth to detect class body methods.
|
||||
braceDepth := 0
|
||||
inClass := false
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Count braces on this line (applied at end of iteration).
|
||||
opens := strings.Count(line, "{")
|
||||
closes := strings.Count(line, "}")
|
||||
|
||||
// Import lines — skip everything else, just track braces.
|
||||
if tsImportRe.MatchString(line) {
|
||||
importPath := trimmed
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: importPath,
|
||||
Kind: "import",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Skip empty and comment-only lines.
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") {
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Interface
|
||||
if matches := tsInterfaceRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "interface",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Type alias
|
||||
if matches := tsTypeRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "type",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Enum
|
||||
if matches := tsEnumRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "enum",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Class
|
||||
if matches := tsClassRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "class",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
// Set inClass BEFORE applying braces for this line, so that
|
||||
// methods on the same line (unlikely) or next line are detected.
|
||||
if opens > 0 {
|
||||
inClass = true
|
||||
}
|
||||
goto applyBraces
|
||||
}
|
||||
|
||||
// Method inside class body (at depth 1 brace level).
|
||||
if inClass && braceDepth == 1 {
|
||||
if matches := tsMethodRe.FindStringSubmatch(line); matches != nil {
|
||||
name := matches[1]
|
||||
if !isKeyword(name) {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: name,
|
||||
Kind: "method",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function declaration (top-level only, or nested function).
|
||||
if !inClass || braceDepth > 1 {
|
||||
if matches := tsFuncRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "function",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow function assigned to const/let/var.
|
||||
if !inClass || braceDepth > 1 {
|
||||
if matches := tsArrowRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "function",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
applyBraces:
|
||||
braceDepth += opens - closes
|
||||
if braceDepth <= 0 {
|
||||
braceDepth = 0
|
||||
inClass = false
|
||||
}
|
||||
}
|
||||
|
||||
sortSymbols(symbols)
|
||||
return symbols, scanner.Err()
|
||||
}
|
||||
|
||||
func isKeyword(s string) bool {
|
||||
switch s {
|
||||
case "if", "else", "for", "while", "switch", "case", "return", "throw",
|
||||
"try", "catch", "finally", "new", "delete", "typeof", "instanceof",
|
||||
"constructor", "get", "set":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Rust symbol extraction (regex-based)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
rsFnRe = regexp.MustCompile(`^\s*(?:pub(?:\s*\(\s*crate\s*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)\s*[<(]`)
|
||||
rsStructRe = regexp.MustCompile(`^\s*(?:pub(?:\s*\(\s*crate\s*\))?\s+)?struct\s+(\w+)`)
|
||||
rsEnumRe = regexp.MustCompile(`^\s*(?:pub(?:\s*\(\s*crate\s*\))?\s+)?enum\s+(\w+)`)
|
||||
rsTraitRe = regexp.MustCompile(`^\s*(?:pub(?:\s*\(\s*crate\s*\))?\s+)?(?:unsafe\s+)?trait\s+(\w+)`)
|
||||
rsImplRe = regexp.MustCompile(`^\s*impl\s+`)
|
||||
rsUseRe = regexp.MustCompile(`^\s*(?:pub\s+)?use\s+`)
|
||||
)
|
||||
|
||||
func extractRustSymbols(data []byte) ([]symbolInfo, error) {
|
||||
var symbols []symbolInfo
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip empty and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use
|
||||
if rsUseRe.MatchString(line) {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: trimmed,
|
||||
Kind: "import",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Struct
|
||||
if matches := rsStructRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "struct",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Enum
|
||||
if matches := rsEnumRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "enum",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Trait
|
||||
if matches := rsTraitRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "trait",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Impl
|
||||
if rsImplRe.MatchString(line) {
|
||||
// Extract type name from "impl Foo" or "impl<T> Foo" or "impl Trait for Foo"
|
||||
rest := strings.TrimPrefix(trimmed, "impl")
|
||||
rest = strings.TrimSpace(rest)
|
||||
// Remove generics
|
||||
if idx := strings.Index(rest, "<"); idx >= 0 {
|
||||
depth := 1
|
||||
for i := idx + 1; i < len(rest); i++ {
|
||||
if rest[i] == '<' {
|
||||
depth++
|
||||
} else if rest[i] == '>' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
rest = strings.TrimSpace(rest[:idx] + rest[i+1:])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle "for Type"
|
||||
if idx := strings.Index(rest, " for "); idx >= 0 {
|
||||
rest = strings.TrimSpace(rest[idx+5:])
|
||||
}
|
||||
name := strings.Fields(rest)[0]
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: name,
|
||||
Kind: "impl",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Function
|
||||
if matches := rsFnRe.FindStringSubmatch(line); matches != nil {
|
||||
symbols = append(symbols, symbolInfo{
|
||||
Name: matches[1],
|
||||
Kind: "function",
|
||||
StartLine: lineNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sortSymbols(symbols)
|
||||
return symbols, scanner.Err()
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func sortSymbols(symbols []symbolInfo) {
|
||||
sort.Slice(symbols, func(i, j int) bool {
|
||||
return symbols[i].StartLine < symbols[j].StartLine
|
||||
})
|
||||
}
|
||||
|
||||
// groupSymbols groups symbols by kind and returns formatted strings.
|
||||
func groupSymbols(symbols []symbolInfo) map[string][]string {
|
||||
groups := make(map[string][]string)
|
||||
for _, s := range symbols {
|
||||
kind := s.Kind
|
||||
switch kind {
|
||||
case "package":
|
||||
groups["package"] = append(groups["package"], s.Name)
|
||||
case "import":
|
||||
groups["imports"] = append(groups["imports"], s.Name)
|
||||
case "function":
|
||||
groups["functions"] = append(groups["functions"], s.Name)
|
||||
case "method":
|
||||
groups["methods"] = append(groups["methods"], s.Name)
|
||||
case "class", "interface", "struct", "enum", "trait", "alias", "func":
|
||||
// All structural types go under "types" for consistency.
|
||||
groups["types"] = append(groups["types"], fmt.Sprintf("%s %s", s.Name, kind))
|
||||
case "type":
|
||||
// TS type alias
|
||||
groups["types"] = append(groups["types"], fmt.Sprintf("%s type", s.Name))
|
||||
case "impl":
|
||||
groups["types"] = append(groups["types"], fmt.Sprintf("impl %s", s.Name))
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// TodoTool — in-memory task tracking
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type todoItem struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"` // "pending", "in_progress", "completed"
|
||||
}
|
||||
|
||||
type TodoTool struct {
|
||||
mu sync.Mutex
|
||||
items []todoItem
|
||||
}
|
||||
|
||||
func NewTodoTool() *TodoTool {
|
||||
return &TodoTool{}
|
||||
}
|
||||
|
||||
func (t *TodoTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "todo",
|
||||
Description: "Manage a task list for the current conversation. Create, update, and track multi-step work. Use this to plan complex tasks and show progress.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["list", "create", "update", "start", "complete", "clear"], "description": "Operation to perform."},
|
||||
"items": {"type": "array", "items": {"type": "string"}, "description": "For 'create': list of task descriptions to add."},
|
||||
"task": {"type": "string", "description": "For 'update'/'start'/'complete': the exact task content string to target."},
|
||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"], "description": "For 'update': new status for the task."}
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type todoArgs struct {
|
||||
Action string `json:"action"`
|
||||
Items []string `json:"items"`
|
||||
Task string `json:"task"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (t *TodoTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args todoArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch args.Action {
|
||||
case "list":
|
||||
return t.listTasks(), nil
|
||||
case "create":
|
||||
return t.createTasks(args.Items)
|
||||
case "start":
|
||||
return t.startTask(args.Task)
|
||||
case "complete":
|
||||
return t.completeTask(args.Task)
|
||||
case "update":
|
||||
return t.updateTask(args.Task, args.Status)
|
||||
case "clear":
|
||||
t.items = nil
|
||||
return "Tasks cleared.", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown todo action: %s", args.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TodoTool) listTasks() string {
|
||||
if len(t.items) == 0 {
|
||||
return "No tasks."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Tasks:\n")
|
||||
for _, item := range t.items {
|
||||
fmt.Fprintf(&b, "[%s] %s\n", item.Status, item.Content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t *TodoTool) createTasks(items []string) (string, error) {
|
||||
if len(items) == 0 {
|
||||
return "", fmt.Errorf("items is required for create")
|
||||
}
|
||||
for _, content := range items {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("task content must not be empty")
|
||||
}
|
||||
t.items = append(t.items, todoItem{
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
})
|
||||
}
|
||||
return t.listTasks(), nil
|
||||
}
|
||||
|
||||
func (t *TodoTool) startTask(task string) (string, error) {
|
||||
idx := -1
|
||||
for i, item := range t.items {
|
||||
if item.Content == task {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("task not found: %s", task)
|
||||
}
|
||||
|
||||
// Set all in_progress items to pending, then mark the target as in_progress.
|
||||
for i := range t.items {
|
||||
if t.items[i].Status == "in_progress" {
|
||||
t.items[i].Status = "pending"
|
||||
}
|
||||
}
|
||||
t.items[idx].Status = "in_progress"
|
||||
return t.listTasks(), nil
|
||||
}
|
||||
|
||||
func (t *TodoTool) completeTask(task string) (string, error) {
|
||||
idx := -1
|
||||
for i, item := range t.items {
|
||||
if item.Content == task {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("task not found: %s", task)
|
||||
}
|
||||
t.items[idx].Status = "completed"
|
||||
return t.listTasks(), nil
|
||||
}
|
||||
|
||||
func (t *TodoTool) updateTask(task, status string) (string, error) {
|
||||
if status != "pending" && status != "in_progress" && status != "completed" {
|
||||
return "", fmt.Errorf("status must be one of: pending, in_progress, completed")
|
||||
}
|
||||
idx := -1
|
||||
for i, item := range t.items {
|
||||
if item.Content == task {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("task not found: %s", task)
|
||||
}
|
||||
t.items[idx].Status = status
|
||||
return t.listTasks(), nil
|
||||
}
|
||||
+47
-6
@@ -34,12 +34,39 @@ type MutatingTool interface {
|
||||
Mutates() bool
|
||||
}
|
||||
|
||||
// CallMutatingTool can refine a tool's mutation classification per call.
|
||||
// This is useful for tools such as apply_patch whose dry-run mode is read-only.
|
||||
type CallMutatingTool interface {
|
||||
MutatesCall(args json.RawMessage) bool
|
||||
}
|
||||
|
||||
// PreviewableTool describes a pending mutation for interactive approval.
|
||||
type PreviewableTool interface {
|
||||
Preview(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
// TargetPathTool reports files that should be snapshotted before a mutation.
|
||||
type TargetPathTool interface {
|
||||
TargetPaths(args json.RawMessage) ([]string, error)
|
||||
}
|
||||
|
||||
// IsMutating reports whether tool implements MutatingTool and returns true.
|
||||
func IsMutating(tool Tool) bool {
|
||||
mutating, ok := tool.(MutatingTool)
|
||||
return ok && mutating.Mutates()
|
||||
}
|
||||
|
||||
// IsMutatingCall reports whether this invocation modifies external state.
|
||||
func IsMutatingCall(tool Tool, args json.RawMessage) bool {
|
||||
if !IsMutating(tool) {
|
||||
return false
|
||||
}
|
||||
if call, ok := tool.(CallMutatingTool); ok {
|
||||
return call.MutatesCall(args)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
primary []string
|
||||
@@ -90,8 +117,10 @@ func ReadOnlyBuiltins(workingDir string) *Registry {
|
||||
gitStatus := NewGitStatusTool(workingDir)
|
||||
gitDiff := NewGitDiffTool(workingDir)
|
||||
gitLog := NewGitLogTool(workingDir)
|
||||
projectOutline := NewProjectOutlineTool(workingDir)
|
||||
todo := NewTodoTool()
|
||||
|
||||
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols, gitStatus, gitDiff, gitLog)
|
||||
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols, projectOutline, todo, gitStatus, gitDiff, gitLog)
|
||||
registry.RegisterAlias("file.read", fileRead)
|
||||
registry.RegisterAlias("file.list", fileList)
|
||||
registry.RegisterAlias("file.search", fileSearch)
|
||||
@@ -106,36 +135,48 @@ func Builtins(workingDir string) *Registry {
|
||||
registry := ReadOnlyBuiltins(workingDir)
|
||||
fileWrite := NewFileWriteTool(workingDir)
|
||||
fileEdit := NewFileEditTool(workingDir)
|
||||
applyPatch := NewApplyPatchTool(workingDir)
|
||||
shellRun := NewShellRunTool(workingDir)
|
||||
registry.Register(fileWrite)
|
||||
registry.Register(fileEdit)
|
||||
registry.Register(applyPatch)
|
||||
registry.Register(shellRun)
|
||||
registry.RegisterAlias("file.write", fileWrite)
|
||||
registry.RegisterAlias("file.edit", fileEdit)
|
||||
registry.RegisterAlias("apply.patch", applyPatch)
|
||||
registry.RegisterAlias("shell.run", shellRun)
|
||||
return registry
|
||||
}
|
||||
|
||||
func AgentInstructions(workingDir string, yolo bool) string {
|
||||
return AgentInstructionsForMode(workingDir, yolo, yolo)
|
||||
}
|
||||
|
||||
func AgentInstructionsForMode(workingDir string, writesEnabled bool, yolo bool) string {
|
||||
searchInstructions := webSearchInstructions()
|
||||
readOnlyTools := "file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
yoloTools := "file_list, file_read, file_search, code_symbols, file_write, file_edit, shell_run, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
if yolo {
|
||||
readOnlyTools := "file_list, file_read, file_search, code_symbols, project_outline, todo, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
writeTools := "file_list, file_read, file_search, code_symbols, project_outline, todo, file_write, file_edit, apply_patch, shell_run, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
if writesEnabled {
|
||||
approval := "Mutating tools require interactive user approval before execution. Explain the intended change through the tool call and wait for the decision."
|
||||
if yolo {
|
||||
approval = "Mutating tools execute automatically because --yolo is enabled."
|
||||
}
|
||||
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.
|
||||
- %s
|
||||
- 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 about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole 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.
|
||||
- When editing existing files, prefer file_edit for small or localized changes; use file_write only for creating files, replacing whole files intentionally, or appending.
|
||||
- Use project_outline first when the user asks for a broad understanding of a project; prefer file_edit for localized changes, apply_patch for related multi-file changes, and file_write for whole-file creation or replacement.
|
||||
- Do not use file tools as a substitute for a command execution request.
|
||||
- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run.
|
||||
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: %s.`, workingDir, searchInstructions, yoloTools)
|
||||
- Available tools: %s.`, workingDir, approval, searchInstructions, writeTools)
|
||||
}
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols", "git_status", "git_diff", "git_log"} {
|
||||
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols", "project_outline", "todo", "git_status", "git_diff", "git_log"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) {
|
||||
|
||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
registry := Builtins(t.TempDir())
|
||||
for _, name := range []string{"file_write", "file_edit", "shell_run", "file.write", "file.edit", "shell.run"} {
|
||||
for _, name := range []string{"file_write", "file_edit", "apply_patch", "shell_run", "file.write", "file.edit", "apply.patch", "shell.run"} {
|
||||
if _, ok := registry.Get(name); !ok {
|
||||
t.Fatalf("tool missing: %s", name)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
|
||||
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
||||
withSearch := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"} {
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, project_outline, todo, git_status, git_diff, git_log, web_search, web_fetch"} {
|
||||
if !strings.Contains(withSearch, want) {
|
||||
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyPatchChangesMultipleFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "old.txt"), []byte("before\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patch := `*** Begin Patch
|
||||
*** Update File: old.txt
|
||||
@@
|
||||
-before
|
||||
+after
|
||||
*** Add File: new.txt
|
||||
+created
|
||||
*** End Patch`
|
||||
out, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "2 file(s) changed") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
assertFileContent(t, filepath.Join(dir, "old.txt"), "after\n")
|
||||
assertFileContent(t, filepath.Join(dir, "new.txt"), "created\n")
|
||||
if info, err := os.Stat(filepath.Join(dir, "old.txt")); err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("updated mode = %v, err = %v", info.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchRollsBackEarlierFilesOnFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "first.txt")
|
||||
if err := os.WriteFile(path, []byte("original\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patch := `*** Begin Patch
|
||||
*** Update File: first.txt
|
||||
@@
|
||||
-original
|
||||
+changed
|
||||
*** Update File: missing.txt
|
||||
@@
|
||||
-missing
|
||||
+changed
|
||||
*** End Patch`
|
||||
_, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch}))
|
||||
if err == nil {
|
||||
t.Fatal("expected patch failure")
|
||||
}
|
||||
assertFileContent(t, path, "original\n")
|
||||
}
|
||||
|
||||
func TestApplyPatchDryRunDoesNotWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
patch := "*** Begin Patch\n*** Add File: dry.txt\n+dry\n*** End Patch"
|
||||
out, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch, "dry_run": true}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "dry run") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dry.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry run created file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchRejectsMalformedEnvelopeAndDuplicatePaths(t *testing.T) {
|
||||
for _, patch := range []string{
|
||||
"*** Begin Patch\n*** Add File: x.txt\n+x",
|
||||
"prefix\n*** Begin Patch\n*** Add File: x.txt\n+x\n*** End Patch",
|
||||
"*** Begin Patch\n*** Add File: x.txt\n+x\n*** Delete File: x.txt\n*** End Patch",
|
||||
} {
|
||||
if _, err := parsePatch(patch); err == nil {
|
||||
t.Fatalf("expected parse failure for %q", patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathRejectsSymlinkEscape(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
base := filepath.Join(parent, "project")
|
||||
outside := filepath.Join(parent, "outside")
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(base, "link")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if _, err := resolvePath(base, "link/secret.txt"); err == nil {
|
||||
t.Fatal("expected read symlink escape rejection")
|
||||
}
|
||||
if _, err := resolvePathForWrite(base, "link/new.txt"); err == nil {
|
||||
t.Fatal("expected write symlink escape rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoRejectsInvalidData(t *testing.T) {
|
||||
tool := NewTodoTool()
|
||||
if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"create","items":[""]}`)); err == nil {
|
||||
t.Fatal("expected empty task rejection")
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"create","items":["one"]}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"update","task":"one","status":"invalid"}`)); err == nil {
|
||||
t.Fatal("expected invalid status rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectOutlineIncludesGoTypeScriptAndRustSymbols(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"main.go": "package demo\nfunc Run() {}\n",
|
||||
"ui.ts": "export class Panel {}\nexport function render() {}\n",
|
||||
"lib.rs": "pub struct Worker;\npub fn execute() {}\n",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
out, err := NewProjectOutlineTool(dir).Execute(context.Background(), json.RawMessage(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"main.go", "Run", "ui.ts", "Panel", "render", "lib.rs", "Worker", "execute"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("outline missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackStoreRestoresInReverseOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.txt")
|
||||
if err := os.WriteFile(path, []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewRollbackStore()
|
||||
first, err := store.Capture("edit", "state.txt", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.Capture("edit", "state.txt", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("three"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.LastID() != second {
|
||||
t.Fatalf("last ID = %q", store.LastID())
|
||||
}
|
||||
if _, err := store.Restore(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertFileContent(t, path, "two")
|
||||
if store.LastID() != first {
|
||||
t.Fatalf("last ID after restore = %q, want %q", store.LastID(), first)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("%s content = %q, want %q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user