diff options
Diffstat (limited to 'packages/core/src/tools/write-file.ts')
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 339 |
1 files changed, 339 insertions, 0 deletions
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts new file mode 100644 index 00000000..dc634cc8 --- /dev/null +++ b/packages/core/src/tools/write-file.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import * as Diff from 'diff'; +import { Config, ApprovalMode } from '../config/config.js'; +import { + BaseTool, + ToolResult, + FileDiff, + ToolEditConfirmationDetails, + ToolConfirmationOutcome, + ToolCallConfirmationDetails, +} from './tools.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { makeRelative, shortenPath } from '../utils/paths.js'; +import { getErrorMessage, isNodeError } from '../utils/errors.js'; +import { + ensureCorrectEdit, + ensureCorrectFileContent, +} from '../utils/editCorrector.js'; +import { GeminiClient } from '../core/client.js'; +import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; + +/** + * Parameters for the WriteFile tool + */ +export interface WriteFileToolParams { + /** + * The absolute path to the file to write to + */ + file_path: string; + + /** + * The content to write to the file + */ + content: string; +} + +interface GetCorrectedFileContentResult { + originalContent: string; + correctedContent: string; + fileExists: boolean; + error?: { message: string; code?: string }; +} + +/** + * Implementation of the WriteFile tool logic + */ +export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> { + static readonly Name: string = 'write_file'; + private readonly client: GeminiClient; + + constructor(private readonly config: Config) { + super( + WriteFileTool.Name, + 'WriteFile', + 'Writes content to a specified file in the local filesystem.', + { + 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', + }, + ); + + this.client = this.config.getGeminiClient(); + } + + private isWithinRoot(pathToCheck: string): boolean { + const normalizedPath = path.normalize(pathToCheck); + const normalizedRoot = path.normalize(this.config.getTargetDir()); + const rootWithSep = normalizedRoot.endsWith(path.sep) + ? normalizedRoot + : normalizedRoot + path.sep; + return ( + normalizedPath === normalizedRoot || + normalizedPath.startsWith(rootWithSep) + ); + } + + validateToolParams(params: WriteFileToolParams): string | null { + if ( + this.schema.parameters && + !SchemaValidator.validate( + this.schema.parameters as Record<string, unknown>, + params, + ) + ) { + return 'Parameters failed schema validation.'; + } + const filePath = params.file_path; + if (!path.isAbsolute(filePath)) { + return `File path must be absolute: ${filePath}`; + } + if (!this.isWithinRoot(filePath)) { + return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`; + } + + 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; + } + + getDescription(params: WriteFileToolParams): string { + if (!params.file_path || !params.content) { + return `Model did not provide valid parameters for write file tool`; + } + const relativePath = makeRelative( + params.file_path, + this.config.getTargetDir(), + ); + return `Writing to ${shortenPath(relativePath)}`; + } + + /** + * Handles the confirmation prompt for the WriteFile tool. + */ + 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, + abortSignal, + ); + + if (correctedContentResult.error) { + // If file exists but couldn't be read, we can't show a diff for confirmation. + return false; + } + + const { originalContent, correctedContent } = correctedContentResult; + const relativePath = makeRelative( + params.file_path, + this.config.getTargetDir(), + ); + const fileName = path.basename(params.file_path); + + const fileDiff = Diff.createPatch( + fileName, + originalContent, // Original content (empty if new file or unreadable) + correctedContent, // Content after potential correction + 'Current', + 'Proposed', + DEFAULT_DIFF_OPTIONS, + ); + + const confirmationDetails: ToolEditConfirmationDetails = { + type: 'edit', + title: `Confirm Write: ${shortenPath(relativePath)}`, + fileName, + fileDiff, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); + } + }, + }; + return confirmationDetails; + } + + async execute( + params: WriteFileToolParams, + abortSignal: AbortSignal, + ): Promise<ToolResult> { + const validationError = this.validateToolParams(params); + if (validationError) { + return { + llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, + returnDisplay: `Error: ${validationError}`, + }; + } + + const correctedContentResult = await this._getCorrectedFileContent( + params.file_path, + params.content, + abortSignal, + ); + + if (correctedContentResult.error) { + const errDetails = correctedContentResult.error; + const errorMsg = `Error checking existing file: ${errDetails.message}`; + return { + llmContent: `Error checking existing file ${params.file_path}: ${errDetails.message}`, + returnDisplay: errorMsg, + }; + } + + const { + originalContent, + correctedContent: fileContent, + fileExists, + } = correctedContentResult; + // fileExists is true if the file existed (and was readable or unreadable but caught by readError). + // fileExists is false if the file did not exist (ENOENT). + const isNewFile = + !fileExists || + (correctedContentResult.error !== undefined && + !correctedContentResult.fileExists); + + try { + const dirName = path.dirname(params.file_path); + if (!fs.existsSync(dirName)) { + fs.mkdirSync(dirName, { recursive: true }); + } + + fs.writeFileSync(params.file_path, fileContent, 'utf8'); + + // Generate diff for display result + const fileName = path.basename(params.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. + const currentContentForDiff = correctedContentResult.error + ? '' // Or some indicator of unreadable content + : originalContent; + + const fileDiff = Diff.createPatch( + fileName, + currentContentForDiff, + fileContent, + 'Original', + 'Written', + DEFAULT_DIFF_OPTIONS, + ); + + const llmSuccessMessage = isNewFile + ? `Successfully created and wrote to new file: ${params.file_path}` + : `Successfully overwrote file: ${params.file_path}`; + + const displayResult: FileDiff = { fileDiff, fileName }; + + return { + llmContent: llmSuccessMessage, + returnDisplay: displayResult, + }; + } catch (error) { + const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`; + return { + llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`, + returnDisplay: `Error: ${errorMsg}`, + }; + } + } + + private async _getCorrectedFileContent( + filePath: string, + proposedContent: string, + abortSignal: AbortSignal, + ): Promise<GetCorrectedFileContentResult> { + let originalContent = ''; + let fileExists = false; + let correctedContent = proposedContent; + + 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 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( + originalContent, + { + old_string: originalContent, // Treat entire current content as old_string + new_string: proposedContent, + file_path: filePath, + }, + this.client, + abortSignal, + ); + correctedContent = correctedParams.new_string; + } else { + // This implies new file (ENOENT) + correctedContent = await ensureCorrectFileContent( + proposedContent, + this.client, + abortSignal, + ); + } + return { originalContent, correctedContent, fileExists }; + } +} |
