package tools import ( "context" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "agent-native/runtime/filestate" ) type readFile struct { root string files *filestate.Tracker } func (t *readFile) Name() string { return "read_file" } func (t *readFile) Replay() string { return ReplaySafe } func (t *readFile) Description() string { return "Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset." } func (t *readFile) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"offset":{"type":"integer","description":"1-based first line to return"},"limit":{"type":"integer","description":"number of lines to return"}}}`) } func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { var obj struct { Path string `json:"path"` Offset int `json:"offset"` Limit int `json:"limit"` } if err := json.Unmarshal(args, &obj); err != nil { return Result{}, err } path, err := mustPath(t.root, obj.Path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } b, err := os.ReadFile(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } if obj.Offset <= 0 && obj.Limit <= 0 { truncated := false if len(b) > maxReadBytes { b = []byte(CutUTF8(string(b), maxReadBytes)) truncated = true } content := string(b) if truncated { content += "\n[truncated at 200 KiB; use offset/limit to page through the file]" } _ = t.files.NoteRead(path) return Result{Content: content}, nil } if obj.Offset <= 0 { obj.Offset = 1 } if obj.Limit <= 0 { obj.Limit = 1000 } lines := strings.Split(string(b), "\n") if obj.Offset > len(lines) { return Result{Content: fmt.Sprintf("offset %d beyond end of file (%d lines)", obj.Offset, len(lines)), IsError: true}, nil } end := obj.Offset - 1 + obj.Limit if end > len(lines) { end = len(lines) } var out []string for i := obj.Offset - 1; i < end; i++ { out = append(out, fmt.Sprintf("%d\t%s", i+1, lines[i])) } _ = t.files.NoteRead(path) return Result{Content: strings.Join(out, "\n")}, nil } type writeFile struct { root string files *filestate.Tracker mark func(rel string, before []byte, existed bool, after []byte) writable []string // empty = unrestricted (product default) } func (t *writeFile) Name() string { return "write_file" } func (t *writeFile) Description() string { return "Create or replace a UTF-8 text file inside the workspace." } func (t *writeFile) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","required":["path","content"],"properties":{"path":{"type":"string"},"content":{"type":"string"}}}`) } func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { var obj struct { Path string `json:"path"` Content string `json:"content"` } if err := json.Unmarshal(args, &obj); err != nil { return Result{}, err } path, err := mustPath(t.root, obj.Path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" { return Result{Content: msg, IsError: true}, nil } if msg := rejectStaleWrite(t.files, path); msg != "" { return Result{Content: msg, IsError: true}, nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return Result{Content: err.Error(), IsError: true}, nil } before, existed := fileBefore(path) if err := atomicWriteFile(path, []byte(obj.Content), 0o644); err != nil { return Result{Content: err.Error(), IsError: true}, nil } if t.mark != nil { t.mark(obj.Path, before, existed, []byte(obj.Content)) } return Result{Content: "wrote " + obj.Path}, nil } type editFile struct { root string files *filestate.Tracker mark func(rel string, before []byte, existed bool, after []byte) writable []string } func (t *editFile) Name() string { return "edit_file" } func (t *editFile) Description() string { return "Replace one unique exact text span in a workspace file." } func (t *editFile) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","required":["path","old","new"],"properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}}}`) } func (t *editFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { var obj struct { Path string `json:"path"` Old string `json:"old"` New string `json:"new"` } if err := json.Unmarshal(args, &obj); err != nil { return Result{}, err } if obj.Old == "" { return Result{Content: "old text is empty", IsError: true}, nil } path, err := mustPath(t.root, obj.Path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" { return Result{Content: msg, IsError: true}, nil } if msg := rejectStaleWrite(t.files, path); msg != "" { return Result{Content: msg, IsError: true}, nil } b, err := os.ReadFile(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } text := string(b) n := strings.Count(text, obj.Old) if n != 1 { return Result{Content: fmt.Sprintf("expected one match, found %d", n), IsError: true}, nil } updated := strings.Replace(text, obj.Old, obj.New, 1) if err := atomicWriteFile(path, []byte(updated), 0o644); err != nil { return Result{Content: err.Error(), IsError: true}, nil } if t.mark != nil { t.mark(obj.Path, b, true, []byte(updated)) } return Result{Content: "edited " + obj.Path}, nil } type listDir struct{ root string } func (t *listDir) Name() string { return "list_dir" } func (t *listDir) Replay() string { return ReplaySafe } func (t *listDir) Description() string { return "List names in a workspace directory." } func (t *listDir) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`) } func (t *listDir) Execute(_ context.Context, args json.RawMessage) (Result, error) { rel, _ := readArg(args, "path") if rel == "" { rel = "." } path, err := mustPath(t.root, rel) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } entries, err := os.ReadDir(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } names := make([]string, 0, len(entries)) for _, e := range entries { name := e.Name() if e.IsDir() { name += "/" } names = append(names, name) } return Result{Content: strings.Join(names, "\n")}, nil } type grepTool struct{ root string } func (t *grepTool) Name() string { return "grep" } func (t *grepTool) Replay() string { return ReplaySafe } func (t *grepTool) Description() string { return "Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits." } func (t *grepTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"},"regex":{"type":"boolean"},"ignore_case":{"type":"boolean"},"include":{"type":"string","description":"file-name glob filter such as *.go"}}}`) } func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, error) { var obj struct { Pattern string `json:"pattern"` Path string `json:"path"` Regex bool `json:"regex"` IgnoreCase bool `json:"ignore_case"` Include string `json:"include"` } if err := json.Unmarshal(args, &obj); err != nil { return Result{}, err } if strings.TrimSpace(obj.Pattern) == "" { return Result{Content: "pattern is empty", IsError: true}, nil } if obj.Path == "" { obj.Path = "." } var includeRe *globMatcher if strings.TrimSpace(obj.Include) != "" { m, err := newGlobMatcher(obj.Include) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } includeRe = m } contains := func(line string) bool { return strings.Contains(line, obj.Pattern) } if obj.Regex { pat := obj.Pattern if obj.IgnoreCase { pat = "(?i)" + pat } re, cerr := regexp.Compile(pat) if cerr != nil { return Result{Content: cerr.Error(), IsError: true}, nil } contains = re.MatchString } else if obj.IgnoreCase { lower := strings.ToLower(obj.Pattern) contains = func(line string) bool { return strings.Contains(strings.ToLower(line), lower) } } start, err := mustPath(t.root, obj.Path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } var hits []string err = filepath.WalkDir(start, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil || d.IsDir() { return nil } if len(hits) >= 50 { return fmt.Errorf("stop") } if includeRe != nil && !includeRe.Match(filepath.Base(path)) { return nil } b, err := os.ReadFile(path) if err != nil || len(b) > maxReadBytes { return nil } rel, _ := filepath.Rel(t.root, path) for i, line := range strings.Split(string(b), "\n") { if contains(line) { hits = append(hits, fmt.Sprintf("%s:%d:%s", filepath.ToSlash(rel), i+1, line)) if len(hits) >= 50 { return fmt.Errorf("stop") } } } return nil }) if err != nil && err.Error() != "stop" { return Result{Content: err.Error(), IsError: true}, nil } if len(hits) == 0 { return Result{Content: "no matches"}, nil } return Result{Content: strings.Join(hits, "\n")}, nil } // multiEditTool 一次多处替换:全部命中且互不冲突才落盘,任一失败即整体不动。 type multiEditTool struct { root string files *filestate.Tracker mark func(rel string, before []byte, existed bool, after []byte) writable []string } func (t *multiEditTool) Name() string { return "multi_edit" } func (t *multiEditTool) Description() string { return "Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched." } func (t *multiEditTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","required":["old","new"],"properties":{"old":{"type":"string"},"new":{"type":"string"}}}}}}`) } type multiEditSpan struct { Old string `json:"old"` New string `json:"new"` } func (t *multiEditTool) Execute(_ context.Context, args json.RawMessage) (Result, error) { var obj struct { Path string `json:"path"` Edits []multiEditSpan `json:"edits"` } if err := json.Unmarshal(args, &obj); err != nil { return Result{}, err } if len(obj.Edits) == 0 { return Result{Content: "edits is empty", IsError: true}, nil } path, err := mustPath(t.root, obj.Path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" { return Result{Content: msg, IsError: true}, nil } if msg := rejectStaleWrite(t.files, path); msg != "" { return Result{Content: msg, IsError: true}, nil } b, err := os.ReadFile(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil } text := string(b) for i, e := range obj.Edits { if e.Old == "" { return Result{Content: fmt.Sprintf("edit %d: old text is empty", i+1), IsError: true}, nil } n := strings.Count(text, e.Old) if n != 1 { return Result{Content: fmt.Sprintf("edit %d: expected one match, found %d; file unchanged", i+1, n), IsError: true}, nil } text = strings.Replace(text, e.Old, e.New, 1) } if err := atomicWriteFile(path, []byte(text), 0o644); err != nil { return Result{Content: err.Error(), IsError: true}, nil } if t.mark != nil { t.mark(obj.Path, b, true, []byte(text)) } return Result{Content: fmt.Sprintf("applied %d edits to %s", len(obj.Edits), obj.Path)}, nil } func fileBefore(path string) ([]byte, bool) { b, err := os.ReadFile(path) if err != nil { return nil, false } return b, true }