Skip to content

Commit b883c67

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

File tree

4 files changed

+292
-0
lines changed

4 files changed

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

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)