feat: adjust read tool so that it can handle dirs too (#13090)
This commit is contained in:
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
|
|||||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||||
|
|
||||||
|
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
||||||
|
Use Filesystem.exists(...) instead if path can be file or directory
|
||||||
|
|
||||||
## Quick checklist
|
## Quick checklist
|
||||||
|
|
||||||
- Use Bun APIs first.
|
- Use Bun APIs first.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ Performs exact string replacements in files.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||||
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
||||||
- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||||
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const MAX_BYTES = 50 * 1024
|
|||||||
export const ReadTool = Tool.define("read", {
|
export const ReadTool = Tool.define("read", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
filePath: z.string().describe("The path to the file to read"),
|
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
||||||
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
|
offset: z.coerce.number().describe("The 0-based line offset to start reading from").optional(),
|
||||||
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
|
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
let filepath = params.filePath
|
let filepath = params.filePath
|
||||||
@@ -28,8 +28,12 @@ export const ReadTool = Tool.define("read", {
|
|||||||
}
|
}
|
||||||
const title = path.relative(Instance.worktree, filepath)
|
const title = path.relative(Instance.worktree, filepath)
|
||||||
|
|
||||||
|
const file = Bun.file(filepath)
|
||||||
|
const stat = await file.stat().catch(() => undefined)
|
||||||
|
|
||||||
await assertExternalDirectory(ctx, filepath, {
|
await assertExternalDirectory(ctx, filepath, {
|
||||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||||
|
kind: stat?.isDirectory() ? "directory" : "file",
|
||||||
})
|
})
|
||||||
|
|
||||||
await ctx.ask({
|
await ctx.ask({
|
||||||
@@ -39,8 +43,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const file = Bun.file(filepath)
|
if (!stat) {
|
||||||
if (!(await file.exists())) {
|
|
||||||
const dir = path.dirname(filepath)
|
const dir = path.dirname(filepath)
|
||||||
const base = path.basename(filepath)
|
const base = path.basename(filepath)
|
||||||
|
|
||||||
@@ -60,6 +63,47 @@ export const ReadTool = Tool.define("read", {
|
|||||||
throw new Error(`File not found: ${filepath}`)
|
throw new Error(`File not found: ${filepath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
|
||||||
|
const entries = await Promise.all(
|
||||||
|
dirents.map(async (dirent) => {
|
||||||
|
if (dirent.isDirectory()) return dirent.name + "/"
|
||||||
|
if (dirent.isSymbolicLink()) {
|
||||||
|
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
||||||
|
if (target?.isDirectory()) return dirent.name + "/"
|
||||||
|
}
|
||||||
|
return dirent.name
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
entries.sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
|
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||||
|
const offset = params.offset || 0
|
||||||
|
const sliced = entries.slice(offset, offset + limit)
|
||||||
|
const truncated = offset + sliced.length < entries.length
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
`<path>${filepath}</path>`,
|
||||||
|
`<type>directory</type>`,
|
||||||
|
`<entries>`,
|
||||||
|
sliced.join("\n"),
|
||||||
|
truncated
|
||||||
|
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
||||||
|
: `\n(${entries.length} entries)`,
|
||||||
|
`</entries>`,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
output,
|
||||||
|
metadata: {
|
||||||
|
preview: sliced.slice(0, 20).join("\n"),
|
||||||
|
truncated,
|
||||||
|
loaded: [] as string[],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
|
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
|
||||||
|
|
||||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||||
@@ -75,7 +119,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
metadata: {
|
metadata: {
|
||||||
preview: msg,
|
preview: msg,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
loaded: instructions.map((i) => i.filepath),
|
||||||
},
|
},
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
@@ -112,11 +156,11 @@ export const ReadTool = Tool.define("read", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = raw.map((line, index) => {
|
const content = raw.map((line, index) => {
|
||||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
|
return `${index + offset + 1}: ${line}`
|
||||||
})
|
})
|
||||||
const preview = raw.slice(0, 20).join("\n")
|
const preview = raw.slice(0, 20).join("\n")
|
||||||
|
|
||||||
let output = "<file>\n"
|
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||||
output += content.join("\n")
|
output += content.join("\n")
|
||||||
|
|
||||||
const totalLines = lines.length
|
const totalLines = lines.length
|
||||||
@@ -131,7 +175,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
} else {
|
} else {
|
||||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||||
}
|
}
|
||||||
output += "\n</file>"
|
output += "\n</content>"
|
||||||
|
|
||||||
// just warms the lsp client
|
// just warms the lsp client
|
||||||
LSP.touchFile(filepath, false)
|
LSP.touchFile(filepath, false)
|
||||||
@@ -147,7 +191,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
metadata: {
|
metadata: {
|
||||||
preview,
|
preview,
|
||||||
truncated,
|
truncated,
|
||||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
loaded: instructions.map((i) => i.filepath),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
Read a file or directory from the local filesystem. If the path does not exist, an error is returned.
|
||||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
- The filePath parameter must be an absolute path, not a relative path
|
- The filePath parameter should be an absolute path.
|
||||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
- By default, this tool returns up to 2000 lines from the start of the file.
|
||||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
- To read later sections, call this tool again with a larger offset.
|
||||||
- Any lines longer than 2000 characters will be truncated
|
- Use the grep tool to find specific content in large files or files with long lines.
|
||||||
- Results are returned using cat -n format, with line numbers starting at 1
|
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
|
||||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
- Any line longer than 2000 characters is truncated.
|
||||||
- You can read image files using this tool.
|
- Call this tool in parallel when you know there are multiple files you want to read.
|
||||||
|
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
|
||||||
|
- This tool can read image files and PDFs and return them as file attachments.
|
||||||
|
|||||||
@@ -78,6 +78,32 @@ describe("tool.read external_directory permission", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
|
||||||
|
await using outerTmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "external", "a.txt"), "a")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const testCtx = {
|
||||||
|
...ctx,
|
||||||
|
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
|
||||||
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||||
|
expect(extDirReq).toBeDefined()
|
||||||
|
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("asks for external_directory permission when reading relative path outside project", async () => {
|
test("asks for external_directory permission when reading relative path outside project", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -249,6 +275,25 @@ describe("tool.read truncation", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not mark final directory page as truncated", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i}.txt`), `line${i}`)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 5, limit: 5 }, ctx)
|
||||||
|
expect(result.metadata.truncated).toBe(false)
|
||||||
|
expect(result.output).not.toContain("Showing 5 of 10 entries")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("truncates long lines", async () => {
|
test("truncates long lines", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user