Skip to content

Commit a406daa

Browse files
committed
fix(coderd/x/chatd/chattool): list directories in read_file
1 parent 5d8cd2e commit a406daa

2 files changed

Lines changed: 561 additions & 3 deletions

File tree

coderd/x/chatd/chattool/readfile.go

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package chattool
22

33
import (
44
"context"
5+
"fmt"
6+
"strings"
57

68
"charm.land/fantasy"
9+
"golang.org/x/xerrors"
710

811
"github.com/coder/coder/v2/codersdk/workspacesdk"
912
)
@@ -22,9 +25,10 @@ func ReadFile(options ReadFileOptions) fantasy.AgentTool {
2225
return fantasy.NewAgentTool(
2326
"read_file",
2427
"Read a file from the workspace. Returns line-numbered content. "+
25-
"The offset parameter is a 1-based line number (default: 1). "+
26-
"The limit parameter is the number of lines to return (default: 2000). "+
27-
"For large files, use offset and limit to paginate.",
28+
"When reading a directory, returns a non-recursive directory listing. "+
29+
"The offset parameter is a 1-based line number or directory entry (default: 1). "+
30+
"The limit parameter is the number of lines or directory entries to return (default: 2000). "+
31+
"For large files and directories, use offset and limit to paginate.",
2832
func(ctx context.Context, args ReadFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
2933
if options.GetWorkspaceConn == nil {
3034
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
@@ -62,6 +66,9 @@ func executeReadFileTool(
6266
}
6367

6468
if !resp.Success {
69+
if readFileLinesErrorIsDirectory(resp.Error) {
70+
return executeReadFileDirectoryListing(ctx, conn, args, offset, limit, resp.Error)
71+
}
6572
return fantasy.NewTextErrorResponse(resp.Error), nil
6673
}
6774

@@ -72,3 +79,101 @@ func executeReadFileTool(
7279
"lines_read": resp.LinesRead,
7380
}), nil
7481
}
82+
83+
func readFileLinesErrorIsDirectory(err string) bool {
84+
return strings.HasPrefix(strings.TrimSpace(err), "not a file:")
85+
}
86+
87+
func executeReadFileDirectoryListing(
88+
ctx context.Context,
89+
conn workspacesdk.AgentConn,
90+
args ReadFileArgs,
91+
offset int64,
92+
limit int64,
93+
readErr string,
94+
) (fantasy.ToolResponse, error) {
95+
resp, err := conn.LS(ctx, args.Path, workspacesdk.LSRequest{
96+
Relativity: workspacesdk.LSRelativityRoot,
97+
})
98+
if err != nil {
99+
return fantasy.NewTextErrorResponse(
100+
fmt.Sprintf("%s; failed to list directory: %s", readErr, err),
101+
), nil
102+
}
103+
104+
listing, err := directoryListingResult(resp, offset, limit)
105+
if err != nil {
106+
return fantasy.NewTextErrorResponse(err.Error()), nil
107+
}
108+
109+
return toolResponse(map[string]any{
110+
"content": listing.content,
111+
"is_directory": true,
112+
"absolute_path": resp.AbsolutePath,
113+
"absolute_path_string": resp.AbsolutePathString,
114+
"entries": listing.entries,
115+
"entries_read": listing.entriesRead,
116+
"total_entries": len(resp.Contents),
117+
"truncated": listing.truncated,
118+
}), nil
119+
}
120+
121+
type directoryListing struct {
122+
content string
123+
entries []workspacesdk.LSFile
124+
entriesRead int
125+
truncated bool
126+
}
127+
128+
func directoryListingResult(resp workspacesdk.LSResponse, offset, limit int64) (directoryListing, error) {
129+
if offset < 1 {
130+
offset = 1
131+
}
132+
if limit <= 0 {
133+
limit = workspacesdk.DefaultMaxResponseLines
134+
}
135+
136+
totalEntries := len(resp.Contents)
137+
if totalEntries == 0 {
138+
return directoryListing{}, nil
139+
}
140+
if offset > int64(totalEntries) {
141+
return directoryListing{}, xerrors.Errorf("offset %d is beyond the directory length of %d entries", offset, totalEntries)
142+
}
143+
144+
start := int(offset - 1)
145+
remaining := totalEntries - start
146+
entriesToRead := remaining
147+
if limit < int64(remaining) {
148+
entriesToRead = int(limit)
149+
}
150+
end := start + entriesToRead
151+
152+
content, entriesRead, byteTruncated := formatDirectoryListing(
153+
resp.Contents[start:end],
154+
offset,
155+
int(workspacesdk.DefaultMaxResponseBytes),
156+
)
157+
return directoryListing{
158+
content: content,
159+
entries: resp.Contents[start : start+entriesRead],
160+
entriesRead: entriesRead,
161+
truncated: byteTruncated || start+entriesRead < totalEntries,
162+
}, nil
163+
}
164+
165+
func formatDirectoryListing(entries []workspacesdk.LSFile, offset int64, maxBytes int) (string, int, bool) {
166+
var b strings.Builder
167+
for i, entry := range entries {
168+
name := entry.Name
169+
if entry.IsDir {
170+
name += "/"
171+
}
172+
line := fmt.Sprintf("%d\t%s\n", offset+int64(i), name)
173+
if b.Len()+len(line) > maxBytes {
174+
return b.String(), i, true
175+
}
176+
_, _ = b.WriteString(line)
177+
}
178+
return b.String(), len(entries), false
179+
}

0 commit comments

Comments
 (0)