Why: token budget let the current tool batch run, compaction could split a tool pair or race an append, writes were not crash-atomic, and several MCP and session edges only existed as comments. Evidence: GOWORK=off go test -count=1 -race ./runtime/ ./runtime/llm/ ./runtime/session/ ./runtime/tools/ ./runtime/mcp/ ./adapters/native/ ./adapters/intentgate/ passed, including the TestR2* cases for budget, replacement, cancel, compaction, SSE, UTF-8, schema, MCP retry, session terminate, poison reload, and registry reload. Impact: over-budget tool calls are skipped and paired; Stop.Replacement is appended to the session; the intent gate can follow Registry.Reload. Refs: N5, N6, N7, N8, N9, N11, N12, N15, N16, N17, N18, N19, N21, I9 Co-authored-by: Cursor <cursoragent@cursor.com>
315 lines
10 KiB
Go
315 lines
10 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type readFile struct{ root string }
|
|
|
|
func (t *readFile) Name() string { return "read_file" }
|
|
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]"
|
|
}
|
|
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]))
|
|
}
|
|
return Result{Content: strings.Join(out, "\n")}, nil
|
|
}
|
|
|
|
type writeFile struct{ root string }
|
|
|
|
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 err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return Result{Content: err.Error(), IsError: true}, nil
|
|
}
|
|
if err := atomicWriteFile(path, []byte(obj.Content), 0o644); err != nil {
|
|
return Result{Content: err.Error(), IsError: true}, nil
|
|
}
|
|
return Result{Content: "wrote " + obj.Path}, nil
|
|
}
|
|
|
|
type editFile struct{ root 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
|
|
}
|
|
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
|
|
}
|
|
return Result{Content: "edited " + obj.Path}, nil
|
|
}
|
|
|
|
type listDir struct{ root string }
|
|
|
|
func (t *listDir) Name() string { return "list_dir" }
|
|
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) 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 }
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
return Result{Content: fmt.Sprintf("applied %d edits to %s", len(obj.Edits), obj.Path)}, nil
|
|
}
|