Browse Source

feat: simple read_file tool for single-file-only models (#7222)

Daniel 4 months ago
parent
commit
9a7ddab1bc

+ 1 - 0
packages/types/src/index.ts

@@ -12,6 +12,7 @@ export * from "./message.js"
 export * from "./mode.js"
 export * from "./mode.js"
 export * from "./model.js"
 export * from "./model.js"
 export * from "./provider-settings.js"
 export * from "./provider-settings.js"
+export * from "./single-file-read-models.js"
 export * from "./task.js"
 export * from "./task.js"
 export * from "./todo.js"
 export * from "./todo.js"
 export * from "./telemetry.js"
 export * from "./telemetry.js"

+ 32 - 0
packages/types/src/single-file-read-models.ts

@@ -0,0 +1,32 @@
+/**
+ * Configuration for models that should use simplified single-file read_file tool
+ * These models will use the simpler <read_file><path>...</path></read_file> format
+ * instead of the more complex multi-file args format
+ */
+
+// List of model IDs (or patterns) that should use single file reads only
+export const SINGLE_FILE_READ_MODELS = new Set<string>(["roo/sonic"])
+
+/**
+ * Check if a model should use single file read format
+ * @param modelId The model ID to check
+ * @returns true if the model should use single file reads
+ */
+export function shouldUseSingleFileRead(modelId: string): boolean {
+	// Direct match
+	if (SINGLE_FILE_READ_MODELS.has(modelId)) {
+		return true
+	}
+
+	// Pattern matching for model families
+	// Check if model ID starts with any configured pattern
+	// Using Array.from for compatibility with older TypeScript targets
+	const patterns = Array.from(SINGLE_FILE_READ_MODELS)
+	for (const pattern of patterns) {
+		if (pattern.endsWith("*") && modelId.startsWith(pattern.slice(0, -1))) {
+			return true
+		}
+	}
+
+	return false
+}

+ 23 - 3
src/core/assistant-message/presentAssistantMessage.ts

@@ -10,6 +10,8 @@ import type { ToolParamName, ToolResponse } from "../../shared/tools"
 import { fetchInstructionsTool } from "../tools/fetchInstructionsTool"
 import { fetchInstructionsTool } from "../tools/fetchInstructionsTool"
 import { listFilesTool } from "../tools/listFilesTool"
 import { listFilesTool } from "../tools/listFilesTool"
 import { getReadFileToolDescription, readFileTool } from "../tools/readFileTool"
 import { getReadFileToolDescription, readFileTool } from "../tools/readFileTool"
+import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool"
+import { shouldUseSingleFileRead } from "@roo-code/types"
 import { writeToFileTool } from "../tools/writeToFileTool"
 import { writeToFileTool } from "../tools/writeToFileTool"
 import { applyDiffTool } from "../tools/multiApplyDiffTool"
 import { applyDiffTool } from "../tools/multiApplyDiffTool"
 import { insertContentTool } from "../tools/insertContentTool"
 import { insertContentTool } from "../tools/insertContentTool"
@@ -155,7 +157,13 @@ export async function presentAssistantMessage(cline: Task) {
 					case "execute_command":
 					case "execute_command":
 						return `[${block.name} for '${block.params.command}']`
 						return `[${block.name} for '${block.params.command}']`
 					case "read_file":
 					case "read_file":
-						return getReadFileToolDescription(block.name, block.params)
+						// Check if this model should use the simplified description
+						const modelId = cline.api.getModel().id
+						if (shouldUseSingleFileRead(modelId)) {
+							return getSimpleReadFileToolDescription(block.name, block.params)
+						} else {
+							return getReadFileToolDescription(block.name, block.params)
+						}
 					case "fetch_instructions":
 					case "fetch_instructions":
 						return `[${block.name} for '${block.params.task}']`
 						return `[${block.name} for '${block.params.task}']`
 					case "write_to_file":
 					case "write_to_file":
@@ -454,8 +462,20 @@ export async function presentAssistantMessage(cline: Task) {
 					await searchAndReplaceTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
 					await searchAndReplaceTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
 					break
 					break
 				case "read_file":
 				case "read_file":
-					await readFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
-
+					// Check if this model should use the simplified single-file read tool
+					const modelId = cline.api.getModel().id
+					if (shouldUseSingleFileRead(modelId)) {
+						await simpleReadFileTool(
+							cline,
+							block,
+							askApproval,
+							handleError,
+							pushToolResult,
+							removeClosingTag,
+						)
+					} else {
+						await readFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					}
 					break
 					break
 				case "fetch_instructions":
 				case "fetch_instructions":
 					await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult)
 					await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult)

+ 4 - 0
src/core/prompts/system.ts

@@ -61,6 +61,7 @@ async function generatePrompt(
 	partialReadsEnabled?: boolean,
 	partialReadsEnabled?: boolean,
 	settings?: SystemPromptSettings,
 	settings?: SystemPromptSettings,
 	todoList?: TodoItem[],
 	todoList?: TodoItem[],
+	modelId?: string,
 ): Promise<string> {
 ): Promise<string> {
 	if (!context) {
 	if (!context) {
 		throw new Error("Extension context is required for generating system prompt")
 		throw new Error("Extension context is required for generating system prompt")
@@ -106,6 +107,7 @@ ${getToolDescriptionsForMode(
 	partialReadsEnabled,
 	partialReadsEnabled,
 	settings,
 	settings,
 	enableMcpServerCreation,
 	enableMcpServerCreation,
+	modelId,
 )}
 )}
 
 
 ${getToolUseGuidelinesSection(codeIndexManager)}
 ${getToolUseGuidelinesSection(codeIndexManager)}
@@ -150,6 +152,7 @@ export const SYSTEM_PROMPT = async (
 	partialReadsEnabled?: boolean,
 	partialReadsEnabled?: boolean,
 	settings?: SystemPromptSettings,
 	settings?: SystemPromptSettings,
 	todoList?: TodoItem[],
 	todoList?: TodoItem[],
+	modelId?: string,
 ): Promise<string> => {
 ): Promise<string> => {
 	if (!context) {
 	if (!context) {
 		throw new Error("Extension context is required for generating system prompt")
 		throw new Error("Extension context is required for generating system prompt")
@@ -221,5 +224,6 @@ ${customInstructions}`
 		partialReadsEnabled,
 		partialReadsEnabled,
 		settings,
 		settings,
 		todoList,
 		todoList,
+		modelId,
 	)
 	)
 }
 }

+ 13 - 1
src/core/prompts/tools/index.ts

@@ -7,7 +7,9 @@ import { Mode, getModeConfig, isToolAllowedForMode, getGroupName } from "../../.
 import { ToolArgs } from "./types"
 import { ToolArgs } from "./types"
 import { getExecuteCommandDescription } from "./execute-command"
 import { getExecuteCommandDescription } from "./execute-command"
 import { getReadFileDescription } from "./read-file"
 import { getReadFileDescription } from "./read-file"
+import { getSimpleReadFileDescription } from "./simple-read-file"
 import { getFetchInstructionsDescription } from "./fetch-instructions"
 import { getFetchInstructionsDescription } from "./fetch-instructions"
+import { shouldUseSingleFileRead } from "@roo-code/types"
 import { getWriteToFileDescription } from "./write-to-file"
 import { getWriteToFileDescription } from "./write-to-file"
 import { getSearchFilesDescription } from "./search-files"
 import { getSearchFilesDescription } from "./search-files"
 import { getListFilesDescription } from "./list-files"
 import { getListFilesDescription } from "./list-files"
@@ -28,7 +30,14 @@ import { CodeIndexManager } from "../../../services/code-index/manager"
 // Map of tool names to their description functions
 // Map of tool names to their description functions
 const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {
 const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {
 	execute_command: (args) => getExecuteCommandDescription(args),
 	execute_command: (args) => getExecuteCommandDescription(args),
-	read_file: (args) => getReadFileDescription(args),
+	read_file: (args) => {
+		// Check if the current model should use the simplified read_file tool
+		const modelId = args.settings?.modelId
+		if (modelId && shouldUseSingleFileRead(modelId)) {
+			return getSimpleReadFileDescription(args)
+		}
+		return getReadFileDescription(args)
+	},
 	fetch_instructions: (args) => getFetchInstructionsDescription(args.settings?.enableMcpServerCreation),
 	fetch_instructions: (args) => getFetchInstructionsDescription(args.settings?.enableMcpServerCreation),
 	write_to_file: (args) => getWriteToFileDescription(args),
 	write_to_file: (args) => getWriteToFileDescription(args),
 	search_files: (args) => getSearchFilesDescription(args),
 	search_files: (args) => getSearchFilesDescription(args),
@@ -62,6 +71,7 @@ export function getToolDescriptionsForMode(
 	partialReadsEnabled?: boolean,
 	partialReadsEnabled?: boolean,
 	settings?: Record<string, any>,
 	settings?: Record<string, any>,
 	enableMcpServerCreation?: boolean,
 	enableMcpServerCreation?: boolean,
+	modelId?: string,
 ): string {
 ): string {
 	const config = getModeConfig(mode, customModes)
 	const config = getModeConfig(mode, customModes)
 	const args: ToolArgs = {
 	const args: ToolArgs = {
@@ -74,6 +84,7 @@ export function getToolDescriptionsForMode(
 		settings: {
 		settings: {
 			...settings,
 			...settings,
 			enableMcpServerCreation,
 			enableMcpServerCreation,
+			modelId,
 		},
 		},
 		experiments,
 		experiments,
 	}
 	}
@@ -138,6 +149,7 @@ export function getToolDescriptionsForMode(
 export {
 export {
 	getExecuteCommandDescription,
 	getExecuteCommandDescription,
 	getReadFileDescription,
 	getReadFileDescription,
+	getSimpleReadFileDescription,
 	getFetchInstructionsDescription,
 	getFetchInstructionsDescription,
 	getWriteToFileDescription,
 	getWriteToFileDescription,
 	getSearchFilesDescription,
 	getSearchFilesDescription,

+ 35 - 0
src/core/prompts/tools/simple-read-file.ts

@@ -0,0 +1,35 @@
+import { ToolArgs } from "./types"
+
+/**
+ * Generate a simplified read_file tool description for models that only support single file reads
+ * Uses the simpler format: <read_file><path>file/path.ext</path></read_file>
+ */
+export function getSimpleReadFileDescription(args: ToolArgs): string {
+	return `## read_file
+Description: Request to read the contents of a file. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when discussing code.
+
+Parameters:
+- path: (required) File path (relative to workspace directory ${args.cwd})
+
+Usage:
+<read_file>
+<path>path/to/file</path>
+</read_file>
+
+Examples:
+
+1. Reading a TypeScript file:
+<read_file>
+<path>src/app.ts</path>
+</read_file>
+
+2. Reading a configuration file:
+<read_file>
+<path>config.json</path>
+</read_file>
+
+3. Reading a markdown file:
+<read_file>
+<path>README.md</path>
+</read_file>`
+}

+ 2 - 0
src/core/task/Task.ts

@@ -2226,6 +2226,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 					todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
 					todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
 					useAgentRules: vscode.workspace.getConfiguration("roo-cline").get<boolean>("useAgentRules") ?? true,
 					useAgentRules: vscode.workspace.getConfiguration("roo-cline").get<boolean>("useAgentRules") ?? true,
 				},
 				},
+				undefined, // todoList
+				this.api.getModel().id,
 			)
 			)
 		})()
 		})()
 	}
 	}

+ 287 - 0
src/core/tools/simpleReadFileTool.ts

@@ -0,0 +1,287 @@
+import path from "path"
+import { isBinaryFile } from "isbinaryfile"
+
+import { Task } from "../task/Task"
+import { ClineSayTool } from "../../shared/ExtensionMessage"
+import { formatResponse } from "../prompts/responses"
+import { t } from "../../i18n"
+import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
+import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
+import { isPathOutsideWorkspace } from "../../utils/pathUtils"
+import { getReadablePath } from "../../utils/path"
+import { countFileLines } from "../../integrations/misc/line-counter"
+import { readLines } from "../../integrations/misc/read-lines"
+import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
+import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
+import {
+	DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
+	DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
+	isSupportedImageFormat,
+	validateImageForProcessing,
+	processImageFile,
+} from "./helpers/imageHelpers"
+
+/**
+ * Simplified read file tool for models that only support single file reads
+ * Uses the format: <read_file><path>file/path.ext</path></read_file>
+ *
+ * This is a streamlined version of readFileTool that:
+ * - Only accepts a single path parameter
+ * - Does not support multiple files
+ * - Does not support line ranges
+ * - Has simpler XML parsing
+ */
+export async function simpleReadFileTool(
+	cline: Task,
+	block: ToolUse,
+	askApproval: AskApproval,
+	handleError: HandleError,
+	pushToolResult: PushToolResult,
+	_removeClosingTag: RemoveClosingTag,
+) {
+	const filePath: string | undefined = block.params.path
+
+	// Check if the current model supports images
+	const modelInfo = cline.api.getModel().info
+	const supportsImages = modelInfo.supportsImages ?? false
+
+	// Handle partial message
+	if (block.partial) {
+		const fullPath = filePath ? path.resolve(cline.cwd, filePath) : ""
+		const sharedMessageProps: ClineSayTool = {
+			tool: "readFile",
+			path: getReadablePath(cline.cwd, filePath || ""),
+			isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false,
+		}
+		const partialMessage = JSON.stringify({
+			...sharedMessageProps,
+			content: undefined,
+		} satisfies ClineSayTool)
+		await cline.ask("tool", partialMessage, block.partial).catch(() => {})
+		return
+	}
+
+	// Validate path parameter
+	if (!filePath) {
+		cline.consecutiveMistakeCount++
+		cline.recordToolError("read_file")
+		const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "path")
+		pushToolResult(`<file><error>${errorMsg}</error></file>`)
+		return
+	}
+
+	const relPath = filePath
+	const fullPath = path.resolve(cline.cwd, relPath)
+
+	try {
+		// Check RooIgnore validation
+		const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
+		if (!accessAllowed) {
+			await cline.say("rooignore_error", relPath)
+			const errorMsg = formatResponse.rooIgnoreError(relPath)
+			pushToolResult(`<file><path>${relPath}</path><error>${errorMsg}</error></file>`)
+			return
+		}
+
+		// Get max read file line setting
+		const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {}
+
+		// Create approval message
+		const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
+		let lineSnippet = ""
+		if (maxReadFileLine === 0) {
+			lineSnippet = t("tools:readFile.definitionsOnly")
+		} else if (maxReadFileLine > 0) {
+			lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine })
+		}
+
+		const completeMessage = JSON.stringify({
+			tool: "readFile",
+			path: getReadablePath(cline.cwd, relPath),
+			isOutsideWorkspace,
+			content: fullPath,
+			reason: lineSnippet,
+		} satisfies ClineSayTool)
+
+		const { response, text, images } = await cline.ask("tool", completeMessage, false)
+
+		if (response !== "yesButtonClicked") {
+			// Handle denial
+			if (text) {
+				await cline.say("user_feedback", text, images)
+			}
+			cline.didRejectTool = true
+
+			const statusMessage = text ? formatResponse.toolDeniedWithFeedback(text) : formatResponse.toolDenied()
+
+			pushToolResult(`${statusMessage}\n<file><path>${relPath}</path><status>Denied by user</status></file>`)
+			return
+		}
+
+		// Handle approval with feedback
+		if (text) {
+			await cline.say("user_feedback", text, images)
+		}
+
+		// Process the file
+		const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)])
+
+		// Handle binary files
+		if (isBinary) {
+			const fileExtension = path.extname(relPath).toLowerCase()
+			const supportedBinaryFormats = getSupportedBinaryFormats()
+
+			// Check if it's a supported image format
+			if (isSupportedImageFormat(fileExtension)) {
+				try {
+					const {
+						maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
+						maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
+					} = (await cline.providerRef.deref()?.getState()) ?? {}
+
+					// Validate image for processing
+					const validationResult = await validateImageForProcessing(
+						fullPath,
+						supportsImages,
+						maxImageFileSize,
+						maxTotalImageSize,
+						0, // No cumulative memory for single file
+					)
+
+					if (!validationResult.isValid) {
+						await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
+						pushToolResult(
+							`<file><path>${relPath}</path>\n<notice>${validationResult.notice}</notice>\n</file>`,
+						)
+						return
+					}
+
+					// Process the image
+					const imageResult = await processImageFile(fullPath)
+					await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
+
+					// Return result with image data
+					const result = formatResponse.toolResult(
+						`<file><path>${relPath}</path>\n<notice>${imageResult.notice}</notice>\n</file>`,
+						supportsImages ? [imageResult.dataUrl] : undefined,
+					)
+
+					if (typeof result === "string") {
+						pushToolResult(result)
+					} else {
+						pushToolResult(result)
+					}
+					return
+				} catch (error) {
+					const errorMsg = error instanceof Error ? error.message : String(error)
+					pushToolResult(
+						`<file><path>${relPath}</path><error>Error reading image file: ${errorMsg}</error></file>`,
+					)
+					await handleError(
+						`reading image file ${relPath}`,
+						error instanceof Error ? error : new Error(errorMsg),
+					)
+					return
+				}
+			}
+
+			// Check if it's a supported binary format that can be processed
+			if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) {
+				// For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile
+				// Fall through to the normal extractTextFromFile processing below
+			} else {
+				// Handle unknown binary format
+				const fileFormat = fileExtension.slice(1) || "bin"
+				pushToolResult(
+					`<file><path>${relPath}</path>\n<binary_file format="${fileFormat}">Binary file - content not displayed</binary_file>\n</file>`,
+				)
+				return
+			}
+		}
+
+		// Handle definitions-only mode
+		if (maxReadFileLine === 0) {
+			try {
+				const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController)
+				if (defResult) {
+					let xmlInfo = `<notice>Showing only definitions. Use standard read_file if you need to read actual content</notice>\n`
+					pushToolResult(
+						`<file><path>${relPath}</path>\n<list_code_definition_names>${defResult}</list_code_definition_names>\n${xmlInfo}</file>`,
+					)
+				}
+			} catch (error) {
+				if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
+					console.warn(`[simple_read_file] Warning: ${error.message}`)
+				} else {
+					console.error(
+						`[simple_read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
+					)
+				}
+			}
+			return
+		}
+
+		// Handle files exceeding line threshold
+		if (maxReadFileLine > 0 && totalLines > maxReadFileLine) {
+			const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0))
+			const lineRangeAttr = ` lines="1-${maxReadFileLine}"`
+			let xmlInfo = `<content${lineRangeAttr}>\n${content}</content>\n`
+
+			try {
+				const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController)
+				if (defResult) {
+					xmlInfo += `<list_code_definition_names>${defResult}</list_code_definition_names>\n`
+				}
+				xmlInfo += `<notice>Showing only ${maxReadFileLine} of ${totalLines} total lines. File is too large for complete display</notice>\n`
+				pushToolResult(`<file><path>${relPath}</path>\n${xmlInfo}</file>`)
+			} catch (error) {
+				if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
+					console.warn(`[simple_read_file] Warning: ${error.message}`)
+				} else {
+					console.error(
+						`[simple_read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
+					)
+				}
+			}
+			return
+		}
+
+		// Handle normal file read
+		const content = await extractTextFromFile(fullPath)
+		const lineRangeAttr = ` lines="1-${totalLines}"`
+		let xmlInfo = totalLines > 0 ? `<content${lineRangeAttr}>\n${content}</content>\n` : `<content/>`
+
+		if (totalLines === 0) {
+			xmlInfo += `<notice>File is empty</notice>\n`
+		}
+
+		// Track file read
+		await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
+
+		// Return the result
+		if (text) {
+			const statusMessage = formatResponse.toolApprovedWithFeedback(text)
+			pushToolResult(`${statusMessage}\n<file><path>${relPath}</path>\n${xmlInfo}</file>`)
+		} else {
+			pushToolResult(`<file><path>${relPath}</path>\n${xmlInfo}</file>`)
+		}
+	} catch (error) {
+		const errorMsg = error instanceof Error ? error.message : String(error)
+		pushToolResult(`<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`)
+		await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg))
+	}
+}
+
+/**
+ * Get description for the simple read file tool
+ * @param blockName The name of the tool block
+ * @param blockParams The parameters passed to the tool
+ * @returns A description string for the tool use
+ */
+export function getSimpleReadFileToolDescription(blockName: string, blockParams: any): string {
+	if (blockParams.path) {
+		return `[${blockName} for '${blockParams.path}']`
+	} else {
+		return `[${blockName} with missing path]`
+	}
+}