feat(core): Migrate web-search, write-file, and discovered-tool. (#6188)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
5c5fc89eb1
commit
48af0456c1
|
@ -5,7 +5,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FunctionDeclaration } from '@google/genai';
|
import { FunctionDeclaration } from '@google/genai';
|
||||||
import { AnyDeclarativeTool, Kind, ToolResult, BaseTool } from './tools.js';
|
import {
|
||||||
|
AnyDeclarativeTool,
|
||||||
|
Kind,
|
||||||
|
ToolResult,
|
||||||
|
BaseDeclarativeTool,
|
||||||
|
BaseToolInvocation,
|
||||||
|
ToolInvocation,
|
||||||
|
} from './tools.js';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { StringDecoder } from 'node:string_decoder';
|
import { StringDecoder } from 'node:string_decoder';
|
||||||
|
@ -15,46 +22,29 @@ import { parse } from 'shell-quote';
|
||||||
|
|
||||||
type ToolParams = Record<string, unknown>;
|
type ToolParams = Record<string, unknown>;
|
||||||
|
|
||||||
export class DiscoveredTool extends BaseTool<ToolParams, ToolResult> {
|
class DiscoveredToolInvocation extends BaseToolInvocation<
|
||||||
|
ToolParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
name: string,
|
private readonly toolName: string,
|
||||||
override readonly description: string,
|
params: ToolParams,
|
||||||
override readonly parameterSchema: Record<string, unknown>,
|
|
||||||
) {
|
) {
|
||||||
const discoveryCmd = config.getToolDiscoveryCommand()!;
|
super(params);
|
||||||
const callCommand = config.getToolCallCommand()!;
|
|
||||||
description += `
|
|
||||||
|
|
||||||
This tool was discovered from the project by executing the command \`${discoveryCmd}\` on project root.
|
|
||||||
When called, this tool will execute the command \`${callCommand} ${name}\` on project root.
|
|
||||||
Tool discovery and call commands can be configured in project or user settings.
|
|
||||||
|
|
||||||
When called, the tool call command is executed as a subprocess.
|
|
||||||
On success, tool output is returned as a json string.
|
|
||||||
Otherwise, the following information is returned:
|
|
||||||
|
|
||||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial.
|
|
||||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial.
|
|
||||||
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
|
||||||
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
|
||||||
Signal: Signal number or \`(none)\` if no signal was received.
|
|
||||||
`;
|
|
||||||
super(
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
Kind.Other,
|
|
||||||
parameterSchema,
|
|
||||||
false, // isOutputMarkdown
|
|
||||||
false, // canUpdateOutput
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(params: ToolParams): Promise<ToolResult> {
|
getDescription(): string {
|
||||||
|
return `Calling discovered tool: ${this.toolName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
_signal: AbortSignal,
|
||||||
|
_updateOutput?: (output: string) => void,
|
||||||
|
): Promise<ToolResult> {
|
||||||
const callCommand = this.config.getToolCallCommand()!;
|
const callCommand = this.config.getToolCallCommand()!;
|
||||||
const child = spawn(callCommand, [this.name]);
|
const child = spawn(callCommand, [this.toolName]);
|
||||||
child.stdin.write(JSON.stringify(params));
|
child.stdin.write(JSON.stringify(this.params));
|
||||||
child.stdin.end();
|
child.stdin.end();
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
@ -124,6 +114,52 @@ Signal: Signal number or \`(none)\` if no signal was received.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DiscoveredTool extends BaseDeclarativeTool<
|
||||||
|
ToolParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
name: string,
|
||||||
|
override readonly description: string,
|
||||||
|
override readonly parameterSchema: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const discoveryCmd = config.getToolDiscoveryCommand()!;
|
||||||
|
const callCommand = config.getToolCallCommand()!;
|
||||||
|
description += `
|
||||||
|
|
||||||
|
This tool was discovered from the project by executing the command \`${discoveryCmd}\` on project root.
|
||||||
|
When called, this tool will execute the command \`${callCommand} ${name}\` on project root.
|
||||||
|
Tool discovery and call commands can be configured in project or user settings.
|
||||||
|
|
||||||
|
When called, the tool call command is executed as a subprocess.
|
||||||
|
On success, tool output is returned as a json string.
|
||||||
|
Otherwise, the following information is returned:
|
||||||
|
|
||||||
|
Stdout: Output on stdout stream. Can be \`(empty)\` or partial.
|
||||||
|
Stderr: Output on stderr stream. Can be \`(empty)\` or partial.
|
||||||
|
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
||||||
|
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
||||||
|
Signal: Signal number or \`(none)\` if no signal was received.
|
||||||
|
`;
|
||||||
|
super(
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
Kind.Other,
|
||||||
|
parameterSchema,
|
||||||
|
false, // isOutputMarkdown
|
||||||
|
false, // canUpdateOutput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: ToolParams,
|
||||||
|
): ToolInvocation<ToolParams, ToolResult> {
|
||||||
|
return new DiscoveredToolInvocation(this.config, this.name, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ToolRegistry {
|
export class ToolRegistry {
|
||||||
private tools: Map<string, AnyDeclarativeTool> = new Map();
|
private tools: Map<string, AnyDeclarativeTool> = new Map();
|
||||||
private config: Config;
|
private config: Config;
|
||||||
|
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
|
import { WebSearchTool, WebSearchToolParams } from './web-search.js';
|
||||||
|
import { Config } from '../config/config.js';
|
||||||
|
import { GeminiClient } from '../core/client.js';
|
||||||
|
|
||||||
|
// Mock GeminiClient and Config constructor
|
||||||
|
vi.mock('../core/client.js');
|
||||||
|
vi.mock('../config/config.js');
|
||||||
|
|
||||||
|
describe('WebSearchTool', () => {
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
let mockGeminiClient: GeminiClient;
|
||||||
|
let tool: WebSearchTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockConfigInstance = {
|
||||||
|
getGeminiClient: () => mockGeminiClient,
|
||||||
|
getProxy: () => undefined,
|
||||||
|
} as unknown as Config;
|
||||||
|
mockGeminiClient = new GeminiClient(mockConfigInstance);
|
||||||
|
tool = new WebSearchTool(mockConfigInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('build', () => {
|
||||||
|
it('should return an invocation for a valid query', () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'test query' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(invocation).toBeDefined();
|
||||||
|
expect(invocation.params).toEqual(params);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error for an empty query', () => {
|
||||||
|
const params: WebSearchToolParams = { query: '' };
|
||||||
|
expect(() => tool.build(params)).toThrow(
|
||||||
|
"The 'query' parameter cannot be empty.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error for a query with only whitespace', () => {
|
||||||
|
const params: WebSearchToolParams = { query: ' ' };
|
||||||
|
expect(() => tool.build(params)).toThrow(
|
||||||
|
"The 'query' parameter cannot be empty.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDescription', () => {
|
||||||
|
it('should return a description of the search', () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'test query' };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(invocation.getDescription()).toBe(
|
||||||
|
'Searching the web for: "test query"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should return search results for a successful query', async () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'successful query' };
|
||||||
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Here are your results.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
expect(result.llmContent).toBe(
|
||||||
|
'Web search results for "successful query":\n\nHere are your results.',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toBe(
|
||||||
|
'Search results for "successful query" returned.',
|
||||||
|
);
|
||||||
|
expect(result.sources).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no search results found', async () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'no results query' };
|
||||||
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: '' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
expect(result.llmContent).toBe(
|
||||||
|
'No search results or information found for query: "no results query"',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toBe('No information found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API errors gracefully', async () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'error query' };
|
||||||
|
const testError = new Error('API Failure');
|
||||||
|
(mockGeminiClient.generateContent as Mock).mockRejectedValue(testError);
|
||||||
|
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
expect(result.llmContent).toContain('Error:');
|
||||||
|
expect(result.llmContent).toContain('API Failure');
|
||||||
|
expect(result.returnDisplay).toBe('Error performing web search.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly format results with sources and citations', async () => {
|
||||||
|
const params: WebSearchToolParams = { query: 'grounding query' };
|
||||||
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is a test response.' }],
|
||||||
|
},
|
||||||
|
groundingMetadata: {
|
||||||
|
groundingChunks: [
|
||||||
|
{ web: { uri: 'https://example.com', title: 'Example Site' } },
|
||||||
|
{ web: { uri: 'https://google.com', title: 'Google' } },
|
||||||
|
],
|
||||||
|
groundingSupports: [
|
||||||
|
{
|
||||||
|
segment: { startIndex: 5, endIndex: 14 },
|
||||||
|
groundingChunkIndices: [0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: { startIndex: 15, endIndex: 24 },
|
||||||
|
groundingChunkIndices: [0, 1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
const expectedLlmContent = `Web search results for "grounding query":
|
||||||
|
|
||||||
|
This is a test[1] response.[1][2]
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
[1] Example Site (https://example.com)
|
||||||
|
[2] Google (https://google.com)`;
|
||||||
|
|
||||||
|
expect(result.llmContent).toBe(expectedLlmContent);
|
||||||
|
expect(result.returnDisplay).toBe(
|
||||||
|
'Search results for "grounding query" returned.',
|
||||||
|
);
|
||||||
|
expect(result.sources).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,8 +5,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { GroundingMetadata } from '@google/genai';
|
import { GroundingMetadata } from '@google/genai';
|
||||||
import { BaseTool, Kind, ToolResult } from './tools.js';
|
import {
|
||||||
import { Type } from '@google/genai';
|
BaseDeclarativeTool,
|
||||||
|
BaseToolInvocation,
|
||||||
|
Kind,
|
||||||
|
ToolInvocation,
|
||||||
|
ToolResult,
|
||||||
|
} from './tools.js';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
|
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
|
@ -55,74 +60,27 @@ export interface WebSearchToolResult extends ToolResult {
|
||||||
: GroundingChunkItem[];
|
: GroundingChunkItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class WebSearchToolInvocation extends BaseToolInvocation<
|
||||||
* A tool to perform web searches using Google Search via the Gemini API.
|
|
||||||
*/
|
|
||||||
export class WebSearchTool extends BaseTool<
|
|
||||||
WebSearchToolParams,
|
WebSearchToolParams,
|
||||||
WebSearchToolResult
|
WebSearchToolResult
|
||||||
> {
|
> {
|
||||||
static readonly Name: string = 'google_web_search';
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
constructor(private readonly config: Config) {
|
|
||||||
super(
|
|
||||||
WebSearchTool.Name,
|
|
||||||
'GoogleSearch',
|
|
||||||
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
|
|
||||||
Kind.Search,
|
|
||||||
{
|
|
||||||
type: Type.OBJECT,
|
|
||||||
properties: {
|
|
||||||
query: {
|
|
||||||
type: Type.STRING,
|
|
||||||
description: 'The search query to find information on the web.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the parameters for the WebSearchTool.
|
|
||||||
* @param params The parameters to validate
|
|
||||||
* @returns An error message string if validation fails, null if valid
|
|
||||||
*/
|
|
||||||
validateParams(params: WebSearchToolParams): string | null {
|
|
||||||
const errors = SchemaValidator.validate(
|
|
||||||
this.schema.parametersJsonSchema,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
if (errors) {
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.query || params.query.trim() === '') {
|
|
||||||
return "The 'query' parameter cannot be empty.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
override getDescription(params: WebSearchToolParams): string {
|
|
||||||
return `Searching the web for: "${params.query}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(
|
|
||||||
params: WebSearchToolParams,
|
params: WebSearchToolParams,
|
||||||
signal: AbortSignal,
|
) {
|
||||||
): Promise<WebSearchToolResult> {
|
super(params);
|
||||||
const validationError = this.validateToolParams(params);
|
|
||||||
if (validationError) {
|
|
||||||
return {
|
|
||||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
|
||||||
returnDisplay: validationError,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override getDescription(): string {
|
||||||
|
return `Searching the web for: "${this.params.query}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
|
||||||
const geminiClient = this.config.getGeminiClient();
|
const geminiClient = this.config.getGeminiClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await geminiClient.generateContent(
|
const response = await geminiClient.generateContent(
|
||||||
[{ role: 'user', parts: [{ text: params.query }] }],
|
[{ role: 'user', parts: [{ text: this.params.query }] }],
|
||||||
{ tools: [{ googleSearch: {} }] },
|
{ tools: [{ googleSearch: {} }] },
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
@ -138,7 +96,7 @@ export class WebSearchTool extends BaseTool<
|
||||||
|
|
||||||
if (!responseText || !responseText.trim()) {
|
if (!responseText || !responseText.trim()) {
|
||||||
return {
|
return {
|
||||||
llmContent: `No search results or information found for query: "${params.query}"`,
|
llmContent: `No search results or information found for query: "${this.params.query}"`,
|
||||||
returnDisplay: 'No information found.',
|
returnDisplay: 'No information found.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -172,7 +130,6 @@ export class WebSearchTool extends BaseTool<
|
||||||
|
|
||||||
const responseChars = modifiedResponseText.split(''); // Use new variable
|
const responseChars = modifiedResponseText.split(''); // Use new variable
|
||||||
insertions.forEach((insertion) => {
|
insertions.forEach((insertion) => {
|
||||||
// Fixed arrow function syntax
|
|
||||||
responseChars.splice(insertion.index, 0, insertion.marker);
|
responseChars.splice(insertion.index, 0, insertion.marker);
|
||||||
});
|
});
|
||||||
modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
|
modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
|
||||||
|
@ -180,17 +137,19 @@ export class WebSearchTool extends BaseTool<
|
||||||
|
|
||||||
if (sourceListFormatted.length > 0) {
|
if (sourceListFormatted.length > 0) {
|
||||||
modifiedResponseText +=
|
modifiedResponseText +=
|
||||||
'\n\nSources:\n' + sourceListFormatted.join('\n'); // Fixed string concatenation
|
'\n\nSources:\n' + sourceListFormatted.join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
llmContent: `Web search results for "${params.query}":\n\n${modifiedResponseText}`,
|
llmContent: `Web search results for "${this.params.query}":\n\n${modifiedResponseText}`,
|
||||||
returnDisplay: `Search results for "${params.query}" returned.`,
|
returnDisplay: `Search results for "${this.params.query}" returned.`,
|
||||||
sources,
|
sources,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(error)}`;
|
const errorMessage = `Error during web search for query "${
|
||||||
|
this.params.query
|
||||||
|
}": ${getErrorMessage(error)}`;
|
||||||
console.error(errorMessage, error);
|
console.error(errorMessage, error);
|
||||||
return {
|
return {
|
||||||
llmContent: `Error: ${errorMessage}`,
|
llmContent: `Error: ${errorMessage}`,
|
||||||
|
@ -199,3 +158,60 @@ export class WebSearchTool extends BaseTool<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool to perform web searches using Google Search via the Gemini API.
|
||||||
|
*/
|
||||||
|
export class WebSearchTool extends BaseDeclarativeTool<
|
||||||
|
WebSearchToolParams,
|
||||||
|
WebSearchToolResult
|
||||||
|
> {
|
||||||
|
static readonly Name: string = 'google_web_search';
|
||||||
|
|
||||||
|
constructor(private readonly config: Config) {
|
||||||
|
super(
|
||||||
|
WebSearchTool.Name,
|
||||||
|
'GoogleSearch',
|
||||||
|
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
|
||||||
|
Kind.Search,
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The search query to find information on the web.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the parameters for the WebSearchTool.
|
||||||
|
* @param params The parameters to validate
|
||||||
|
* @returns An error message string if validation fails, null if valid
|
||||||
|
*/
|
||||||
|
protected override validateToolParams(
|
||||||
|
params: WebSearchToolParams,
|
||||||
|
): string | null {
|
||||||
|
const errors = SchemaValidator.validate(
|
||||||
|
this.schema.parametersJsonSchema,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
if (errors) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.query || params.query.trim() === '') {
|
||||||
|
return "The 'query' parameter cannot be empty.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: WebSearchToolParams,
|
||||||
|
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
|
||||||
|
return new WebSearchToolInvocation(this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,11 @@ import {
|
||||||
vi,
|
vi,
|
||||||
type Mocked,
|
type Mocked,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { WriteFileTool, WriteFileToolParams } from './write-file.js';
|
import {
|
||||||
|
getCorrectedFileContent,
|
||||||
|
WriteFileTool,
|
||||||
|
WriteFileToolParams,
|
||||||
|
} from './write-file.js';
|
||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import {
|
import {
|
||||||
FileDiff,
|
FileDiff,
|
||||||
|
@ -174,74 +178,67 @@ describe('WriteFileTool', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateToolParams', () => {
|
describe('build', () => {
|
||||||
it('should return null for valid absolute path within root', () => {
|
it('should return an invocation for a valid absolute path within root', () => {
|
||||||
const params = {
|
const params = {
|
||||||
file_path: path.join(rootDir, 'test.txt'),
|
file_path: path.join(rootDir, 'test.txt'),
|
||||||
content: 'hello',
|
content: 'hello',
|
||||||
};
|
};
|
||||||
expect(tool.validateToolParams(params)).toBeNull();
|
const invocation = tool.build(params);
|
||||||
|
expect(invocation).toBeDefined();
|
||||||
|
expect(invocation.params).toEqual(params);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error for relative path', () => {
|
it('should throw an error for a relative path', () => {
|
||||||
const params = { file_path: 'test.txt', content: 'hello' };
|
const params = { file_path: 'test.txt', content: 'hello' };
|
||||||
expect(tool.validateToolParams(params)).toMatch(
|
expect(() => tool.build(params)).toThrow(/File path must be absolute/);
|
||||||
/File path must be absolute/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error for path outside root', () => {
|
it('should throw an error for a path outside root', () => {
|
||||||
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
||||||
const params = {
|
const params = {
|
||||||
file_path: outsidePath,
|
file_path: outsidePath,
|
||||||
content: 'hello',
|
content: 'hello',
|
||||||
};
|
};
|
||||||
const error = tool.validateToolParams(params);
|
expect(() => tool.build(params)).toThrow(
|
||||||
expect(error).toContain(
|
/File path must be within one of the workspace directories/,
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error if path is a directory', () => {
|
it('should throw an error if path is a directory', () => {
|
||||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||||
fs.mkdirSync(dirAsFilePath);
|
fs.mkdirSync(dirAsFilePath);
|
||||||
const params = {
|
const params = {
|
||||||
file_path: dirAsFilePath,
|
file_path: dirAsFilePath,
|
||||||
content: 'hello',
|
content: 'hello',
|
||||||
};
|
};
|
||||||
expect(tool.validateToolParams(params)).toMatch(
|
expect(() => tool.build(params)).toThrow(
|
||||||
`Path is a directory, not a file: ${dirAsFilePath}`,
|
`Path is a directory, not a file: ${dirAsFilePath}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error if the content is null', () => {
|
it('should throw an error if the content is null', () => {
|
||||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||||
fs.mkdirSync(dirAsFilePath);
|
fs.mkdirSync(dirAsFilePath);
|
||||||
const params = {
|
const params = {
|
||||||
file_path: dirAsFilePath,
|
file_path: dirAsFilePath,
|
||||||
content: null,
|
content: null,
|
||||||
} as unknown as WriteFileToolParams; // Intentionally non-conforming
|
} as unknown as WriteFileToolParams; // Intentionally non-conforming
|
||||||
expect(tool.validateToolParams(params)).toMatch(
|
expect(() => tool.build(params)).toThrow('params/content must be string');
|
||||||
`params/content must be string`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDescription', () => {
|
it('should throw error if the file_path is empty', () => {
|
||||||
it('should return error if the file_path is empty', () => {
|
|
||||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||||
fs.mkdirSync(dirAsFilePath);
|
fs.mkdirSync(dirAsFilePath);
|
||||||
const params = {
|
const params = {
|
||||||
file_path: '',
|
file_path: '',
|
||||||
content: '',
|
content: '',
|
||||||
};
|
};
|
||||||
expect(tool.getDescription(params)).toMatch(
|
expect(() => tool.build(params)).toThrow(`Missing or empty "file_path"`);
|
||||||
`Model did not provide valid parameters for write file tool, missing or empty "file_path"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_getCorrectedFileContent', () => {
|
describe('getCorrectedFileContent', () => {
|
||||||
it('should call ensureCorrectFileContent for a new file', async () => {
|
it('should call ensureCorrectFileContent for a new file', async () => {
|
||||||
const filePath = path.join(rootDir, 'new_corrected_file.txt');
|
const filePath = path.join(rootDir, 'new_corrected_file.txt');
|
||||||
const proposedContent = 'Proposed new content.';
|
const proposedContent = 'Proposed new content.';
|
||||||
|
@ -250,8 +247,8 @@ describe('WriteFileTool', () => {
|
||||||
// Ensure the mock is set for this specific test case if needed, or rely on beforeEach
|
// Ensure the mock is set for this specific test case if needed, or rely on beforeEach
|
||||||
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
|
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
|
||||||
|
|
||||||
// @ts-expect-error _getCorrectedFileContent is private
|
const result = await getCorrectedFileContent(
|
||||||
const result = await tool._getCorrectedFileContent(
|
mockConfig,
|
||||||
filePath,
|
filePath,
|
||||||
proposedContent,
|
proposedContent,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
@ -287,8 +284,8 @@ describe('WriteFileTool', () => {
|
||||||
occurrences: 1,
|
occurrences: 1,
|
||||||
} as CorrectedEditResult);
|
} as CorrectedEditResult);
|
||||||
|
|
||||||
// @ts-expect-error _getCorrectedFileContent is private
|
const result = await getCorrectedFileContent(
|
||||||
const result = await tool._getCorrectedFileContent(
|
mockConfig,
|
||||||
filePath,
|
filePath,
|
||||||
proposedContent,
|
proposedContent,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
@ -324,8 +321,8 @@ describe('WriteFileTool', () => {
|
||||||
throw readError;
|
throw readError;
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-expect-error _getCorrectedFileContent is private
|
const result = await getCorrectedFileContent(
|
||||||
const result = await tool._getCorrectedFileContent(
|
mockConfig,
|
||||||
filePath,
|
filePath,
|
||||||
proposedContent,
|
proposedContent,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
@ -349,18 +346,6 @@ describe('WriteFileTool', () => {
|
||||||
|
|
||||||
describe('shouldConfirmExecute', () => {
|
describe('shouldConfirmExecute', () => {
|
||||||
const abortSignal = new AbortController().signal;
|
const abortSignal = new AbortController().signal;
|
||||||
it('should return false if params are invalid (relative path)', async () => {
|
|
||||||
const params = { file_path: 'relative.txt', content: 'test' };
|
|
||||||
const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
|
|
||||||
expect(confirmation).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if params are invalid (outside root)', async () => {
|
|
||||||
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
|
||||||
const params = { file_path: outsidePath, content: 'test' };
|
|
||||||
const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
|
|
||||||
expect(confirmation).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if _getCorrectedFileContent returns an error', async () => {
|
it('should return false if _getCorrectedFileContent returns an error', async () => {
|
||||||
const filePath = path.join(rootDir, 'confirm_error_file.txt');
|
const filePath = path.join(rootDir, 'confirm_error_file.txt');
|
||||||
|
@ -373,7 +358,8 @@ describe('WriteFileTool', () => {
|
||||||
throw readError;
|
throw readError;
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const confirmation = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
expect(confirmation).toBe(false);
|
expect(confirmation).toBe(false);
|
||||||
|
|
||||||
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
|
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
|
||||||
|
@ -387,8 +373,8 @@ describe('WriteFileTool', () => {
|
||||||
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent); // Ensure this mock is active
|
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent); // Ensure this mock is active
|
||||||
|
|
||||||
const params = { file_path: filePath, content: proposedContent };
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
const confirmation = (await tool.shouldConfirmExecute(
|
const invocation = tool.build(params);
|
||||||
params,
|
const confirmation = (await invocation.shouldConfirmExecute(
|
||||||
abortSignal,
|
abortSignal,
|
||||||
)) as ToolEditConfirmationDetails;
|
)) as ToolEditConfirmationDetails;
|
||||||
|
|
||||||
|
@ -430,8 +416,8 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: filePath, content: proposedContent };
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
const confirmation = (await tool.shouldConfirmExecute(
|
const invocation = tool.build(params);
|
||||||
params,
|
const confirmation = (await invocation.shouldConfirmExecute(
|
||||||
abortSignal,
|
abortSignal,
|
||||||
)) as ToolEditConfirmationDetails;
|
)) as ToolEditConfirmationDetails;
|
||||||
|
|
||||||
|
@ -461,31 +447,6 @@ describe('WriteFileTool', () => {
|
||||||
|
|
||||||
describe('execute', () => {
|
describe('execute', () => {
|
||||||
const abortSignal = new AbortController().signal;
|
const abortSignal = new AbortController().signal;
|
||||||
it('should return error if params are invalid (relative path)', async () => {
|
|
||||||
const params = { file_path: 'relative.txt', content: 'test' };
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
|
||||||
expect(result.llmContent).toContain(
|
|
||||||
'Could not write file due to invalid parameters:',
|
|
||||||
);
|
|
||||||
expect(result.returnDisplay).toMatch(/File path must be absolute/);
|
|
||||||
expect(result.error).toEqual({
|
|
||||||
message: 'File path must be absolute: relative.txt',
|
|
||||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error if params are invalid (path outside root)', async () => {
|
|
||||||
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
|
||||||
const params = { file_path: outsidePath, content: 'test' };
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
|
||||||
expect(result.llmContent).toContain(
|
|
||||||
'Could not write file due to invalid parameters:',
|
|
||||||
);
|
|
||||||
expect(result.returnDisplay).toContain(
|
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
|
||||||
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
|
it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
|
||||||
const filePath = path.join(rootDir, 'execute_error_file.txt');
|
const filePath = path.join(rootDir, 'execute_error_file.txt');
|
||||||
|
@ -498,7 +459,8 @@ describe('WriteFileTool', () => {
|
||||||
throw readError;
|
throw readError;
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
expect(result.llmContent).toContain('Error checking existing file:');
|
expect(result.llmContent).toContain('Error checking existing file:');
|
||||||
expect(result.returnDisplay).toMatch(
|
expect(result.returnDisplay).toMatch(
|
||||||
/Error checking existing file: Simulated read error for execute/,
|
/Error checking existing file: Simulated read error for execute/,
|
||||||
|
@ -520,11 +482,9 @@ describe('WriteFileTool', () => {
|
||||||
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
|
mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
|
||||||
|
|
||||||
const params = { file_path: filePath, content: proposedContent };
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
|
||||||
const confirmDetails = await tool.shouldConfirmExecute(
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
params,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
typeof confirmDetails === 'object' &&
|
typeof confirmDetails === 'object' &&
|
||||||
'onConfirm' in confirmDetails &&
|
'onConfirm' in confirmDetails &&
|
||||||
|
@ -533,7 +493,7 @@ describe('WriteFileTool', () => {
|
||||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
|
expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
|
||||||
proposedContent,
|
proposedContent,
|
||||||
|
@ -578,11 +538,9 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: filePath, content: proposedContent };
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
|
||||||
const confirmDetails = await tool.shouldConfirmExecute(
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
params,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
typeof confirmDetails === 'object' &&
|
typeof confirmDetails === 'object' &&
|
||||||
'onConfirm' in confirmDetails &&
|
'onConfirm' in confirmDetails &&
|
||||||
|
@ -591,7 +549,7 @@ describe('WriteFileTool', () => {
|
||||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
|
expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
|
||||||
filePath,
|
filePath,
|
||||||
|
@ -623,11 +581,9 @@ describe('WriteFileTool', () => {
|
||||||
mockEnsureCorrectFileContent.mockResolvedValue(content); // Ensure this mock is active
|
mockEnsureCorrectFileContent.mockResolvedValue(content); // Ensure this mock is active
|
||||||
|
|
||||||
const params = { file_path: filePath, content };
|
const params = { file_path: filePath, content };
|
||||||
|
const invocation = tool.build(params);
|
||||||
// Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
|
// Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
|
||||||
const confirmDetails = await tool.shouldConfirmExecute(
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
params,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
typeof confirmDetails === 'object' &&
|
typeof confirmDetails === 'object' &&
|
||||||
'onConfirm' in confirmDetails &&
|
'onConfirm' in confirmDetails &&
|
||||||
|
@ -636,7 +592,7 @@ describe('WriteFileTool', () => {
|
||||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
}
|
}
|
||||||
|
|
||||||
await tool.execute(params, abortSignal);
|
await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(fs.existsSync(dirPath)).toBe(true);
|
expect(fs.existsSync(dirPath)).toBe(true);
|
||||||
expect(fs.statSync(dirPath).isDirectory()).toBe(true);
|
expect(fs.statSync(dirPath).isDirectory()).toBe(true);
|
||||||
|
@ -654,7 +610,8 @@ describe('WriteFileTool', () => {
|
||||||
content,
|
content,
|
||||||
modified_by_user: true,
|
modified_by_user: true,
|
||||||
};
|
};
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.llmContent).toMatch(/User modified the `content`/);
|
expect(result.llmContent).toMatch(/User modified the `content`/);
|
||||||
});
|
});
|
||||||
|
@ -669,7 +626,8 @@ describe('WriteFileTool', () => {
|
||||||
content,
|
content,
|
||||||
modified_by_user: false,
|
modified_by_user: false,
|
||||||
};
|
};
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.llmContent).not.toMatch(/User modified the `content`/);
|
expect(result.llmContent).not.toMatch(/User modified the `content`/);
|
||||||
});
|
});
|
||||||
|
@ -683,7 +641,8 @@ describe('WriteFileTool', () => {
|
||||||
file_path: filePath,
|
file_path: filePath,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.llmContent).not.toMatch(/User modified the `content`/);
|
expect(result.llmContent).not.toMatch(/User modified the `content`/);
|
||||||
});
|
});
|
||||||
|
@ -695,7 +654,7 @@ describe('WriteFileTool', () => {
|
||||||
file_path: path.join(rootDir, 'file.txt'),
|
file_path: path.join(rootDir, 'file.txt'),
|
||||||
content: 'test content',
|
content: 'test content',
|
||||||
};
|
};
|
||||||
expect(tool.validateToolParams(params)).toBeNull();
|
expect(() => tool.build(params)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject paths outside workspace root', () => {
|
it('should reject paths outside workspace root', () => {
|
||||||
|
@ -703,24 +662,9 @@ describe('WriteFileTool', () => {
|
||||||
file_path: '/etc/passwd',
|
file_path: '/etc/passwd',
|
||||||
content: 'malicious',
|
content: 'malicious',
|
||||||
};
|
};
|
||||||
const error = tool.validateToolParams(params);
|
expect(() => tool.build(params)).toThrow(
|
||||||
expect(error).toContain(
|
/File path must be within one of the workspace directories/,
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
);
|
||||||
expect(error).toContain(rootDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide clear error message with workspace directories', () => {
|
|
||||||
const outsidePath = path.join(tempDir, 'outside-root.txt');
|
|
||||||
const params = {
|
|
||||||
file_path: outsidePath,
|
|
||||||
content: 'test',
|
|
||||||
};
|
|
||||||
const error = tool.validateToolParams(params);
|
|
||||||
expect(error).toContain(
|
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
|
||||||
expect(error).toContain(rootDir);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -740,13 +684,16 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: filePath, content };
|
const params = { file_path: filePath, content };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.error?.type).toBe(ToolErrorType.PERMISSION_DENIED);
|
expect(result.error?.type).toBe(ToolErrorType.PERMISSION_DENIED);
|
||||||
expect(result.llmContent).toContain(
|
expect(result.llmContent).toContain(
|
||||||
`Permission denied writing to file: ${filePath} (EACCES)`,
|
`Permission denied writing to file: ${filePath} (EACCES)`,
|
||||||
);
|
);
|
||||||
expect(result.returnDisplay).toContain('Permission denied');
|
expect(result.returnDisplay).toContain(
|
||||||
|
`Permission denied writing to file: ${filePath} (EACCES)`,
|
||||||
|
);
|
||||||
|
|
||||||
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
||||||
});
|
});
|
||||||
|
@ -766,13 +713,16 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: filePath, content };
|
const params = { file_path: filePath, content };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.error?.type).toBe(ToolErrorType.NO_SPACE_LEFT);
|
expect(result.error?.type).toBe(ToolErrorType.NO_SPACE_LEFT);
|
||||||
expect(result.llmContent).toContain(
|
expect(result.llmContent).toContain(
|
||||||
`No space left on device: ${filePath} (ENOSPC)`,
|
`No space left on device: ${filePath} (ENOSPC)`,
|
||||||
);
|
);
|
||||||
expect(result.returnDisplay).toContain('No space left');
|
expect(result.returnDisplay).toContain(
|
||||||
|
`No space left on device: ${filePath} (ENOSPC)`,
|
||||||
|
);
|
||||||
|
|
||||||
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
||||||
});
|
});
|
||||||
|
@ -799,13 +749,16 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: dirPath, content };
|
const params = { file_path: dirPath, content };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||||
expect(result.llmContent).toContain(
|
expect(result.llmContent).toContain(
|
||||||
`Target is a directory, not a file: ${dirPath} (EISDIR)`,
|
`Target is a directory, not a file: ${dirPath} (EISDIR)`,
|
||||||
);
|
);
|
||||||
expect(result.returnDisplay).toContain('Target is a directory');
|
expect(result.returnDisplay).toContain(
|
||||||
|
`Target is a directory, not a file: ${dirPath} (EISDIR)`,
|
||||||
|
);
|
||||||
|
|
||||||
vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync);
|
vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync);
|
||||||
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
||||||
|
@ -824,13 +777,16 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { file_path: filePath, content };
|
const params = { file_path: filePath, content };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const invocation = tool.build(params);
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
|
||||||
expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);
|
expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);
|
||||||
expect(result.llmContent).toContain(
|
expect(result.llmContent).toContain(
|
||||||
'Error writing to file: Generic write error',
|
'Error writing to file: Generic write error',
|
||||||
);
|
);
|
||||||
expect(result.returnDisplay).toContain('Generic write error');
|
expect(result.returnDisplay).toContain(
|
||||||
|
'Error writing to file: Generic write error',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,14 +9,16 @@ import path from 'path';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
import { Config, ApprovalMode } from '../config/config.js';
|
import { Config, ApprovalMode } from '../config/config.js';
|
||||||
import {
|
import {
|
||||||
BaseTool,
|
BaseDeclarativeTool,
|
||||||
ToolResult,
|
BaseToolInvocation,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
ToolEditConfirmationDetails,
|
|
||||||
ToolConfirmationOutcome,
|
|
||||||
ToolCallConfirmationDetails,
|
|
||||||
Kind,
|
Kind,
|
||||||
|
ToolCallConfirmationDetails,
|
||||||
|
ToolConfirmationOutcome,
|
||||||
|
ToolEditConfirmationDetails,
|
||||||
|
ToolInvocation,
|
||||||
ToolLocation,
|
ToolLocation,
|
||||||
|
ToolResult,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
|
@ -67,342 +69,8 @@ interface GetCorrectedFileContentResult {
|
||||||
error?: { message: string; code?: string };
|
error?: { message: string; code?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getCorrectedFileContent(
|
||||||
* Implementation of the WriteFile tool logic
|
config: Config,
|
||||||
*/
|
|
||||||
export class WriteFileTool
|
|
||||||
extends BaseTool<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',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
override toolLocations(params: WriteFileToolParams): ToolLocation[] {
|
|
||||||
return [{ path: params.file_path }];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getDescription(params: WriteFileToolParams): string {
|
|
||||||
if (!params.file_path) {
|
|
||||||
return `Model did not provide valid parameters for write file tool, missing or empty "file_path"`;
|
|
||||||
}
|
|
||||||
const relativePath = makeRelative(
|
|
||||||
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,
|
|
||||||
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 ideClient = this.config.getIdeClient();
|
|
||||||
const ideConfirmation =
|
|
||||||
this.config.getIdeMode() &&
|
|
||||||
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
|
|
||||||
? ideClient.openDiff(params.file_path, correctedContent)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
|
||||||
type: 'edit',
|
|
||||||
title: `Confirm Write: ${shortenPath(relativePath)}`,
|
|
||||||
fileName,
|
|
||||||
filePath: params.file_path,
|
|
||||||
fileDiff,
|
|
||||||
originalContent,
|
|
||||||
newContent: correctedContent,
|
|
||||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
|
||||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
|
||||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ideConfirmation) {
|
|
||||||
const result = await ideConfirmation;
|
|
||||||
if (result.status === 'accepted' && result.content) {
|
|
||||||
params.content = result.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ideConfirmation,
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
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: ${errDetails.message}`;
|
|
||||||
return {
|
|
||||||
llmContent: errorMsg,
|
|
||||||
returnDisplay: errorMsg,
|
|
||||||
error: {
|
|
||||||
message: errorMsg,
|
|
||||||
type: ToolErrorType.FILE_WRITE_FAILURE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 originallyProposedContent =
|
|
||||||
params.ai_proposed_content || params.content;
|
|
||||||
const diffStat = getDiffStat(
|
|
||||||
fileName,
|
|
||||||
currentContentForDiff,
|
|
||||||
originallyProposedContent,
|
|
||||||
params.content,
|
|
||||||
);
|
|
||||||
|
|
||||||
const llmSuccessMessageParts = [
|
|
||||||
isNewFile
|
|
||||||
? `Successfully created and wrote to new file: ${params.file_path}.`
|
|
||||||
: `Successfully overwrote file: ${params.file_path}.`,
|
|
||||||
];
|
|
||||||
if (params.modified_by_user) {
|
|
||||||
llmSuccessMessageParts.push(
|
|
||||||
`User modified the \`content\` to be: ${params.content}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayResult: FileDiff = {
|
|
||||||
fileDiff,
|
|
||||||
fileName,
|
|
||||||
originalContent: correctedContentResult.originalContent,
|
|
||||||
newContent: correctedContentResult.correctedContent,
|
|
||||||
diffStat,
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines = fileContent.split('\n').length;
|
|
||||||
const mimetype = getSpecificMimeType(params.file_path);
|
|
||||||
const extension = path.extname(params.file_path); // Get extension
|
|
||||||
if (isNewFile) {
|
|
||||||
recordFileOperationMetric(
|
|
||||||
this.config,
|
|
||||||
FileOperation.CREATE,
|
|
||||||
lines,
|
|
||||||
mimetype,
|
|
||||||
extension,
|
|
||||||
diffStat,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
recordFileOperationMetric(
|
|
||||||
this.config,
|
|
||||||
FileOperation.UPDATE,
|
|
||||||
lines,
|
|
||||||
mimetype,
|
|
||||||
extension,
|
|
||||||
diffStat,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
llmContent: llmSuccessMessageParts.join(' '),
|
|
||||||
returnDisplay: displayResult,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// Capture detailed error information for debugging
|
|
||||||
let errorMsg: string;
|
|
||||||
let errorType = ToolErrorType.FILE_WRITE_FAILURE;
|
|
||||||
|
|
||||||
if (isNodeError(error)) {
|
|
||||||
// Handle specific Node.js errors with their error codes
|
|
||||||
errorMsg = `Error writing to file '${params.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})`;
|
|
||||||
errorType = ToolErrorType.PERMISSION_DENIED;
|
|
||||||
} else if (error.code === 'ENOSPC') {
|
|
||||||
errorMsg = `No space left on device: ${params.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})`;
|
|
||||||
errorType = ToolErrorType.TARGET_IS_DIRECTORY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include stack trace in debug mode for better troubleshooting
|
|
||||||
if (this.config.getDebugMode() && error.stack) {
|
|
||||||
console.error('Write file error stack:', error.stack);
|
|
||||||
}
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMsg = `Error writing to file: ${error.message}`;
|
|
||||||
} else {
|
|
||||||
errorMsg = `Error writing to file: ${String(error)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
llmContent: errorMsg,
|
|
||||||
returnDisplay: errorMsg,
|
|
||||||
error: {
|
|
||||||
message: errorMsg,
|
|
||||||
type: errorType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getCorrectedFileContent(
|
|
||||||
filePath: string,
|
filePath: string,
|
||||||
proposedContent: string,
|
proposedContent: string,
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
|
@ -445,7 +113,7 @@ export class WriteFileTool
|
||||||
new_string: proposedContent,
|
new_string: proposedContent,
|
||||||
file_path: filePath,
|
file_path: filePath,
|
||||||
},
|
},
|
||||||
this.config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
abortSignal,
|
abortSignal,
|
||||||
);
|
);
|
||||||
correctedContent = correctedParams.new_string;
|
correctedContent = correctedParams.new_string;
|
||||||
|
@ -453,20 +121,357 @@ export class WriteFileTool
|
||||||
// This implies new file (ENOENT)
|
// This implies new file (ENOENT)
|
||||||
correctedContent = await ensureCorrectFileContent(
|
correctedContent = await ensureCorrectFileContent(
|
||||||
proposedContent,
|
proposedContent,
|
||||||
this.config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
abortSignal,
|
abortSignal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { originalContent, correctedContent, fileExists };
|
return { originalContent, correctedContent, fileExists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WriteFileToolInvocation extends BaseToolInvocation<
|
||||||
|
WriteFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
params: WriteFileToolParams,
|
||||||
|
) {
|
||||||
|
super(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
override toolLocations(): ToolLocation[] {
|
||||||
|
return [{ path: this.params.file_path }];
|
||||||
|
}
|
||||||
|
|
||||||
|
override getDescription(): string {
|
||||||
|
const relativePath = makeRelative(
|
||||||
|
this.params.file_path,
|
||||||
|
this.config.getTargetDir(),
|
||||||
|
);
|
||||||
|
return `Writing to ${shortenPath(relativePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async shouldConfirmExecute(
|
||||||
|
abortSignal: AbortSignal,
|
||||||
|
): Promise<ToolCallConfirmationDetails | false> {
|
||||||
|
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctedContentResult = await getCorrectedFileContent(
|
||||||
|
this.config,
|
||||||
|
this.params.file_path,
|
||||||
|
this.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(
|
||||||
|
this.params.file_path,
|
||||||
|
this.config.getTargetDir(),
|
||||||
|
);
|
||||||
|
const fileName = path.basename(this.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 ideClient = this.config.getIdeClient();
|
||||||
|
const ideConfirmation =
|
||||||
|
this.config.getIdeMode() &&
|
||||||
|
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||||
|
? ideClient.openDiff(this.params.file_path, correctedContent)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||||
|
type: 'edit',
|
||||||
|
title: `Confirm Write: ${shortenPath(relativePath)}`,
|
||||||
|
fileName,
|
||||||
|
filePath: this.params.file_path,
|
||||||
|
fileDiff,
|
||||||
|
originalContent,
|
||||||
|
newContent: correctedContent,
|
||||||
|
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||||
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||||
|
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ideConfirmation) {
|
||||||
|
const result = await ideConfirmation;
|
||||||
|
if (result.status === 'accepted' && result.content) {
|
||||||
|
this.params.content = result.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ideConfirmation,
|
||||||
|
};
|
||||||
|
return confirmationDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '${file_path}': ${errDetails.message} (${errDetails.code})`
|
||||||
|
: `Error checking existing file: ${errDetails.message}`;
|
||||||
|
return {
|
||||||
|
llmContent: errorMsg,
|
||||||
|
returnDisplay: errorMsg,
|
||||||
|
error: {
|
||||||
|
message: errorMsg,
|
||||||
|
type: ToolErrorType.FILE_WRITE_FAILURE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(file_path);
|
||||||
|
if (!fs.existsSync(dirName)) {
|
||||||
|
fs.mkdirSync(dirName, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(file_path, fileContent, 'utf8');
|
||||||
|
|
||||||
|
// Generate diff for display result
|
||||||
|
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.
|
||||||
|
const currentContentForDiff = correctedContentResult.error
|
||||||
|
? '' // Or some indicator of unreadable content
|
||||||
|
: originalContent;
|
||||||
|
|
||||||
|
const fileDiff = Diff.createPatch(
|
||||||
|
fileName,
|
||||||
|
currentContentForDiff,
|
||||||
|
fileContent,
|
||||||
|
'Original',
|
||||||
|
'Written',
|
||||||
|
DEFAULT_DIFF_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const originallyProposedContent = ai_proposed_content || content;
|
||||||
|
const diffStat = getDiffStat(
|
||||||
|
fileName,
|
||||||
|
currentContentForDiff,
|
||||||
|
originallyProposedContent,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
|
||||||
|
const llmSuccessMessageParts = [
|
||||||
|
isNewFile
|
||||||
|
? `Successfully created and wrote to new file: ${file_path}.`
|
||||||
|
: `Successfully overwrote file: ${file_path}.`,
|
||||||
|
];
|
||||||
|
if (modified_by_user) {
|
||||||
|
llmSuccessMessageParts.push(
|
||||||
|
`User modified the \`content\` to be: ${content}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayResult: FileDiff = {
|
||||||
|
fileDiff,
|
||||||
|
fileName,
|
||||||
|
originalContent: correctedContentResult.originalContent,
|
||||||
|
newContent: correctedContentResult.correctedContent,
|
||||||
|
diffStat,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = fileContent.split('\n').length;
|
||||||
|
const mimetype = getSpecificMimeType(file_path);
|
||||||
|
const extension = path.extname(file_path); // Get extension
|
||||||
|
if (isNewFile) {
|
||||||
|
recordFileOperationMetric(
|
||||||
|
this.config,
|
||||||
|
FileOperation.CREATE,
|
||||||
|
lines,
|
||||||
|
mimetype,
|
||||||
|
extension,
|
||||||
|
diffStat,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
recordFileOperationMetric(
|
||||||
|
this.config,
|
||||||
|
FileOperation.UPDATE,
|
||||||
|
lines,
|
||||||
|
mimetype,
|
||||||
|
extension,
|
||||||
|
diffStat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
llmContent: llmSuccessMessageParts.join(' '),
|
||||||
|
returnDisplay: displayResult,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Capture detailed error information for debugging
|
||||||
|
let errorMsg: string;
|
||||||
|
let errorType = ToolErrorType.FILE_WRITE_FAILURE;
|
||||||
|
|
||||||
|
if (isNodeError(error)) {
|
||||||
|
// Handle specific Node.js errors with their error codes
|
||||||
|
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: ${file_path} (${error.code})`;
|
||||||
|
errorType = ToolErrorType.PERMISSION_DENIED;
|
||||||
|
} else if (error.code === 'ENOSPC') {
|
||||||
|
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: ${file_path} (${error.code})`;
|
||||||
|
errorType = ToolErrorType.TARGET_IS_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include stack trace in debug mode for better troubleshooting
|
||||||
|
if (this.config.getDebugMode() && error.stack) {
|
||||||
|
console.error('Write file error stack:', error.stack);
|
||||||
|
}
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMsg = `Error writing to file: ${error.message}`;
|
||||||
|
} else {
|
||||||
|
errorMsg = `Error writing to file: ${String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
llmContent: errorMsg,
|
||||||
|
returnDisplay: errorMsg,
|
||||||
|
error: {
|
||||||
|
message: errorMsg,
|
||||||
|
type: errorType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: WriteFileToolParams,
|
||||||
|
): ToolInvocation<WriteFileToolParams, ToolResult> {
|
||||||
|
return new WriteFileToolInvocation(this.config, params);
|
||||||
|
}
|
||||||
|
|
||||||
getModifyContext(
|
getModifyContext(
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): ModifyContext<WriteFileToolParams> {
|
): ModifyContext<WriteFileToolParams> {
|
||||||
return {
|
return {
|
||||||
getFilePath: (params: WriteFileToolParams) => params.file_path,
|
getFilePath: (params: WriteFileToolParams) => params.file_path,
|
||||||
getCurrentContent: async (params: WriteFileToolParams) => {
|
getCurrentContent: async (params: WriteFileToolParams) => {
|
||||||
const correctedContentResult = await this._getCorrectedFileContent(
|
const correctedContentResult = await getCorrectedFileContent(
|
||||||
|
this.config,
|
||||||
params.file_path,
|
params.file_path,
|
||||||
params.content,
|
params.content,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
@ -474,7 +479,8 @@ export class WriteFileTool
|
||||||
return correctedContentResult.originalContent;
|
return correctedContentResult.originalContent;
|
||||||
},
|
},
|
||||||
getProposedContent: async (params: WriteFileToolParams) => {
|
getProposedContent: async (params: WriteFileToolParams) => {
|
||||||
const correctedContentResult = await this._getCorrectedFileContent(
|
const correctedContentResult = await getCorrectedFileContent(
|
||||||
|
this.config,
|
||||||
params.file_path,
|
params.file_path,
|
||||||
params.content,
|
params.content,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
|
Loading…
Reference in New Issue