diff options
| author | Keith Ballinger <[email protected]> | 2025-06-06 22:54:37 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-06-06 22:54:37 -0700 |
| commit | 0c868746777e95255ce870aff4a61fb584d60a62 (patch) | |
| tree | 07fd51b91eee0df77d7014828308facaea03778f /packages/core/src/tools/write-file.ts | |
| parent | 76ec9122c0dd36f0535a74c65811c0f7bd138f4d (diff) | |
Add batch editing capabilities to Edit Tool (#648)
Co-authored-by: N. Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/core/src/tools/write-file.ts')
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 339 |
1 files changed, 0 insertions, 339 deletions
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts deleted file mode 100644 index dc634cc8..00000000 --- a/packages/core/src/tools/write-file.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * @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 }; - } -} |
