Skip to content

Commit d5808a9

Browse files
committed
feat(chat): Add ListDirectory tool
1 parent aa76262 commit d5808a9

File tree

8 files changed

+252
-50
lines changed

8 files changed

+252
-50
lines changed

packages/core/src/codewhispererChat/tools/fsRead.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55
import * as vscode from 'vscode'
66
import { getLogger } from '../../shared/logger/logger'
7-
import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils'
87
import fs from '../../shared/fs/fs'
98
import { InvokeOutput, maxToolResponseSize, OutputKind, sanitizePath } from './toolShared'
109
import { Writable } from 'stream'
@@ -18,7 +17,6 @@ export interface FsReadParams {
1817
export class FsRead {
1918
private fsPath: string
2019
private readonly readRange?: number[]
21-
private isFile?: boolean // true for file, false for directory
2220
private readonly logger = getLogger('fsRead')
2321

2422
constructor(params: FsReadParams) {
@@ -46,7 +44,6 @@ export class FsRead {
4644
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`)
4745
}
4846

49-
this.isFile = await fs.existsFile(fileUri)
5047
this.logger.debug(`Validation succeeded for path: ${this.fsPath}`)
5148
}
5249

@@ -61,17 +58,9 @@ export class FsRead {
6158
try {
6259
const fileUri = vscode.Uri.file(this.fsPath)
6360

64-
if (this.isFile) {
65-
const fileContents = await this.readFile(fileUri)
66-
this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`)
67-
return this.handleFileRange(fileContents)
68-
} else if (!this.isFile) {
69-
const maxDepth = this.getDirectoryDepth() ?? 0
70-
const listing = await readDirectoryRecursively(fileUri, maxDepth)
71-
return this.createOutput(listing.join('\n'))
72-
} else {
73-
throw new Error(`"${this.fsPath}" is neither a standard file nor directory.`)
74-
}
61+
const fileContents = await this.readFile(fileUri)
62+
this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`)
63+
return this.handleFileRange(fileContents)
7564
} catch (error: any) {
7665
this.logger.error(`Failed to read "${this.fsPath}": ${error.message || error}`)
7766
throw new Error(`[fs_read] Failed to read "${this.fsPath}": ${error.message || error}`)
@@ -118,13 +107,6 @@ export class FsRead {
118107
return [finalStart, finalEnd]
119108
}
120109

121-
private getDirectoryDepth(): number | undefined {
122-
if (!this.readRange || this.readRange.length === 0) {
123-
return 0
124-
}
125-
return this.readRange[0]
126-
}
127-
128110
private enforceMaxSize(content: string): string {
129111
const byteCount = Buffer.byteLength(content, 'utf8')
130112
if (byteCount > maxToolResponseSize) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
import { getLogger } from '../../shared/logger/logger'
7+
import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils'
8+
import fs from '../../shared/fs/fs'
9+
import { InvokeOutput, OutputKind, sanitizePath } from './toolShared'
10+
import { Writable } from 'stream'
11+
import path from 'path'
12+
13+
export interface ListDirectoryParams {
14+
path: string
15+
}
16+
17+
export class ListDirectory {
18+
private fsPath: string
19+
private readonly logger = getLogger('listDirectory')
20+
21+
constructor(params: ListDirectoryParams) {
22+
this.fsPath = params.path
23+
}
24+
25+
public async validate(): Promise<void> {
26+
this.logger.debug(`Validating fsPath: ${this.fsPath}`)
27+
if (!this.fsPath || this.fsPath.trim().length === 0) {
28+
throw new Error('Path cannot be empty.')
29+
}
30+
31+
const sanitized = sanitizePath(this.fsPath)
32+
this.fsPath = sanitized
33+
34+
const fileUri = vscode.Uri.file(this.fsPath)
35+
let exists: boolean
36+
try {
37+
exists = await fs.exists(fileUri)
38+
if (!exists) {
39+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`)
40+
}
41+
} catch (err) {
42+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`)
43+
}
44+
45+
this.logger.debug(`Validation succeeded for path: ${this.fsPath}`)
46+
}
47+
48+
public queueDescription(updates: Writable): void {
49+
const fileName = path.basename(this.fsPath)
50+
updates.write(`Listing directory on: [${fileName}]`)
51+
updates.end()
52+
}
53+
54+
public async invoke(updates: Writable): Promise<InvokeOutput> {
55+
try {
56+
const fileUri = vscode.Uri.file(this.fsPath)
57+
const listing = await readDirectoryRecursively(fileUri, 0)
58+
return this.createOutput(listing.join('\n'))
59+
} catch (error: any) {
60+
this.logger.error(`Failed to list directory "${this.fsPath}": ${error.message || error}`)
61+
throw new Error(`[fs_read] Failed to list directory "${this.fsPath}": ${error.message || error}`)
62+
}
63+
}
64+
65+
private createOutput(content: string): InvokeOutput {
66+
return {
67+
output: {
68+
kind: OutputKind.Text,
69+
content: content,
70+
},
71+
}
72+
}
73+
}

packages/core/src/codewhispererChat/tools/toolUtils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import { FsWrite, FsWriteParams } from './fsWrite'
88
import { ExecuteBash, ExecuteBashParams } from './executeBash'
99
import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming'
1010
import { InvokeOutput } from './toolShared'
11+
import { ListDirectory, ListDirectoryParams } from './listDirectory'
1112

1213
export enum ToolType {
1314
FsRead = 'fsRead',
1415
FsWrite = 'fsWrite',
1516
ExecuteBash = 'executeBash',
17+
ListDirectory = 'listDirectory',
1618
}
1719

1820
export type Tool =
1921
| { type: ToolType.FsRead; tool: FsRead }
2022
| { type: ToolType.FsWrite; tool: FsWrite }
2123
| { type: ToolType.ExecuteBash; tool: ExecuteBash }
24+
| { type: ToolType.ListDirectory; tool: ListDirectory }
2225

2326
export class ToolUtils {
2427
static displayName(tool: Tool): string {
@@ -29,6 +32,8 @@ export class ToolUtils {
2932
return 'Write to filesystem'
3033
case ToolType.ExecuteBash:
3134
return 'Execute shell command'
35+
case ToolType.ListDirectory:
36+
return 'List directory from filesystem'
3237
}
3338
}
3439

@@ -40,6 +45,8 @@ export class ToolUtils {
4045
return true
4146
case ToolType.ExecuteBash:
4247
return tool.tool.requiresAcceptance()
48+
case ToolType.ListDirectory:
49+
return false
4350
}
4451
}
4552

@@ -51,6 +58,8 @@ export class ToolUtils {
5158
return tool.tool.invoke(updates)
5259
case ToolType.ExecuteBash:
5360
return tool.tool.invoke(updates)
61+
case ToolType.ListDirectory:
62+
return tool.tool.invoke(updates)
5463
}
5564
}
5665

@@ -65,6 +74,9 @@ export class ToolUtils {
6574
case ToolType.ExecuteBash:
6675
tool.tool.queueDescription(updates)
6776
break
77+
case ToolType.ListDirectory:
78+
tool.tool.queueDescription(updates)
79+
break
6880
}
6981
}
7082

@@ -76,6 +88,8 @@ export class ToolUtils {
7688
return tool.tool.validate()
7789
case ToolType.ExecuteBash:
7890
return tool.tool.validate()
91+
case ToolType.ListDirectory:
92+
return tool.tool.validate()
7993
}
8094
}
8195

@@ -108,6 +122,11 @@ export class ToolUtils {
108122
type: ToolType.ExecuteBash,
109123
tool: new ExecuteBash(value.input as unknown as ExecuteBashParams),
110124
}
125+
case ToolType.ListDirectory:
126+
return {
127+
type: ToolType.ListDirectory,
128+
tool: new ListDirectory(value.input as unknown as ListDirectoryParams),
129+
}
111130
default:
112131
return {
113132
toolUseId: value.toolUseId,

packages/core/src/codewhispererChat/tools/tool_index.json

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
{
22
"fsRead": {
33
"name": "fsRead",
4-
"description": "A tool for reading files (e.g. `cat -n`), or listing files/directories (e.g. `ls -la` or `find . -maxdepth 2). The behavior of this tool is determined by the `path` parameter pointing to a file or directory.\n* If `path` is a file, this tool returns the result of running `cat -n`, and the optional `readRange` determines what range of lines will be read from the specified file.\n* If `path` is a directory, this tool returns the listed files and directories of the specified path, as if running `ls -la`. If the `readRange` parameter is provided, the tool acts like the `find . -maxdepth <readRange>`, where `readRange` is the number of subdirectories deep to search, e.g. [2] will run `find . -maxdepth 2`.",
4+
"description": "A tool for reading a file. \n* This tool returns the contents of a file, and the optional `readRange` determines what range of lines will be read from the specified file.",
55
"inputSchema": {
66
"type": "object",
77
"properties": {
88
"path": {
9-
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.",
9+
"description": "Absolute path to a file, e.g. `/repo/file.py`.",
1010
"type": "string"
1111
},
1212
"readRange": {
13-
"description": "Optional parameter when reading either files or directories.\n* When `path` is a file, if none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file.\n* When `path` is a directory, if none is given, the results of `ls -l` are given. If provided, the current directory and indicated number of subdirectories will be shown, e.g. [2] will show the current directory and directories two levels deep.",
13+
"description": "Optional parameter when reading files.\n* If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file.",
1414
"items": {
1515
"type": "integer"
1616
},
@@ -72,5 +72,23 @@
7272
},
7373
"required": ["command", "cwd"]
7474
}
75+
},
76+
"listDirectory": {
77+
"name": "listDirectory",
78+
"description": "List the contents of a directory. Use this tool for discovery, before using more targeted tools like fsRead. Useful to try to understand the file structure before diving deeper into specific files. Can be used to explore the codebase.",
79+
"inputSchema": {
80+
"type": "object",
81+
"properties": {
82+
"explanation": {
83+
"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
84+
"type": "string"
85+
},
86+
"path": {
87+
"type": "string",
88+
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
89+
}
90+
},
91+
"required": ["path"]
92+
}
7593
}
7694
}

packages/core/src/shared/logger/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type LogTopic =
1919
| 'fsRead'
2020
| 'fsWrite'
2121
| 'executeBash'
22+
| 'listDirectory'
2223
| 'chatStream'
2324
| 'unknown'
2425

packages/core/src/test/codewhispererChat/tools/fsRead.test.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,6 @@ describe('FsRead Tool', () => {
4343
assert.strictEqual(result.output.content, 'B\nC\nD')
4444
})
4545

46-
it('lists directory contents up to depth = 1', async () => {
47-
await testFolder.mkdir('subfolder')
48-
await testFolder.write('fileA.txt', 'fileA content')
49-
await testFolder.write(path.join('subfolder', 'fileB.md'), '# fileB')
50-
51-
const fsRead = new FsRead({ path: testFolder.path, readRange: [1] })
52-
await fsRead.validate()
53-
const result = await fsRead.invoke(process.stdout)
54-
55-
const lines = result.output.content.split('\n')
56-
const hasFileA = lines.some((line: string | string[]) => line.includes('- ') && line.includes('fileA.txt'))
57-
const hasSubfolder = lines.some((line: string | string[]) => line.includes('d ') && line.includes('subfolder'))
58-
59-
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
60-
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
61-
})
62-
6346
it('throws error if path does not exist', async () => {
6447
const missingPath = path.join(testFolder.path, 'no_such_file.txt')
6548
const fsRead = new FsRead({ path: missingPath })
@@ -95,15 +78,6 @@ describe('FsRead Tool', () => {
9578
assert.strictEqual(result.output.content, '')
9679
})
9780

98-
it('expands ~ path', async () => {
99-
const fsRead = new FsRead({ path: '~' })
100-
await fsRead.validate()
101-
const result = await fsRead.invoke(process.stdout)
102-
103-
assert.strictEqual(result.output.kind, 'text')
104-
assert.ok(result.output.content.length > 0)
105-
})
106-
10781
it('resolves relative path', async () => {
10882
await testFolder.mkdir('relTest')
10983
const filePath = path.join('relTest', 'relFile.txt')
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import assert from 'assert'
6+
import { ListDirectory } from '../../../codewhispererChat/tools/listDirectory'
7+
import { TestFolder } from '../../testUtil'
8+
import path from 'path'
9+
10+
describe('ListDirectory Tool', () => {
11+
let testFolder: TestFolder
12+
13+
before(async () => {
14+
testFolder = await TestFolder.create()
15+
})
16+
17+
it('throws if path is empty', async () => {
18+
const listDirectory = new ListDirectory({ path: '' })
19+
await assert.rejects(listDirectory.validate(), /Path cannot be empty/i, 'Expected an error about empty path')
20+
})
21+
22+
it('lists directory contents', async () => {
23+
await testFolder.mkdir('subfolder')
24+
await testFolder.write('fileA.txt', 'fileA content')
25+
await testFolder.write(path.join('subfolder', 'fileB.md'), '# fileB')
26+
27+
const listDirectory = new ListDirectory({ path: testFolder.path })
28+
await listDirectory.validate()
29+
const result = await listDirectory.invoke(process.stdout)
30+
31+
const lines = result.output.content.split('\n')
32+
const hasFileA = lines.some((line: string | string[]) => line.includes('- ') && line.includes('fileA.txt'))
33+
const hasSubfolder = lines.some((line: string | string[]) => line.includes('d ') && line.includes('subfolder'))
34+
35+
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
36+
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
37+
})
38+
39+
it('throws error if path does not exist', async () => {
40+
const missingPath = path.join(testFolder.path, 'no_such_file.txt')
41+
const listDirectory = new ListDirectory({ path: missingPath })
42+
43+
await assert.rejects(
44+
listDirectory.validate(),
45+
/does not exist or cannot be accessed/i,
46+
'Expected an error indicating the path does not exist'
47+
)
48+
})
49+
50+
it('expands ~ path', async () => {
51+
const listDirectory = new ListDirectory({ path: '~' })
52+
await listDirectory.validate()
53+
const result = await listDirectory.invoke(process.stdout)
54+
55+
assert.strictEqual(result.output.kind, 'text')
56+
assert.ok(result.output.content.length > 0)
57+
})
58+
59+
it('resolves relative path', async () => {
60+
await testFolder.mkdir('relTest')
61+
const filePath = path.join('relTest', 'relFile.txt')
62+
const content = 'Hello from a relative file!'
63+
await testFolder.write(filePath, content)
64+
65+
const relativePath = path.relative(process.cwd(), path.join(testFolder.path, filePath))
66+
67+
const listDirectory = new ListDirectory({ path: relativePath })
68+
await listDirectory.validate()
69+
const result = await listDirectory.invoke(process.stdout)
70+
71+
assert.strictEqual(result.output.kind, 'text')
72+
assert.strictEqual(result.output.content, content)
73+
})
74+
})

0 commit comments

Comments
 (0)