Skip to content

Commit 5a51146

Browse files
authored
feat(chat): Add fsRead tool for Amazon Q Agentic Chat (#6829)
## Problem Amazon Q Agentic Chat needs the ability to read files and directories from the user's workspace to provide context-aware assistance. Currently, there's no tool available for Amazon Q to access the file system in a controlled and secure manner. ## Solution - Implemented a new fsRead tool that allows Amazon Q to: - Read entire files or specific line ranges - List directory contents with configurable depth - Handle error cases gracefully with informative messages - Enforce size limits (30KB) to prevent excessive memory usage ## Testing ``` FsRead Unit Tests extensionHostProcess.js:178 ✔ reads entire file extensionHostProcess.js:178 ✔ reads partial lines of a file extensionHostProcess.js:178 ✔ lists directory contents up to depth = 1 extensionHostProcess.js:178 ✔ throws error if path does not exist extensionHostProcess.js:178 ✔ throws error if content exceeds 30KB extensionHostProcess.js:178 ✔ invalid line range ``` --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 8e906b2 commit 5a51146

File tree

5 files changed

+296
-0
lines changed

5 files changed

+296
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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, maxToolResponseSize, OutputKind } from './toolShared'
10+
11+
export interface FsReadParams {
12+
path: string
13+
readRange?: number[]
14+
}
15+
16+
export class FsRead {
17+
private readonly fsPath: string
18+
private readonly readRange?: number[]
19+
private readonly logger = getLogger('fsRead')
20+
21+
constructor(params: FsReadParams) {
22+
this.fsPath = params.path
23+
this.readRange = params.readRange
24+
}
25+
26+
public async invoke(): Promise<InvokeOutput> {
27+
try {
28+
const fileUri = vscode.Uri.file(this.fsPath)
29+
let exists: boolean
30+
try {
31+
exists = await fs.exists(fileUri)
32+
if (!exists) {
33+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`)
34+
}
35+
} catch (err) {
36+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`)
37+
}
38+
39+
const isFile = await fs.existsFile(fileUri)
40+
const isDirectory = await fs.existsDir(fileUri)
41+
42+
if (isFile) {
43+
const fileContents = await this.readFile(fileUri)
44+
this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`)
45+
return this.handleFileRange(fileContents)
46+
} else if (isDirectory) {
47+
const maxDepth = this.getDirectoryDepth() ?? 0
48+
const listing = await readDirectoryRecursively(fileUri, maxDepth)
49+
return this.createOutput(listing.join('\n'))
50+
} else {
51+
throw new Error(`"${this.fsPath}" is neither a standard file nor directory.`)
52+
}
53+
} catch (error: any) {
54+
this.logger.error(`Failed to read "${this.fsPath}": ${error.message || error}`)
55+
throw new Error(`[fs_read] Failed to read "${this.fsPath}": ${error.message || error}`)
56+
}
57+
}
58+
59+
private async readFile(fileUri: vscode.Uri): Promise<string> {
60+
this.logger.info(`Reading file: ${fileUri.fsPath}`)
61+
return await fs.readFileText(fileUri)
62+
}
63+
64+
private handleFileRange(fullText: string): InvokeOutput {
65+
if (!this.readRange || this.readRange.length === 0) {
66+
this.logger.info('No range provided. returning entire file.')
67+
return this.createOutput(this.enforceMaxSize(fullText))
68+
}
69+
70+
const lines = fullText.split('\n')
71+
const [start, end] = this.parseLineRange(lines.length, this.readRange)
72+
if (start > end) {
73+
this.logger.error(`Invalid range: ${this.readRange.join('-')}`)
74+
return this.createOutput('')
75+
}
76+
77+
this.logger.info(`Reading file: ${this.fsPath}, lines ${start + 1}-${end + 1}`)
78+
const slice = lines.slice(start, end + 1).join('\n')
79+
return this.createOutput(this.enforceMaxSize(slice))
80+
}
81+
82+
private parseLineRange(lineCount: number, range: number[]): [number, number] {
83+
const startIdx = range[0]
84+
let endIdx = range.length >= 2 ? range[1] : undefined
85+
86+
if (endIdx === undefined) {
87+
endIdx = -1
88+
}
89+
90+
const convert = (i: number): number => {
91+
return i < 0 ? lineCount + i : i - 1
92+
}
93+
94+
const finalStart = Math.max(0, Math.min(lineCount - 1, convert(startIdx)))
95+
const finalEnd = Math.max(0, Math.min(lineCount - 1, convert(endIdx)))
96+
return [finalStart, finalEnd]
97+
}
98+
99+
private getDirectoryDepth(): number | undefined {
100+
if (!this.readRange || this.readRange.length === 0) {
101+
return 0
102+
}
103+
return this.readRange[0]
104+
}
105+
106+
private enforceMaxSize(content: string): string {
107+
const byteCount = Buffer.byteLength(content, 'utf8')
108+
if (byteCount > maxToolResponseSize) {
109+
throw new Error(
110+
`This tool only supports reading ${maxToolResponseSize} bytes at a time.
111+
You tried to read ${byteCount} bytes. Try executing with fewer lines specified.`
112+
)
113+
}
114+
return content
115+
}
116+
117+
private createOutput(content: string): InvokeOutput {
118+
return {
119+
output: {
120+
kind: OutputKind.Text,
121+
content: content,
122+
},
123+
}
124+
}
125+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
export const maxToolResponseSize = 30720 // 30KB
6+
7+
export enum OutputKind {
8+
Text = 'text',
9+
Json = 'json',
10+
}
11+
12+
export interface InvokeOutput {
13+
output: {
14+
kind: OutputKind
15+
content: string
16+
}
17+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type LogTopic =
1414
| 'unknown'
1515
| 'chat'
1616
| 'stepfunctions'
17+
| 'fsRead'
1718

1819
class ErrorLog {
1920
constructor(

packages/core/src/shared/utilities/workspaceUtils.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,3 +671,69 @@ export async function findStringInDirectory(searchStr: string, dirPath: string)
671671
})
672672
return spawnResult
673673
}
674+
675+
/**
676+
* Returns a one-character tag for a directory ('d'), symlink ('l'), or file ('-').
677+
*/
678+
export function formatListing(name: string, fileType: vscode.FileType, fullPath: string): string {
679+
let typeChar = '-'
680+
if (fileType === vscode.FileType.Directory) {
681+
typeChar = 'd'
682+
} else if (fileType === vscode.FileType.SymbolicLink) {
683+
typeChar = 'l'
684+
}
685+
return `${typeChar} ${fullPath}`
686+
}
687+
688+
/**
689+
* Recursively lists directories using a BFS approach, returning lines like:
690+
* d /absolute/path/to/folder
691+
* - /absolute/path/to/file.txt
692+
*
693+
* You can either pass a custom callback or rely on the default `formatListing`.
694+
*
695+
* @param dirUri The folder to begin traversing
696+
* @param maxDepth Maximum depth to descend (0 => just this folder)
697+
* @param customFormatCallback Optional. If given, it will override the default line-formatting
698+
*/
699+
export async function readDirectoryRecursively(
700+
dirUri: vscode.Uri,
701+
maxDepth: number,
702+
customFormatCallback?: (name: string, fileType: vscode.FileType, fullPath: string) => string
703+
): Promise<string[]> {
704+
const logger = getLogger()
705+
logger.info(`Reading directory: ${dirUri.fsPath} to max depth: ${maxDepth}`)
706+
707+
const queue: Array<{ uri: vscode.Uri; depth: number }> = [{ uri: dirUri, depth: 0 }]
708+
const results: string[] = []
709+
710+
const formatter = customFormatCallback ?? formatListing
711+
712+
while (queue.length > 0) {
713+
const { uri, depth } = queue.shift()!
714+
if (depth > maxDepth) {
715+
logger.info(`Skipping directory: ${uri.fsPath} (depth ${depth} > max ${maxDepth})`)
716+
continue
717+
}
718+
719+
let entries: [string, vscode.FileType][]
720+
try {
721+
entries = await fs.readdir(uri)
722+
} catch (err) {
723+
logger.error(`Cannot read directory: ${uri.fsPath} (${err})`)
724+
results.push(`Cannot read directory: ${uri.fsPath} (${err})`)
725+
continue
726+
}
727+
728+
for (const [name, fileType] of entries) {
729+
const childUri = vscode.Uri.joinPath(uri, name)
730+
results.push(formatter(name, fileType, childUri.fsPath))
731+
732+
if (fileType === vscode.FileType.Directory && depth < maxDepth) {
733+
queue.push({ uri: childUri, depth: depth + 1 })
734+
}
735+
}
736+
}
737+
738+
return results
739+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 { FsRead } from '../../../codewhispererChat/tools/fsRead'
7+
import { TestFolder } from '../../testUtil'
8+
import path from 'path'
9+
10+
describe('FsRead Tool', () => {
11+
let testFolder: TestFolder
12+
13+
before(async () => {
14+
testFolder = await TestFolder.create()
15+
})
16+
17+
it('reads entire file', async () => {
18+
const fileContent = 'Line 1\nLine 2\nLine 3'
19+
const filePath = await testFolder.write('fullFile.txt', fileContent)
20+
21+
const fsRead = new FsRead({ path: filePath })
22+
const result = await fsRead.invoke()
23+
24+
assert.strictEqual(result.output.kind, 'text', 'Output kind should be "text"')
25+
assert.strictEqual(result.output.content, fileContent, 'File content should match exactly')
26+
})
27+
28+
it('reads partial lines of a file', async () => {
29+
const fileContent = 'A\nB\nC\nD\nE\nF'
30+
const filePath = await testFolder.write('partialFile.txt', fileContent)
31+
32+
const fsRead = new FsRead({ path: filePath, readRange: [2, 4] })
33+
const result = await fsRead.invoke()
34+
35+
assert.strictEqual(result.output.kind, 'text')
36+
assert.strictEqual(result.output.content, 'B\nC\nD')
37+
})
38+
39+
it('lists directory contents up to depth = 1', async () => {
40+
await testFolder.mkdir('subfolder')
41+
await testFolder.write('fileA.txt', 'fileA content')
42+
await testFolder.write(path.join('subfolder', 'fileB.md'), '# fileB')
43+
44+
const fsRead = new FsRead({ path: testFolder.path, readRange: [1] })
45+
const result = await fsRead.invoke()
46+
47+
const lines = result.output.content.split('\n')
48+
const hasFileA = lines.some((line) => line.includes('- ') && line.includes('fileA.txt'))
49+
const hasSubfolder = lines.some((line) => line.includes('d ') && line.includes('subfolder'))
50+
51+
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
52+
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
53+
})
54+
55+
it('throws error if path does not exist', async () => {
56+
const missingPath = path.join(testFolder.path, 'no_such_file.txt')
57+
const fsRead = new FsRead({ path: missingPath })
58+
59+
await assert.rejects(
60+
fsRead.invoke(),
61+
/does not exist or cannot be accessed/i,
62+
'Expected an error indicating the path does not exist'
63+
)
64+
})
65+
66+
it('throws error if content exceeds 30KB', async function () {
67+
const bigContent = 'x'.repeat(35_000)
68+
const bigFilePath = await testFolder.write('bigFile.txt', bigContent)
69+
70+
const fsRead = new FsRead({ path: bigFilePath })
71+
72+
await assert.rejects(
73+
fsRead.invoke(),
74+
/This tool only supports reading \d+ bytes at a time/i,
75+
'Expected a size-limit error'
76+
)
77+
})
78+
79+
it('invalid line range', async () => {
80+
const filePath = await testFolder.write('rangeTest.txt', '1\n2\n3')
81+
const fsRead = new FsRead({ path: filePath, readRange: [3, 2] })
82+
83+
const result = await fsRead.invoke()
84+
assert.strictEqual(result.output.kind, 'text')
85+
assert.strictEqual(result.output.content, '')
86+
})
87+
})

0 commit comments

Comments
 (0)