summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/write-file.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/tools/write-file.ts')
-rw-r--r--packages/core/src/tools/write-file.ts339
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 };
+ }
+}