Files
enkly-ai/services/workspace/internal/infrastructure/sandboxfs/tools.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

765 lines
27 KiB
Go

package sandboxfs
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ignoredDirNames are skipped by every tool that walks the workspace tree
// (list_directory, glob_search, grep) — dependency/build/VCS directories
// that are almost never useful to an agent browsing a codebase and can be
// enormous (node_modules, vendor, .git), which would otherwise blow past
// the result caps below on the first call.
var ignoredDirNames = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".venv": true,
"venv": true, "__pycache__": true, "dist": true, "build": true,
".idea": true, ".vscode": true, "target": true, ".next": true,
}
const (
// maxReadLines/maxReadFileBytes bound read_file: a local model's
// context window is small relative to a real codebase, so reading an
// entire large generated file (a lockfile, a minified bundle) would
// silently crowd out everything else in the conversation. offset/
// limit let the model page through a large file deliberately instead.
maxReadLines = 2000
maxReadFileSize = 5 * 1024 * 1024 // 5 MiB
maxWalkEntries = 500 // cap for list_directory / glob_search results
maxGrepMatches = 200
maxGrepFileSize = 2 * 1024 * 1024
// diffContextLines is how many unchanged lines of context are shown
// before and after each changed block in the diff preview that
// edit_file/multi_edit_file return — enough to orient a reviewer
// (human or model) without dumping the whole file back into context,
// mirroring how a frontier coding agent confirms an edit.
diffContextLines = 4
// maxHunkChangedLines caps how many changed (-/+) lines a single hunk
// prints before eliding the middle, so replacing a huge block doesn't
// flood the tool result.
maxHunkChangedLines = 120
// maxDiffHunksPerEdit caps how many occurrences of a replace_all edit
// get a full diff hunk; beyond that, later occurrences are just
// counted, not rendered.
maxDiffHunksPerEdit = 8
)
// Toolbox executes the filesystem/bash tools on the host machine,
// jailed to WorkspaceRoot. This is the same Toolbox the monolith's
// internal/llm package had — minus Defs()/Execute() (tool-schema
// presentation and dispatch are now the agent service's job; see its
// internal/infrastructure/toolcatalog) and minus the Searcher field
// (search_codebase is now a direct agent-to-knowledge-service gRPC
// call, not something the workspace service's toolbox mediates).
type Toolbox struct {
WorkspaceRoot string
// CommandTimeout bounds how long run_bash_command may run. Overridable
// per Toolbox (see NewToolbox / ENKLY_BASH_TIMEOUT_SECONDS in main.go)
// since a "go test ./..." or a from-scratch npm install can easily
// outrun a timeout tuned for quick commands.
CommandTimeout time.Duration
// AllowedCommands is the allowlist of base binaries run_bash_command
// may execute (first token only; see sandbox.go). Empty means
// DefaultAllowedCommands.
AllowedCommands []string
}
// DefaultCommandTimeout is how long run_bash_command may run when
// ENKLY_BASH_TIMEOUT_SECONDS isn't set — long enough for a typical build
// or test run, short enough that a genuinely hung command (e.g. a
// process waiting on stdin) doesn't stall the whole agent turn.
const DefaultCommandTimeout = 60 * time.Second
// NewToolbox builds a Toolbox rooted at workspaceRoot (created if missing).
func NewToolbox(workspaceRoot string) (*Toolbox, error) {
abs, err := filepath.Abs(workspaceRoot)
if err != nil {
return nil, fmt.Errorf("tools: resolve workspace root: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("tools: create workspace root: %w", err)
}
return &Toolbox{
WorkspaceRoot: abs,
CommandTimeout: DefaultCommandTimeout,
}, nil
}
func (t *Toolbox) resolvePath(rel string) (string, error) {
cleaned := filepath.Clean("/" + rel) // force it to look absolute-from-root, collapsing ".."
full := filepath.Join(t.WorkspaceRoot, cleaned)
// Compare whole path segments: a plain HasPrefix would accept a sibling
// directory like "<root>-evil".
if !within(t.WorkspaceRoot, full) {
return "", fmt.Errorf("path %q escapes the workspace root", rel)
}
// Symlinks inside the workspace must not lead out of it.
realRoot, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return "", fmt.Errorf("workspace root: %w", err)
}
if resolved, _ := evalExisting(full); !within(realRoot, resolved) {
return "", fmt.Errorf("path %q escapes the workspace root via a symlink", rel)
}
return full, nil
}
// ResolvePath is the exported form of resolvePath, for callers outside
// this package that need the exact same jail the read_file/write_file/
// etc. tools use — namely the file upload/download HTTP handlers in
// internal/handlers, which save/serve files under WorkspaceRoot and must
// never let a client-supplied path escape it. Keeping one implementation
// here (rather than a second copy of the containment check in handlers)
// means both the agent's tools and the HTTP endpoints stay in lockstep if
// the jailing logic ever changes.
func (t *Toolbox) ResolvePath(rel string) (string, error) {
return t.resolvePath(rel)
}
// argInt reads an optional numeric argument. A tool call's "arguments"
// JSON string is decoded via encoding/json into map[string]any, which
// always produces float64 for a JSON number regardless of the schema's
// declared type, so this accepts float64 (the common case) and falls
// back to a string (seen from some models that emit stringified
// numbers) before giving up.
func argInt(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok || v == nil {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return i, true
}
}
return 0, false
}
func argBool(args map[string]any, key string) bool {
v, _ := args[key].(bool)
return v
}
func (t *Toolbox) readFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
info, err := os.Stat(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
if info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is a directory, not a file", relPath), IsError: true}
}
if info.Size() > maxReadFileSize {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is %d bytes, larger than the %d-byte limit — use run_bash_command (e.g. sed/head/grep) to inspect it in pieces instead", relPath, info.Size(), maxReadFileSize), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
offset, _ := argInt(call.Arguments, "offset")
if offset < 1 {
offset = 1
}
limit, ok := argInt(call.Arguments, "limit")
if !ok || limit <= 0 || limit > maxReadLines {
limit = maxReadLines
}
lines := strings.Split(string(data), "\n")
if offset > len(lines) {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: offset %d is past end of file (%d lines)", offset, len(lines)), IsError: true}
}
end := offset - 1 + limit
if end > len(lines) {
end = len(lines)
}
var sb strings.Builder
for i := offset - 1; i < end; i++ {
fmt.Fprintf(&sb, "%6d\t%s\n", i+1, lines[i])
}
if end < len(lines) {
fmt.Fprintf(&sb, "\n… %d more lines not shown; re-call read_file with offset=%d to continue …\n", len(lines)-end, end+1)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
func (t *Toolbox) writeFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
content, _ := call.Arguments["content"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: mkdir: %v", err), IsError: true}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: %v", err), IsError: true}
}
return ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", len(content), relPath)}
}
func (t *Toolbox) editFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
oldStr, _ := call.Arguments["old_string"].(string)
newStr, _ := call.Arguments["new_string"].(string)
replaceAll := argBool(call.Arguments, "replace_all")
if oldStr == "" {
return ToolResult{Name: call.Name, Content: "edit_file: old_string must not be empty (use write_file to create a new file)", IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: "edit_file: old_string and new_string are identical — nothing to do", IsError: true}
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: %v", err), IsError: true}
}
content := string(data)
count := strings.Count(content, oldStr)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string not found in %s — it must match the file's current content exactly (re-read the file if you're not sure)", relPath), IsError: true}
case count > 1 && !replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string appears %d times in %s, so the edit is ambiguous — include more surrounding context to make it unique, or pass replace_all=true if you really mean every occurrence", count, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, oldStr, newStr, replaceAll)
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: write: %v", err), IsError: true}
}
verb := "1 occurrence"
if replaceAll && count > 1 {
verb = fmt.Sprintf("%d occurrences", count)
}
header := fmt.Sprintf("edited %s: replaced %s\n\n", relPath, verb)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(hunks)}
}
// multiEditFile applies a sequence of edit_file-style replacements to one
// file as a single all-or-nothing operation. Each edit is checked against
// the content as it stands after every prior edit in the batch, so later
// edits can depend on earlier ones (e.g. renaming a symbol's definition,
// then a call site that only exists after the first edit) — but the
// moment any edit fails, the function returns without writing anything,
// so a batch that's wrong in step 4 of 5 can never leave the file
// half-edited.
func (t *Toolbox) multiEditFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
rawEdits, ok := call.Arguments["edits"].([]any)
if !ok || len(rawEdits) == 0 {
return ToolResult{Name: call.Name, Content: "multi_edit_file: \"edits\" must be a non-empty array", IsError: true}
}
type editSpec struct {
old, new string
replaceAll bool
}
edits := make([]editSpec, 0, len(rawEdits))
for i, raw := range rawEdits {
m, ok := raw.(map[string]any)
if !ok {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d] must be an object", i), IsError: true}
}
oldStr, _ := m["old_string"].(string)
newStr, _ := m["new_string"].(string)
if oldStr == "" {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string must not be empty", i), IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string and new_string are identical — nothing to do", i), IsError: true}
}
edits = append(edits, editSpec{old: oldStr, new: newStr, replaceAll: argBool(m, "replace_all")})
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: %v", err), IsError: true}
}
content := string(data)
var allHunks []string
totalReplacements := 0
for i, e := range edits {
count := strings.Count(content, e.old)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string not found (after applying edits[0:%d]) — no changes were written to %s; the whole batch is atomic, so fix this edit and retry from the top", i, i, relPath), IsError: true}
case count > 1 && !e.replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string appears %d times (after applying edits[0:%d]), so it's ambiguous — no changes were written to %s; add context or set this edit's replace_all", i, count, i, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, e.old, e.new, e.replaceAll)
allHunks = append(allHunks, hunks...)
content = updated
if e.replaceAll {
totalReplacements += count
} else {
totalReplacements++
}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: write: %v", err), IsError: true}
}
header := fmt.Sprintf("edited %s: applied %d edit(s), %d replacement(s)\n\n", relPath, len(edits), totalReplacements)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(allHunks)}
}
// applyEditWithDiff replaces old with new in content — every occurrence if
// replaceAll, otherwise the sole occurrence (callers must already have
// checked count/ambiguity themselves) — and returns the updated content
// alongside a unified-diff-style hunk for each replacement performed. This
// is shared by edit_file and multi_edit_file so both tools report changes
// the same way.
func applyEditWithDiff(content, oldStr, newStr string, replaceAll bool) (updated string, hunks []string) {
if !replaceAll {
idx := strings.Index(content, oldStr)
if idx < 0 { // shouldn't happen; callers check first, but stay safe
return content, nil
}
return content[:idx] + newStr + content[idx+len(oldStr):], []string{diffHunk(content, idx, oldStr, newStr)}
}
total := strings.Count(content, oldStr)
var sb strings.Builder
pos, shown := 0, 0
for {
idx := strings.Index(content[pos:], oldStr)
if idx < 0 {
break
}
abs := pos + idx
if shown < maxDiffHunksPerEdit {
hunks = append(hunks, diffHunk(content, abs, oldStr, newStr))
shown++
}
sb.WriteString(content[pos:abs])
sb.WriteString(newStr)
pos = abs + len(oldStr)
}
sb.WriteString(content[pos:])
if total > shown {
hunks = append(hunks, fmt.Sprintf("… %d further occurrence(s) replaced but not individually shown …", total-shown))
}
return sb.String(), hunks
}
// renderDiffHunks joins hunks with a small separator so multiple
// non-adjacent changes in one tool result read clearly as distinct
// hunks rather than one continuous block.
func renderDiffHunks(hunks []string) string {
if len(hunks) == 0 {
return ""
}
return strings.Join(hunks, " ⋮\n")
}
// diffHunk renders one unified-diff-style hunk for replacing old with new
// at byte offset `at` in content, expanded outward to whole lines (so a
// hunk never splits a line mid-way even though old/new themselves may
// start or end mid-line) and padded with up to diffContextLines lines of
// unchanged context on each side. It's built directly from the known
// old/new span — rather than a general line-diff algorithm — since
// edit_file's contract already guarantees the exact substring replaced.
func diffHunk(content string, at int, oldStr, newStr string) string {
lineStart, lineEnd := wholeLineRange(content, at, len(oldStr))
oldBlock := content[lineStart:lineEnd]
newBlock := oldBlock[:at-lineStart] + newStr + oldBlock[at-lineStart+len(oldStr):]
ctxStart := expandLinesBack(content, lineStart, diffContextLines)
ctxEnd := expandLinesForward(content, lineEnd, diffContextLines)
lineNo := strings.Count(content[:ctxStart], "\n") + 1
var sb strings.Builder
lineNo = writeDiffBlock(&sb, linesOf(content[ctxStart:lineStart]), " ", lineNo)
oldEnd := writeDiffBlock(&sb, linesOf(oldBlock), "-", lineNo)
writeDiffBlock(&sb, linesOf(newBlock), "+", lineNo)
writeDiffBlock(&sb, linesOf(content[lineEnd:ctxEnd]), " ", oldEnd)
return sb.String()
}
// wholeLineRange returns the [start,end) byte offsets of the smallest run
// of whole lines in content that fully contains the byte range
// [at, at+length), including the trailing newline of the last line (or
// running to EOF if there isn't one).
func wholeLineRange(content string, at, length int) (start, end int) {
start = strings.LastIndexByte(content[:at], '\n') + 1 // 0 if none found
end = at + length
if idx := strings.IndexByte(content[end:], '\n'); idx >= 0 {
end += idx + 1
} else {
end = len(content)
}
return start, end
}
func expandLinesBack(content string, pos, n int) int {
for i := 0; i < n && pos > 0; i++ {
pos = strings.LastIndexByte(content[:pos-1], '\n') + 1
}
return pos
}
func expandLinesForward(content string, pos, n int) int {
for i := 0; i < n && pos < len(content); i++ {
idx := strings.IndexByte(content[pos:], '\n')
if idx < 0 {
return len(content)
}
pos += idx + 1
}
return pos
}
// linesOf splits a whole-lines block (as produced by wholeLineRange) into
// its constituent lines, dropping the trailing empty element a final "\n"
// would otherwise produce.
func linesOf(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
// writeDiffBlock prints lines with a diff marker ("-", "+", or " ") and
// 1-based line numbers, eliding the middle of very long blocks (see
// maxHunkChangedLines) so a giant replacement doesn't flood the result.
// It returns the next line number after this block.
func writeDiffBlock(sb *strings.Builder, lines []string, marker string, lineNo int) int {
if len(lines) > maxHunkChangedLines {
half := maxHunkChangedLines / 2
for i := 0; i < half; i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
fmt.Fprintf(sb, " %s … %d lines omitted …\n", marker, len(lines)-maxHunkChangedLines)
for i := len(lines) - half; i < len(lines); i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
} else {
for i, l := range lines {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, l)
}
}
return lineNo + len(lines)
}
func (t *Toolbox) listDirectory(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
if strings.TrimSpace(relPath) == "" {
relPath = "."
}
maxDepth, ok := argInt(call.Arguments, "max_depth")
if !ok || maxDepth <= 0 {
maxDepth = 3
}
root, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %q is not a directory in this workspace", relPath), IsError: true}
}
type entry struct {
rel string
isDir bool
}
var entries []entry
truncated := false
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than failing the whole listing
}
if path == root {
return nil
}
rel, _ := filepath.Rel(root, path)
depth := strings.Count(rel, string(filepath.Separator)) + 1
if d.IsDir() && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
if depth > maxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if len(entries) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
entries = append(entries, entry{rel: filepath.ToSlash(rel), isDir: d.IsDir()})
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %v", err), IsError: true}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].isDir != entries[j].isDir {
return entries[i].isDir // directories first
}
return entries[i].rel < entries[j].rel
})
var sb strings.Builder
fmt.Fprintf(&sb, "%s/\n", strings.TrimSuffix(relPath, "/"))
for _, e := range entries {
suffix := ""
if e.isDir {
suffix = "/"
}
fmt.Fprintf(&sb, " %s%s\n", e.rel, suffix)
}
if len(entries) == 0 {
sb.WriteString(" (empty)\n")
}
if truncated {
fmt.Fprintf(&sb, "\n… output truncated at %d entries; narrow `path` or `max_depth` for a fuller view of one area …\n", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
// segmentToRegexp converts one path segment (no "/" in it) of a glob into
// regexp source, treating `*` as "within this segment" and `?` as a single
// rune, and escaping everything else.
func segmentToRegexp(seg string) string {
var sb strings.Builder
for _, r := range seg {
switch r {
case '*':
sb.WriteString("[^/]*")
case '?':
sb.WriteString("[^/]")
default:
sb.WriteString(regexp.QuoteMeta(string(r)))
}
}
return sb.String()
}
// globToRegexp compiles a shell-style glob into an anchored regexp over
// forward-slash-separated relative paths. `*` matches within one path
// segment; `?` matches a single rune within a segment; a `**` segment
// matches zero or more whole path segments, so "**/*.go" matches both
// "main.go" at the root and "internal/llm/tools.go" — the standard
// "globstar" behavior, which a naive character-by-character translation
// (treating `**` as ".*" in place) gets wrong for the common leading-`**/`
// case, since it would then require a literal "/" for a root-level match.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = filepath.ToSlash(strings.TrimPrefix(pattern, "./"))
segments := strings.Split(pattern, "/")
var sb strings.Builder
sb.WriteString("^")
for i, seg := range segments {
last := i == len(segments)-1
if seg == "**" {
if last {
sb.WriteString(".*") // trailing "**" matches everything under here, any depth
} else {
sb.WriteString(`(?:[^/]+/)*`) // zero or more whole segments, each including its "/"
}
continue
}
sb.WriteString(segmentToRegexp(seg))
if !last {
sb.WriteString("/")
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
func (t *Toolbox) globSearch(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "glob_search: empty pattern", IsError: true}
}
re, err := globToRegexp(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: invalid pattern: %v", err), IsError: true}
}
var matches []string
truncated := false
err = filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == t.WorkspaceRoot {
return nil
}
if d.IsDir() {
if ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if !re.MatchString(rel) {
return nil
}
if len(matches) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
matches = append(matches, rel)
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: %v", err), IsError: true}
}
sort.Strings(matches)
if len(matches) == 0 {
return ToolResult{Name: call.Name, Content: "no files matched that pattern"}
}
out := strings.Join(matches, "\n")
if truncated {
out += fmt.Sprintf("\n\n… truncated at %d matches; narrow the pattern for a fuller view …", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: out}
}
func (t *Toolbox) grep(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "grep: empty pattern", IsError: true}
}
if argBool(call.Arguments, "case_insensitive") && !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid regular expression: %v", err), IsError: true}
}
var pathRe *regexp.Regexp
if pathGlob, _ := call.Arguments["path_glob"].(string); strings.TrimSpace(pathGlob) != "" {
pathRe, err = globToRegexp(pathGlob)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid path_glob: %v", err), IsError: true}
}
}
var sb strings.Builder
matchCount := 0
stop := false
walkErr := filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || stop {
return nil
}
if d.IsDir() {
if path != t.WorkspaceRoot && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if pathRe != nil && !pathRe.MatchString(rel) {
return nil
}
info, err := d.Info()
if err != nil || info.Size() > maxGrepFileSize || info.Size() == 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if lineNo == 1 && strings.ContainsRune(line, 0) {
break // looks binary (NUL byte on the first line) — skip it
}
if re.MatchString(line) {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 300 {
trimmed = trimmed[:300] + "…"
}
fmt.Fprintf(&sb, "%s:%d: %s\n", rel, lineNo, trimmed)
matchCount++
if matchCount >= maxGrepMatches {
stop = true
return filepath.SkipAll
}
}
}
return nil
})
if walkErr != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: %v", walkErr), IsError: true}
}
if matchCount == 0 {
return ToolResult{Name: call.Name, Content: "no matches found"}
}
out := sb.String()
if stop {
out += fmt.Sprintf("\n… truncated at %d matches; narrow `pattern` or `path_glob` …\n", maxGrepMatches)
}
return ToolResult{Name: call.Name, Content: out}
}
// writeTodos doesn't touch the filesystem at all — it just validates and
// reformats the model's plan into a checklist. The tool call + result is
// persisted to the messages/tool_calls tables exactly like any other tool
// (see internal/handlers' chat flow), which is what makes the plan visible
// to the user as it updates, and gives the model a compact record of its
// own plan to refer back to on later rounds.