| | |
| | |
| | |
| | |
| | |
| |
|
| | 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'; |
| | import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; |
| | import { getSpecificMimeType } from '../utils/fileUtils.js'; |
| | import { |
| | recordFileOperationMetric, |
| | FileOperation, |
| | } from '../telemetry/metrics.js'; |
| |
|
| | |
| | |
| | |
| | export interface WriteFileToolParams { |
| | |
| | |
| | |
| | file_path: string; |
| |
|
| | |
| | |
| | |
| | content: string; |
| | } |
| |
|
| | interface GetCorrectedFileContentResult { |
| | originalContent: string; |
| | correctedContent: string; |
| | fileExists: boolean; |
| | error?: { message: string; code?: string }; |
| | } |
| |
|
| | |
| | |
| | |
| | export class WriteFileTool |
| | extends BaseTool<WriteFileToolParams, ToolResult> |
| | implements ModifiableTool<WriteFileToolParams> |
| | { |
| | 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 { |
| | |
| | |
| | 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)}`; |
| | } |
| |
|
| | 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)}`; |
| | } |
| |
|
| | |
| | |
| | |
| | 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) { |
| | |
| | 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, |
| | correctedContent, |
| | '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; |
| | |
| | |
| | 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'); |
| |
|
| | |
| | const fileName = path.basename(params.file_path); |
| | |
| | |
| | |
| | const currentContentForDiff = correctedContentResult.error |
| | ? '' |
| | : 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 }; |
| |
|
| | const lines = fileContent.split('\n').length; |
| | const mimetype = getSpecificMimeType(params.file_path); |
| | const extension = path.extname(params.file_path); |
| | if (isNewFile) { |
| | recordFileOperationMetric( |
| | this.config, |
| | FileOperation.CREATE, |
| | lines, |
| | mimetype, |
| | extension, |
| | ); |
| | } else { |
| | recordFileOperationMetric( |
| | this.config, |
| | FileOperation.UPDATE, |
| | lines, |
| | mimetype, |
| | extension, |
| | ); |
| | } |
| |
|
| | 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; |
| | } catch (err) { |
| | if (isNodeError(err) && err.code === 'ENOENT') { |
| | fileExists = false; |
| | originalContent = ''; |
| | } else { |
| | |
| | fileExists = true; |
| | originalContent = ''; |
| | const error = { |
| | message: getErrorMessage(err), |
| | code: isNodeError(err) ? err.code : undefined, |
| | }; |
| | |
| | return { originalContent, correctedContent, fileExists, error }; |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | if (fileExists) { |
| | |
| | const { params: correctedParams } = await ensureCorrectEdit( |
| | originalContent, |
| | { |
| | old_string: originalContent, |
| | new_string: proposedContent, |
| | file_path: filePath, |
| | }, |
| | this.client, |
| | abortSignal, |
| | ); |
| | correctedContent = correctedParams.new_string; |
| | } else { |
| | |
| | correctedContent = await ensureCorrectFileContent( |
| | proposedContent, |
| | this.client, |
| | abortSignal, |
| | ); |
| | } |
| | return { originalContent, correctedContent, fileExists }; |
| | } |
| |
|
| | getModifyContext( |
| | abortSignal: AbortSignal, |
| | ): ModifyContext<WriteFileToolParams> { |
| | return { |
| | getFilePath: (params: WriteFileToolParams) => params.file_path, |
| | getCurrentContent: async (params: WriteFileToolParams) => { |
| | const correctedContentResult = await this._getCorrectedFileContent( |
| | params.file_path, |
| | params.content, |
| | abortSignal, |
| | ); |
| | return correctedContentResult.originalContent; |
| | }, |
| | getProposedContent: async (params: WriteFileToolParams) => { |
| | const correctedContentResult = await this._getCorrectedFileContent( |
| | params.file_path, |
| | params.content, |
| | abortSignal, |
| | ); |
| | return correctedContentResult.correctedContent; |
| | }, |
| | createUpdatedParams: ( |
| | _oldContent: string, |
| | modifiedProposedContent: string, |
| | originalParams: WriteFileToolParams, |
| | ) => ({ |
| | ...originalParams, |
| | content: modifiedProposedContent, |
| | }), |
| | }; |
| | } |
| | } |
| |
|