Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Tools the model may invoke during a multi-step turn. All are read-only (except `

| Name | Arguments | Returns |
|---|---|---|
| `list_dir` | `{path: string, depth?: int=1, max_entries?: int=200}` | List of `{name, type, size}` |
| `list_dir` | `{path: string, depth?: int=1, max_entries?: int=200}` | List of `{name, path, type, size}` relative to the requested directory; `depth>1` recurses |
| `read_file` | `{path: string, max_bytes?: int=8192, start_line?: int, end_line?: int}` | `{content, truncated, size, total_lines?, start_line?, end_line?}` |
| `head_file` | `{path: string, lines?: int=50}` | `{lines, total_lines}` |
| `which` | `{name: string}` | `{found: bool, path?: string}` |
Expand Down
10 changes: 6 additions & 4 deletions internal/model/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (

// PromptTemplateVersion is bumped on every prompt change. Skill cache keys
// include this; bumping it invalidates the entire cache.
const PromptTemplateVersion = "7"
const PromptTemplateVersion = "8"

// SystemPromptInputs is everything intent injects into the system prompt
// at request time. Stable across daemon lifetime.
Expand Down Expand Up @@ -87,9 +87,11 @@ Tool-use strategy (use these aggressively; each call is cheap):
Use this the moment the user mentions a command
you don't know. Never guess flags for unknown
tools.
- list_dir(path) — what files are in this directory? Use before
generating commands that reference "the files"
or "those two files" without names.
- list_dir(path[, depth]) — what files are in this directory tree? Use
before generating commands that reference "the
files" or "those two files" without names. Raise
depth when you need one or two nested levels of
filenames without guessing them.
- read_file(path,...) — read text content. Supports start_line/end_line
for large files.
- head_file(path, N) — first N lines only.
Expand Down
68 changes: 47 additions & 21 deletions internal/tools/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,36 +102,62 @@ func listDir(raw json.RawMessage) (Result, error) {
if args.Path == "" {
args.Path = "."
}
if args.Depth <= 0 {
args.Depth = 1
}
if args.MaxEntries <= 0 {
args.MaxEntries = 200
}
entries, err := os.ReadDir(args.Path)
root, err := filepath.Abs(args.Path)
if err != nil {
return Result{"error": err.Error()}, nil
}
out := make([]map[string]any, 0, len(entries))
for i, e := range entries {
if i >= args.MaxEntries {
break
}
info, _ := e.Info()
var size int64
if info != nil {
size = info.Size()
out := make([]map[string]any, 0, min(args.MaxEntries, 32))
truncated := false
var walk func(dir string, relDir string, depth int) error
walk = func(dir string, relDir string, depth int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
typ := "file"
if e.IsDir() {
typ = "dir"
} else if info != nil && info.Mode()&os.ModeSymlink != 0 {
typ = "symlink"
for _, e := range entries {
if len(out) >= args.MaxEntries {
truncated = true
return nil
}
info, _ := e.Info()
var size int64
if info != nil {
size = info.Size()
}
typ := "file"
if e.IsDir() {
typ = "dir"
} else if info != nil && info.Mode()&os.ModeSymlink != 0 {
typ = "symlink"
}
relPath := e.Name()
if relDir != "" {
relPath = filepath.Join(relDir, e.Name())
}
out = append(out, map[string]any{
"name": e.Name(),
"path": relPath,
"type": typ,
"size": size,
})
if depth > 1 && e.IsDir() {
if err := walk(filepath.Join(dir, e.Name()), relPath, depth-1); err != nil {
return err
Comment on lines +150 to +151
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip unreadable subdirectories during recursive list_dir

When depth > 1, a single os.ReadDir failure in any child directory aborts the entire list_dir call and returns only an error, even if the parent directory was readable and already had useful entries. This is a regression from the non-recursive behavior and will surface in repos that contain permission-restricted folders (or transiently inaccessible mounts), where callers now lose all listing data instead of getting a partial tree. Consider treating child traversal failures as per-entry errors (or skipping that subtree) rather than failing the whole tool result.

Useful? React with 👍 / 👎.

}
}
}
out = append(out, map[string]any{
"name": e.Name(),
"type": typ,
"size": size,
})
return nil
}
if err := walk(root, "", args.Depth); err != nil {
return Result{"error": err.Error()}, nil
}
return Result{"entries": out, "truncated": len(entries) > args.MaxEntries}, nil
return Result{"entries": out, "truncated": truncated}, nil
}

func readFile(raw json.RawMessage) (Result, error) {
Expand Down
71 changes: 71 additions & 0 deletions internal/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -95,6 +96,76 @@ func TestReadFileLineRange(t *testing.T) {
}
}

func TestListDirDepth(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "nested", "deeper"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "root.txt"), []byte("root"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nested", "child.txt"), []byte("child"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nested", "deeper", "leaf.txt"), []byte("leaf"), 0o644); err != nil {
t.Fatal(err)
}

res := runTool(t, nil, "list_dir", map[string]any{
"path": dir,
"depth": 2,
})
entries, ok := res["entries"].([]map[string]any)
if !ok {
t.Fatalf("entries type = %T, want []map[string]any", res["entries"])
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
path, _ := entry["path"].(string)
paths = append(paths, path)
}
if slices.Contains(paths, filepath.Join("nested", "deeper", "leaf.txt")) {
t.Fatalf("depth=2 should not include grandchild file, got %v", paths)
}
for _, want := range []string{"nested", filepath.Join("nested", "child.txt"), "root.txt"} {
if !slices.Contains(paths, want) {
t.Fatalf("missing %q in %v", want, paths)
}
}
}

func TestListDirMaxEntriesTruncatesRecursiveWalk(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"a.txt", "nested/b.txt", "nested/c.txt"} {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.WriteFile(path, []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}

res := runTool(t, nil, "list_dir", map[string]any{
"path": dir,
"depth": 2,
"max_entries": 2,
})
entries, ok := res["entries"].([]map[string]any)
if !ok {
t.Fatalf("entries type = %T, want []map[string]any", res["entries"])
}
if len(entries) != 2 {
t.Fatalf("len(entries)=%d want 2", len(entries))
}
if truncated, _ := res["truncated"].(bool); !truncated {
t.Fatalf("expected truncated=true, got %+v", res)
}
}

func TestHelpTool_MissingBinary(t *testing.T) {
res := runTool(t, nil, "help", map[string]any{"name": "definitely-not-installed-zzz-xyz"})
if found, _ := res["found"].(bool); found {
Expand Down
Loading