summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/write-file.ts
diff options
context:
space:
mode:
authorKeith Ballinger <[email protected]>2025-06-06 22:54:37 -0700
committerGitHub <[email protected]>2025-06-06 22:54:37 -0700
commit0c868746777e95255ce870aff4a61fb584d60a62 (patch)
tree07fd51b91eee0df77d7014828308facaea03778f /packages/core/src/tools/write-file.ts
parent76ec9122c0dd36f0535a74c65811c0f7bd138f4d (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.ts339
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 };
- }
-}