Skip to content

Commit 1c46419

Browse files
committed
feat(chat): Add fsRead tool for Amazon Q Agentic Chat
1 parent 8e906b2 commit 1c46419

File tree

4 files changed

+291
-0
lines changed

4 files changed

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

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 vscode.workspace.fs.readDirectory(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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 Unit Tests', () => {
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+
try {
60+
await fsRead.invoke()
61+
assert.fail('Expected an error but none was thrown')
62+
} catch (err: any) {
63+
assert.match(err.message, /does not exist or cannot be accessed/i)
64+
}
65+
})
66+
67+
it('throws error if content exceeds 30KB', async function () {
68+
this.timeout(5000)
69+
const bigContent = 'x'.repeat(35_000)
70+
const bigFilePath = await testFolder.write('bigFile.txt', bigContent)
71+
72+
const fsRead = new FsRead({ path: bigFilePath })
73+
74+
try {
75+
await fsRead.invoke()
76+
assert.fail('Expected a size-limit error')
77+
} catch (err: any) {
78+
assert.match(err.message, /This tool only supports reading \d+ bytes at a time/i)
79+
}
80+
})
81+
82+
it('invalid line range', async () => {
83+
const filePath = await testFolder.write('rangeTest.txt', '1\n2\n3')
84+
const fsRead = new FsRead({ path: filePath, readRange: [3, 2] })
85+
86+
const result = await fsRead.invoke()
87+
assert.strictEqual(result.output.kind, 'text')
88+
assert.strictEqual(result.output.content, '')
89+
})
90+
})

0 commit comments

Comments
 (0)