Skip to content

Commit fa19d7c

Browse files
Copilotsawka
andauthored
Add read_dir AI tool for reading directory contents (#2414)
- [x] Explore repository structure and understand existing tools pattern - [x] Create tools_readdir.go file with read_dir tool implementation - [x] Add GetReadDirToolDefinition() function following the pattern of read_text_file - [x] Register the new tool in tools.go GenerateTabStateAndTools function - [x] Create comprehensive tests in tools_readdir_test.go - [x] Test the implementation manually with various scenarios - [x] Run Go tests to ensure no regressions - [x] Run security check with CodeQL - No vulnerabilities found - [x] Revert unintended changes to tsunami demo go.mod and go.sum files - [x] Fix sorting to happen before truncation and preserve real total count ## Summary Successfully implemented a new `read_dir` AI tool that reads and lists directory contents, following the same pattern as the existing `read_text_file` tool. **Key Features:** - Supports path expansion (including ~) - Sorts directories first, then files (sorting happens BEFORE truncation) - Truncates output to prevent overwhelming responses (default 1000 entries) - Preserves the real total count even when truncated - Requires user approval for security - Provides detailed file/directory information (name, type, size, permissions, modification time) - Returns both structured data and formatted listing **Files Changed:** - `pkg/aiusechat/tools_readdir.go` - Main implementation (189 lines) - `pkg/aiusechat/tools_readdir_test.go` - Comprehensive tests (211 lines) - `pkg/aiusechat/tools.go` - Tool registration (1 line) **Testing:** - ✅ All 6 unit tests passing (including new test for sort-before-truncate) - ✅ Manual testing with real directories successful - ✅ CodeQL security scan passed with no vulnerabilities - ✅ Go build and vet successful <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > Right now we have AI tools in go at pkg/aichat ... see tools.go, tools_readfile.go. i'd like to add a new tool to read directories in the style of readfile. </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: sawka <[email protected]> Co-authored-by: sawka <[email protected]>
1 parent 47bdc38 commit fa19d7c

File tree

4 files changed

+514
-1
lines changed

4 files changed

+514
-1
lines changed

pkg/aiusechat/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bo
127127
if widgetAccess {
128128
tools = append(tools, GetCaptureScreenshotToolDefinition(tabid))
129129
tools = append(tools, GetReadTextFileToolDefinition())
130+
tools = append(tools, GetReadDirToolDefinition())
130131
viewTypes := make(map[string]bool)
131132
for _, block := range blocks {
132133
if block.Meta == nil {

pkg/aiusechat/tools_readdir.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package aiusechat
5+
6+
import (
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
"path/filepath"
11+
"sort"
12+
"time"
13+
14+
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
15+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
16+
"github.com/wavetermdev/waveterm/pkg/wavebase"
17+
)
18+
19+
const ReadDirDefaultMaxEntries = 500
20+
const ReadDirHardMaxEntries = 10000
21+
22+
type readDirParams struct {
23+
Path string `json:"path"`
24+
MaxEntries *int `json:"max_entries"`
25+
}
26+
27+
type DirEntryOut struct {
28+
Name string `json:"name"`
29+
Dir bool `json:"dir,omitempty"`
30+
Symlink bool `json:"symlink,omitempty"`
31+
Size int64 `json:"size,omitempty"`
32+
Mode string `json:"mode"`
33+
Modified string `json:"modified"`
34+
ModifiedTime string `json:"modified_time"`
35+
}
36+
37+
func parseReadDirInput(input any) (*readDirParams, error) {
38+
result := &readDirParams{}
39+
40+
if input == nil {
41+
return nil, fmt.Errorf("input is required")
42+
}
43+
44+
if err := utilfn.ReUnmarshal(result, input); err != nil {
45+
return nil, fmt.Errorf("invalid input format: %w", err)
46+
}
47+
48+
if result.Path == "" {
49+
return nil, fmt.Errorf("missing path parameter")
50+
}
51+
52+
if result.MaxEntries == nil {
53+
maxEntries := ReadDirDefaultMaxEntries
54+
result.MaxEntries = &maxEntries
55+
}
56+
57+
if *result.MaxEntries < 1 {
58+
return nil, fmt.Errorf("max_entries must be at least 1, got %d", *result.MaxEntries)
59+
}
60+
61+
if *result.MaxEntries > ReadDirHardMaxEntries {
62+
return nil, fmt.Errorf("max_entries cannot exceed %d, got %d", ReadDirHardMaxEntries, *result.MaxEntries)
63+
}
64+
65+
return result, nil
66+
}
67+
68+
func readDirCallback(input any) (any, error) {
69+
params, err := parseReadDirInput(input)
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
expandedPath, err := wavebase.ExpandHomeDir(params.Path)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to expand path: %w", err)
77+
}
78+
79+
fileInfo, err := os.Stat(expandedPath)
80+
if err != nil {
81+
return nil, fmt.Errorf("failed to stat path: %w", err)
82+
}
83+
84+
if !fileInfo.IsDir() {
85+
return nil, fmt.Errorf("path is not a directory, cannot be read with the read_dir tool. use the read_text_file tool to read files")
86+
}
87+
88+
entries, err := os.ReadDir(expandedPath)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to read directory: %w", err)
91+
}
92+
93+
// Keep track of the original total before truncation
94+
totalEntries := len(entries)
95+
96+
// Build a map of actual directory status, checking symlink targets
97+
isDirMap := make(map[string]bool)
98+
symlinkCount := 0
99+
for _, entry := range entries {
100+
name := entry.Name()
101+
if entry.Type()&fs.ModeSymlink != 0 {
102+
if symlinkCount < 1000 {
103+
symlinkCount++
104+
fullPath := filepath.Join(expandedPath, name)
105+
if info, err := os.Stat(fullPath); err == nil {
106+
isDirMap[name] = info.IsDir()
107+
} else {
108+
isDirMap[name] = entry.IsDir()
109+
}
110+
} else {
111+
isDirMap[name] = entry.IsDir()
112+
}
113+
} else {
114+
isDirMap[name] = entry.IsDir()
115+
}
116+
}
117+
118+
// Sort entries: directories first, then files, alphabetically within each group
119+
sort.Slice(entries, func(i, j int) bool {
120+
iIsDir := isDirMap[entries[i].Name()]
121+
jIsDir := isDirMap[entries[j].Name()]
122+
if iIsDir != jIsDir {
123+
return iIsDir
124+
}
125+
return entries[i].Name() < entries[j].Name()
126+
})
127+
128+
// Truncate after sorting to ensure directories come first
129+
maxEntries := *params.MaxEntries
130+
var truncated bool
131+
if len(entries) > maxEntries {
132+
entries = entries[:maxEntries]
133+
truncated = true
134+
}
135+
136+
var entryList []DirEntryOut
137+
for _, entry := range entries {
138+
info, err := entry.Info()
139+
if err != nil {
140+
continue
141+
}
142+
143+
isDir := isDirMap[entry.Name()]
144+
isSymlink := entry.Type()&fs.ModeSymlink != 0
145+
146+
entryData := DirEntryOut{
147+
Name: entry.Name(),
148+
Dir: isDir,
149+
Symlink: isSymlink,
150+
Mode: info.Mode().String(),
151+
Modified: utilfn.FormatRelativeTime(info.ModTime()),
152+
ModifiedTime: info.ModTime().UTC().Format(time.RFC3339),
153+
}
154+
155+
if !isDir {
156+
entryData.Size = info.Size()
157+
}
158+
159+
entryList = append(entryList, entryData)
160+
}
161+
162+
result := map[string]any{
163+
"path": params.Path,
164+
"absolute_path": expandedPath,
165+
"entry_count": len(entryList),
166+
"total_entries": totalEntries,
167+
"entries": entryList,
168+
}
169+
170+
if truncated {
171+
result["truncated"] = true
172+
result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d total). Increase max_entries to see more.", len(entryList), totalEntries)
173+
}
174+
175+
parentDir := filepath.Dir(expandedPath)
176+
if parentDir != expandedPath {
177+
result["parent_dir"] = parentDir
178+
}
179+
180+
return result, nil
181+
}
182+
183+
func GetReadDirToolDefinition() uctypes.ToolDefinition {
184+
return uctypes.ToolDefinition{
185+
Name: "read_dir",
186+
DisplayName: "Read Directory",
187+
Description: "Read a directory from the filesystem and list its contents. Returns information about files and subdirectories including names, types, sizes, permissions, and modification times. Requires user approval.",
188+
ToolLogName: "gen:readdir",
189+
Strict: false,
190+
InputSchema: map[string]any{
191+
"type": "object",
192+
"properties": map[string]any{
193+
"path": map[string]any{
194+
"type": "string",
195+
"description": "Path to the directory to read",
196+
},
197+
"max_entries": map[string]any{
198+
"type": "integer",
199+
"minimum": 1,
200+
"maximum": 10000,
201+
"default": 500,
202+
"description": "Maximum number of entries to return. Defaults to 500, max 10000.",
203+
},
204+
},
205+
"required": []string{"path"},
206+
"additionalProperties": false,
207+
},
208+
ToolInputDesc: func(input any) string {
209+
parsed, err := parseReadDirInput(input)
210+
if err != nil {
211+
return fmt.Sprintf("error parsing input: %v", err)
212+
}
213+
return fmt.Sprintf("reading directory %q (max_entries: %d)", parsed.Path, *parsed.MaxEntries)
214+
},
215+
ToolAnyCallback: readDirCallback,
216+
ToolApproval: func(input any) string {
217+
return uctypes.ApprovalNeedsApproval
218+
},
219+
}
220+
}

0 commit comments

Comments
 (0)