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.ts368
1 files changed, 187 insertions, 181 deletions
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
index 01c92865..c889d6a3 100644
--- a/packages/core/src/tools/write-file.ts
+++ b/packages/core/src/tools/write-file.ts
@@ -9,14 +9,16 @@ import path from 'path';
import * as Diff from 'diff';
import { Config, ApprovalMode } from '../config/config.js';
import {
- BaseTool,
- ToolResult,
+ BaseDeclarativeTool,
+ BaseToolInvocation,
FileDiff,
- ToolEditConfirmationDetails,
- ToolConfirmationOutcome,
- ToolCallConfirmationDetails,
Kind,
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolEditConfirmationDetails,
+ ToolInvocation,
ToolLocation,
+ ToolResult,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
@@ -67,113 +69,99 @@ interface GetCorrectedFileContentResult {
error?: { message: string; code?: string };
}
-/**
- * Implementation of the WriteFile tool logic
- */
-export class WriteFileTool
- extends BaseTool<WriteFileToolParams, ToolResult>
- implements ModifiableDeclarativeTool<WriteFileToolParams>
-{
- static readonly Name: string = 'write_file';
+export async function getCorrectedFileContent(
+ config: Config,
+ filePath: string,
+ proposedContent: string,
+ abortSignal: AbortSignal,
+): Promise<GetCorrectedFileContentResult> {
+ let originalContent = '';
+ let fileExists = false;
+ let correctedContent = proposedContent;
- constructor(private readonly config: Config) {
- super(
- WriteFileTool.Name,
- 'WriteFile',
- `Writes content to a specified file in the local filesystem.
+ 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 };
+ }
+ }
- The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
- Kind.Edit,
+ // 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(
+ filePath,
+ originalContent,
{
- 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',
+ old_string: originalContent, // Treat entire current content as old_string
+ new_string: proposedContent,
+ file_path: filePath,
},
+ config.getGeminiClient(),
+ abortSignal,
+ );
+ correctedContent = correctedParams.new_string;
+ } else {
+ // This implies new file (ENOENT)
+ correctedContent = await ensureCorrectFileContent(
+ proposedContent,
+ config.getGeminiClient(),
+ abortSignal,
);
}
+ return { originalContent, correctedContent, fileExists };
+}
- override toolLocations(params: WriteFileToolParams): ToolLocation[] {
- return [{ path: params.file_path }];
+class WriteFileToolInvocation extends BaseToolInvocation<
+ WriteFileToolParams,
+ ToolResult
+> {
+ constructor(
+ private readonly config: Config,
+ params: WriteFileToolParams,
+ ) {
+ super(params);
}
- override validateToolParams(params: WriteFileToolParams): string | null {
- const errors = SchemaValidator.validate(
- this.schema.parametersJsonSchema,
- params,
- );
- if (errors) {
- return errors;
- }
-
- const filePath = params.file_path;
- if (!path.isAbsolute(filePath)) {
- return `File path must be absolute: ${filePath}`;
- }
-
- const workspaceContext = this.config.getWorkspaceContext();
- if (!workspaceContext.isPathWithinWorkspace(filePath)) {
- const directories = workspaceContext.getDirectories();
- return `File path must be within one of the workspace directories: ${directories.join(', ')}`;
- }
-
- 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;
+ override toolLocations(): ToolLocation[] {
+ return [{ path: this.params.file_path }];
}
- override getDescription(params: WriteFileToolParams): string {
- if (!params.file_path) {
- return `Model did not provide valid parameters for write file tool, missing or empty "file_path"`;
- }
+ override getDescription(): string {
const relativePath = makeRelative(
- params.file_path,
+ this.params.file_path,
this.config.getTargetDir(),
);
return `Writing to ${shortenPath(relativePath)}`;
}
- /**
- * Handles the confirmation prompt for the WriteFile tool.
- */
override 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,
+ const correctedContentResult = await getCorrectedFileContent(
+ this.config,
+ this.params.file_path,
+ this.params.content,
abortSignal,
);
@@ -184,10 +172,10 @@ export class WriteFileTool
const { originalContent, correctedContent } = correctedContentResult;
const relativePath = makeRelative(
- params.file_path,
+ this.params.file_path,
this.config.getTargetDir(),
);
- const fileName = path.basename(params.file_path);
+ const fileName = path.basename(this.params.file_path);
const fileDiff = Diff.createPatch(
fileName,
@@ -202,14 +190,14 @@ export class WriteFileTool
const ideConfirmation =
this.config.getIdeMode() &&
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
- ? ideClient.openDiff(params.file_path, correctedContent)
+ ? ideClient.openDiff(this.params.file_path, correctedContent)
: undefined;
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Write: ${shortenPath(relativePath)}`,
fileName,
- filePath: params.file_path,
+ filePath: this.params.file_path,
fileDiff,
originalContent,
newContent: correctedContent,
@@ -221,7 +209,7 @@ export class WriteFileTool
if (ideConfirmation) {
const result = await ideConfirmation;
if (result.status === 'accepted' && result.content) {
- params.content = result.content;
+ this.params.content = result.content;
}
}
},
@@ -230,32 +218,20 @@ export class WriteFileTool
return confirmationDetails;
}
- async execute(
- params: WriteFileToolParams,
- abortSignal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Could not write file due to invalid parameters: ${validationError}`,
- returnDisplay: validationError,
- error: {
- message: validationError,
- type: ToolErrorType.INVALID_TOOL_PARAMS,
- },
- };
- }
-
- const correctedContentResult = await this._getCorrectedFileContent(
- params.file_path,
- params.content,
+ async execute(abortSignal: AbortSignal): Promise<ToolResult> {
+ const { file_path, content, ai_proposed_content, modified_by_user } =
+ this.params;
+ const correctedContentResult = await getCorrectedFileContent(
+ this.config,
+ file_path,
+ content,
abortSignal,
);
if (correctedContentResult.error) {
const errDetails = correctedContentResult.error;
const errorMsg = errDetails.code
- ? `Error checking existing file '${params.file_path}': ${errDetails.message} (${errDetails.code})`
+ ? `Error checking existing file '${file_path}': ${errDetails.message} (${errDetails.code})`
: `Error checking existing file: ${errDetails.message}`;
return {
llmContent: errorMsg,
@@ -280,15 +256,15 @@ export class WriteFileTool
!correctedContentResult.fileExists);
try {
- const dirName = path.dirname(params.file_path);
+ const dirName = path.dirname(file_path);
if (!fs.existsSync(dirName)) {
fs.mkdirSync(dirName, { recursive: true });
}
- fs.writeFileSync(params.file_path, fileContent, 'utf8');
+ fs.writeFileSync(file_path, fileContent, 'utf8');
// Generate diff for display result
- const fileName = path.basename(params.file_path);
+ const fileName = path.basename(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.
@@ -305,23 +281,22 @@ export class WriteFileTool
DEFAULT_DIFF_OPTIONS,
);
- const originallyProposedContent =
- params.ai_proposed_content || params.content;
+ const originallyProposedContent = ai_proposed_content || content;
const diffStat = getDiffStat(
fileName,
currentContentForDiff,
originallyProposedContent,
- params.content,
+ content,
);
const llmSuccessMessageParts = [
isNewFile
- ? `Successfully created and wrote to new file: ${params.file_path}.`
- : `Successfully overwrote file: ${params.file_path}.`,
+ ? `Successfully created and wrote to new file: ${file_path}.`
+ : `Successfully overwrote file: ${file_path}.`,
];
- if (params.modified_by_user) {
+ if (modified_by_user) {
llmSuccessMessageParts.push(
- `User modified the \`content\` to be: ${params.content}`,
+ `User modified the \`content\` to be: ${content}`,
);
}
@@ -334,8 +309,8 @@ export class WriteFileTool
};
const lines = fileContent.split('\n').length;
- const mimetype = getSpecificMimeType(params.file_path);
- const extension = path.extname(params.file_path); // Get extension
+ const mimetype = getSpecificMimeType(file_path);
+ const extension = path.extname(file_path); // Get extension
if (isNewFile) {
recordFileOperationMetric(
this.config,
@@ -367,17 +342,17 @@ export class WriteFileTool
if (isNodeError(error)) {
// Handle specific Node.js errors with their error codes
- errorMsg = `Error writing to file '${params.file_path}': ${error.message} (${error.code})`;
+ errorMsg = `Error writing to file '${file_path}': ${error.message} (${error.code})`;
// Log specific error types for better debugging
if (error.code === 'EACCES') {
- errorMsg = `Permission denied writing to file: ${params.file_path} (${error.code})`;
+ errorMsg = `Permission denied writing to file: ${file_path} (${error.code})`;
errorType = ToolErrorType.PERMISSION_DENIED;
} else if (error.code === 'ENOSPC') {
- errorMsg = `No space left on device: ${params.file_path} (${error.code})`;
+ errorMsg = `No space left on device: ${file_path} (${error.code})`;
errorType = ToolErrorType.NO_SPACE_LEFT;
} else if (error.code === 'EISDIR') {
- errorMsg = `Target is a directory, not a file: ${params.file_path} (${error.code})`;
+ errorMsg = `Target is a directory, not a file: ${file_path} (${error.code})`;
errorType = ToolErrorType.TARGET_IS_DIRECTORY;
}
@@ -401,63 +376,92 @@ export class WriteFileTool
};
}
}
+}
- private async _getCorrectedFileContent(
- filePath: string,
- proposedContent: string,
- abortSignal: AbortSignal,
- ): Promise<GetCorrectedFileContentResult> {
- let originalContent = '';
- let fileExists = false;
- let correctedContent = proposedContent;
+/**
+ * Implementation of the WriteFile tool logic
+ */
+export class WriteFileTool
+ extends BaseDeclarativeTool<WriteFileToolParams, ToolResult>
+ implements ModifiableDeclarativeTool<WriteFileToolParams>
+{
+ static readonly Name: string = 'write_file';
+
+ constructor(private readonly config: Config) {
+ super(
+ WriteFileTool.Name,
+ 'WriteFile',
+ `Writes content to a specified file in the local filesystem.
+
+ The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
+ Kind.Edit,
+ {
+ 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',
+ },
+ );
+ }
+
+ protected override validateToolParams(
+ params: WriteFileToolParams,
+ ): string | null {
+ const errors = SchemaValidator.validate(
+ this.schema.parametersJsonSchema,
+ params,
+ );
+ if (errors) {
+ return errors;
+ }
+
+ const filePath = params.file_path;
+
+ if (!filePath) {
+ return `Missing or empty "file_path"`;
+ }
+
+ if (!path.isAbsolute(filePath)) {
+ return `File path must be absolute: ${filePath}`;
+ }
+
+ const workspaceContext = this.config.getWorkspaceContext();
+ if (!workspaceContext.isPathWithinWorkspace(filePath)) {
+ const directories = workspaceContext.getDirectories();
+ return `File path must be within one of the workspace directories: ${directories.join(
+ ', ',
+ )}`;
+ }
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 (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)
+ }`;
}
- // If readError is set, we have returned.
- // So, file was either read successfully (fileExists=true, originalContent set)
- // or it was ENOENT (fileExists=false, originalContent='').
+ return null;
+ }
- if (fileExists) {
- // This implies originalContent is available
- const { params: correctedParams } = await ensureCorrectEdit(
- filePath,
- originalContent,
- {
- old_string: originalContent, // Treat entire current content as old_string
- new_string: proposedContent,
- file_path: filePath,
- },
- this.config.getGeminiClient(),
- abortSignal,
- );
- correctedContent = correctedParams.new_string;
- } else {
- // This implies new file (ENOENT)
- correctedContent = await ensureCorrectFileContent(
- proposedContent,
- this.config.getGeminiClient(),
- abortSignal,
- );
- }
- return { originalContent, correctedContent, fileExists };
+ protected createInvocation(
+ params: WriteFileToolParams,
+ ): ToolInvocation<WriteFileToolParams, ToolResult> {
+ return new WriteFileToolInvocation(this.config, params);
}
getModifyContext(
@@ -466,7 +470,8 @@ export class WriteFileTool
return {
getFilePath: (params: WriteFileToolParams) => params.file_path,
getCurrentContent: async (params: WriteFileToolParams) => {
- const correctedContentResult = await this._getCorrectedFileContent(
+ const correctedContentResult = await getCorrectedFileContent(
+ this.config,
params.file_path,
params.content,
abortSignal,
@@ -474,7 +479,8 @@ export class WriteFileTool
return correctedContentResult.originalContent;
},
getProposedContent: async (params: WriteFileToolParams) => {
- const correctedContentResult = await this._getCorrectedFileContent(
+ const correctedContentResult = await getCorrectedFileContent(
+ this.config,
params.file_path,
params.content,
abortSignal,