diff options
Diffstat (limited to 'packages/core/src/tools/write-file.ts')
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 368 |
1 files changed, 187 insertions, 181 deletions
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 01c92865..c889d6a3 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -9,14 +9,16 @@ import path from 'path'; import * as Diff from 'diff'; import { Config, ApprovalMode } from '../config/config.js'; import { - BaseTool, - ToolResult, + BaseDeclarativeTool, + BaseToolInvocation, FileDiff, - ToolEditConfirmationDetails, - ToolConfirmationOutcome, - ToolCallConfirmationDetails, Kind, + ToolCallConfirmationDetails, + ToolConfirmationOutcome, + ToolEditConfirmationDetails, + ToolInvocation, ToolLocation, + ToolResult, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; @@ -67,113 +69,99 @@ interface GetCorrectedFileContentResult { error?: { message: string; code?: string }; } -/** - * Implementation of the WriteFile tool logic - */ -export class WriteFileTool - extends BaseTool<WriteFileToolParams, ToolResult> - implements ModifiableDeclarativeTool<WriteFileToolParams> -{ - static readonly Name: string = 'write_file'; +export async function getCorrectedFileContent( + config: Config, + filePath: string, + proposedContent: string, + abortSignal: AbortSignal, +): Promise<GetCorrectedFileContentResult> { + let originalContent = ''; + let fileExists = false; + let correctedContent = proposedContent; - constructor(private readonly config: Config) { - super( - WriteFileTool.Name, - 'WriteFile', - `Writes content to a specified file in the local filesystem. + try { + originalContent = fs.readFileSync(filePath, 'utf8'); + fileExists = true; // File exists and was read + } catch (err) { + if (isNodeError(err) && err.code === 'ENOENT') { + fileExists = false; + originalContent = ''; + } else { + // File exists but could not be read (permissions, etc.) + fileExists = true; // Mark as existing but problematic + originalContent = ''; // Can't use its content + const error = { + message: getErrorMessage(err), + code: isNodeError(err) ? err.code : undefined, + }; + // Return early as we can't proceed with content correction meaningfully + return { originalContent, correctedContent, fileExists, error }; + } + } - The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, - Kind.Edit, + // If readError is set, we have returned. + // So, file was either read successfully (fileExists=true, originalContent set) + // or it was ENOENT (fileExists=false, originalContent=''). + + if (fileExists) { + // This implies originalContent is available + const { params: correctedParams } = await ensureCorrectEdit( + filePath, + originalContent, { - properties: { - file_path: { - description: - "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.", - type: 'string', - }, - content: { - description: 'The content to write to the file.', - type: 'string', - }, - }, - required: ['file_path', 'content'], - type: 'object', + old_string: originalContent, // Treat entire current content as old_string + new_string: proposedContent, + file_path: filePath, }, + config.getGeminiClient(), + abortSignal, + ); + correctedContent = correctedParams.new_string; + } else { + // This implies new file (ENOENT) + correctedContent = await ensureCorrectFileContent( + proposedContent, + config.getGeminiClient(), + abortSignal, ); } + return { originalContent, correctedContent, fileExists }; +} - override toolLocations(params: WriteFileToolParams): ToolLocation[] { - return [{ path: params.file_path }]; +class WriteFileToolInvocation extends BaseToolInvocation< + WriteFileToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: WriteFileToolParams, + ) { + super(params); } - override validateToolParams(params: WriteFileToolParams): string | null { - const errors = SchemaValidator.validate( - this.schema.parametersJsonSchema, - params, - ); - if (errors) { - return errors; - } - - const filePath = params.file_path; - if (!path.isAbsolute(filePath)) { - return `File path must be absolute: ${filePath}`; - } - - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(filePath)) { - const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')}`; - } - - try { - // This check should be performed only if the path exists. - // If it doesn't exist, it's a new file, which is valid for writing. - if (fs.existsSync(filePath)) { - const stats = fs.lstatSync(filePath); - if (stats.isDirectory()) { - return `Path is a directory, not a file: ${filePath}`; - } - } - } catch (statError: unknown) { - // If fs.existsSync is true but lstatSync fails (e.g., permissions, race condition where file is deleted) - // this indicates an issue with accessing the path that should be reported. - return `Error accessing path properties for validation: ${filePath}. Reason: ${statError instanceof Error ? statError.message : String(statError)}`; - } - - return null; + override toolLocations(): ToolLocation[] { + return [{ path: this.params.file_path }]; } - override getDescription(params: WriteFileToolParams): string { - if (!params.file_path) { - return `Model did not provide valid parameters for write file tool, missing or empty "file_path"`; - } + override getDescription(): string { const relativePath = makeRelative( - params.file_path, + this.params.file_path, this.config.getTargetDir(), ); return `Writing to ${shortenPath(relativePath)}`; } - /** - * Handles the confirmation prompt for the WriteFile tool. - */ override async shouldConfirmExecute( - params: WriteFileToolParams, abortSignal: AbortSignal, ): Promise<ToolCallConfirmationDetails | false> { if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { return false; } - const validationError = this.validateToolParams(params); - if (validationError) { - return false; - } - - const correctedContentResult = await this._getCorrectedFileContent( - params.file_path, - params.content, + const correctedContentResult = await getCorrectedFileContent( + this.config, + this.params.file_path, + this.params.content, abortSignal, ); @@ -184,10 +172,10 @@ export class WriteFileTool const { originalContent, correctedContent } = correctedContentResult; const relativePath = makeRelative( - params.file_path, + this.params.file_path, this.config.getTargetDir(), ); - const fileName = path.basename(params.file_path); + const fileName = path.basename(this.params.file_path); const fileDiff = Diff.createPatch( fileName, @@ -202,14 +190,14 @@ export class WriteFileTool const ideConfirmation = this.config.getIdeMode() && ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected - ? ideClient.openDiff(params.file_path, correctedContent) + ? ideClient.openDiff(this.params.file_path, correctedContent) : undefined; const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', title: `Confirm Write: ${shortenPath(relativePath)}`, fileName, - filePath: params.file_path, + filePath: this.params.file_path, fileDiff, originalContent, newContent: correctedContent, @@ -221,7 +209,7 @@ export class WriteFileTool if (ideConfirmation) { const result = await ideConfirmation; if (result.status === 'accepted' && result.content) { - params.content = result.content; + this.params.content = result.content; } } }, @@ -230,32 +218,20 @@ export class WriteFileTool return confirmationDetails; } - async execute( - params: WriteFileToolParams, - abortSignal: AbortSignal, - ): Promise<ToolResult> { - const validationError = this.validateToolParams(params); - if (validationError) { - return { - llmContent: `Could not write file due to invalid parameters: ${validationError}`, - returnDisplay: validationError, - error: { - message: validationError, - type: ToolErrorType.INVALID_TOOL_PARAMS, - }, - }; - } - - const correctedContentResult = await this._getCorrectedFileContent( - params.file_path, - params.content, + async execute(abortSignal: AbortSignal): Promise<ToolResult> { + const { file_path, content, ai_proposed_content, modified_by_user } = + this.params; + const correctedContentResult = await getCorrectedFileContent( + this.config, + file_path, + content, abortSignal, ); if (correctedContentResult.error) { const errDetails = correctedContentResult.error; const errorMsg = errDetails.code - ? `Error checking existing file '${params.file_path}': ${errDetails.message} (${errDetails.code})` + ? `Error checking existing file '${file_path}': ${errDetails.message} (${errDetails.code})` : `Error checking existing file: ${errDetails.message}`; return { llmContent: errorMsg, @@ -280,15 +256,15 @@ export class WriteFileTool !correctedContentResult.fileExists); try { - const dirName = path.dirname(params.file_path); + const dirName = path.dirname(file_path); if (!fs.existsSync(dirName)) { fs.mkdirSync(dirName, { recursive: true }); } - fs.writeFileSync(params.file_path, fileContent, 'utf8'); + fs.writeFileSync(file_path, fileContent, 'utf8'); // Generate diff for display result - const fileName = path.basename(params.file_path); + const fileName = path.basename(file_path); // If there was a readError, originalContent in correctedContentResult is '', // but for the diff, we want to show the original content as it was before the write if possible. // However, if it was unreadable, currentContentForDiff will be empty. @@ -305,23 +281,22 @@ export class WriteFileTool DEFAULT_DIFF_OPTIONS, ); - const originallyProposedContent = - params.ai_proposed_content || params.content; + const originallyProposedContent = ai_proposed_content || content; const diffStat = getDiffStat( fileName, currentContentForDiff, originallyProposedContent, - params.content, + content, ); const llmSuccessMessageParts = [ isNewFile - ? `Successfully created and wrote to new file: ${params.file_path}.` - : `Successfully overwrote file: ${params.file_path}.`, + ? `Successfully created and wrote to new file: ${file_path}.` + : `Successfully overwrote file: ${file_path}.`, ]; - if (params.modified_by_user) { + if (modified_by_user) { llmSuccessMessageParts.push( - `User modified the \`content\` to be: ${params.content}`, + `User modified the \`content\` to be: ${content}`, ); } @@ -334,8 +309,8 @@ export class WriteFileTool }; const lines = fileContent.split('\n').length; - const mimetype = getSpecificMimeType(params.file_path); - const extension = path.extname(params.file_path); // Get extension + const mimetype = getSpecificMimeType(file_path); + const extension = path.extname(file_path); // Get extension if (isNewFile) { recordFileOperationMetric( this.config, @@ -367,17 +342,17 @@ export class WriteFileTool if (isNodeError(error)) { // Handle specific Node.js errors with their error codes - errorMsg = `Error writing to file '${params.file_path}': ${error.message} (${error.code})`; + errorMsg = `Error writing to file '${file_path}': ${error.message} (${error.code})`; // Log specific error types for better debugging if (error.code === 'EACCES') { - errorMsg = `Permission denied writing to file: ${params.file_path} (${error.code})`; + errorMsg = `Permission denied writing to file: ${file_path} (${error.code})`; errorType = ToolErrorType.PERMISSION_DENIED; } else if (error.code === 'ENOSPC') { - errorMsg = `No space left on device: ${params.file_path} (${error.code})`; + errorMsg = `No space left on device: ${file_path} (${error.code})`; errorType = ToolErrorType.NO_SPACE_LEFT; } else if (error.code === 'EISDIR') { - errorMsg = `Target is a directory, not a file: ${params.file_path} (${error.code})`; + errorMsg = `Target is a directory, not a file: ${file_path} (${error.code})`; errorType = ToolErrorType.TARGET_IS_DIRECTORY; } @@ -401,63 +376,92 @@ export class WriteFileTool }; } } +} - private async _getCorrectedFileContent( - filePath: string, - proposedContent: string, - abortSignal: AbortSignal, - ): Promise<GetCorrectedFileContentResult> { - let originalContent = ''; - let fileExists = false; - let correctedContent = proposedContent; +/** + * Implementation of the WriteFile tool logic + */ +export class WriteFileTool + extends BaseDeclarativeTool<WriteFileToolParams, ToolResult> + implements ModifiableDeclarativeTool<WriteFileToolParams> +{ + static readonly Name: string = 'write_file'; + + constructor(private readonly config: Config) { + super( + WriteFileTool.Name, + 'WriteFile', + `Writes content to a specified file in the local filesystem. + + The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, + Kind.Edit, + { + properties: { + file_path: { + description: + "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.", + type: 'string', + }, + content: { + description: 'The content to write to the file.', + type: 'string', + }, + }, + required: ['file_path', 'content'], + type: 'object', + }, + ); + } + + protected override validateToolParams( + params: WriteFileToolParams, + ): string | null { + const errors = SchemaValidator.validate( + this.schema.parametersJsonSchema, + params, + ); + if (errors) { + return errors; + } + + const filePath = params.file_path; + + if (!filePath) { + return `Missing or empty "file_path"`; + } + + if (!path.isAbsolute(filePath)) { + return `File path must be absolute: ${filePath}`; + } + + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(filePath)) { + const directories = workspaceContext.getDirectories(); + return `File path must be within one of the workspace directories: ${directories.join( + ', ', + )}`; + } try { - originalContent = fs.readFileSync(filePath, 'utf8'); - fileExists = true; // File exists and was read - } catch (err) { - if (isNodeError(err) && err.code === 'ENOENT') { - fileExists = false; - originalContent = ''; - } else { - // File exists but could not be read (permissions, etc.) - fileExists = true; // Mark as existing but problematic - originalContent = ''; // Can't use its content - const error = { - message: getErrorMessage(err), - code: isNodeError(err) ? err.code : undefined, - }; - // Return early as we can't proceed with content correction meaningfully - return { originalContent, correctedContent, fileExists, error }; + if (fs.existsSync(filePath)) { + const stats = fs.lstatSync(filePath); + if (stats.isDirectory()) { + return `Path is a directory, not a file: ${filePath}`; + } } + } catch (statError: unknown) { + return `Error accessing path properties for validation: ${filePath}. Reason: ${ + statError instanceof Error ? statError.message : String(statError) + }`; } - // If readError is set, we have returned. - // So, file was either read successfully (fileExists=true, originalContent set) - // or it was ENOENT (fileExists=false, originalContent=''). + return null; + } - if (fileExists) { - // This implies originalContent is available - const { params: correctedParams } = await ensureCorrectEdit( - filePath, - originalContent, - { - old_string: originalContent, // Treat entire current content as old_string - new_string: proposedContent, - file_path: filePath, - }, - this.config.getGeminiClient(), - abortSignal, - ); - correctedContent = correctedParams.new_string; - } else { - // This implies new file (ENOENT) - correctedContent = await ensureCorrectFileContent( - proposedContent, - this.config.getGeminiClient(), - abortSignal, - ); - } - return { originalContent, correctedContent, fileExists }; + protected createInvocation( + params: WriteFileToolParams, + ): ToolInvocation<WriteFileToolParams, ToolResult> { + return new WriteFileToolInvocation(this.config, params); } getModifyContext( @@ -466,7 +470,8 @@ export class WriteFileTool return { getFilePath: (params: WriteFileToolParams) => params.file_path, getCurrentContent: async (params: WriteFileToolParams) => { - const correctedContentResult = await this._getCorrectedFileContent( + const correctedContentResult = await getCorrectedFileContent( + this.config, params.file_path, params.content, abortSignal, @@ -474,7 +479,8 @@ export class WriteFileTool return correctedContentResult.originalContent; }, getProposedContent: async (params: WriteFileToolParams) => { - const correctedContentResult = await this._getCorrectedFileContent( + const correctedContentResult = await getCorrectedFileContent( + this.config, params.file_path, params.content, abortSignal, |
