765 lines
27 KiB
Go
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.
|