|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
}), |
|
|
}; |
|
|
} |
|
|
} |
|
|
|