From c371048df0d9cfd7e9720a9955a160da482a14f4 Mon Sep 17 00:00:00 2001 From: Corey Ryan Dean Date: Tue, 12 May 2026 09:09:14 -0500 Subject: [PATCH] fix(tools): honor recursive list_dir depth --- docs/SPEC.md | 2 +- internal/model/prompt.go | 10 +++-- internal/tools/tools.go | 68 +++++++++++++++++++++++----------- internal/tools/tools_test.go | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 26 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 7f65093..c385f9f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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}` | diff --git a/internal/model/prompt.go b/internal/model/prompt.go index 58973c7..d0fe7d1 100644 --- a/internal/model/prompt.go +++ b/internal/model/prompt.go @@ -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. @@ -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. diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 2d6ad3d..ab4305c 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -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 + } + } } - 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) { diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 5f0f4b8..e2b1c73 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" "time" @@ -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 {