mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Run npm run format
- Also updated README.md accordingly. Part of https://b.corp.google.com/issues/411384603
This commit is contained in:
committed by
N. Taylor Mullen
parent
7928c1727f
commit
cfc697a96d
@@ -3,7 +3,11 @@ import path from 'path';
|
||||
import * as Diff from 'diff';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { BaseTool, ToolResult } from './tools.js';
|
||||
import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails } from '../ui/types.js';
|
||||
import {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolEditConfirmationDetails,
|
||||
} from '../ui/types.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { ReadFileTool } from './read-file.tool.js';
|
||||
import { WriteFileTool } from './write-file.tool.js';
|
||||
@@ -12,39 +16,38 @@ import { WriteFileTool } from './write-file.tool.js';
|
||||
* Parameters for the Edit tool
|
||||
*/
|
||||
export interface EditToolParams {
|
||||
/**
|
||||
* The absolute path to the file to modify
|
||||
*/
|
||||
file_path: string;
|
||||
/**
|
||||
* The absolute path to the file to modify
|
||||
*/
|
||||
file_path: string;
|
||||
|
||||
/**
|
||||
* The text to replace
|
||||
*/
|
||||
old_string: string;
|
||||
/**
|
||||
* The text to replace
|
||||
*/
|
||||
old_string: string;
|
||||
|
||||
/**
|
||||
* The text to replace it with
|
||||
*/
|
||||
new_string: string;
|
||||
/**
|
||||
* The text to replace it with
|
||||
*/
|
||||
new_string: string;
|
||||
|
||||
/**
|
||||
* The expected number of replacements to perform (optional, defaults to 1)
|
||||
*/
|
||||
expected_replacements?: number;
|
||||
/**
|
||||
* The expected number of replacements to perform (optional, defaults to 1)
|
||||
*/
|
||||
expected_replacements?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from the Edit tool
|
||||
*/
|
||||
export interface EditToolResult extends ToolResult {
|
||||
}
|
||||
export interface EditToolResult extends ToolResult {}
|
||||
|
||||
interface CalculatedEdit {
|
||||
currentContent: string | null;
|
||||
newContent: string;
|
||||
occurrences: number;
|
||||
error?: { display: string, raw: string };
|
||||
isNewFile: boolean;
|
||||
currentContent: string | null;
|
||||
newContent: string;
|
||||
occurrences: number;
|
||||
error?: { display: string; raw: string };
|
||||
isNewFile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,317 +55,350 @@ interface CalculatedEdit {
|
||||
* This tool maintains state for the "Always Edit" confirmation preference.
|
||||
*/
|
||||
export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
|
||||
private shouldAlwaysEdit = false;
|
||||
private readonly rootDirectory: string;
|
||||
private shouldAlwaysEdit = false;
|
||||
private readonly rootDirectory: string;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the EditTool
|
||||
* @param rootDirectory Root directory to ground this tool in.
|
||||
*/
|
||||
constructor(rootDirectory: string) {
|
||||
super(
|
||||
'replace',
|
||||
'Edit',
|
||||
`Replaces a SINGLE, UNIQUE occurrence of text within a file. Requires providing significant context around the change to ensure uniqueness. For moving/renaming files, use the Bash tool with \`mv\`. For replacing entire file contents or creating new files use the ${WriteFileTool.Name} tool. Always use the ${ReadFileTool.Name} tool to examine the file before using this tool.`,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The absolute path to the file to modify. Must start with /. When creating a new file, ensure the parent directory exists (use the `LS` tool to verify).',
|
||||
type: 'string'
|
||||
},
|
||||
old_string: {
|
||||
description: 'The exact text to replace. CRITICAL: Must uniquely identify the single instance to change. Include at least 3-5 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations or does not match exactly, the tool will fail. Use an empty string ("") when creating a new file.',
|
||||
type: 'string'
|
||||
},
|
||||
new_string: {
|
||||
description: 'The text to replace the `old_string` with. When creating a new file (using an empty `old_string`), this should contain the full desired content of the new file. Ensure the resulting code is correct and idiomatic.',
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: ['file_path', 'old_string', 'new_string'],
|
||||
type: 'object'
|
||||
}
|
||||
);
|
||||
this.rootDirectory = path.resolve(rootDirectory);
|
||||
/**
|
||||
* Creates a new instance of the EditTool
|
||||
* @param rootDirectory Root directory to ground this tool in.
|
||||
*/
|
||||
constructor(rootDirectory: string) {
|
||||
super(
|
||||
'replace',
|
||||
'Edit',
|
||||
`Replaces a SINGLE, UNIQUE occurrence of text within a file. Requires providing significant context around the change to ensure uniqueness. For moving/renaming files, use the Bash tool with \`mv\`. For replacing entire file contents or creating new files use the ${WriteFileTool.Name} tool. Always use the ${ReadFileTool.Name} tool to examine the file before using this tool.`,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
description:
|
||||
'The absolute path to the file to modify. Must start with /. When creating a new file, ensure the parent directory exists (use the `LS` tool to verify).',
|
||||
type: 'string',
|
||||
},
|
||||
old_string: {
|
||||
description:
|
||||
'The exact text to replace. CRITICAL: Must uniquely identify the single instance to change. Include at least 3-5 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations or does not match exactly, the tool will fail. Use an empty string ("") when creating a new file.',
|
||||
type: 'string',
|
||||
},
|
||||
new_string: {
|
||||
description:
|
||||
'The text to replace the `old_string` with. When creating a new file (using an empty `old_string`), this should contain the full desired content of the new file. Ensure the resulting code is correct and idiomatic.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['file_path', 'old_string', 'new_string'],
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
this.rootDirectory = path.resolve(rootDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory.
|
||||
* @param pathToCheck The absolute path to check.
|
||||
* @returns True if the path is within the root directory, false otherwise.
|
||||
*/
|
||||
private isWithinRoot(pathToCheck: string): boolean {
|
||||
const normalizedPath = path.normalize(pathToCheck);
|
||||
const normalizedRoot = this.rootDirectory;
|
||||
|
||||
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
|
||||
return (
|
||||
normalizedPath === normalizedRoot ||
|
||||
normalizedPath.startsWith(rootWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the Edit tool
|
||||
* @param params Parameters to validate
|
||||
* @returns True if parameters are valid, false otherwise
|
||||
*/
|
||||
validateParams(params: EditToolParams): boolean {
|
||||
if (
|
||||
this.schema.parameters &&
|
||||
!SchemaValidator.validate(
|
||||
this.schema.parameters as Record<string, unknown>,
|
||||
params,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory.
|
||||
* @param pathToCheck The absolute path to check.
|
||||
* @returns True if the path is within the root directory, false otherwise.
|
||||
*/
|
||||
private isWithinRoot(pathToCheck: string): boolean {
|
||||
const normalizedPath = path.normalize(pathToCheck);
|
||||
const normalizedRoot = this.rootDirectory;
|
||||
|
||||
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep);
|
||||
// Ensure path is absolute
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
console.error(`File path must be absolute: ${params.file_path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the Edit tool
|
||||
* @param params Parameters to validate
|
||||
* @returns True if parameters are valid, false otherwise
|
||||
*/
|
||||
validateParams(params: EditToolParams): boolean {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure path is absolute
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
console.error(`File path must be absolute: ${params.file_path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure path is within the root directory
|
||||
if (!this.isWithinRoot(params.file_path)) {
|
||||
console.error(`File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Validate expected_replacements if provided
|
||||
if (params.expected_replacements !== undefined && params.expected_replacements < 0) {
|
||||
console.error('Expected replacements must be a non-negative number');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Ensure path is within the root directory
|
||||
if (!this.isWithinRoot(params.file_path)) {
|
||||
console.error(
|
||||
`File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the potential outcome of an edit operation.
|
||||
* @param params Parameters for the edit operation
|
||||
* @returns An object describing the potential edit outcome
|
||||
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
|
||||
*/
|
||||
private calculateEdit(params: EditToolParams): CalculatedEdit {
|
||||
const expectedReplacements = params.expected_replacements === undefined ? 1 : params.expected_replacements;
|
||||
let currentContent: string | null = null;
|
||||
let fileExists = false;
|
||||
let isNewFile = false;
|
||||
let newContent = '';
|
||||
let occurrences = 0;
|
||||
let error: { display: string, raw: string } | undefined = undefined;
|
||||
// Validate expected_replacements if provided
|
||||
if (
|
||||
params.expected_replacements !== undefined &&
|
||||
params.expected_replacements < 0
|
||||
) {
|
||||
console.error('Expected replacements must be a non-negative number');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
currentContent = fs.readFileSync(params.file_path, 'utf8');
|
||||
fileExists = true;
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
fileExists = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.old_string === '' && !fileExists) {
|
||||
isNewFile = true;
|
||||
newContent = params.new_string;
|
||||
occurrences = 0;
|
||||
} else if (!fileExists) {
|
||||
error = {
|
||||
display: `File not found.`,
|
||||
raw: `File not found: ${params.file_path}`
|
||||
};
|
||||
} else if (currentContent !== null) {
|
||||
occurrences = this.countOccurrences(currentContent, params.old_string);
|
||||
/**
|
||||
* Calculates the potential outcome of an edit operation.
|
||||
* @param params Parameters for the edit operation
|
||||
* @returns An object describing the potential edit outcome
|
||||
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
|
||||
*/
|
||||
private calculateEdit(params: EditToolParams): CalculatedEdit {
|
||||
const expectedReplacements =
|
||||
params.expected_replacements === undefined
|
||||
? 1
|
||||
: params.expected_replacements;
|
||||
let currentContent: string | null = null;
|
||||
let fileExists = false;
|
||||
let isNewFile = false;
|
||||
let newContent = '';
|
||||
let occurrences = 0;
|
||||
let error: { display: string; raw: string } | undefined = undefined;
|
||||
|
||||
if (occurrences === 0) {
|
||||
error = {
|
||||
display: `No edits made`,
|
||||
raw: `Failed to edit, 0 occurrences found`
|
||||
}
|
||||
} else if (occurrences !== expectedReplacements) {
|
||||
error = {
|
||||
display: `Failed to edit, expected ${expectedReplacements} occurrences but found ${occurrences}`,
|
||||
raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} in file: ${params.file_path}`
|
||||
}
|
||||
} else {
|
||||
newContent = this.replaceAll(currentContent, params.old_string, params.new_string);
|
||||
}
|
||||
} else {
|
||||
error = {
|
||||
display: `Failed to read content`,
|
||||
raw: `Failed to read content of existing file: ${params.file_path}`
|
||||
}
|
||||
}
|
||||
try {
|
||||
currentContent = fs.readFileSync(params.file_path, 'utf8');
|
||||
fileExists = true;
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
return {
|
||||
currentContent,
|
||||
newContent,
|
||||
occurrences,
|
||||
error,
|
||||
isNewFile
|
||||
if (params.old_string === '' && !fileExists) {
|
||||
isNewFile = true;
|
||||
newContent = params.new_string;
|
||||
occurrences = 0;
|
||||
} else if (!fileExists) {
|
||||
error = {
|
||||
display: `File not found.`,
|
||||
raw: `File not found: ${params.file_path}`,
|
||||
};
|
||||
} else if (currentContent !== null) {
|
||||
occurrences = this.countOccurrences(currentContent, params.old_string);
|
||||
|
||||
if (occurrences === 0) {
|
||||
error = {
|
||||
display: `No edits made`,
|
||||
raw: `Failed to edit, 0 occurrences found`,
|
||||
};
|
||||
} else if (occurrences !== expectedReplacements) {
|
||||
error = {
|
||||
display: `Failed to edit, expected ${expectedReplacements} occurrences but found ${occurrences}`,
|
||||
raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} in file: ${params.file_path}`,
|
||||
};
|
||||
} else {
|
||||
newContent = this.replaceAll(
|
||||
currentContent,
|
||||
params.old_string,
|
||||
params.new_string,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error = {
|
||||
display: `Failed to read content`,
|
||||
raw: `Failed to read content of existing file: ${params.file_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if confirmation is needed and prepares the confirmation details.
|
||||
* This method performs the calculation needed to generate the diff and respects the `shouldAlwaysEdit` state.
|
||||
* @param params Parameters for the potential edit operation
|
||||
* @returns Confirmation details object or false if no confirmation is needed/possible.
|
||||
*/
|
||||
async shouldConfirmExecute(params: EditToolParams): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.shouldAlwaysEdit) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
currentContent,
|
||||
newContent,
|
||||
occurrences,
|
||||
error,
|
||||
isNewFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.validateParams(params)) {
|
||||
console.error("[EditTool] Attempted confirmation with invalid parameters.");
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Determines if confirmation is needed and prepares the confirmation details.
|
||||
* This method performs the calculation needed to generate the diff and respects the `shouldAlwaysEdit` state.
|
||||
* @param params Parameters for the potential edit operation
|
||||
* @returns Confirmation details object or false if no confirmation is needed/possible.
|
||||
*/
|
||||
async shouldConfirmExecute(
|
||||
params: EditToolParams,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.shouldAlwaysEdit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let calculatedEdit: CalculatedEdit;
|
||||
try {
|
||||
calculatedEdit = this.calculateEdit(params);
|
||||
} catch (error) {
|
||||
console.error(`Error calculating edit for confirmation: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
if (!this.validateParams(params)) {
|
||||
console.error(
|
||||
'[EditTool] Attempted confirmation with invalid parameters.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (calculatedEdit.error) {
|
||||
return false;
|
||||
}
|
||||
let calculatedEdit: CalculatedEdit;
|
||||
try {
|
||||
calculatedEdit = this.calculateEdit(params);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error calculating edit for confirmation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (calculatedEdit.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = path.basename(params.file_path);
|
||||
const fileDiff = Diff.createPatch(
|
||||
fileName,
|
||||
calculatedEdit.currentContent ?? '',
|
||||
calculatedEdit.newContent,
|
||||
'Current',
|
||||
'Proposed',
|
||||
{ context: 3, ignoreWhitespace: true },
|
||||
);
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
|
||||
fileName,
|
||||
fileDiff,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
this.shouldAlwaysEdit = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
getDescription(params: EditToolParams): string {
|
||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
||||
const oldStringSnippet =
|
||||
params.old_string.split('\n')[0].substring(0, 30) +
|
||||
(params.old_string.length > 30 ? '...' : '');
|
||||
const newStringSnippet =
|
||||
params.new_string.split('\n')[0].substring(0, 30) +
|
||||
(params.new_string.length > 30 ? '...' : '');
|
||||
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the edit operation with the given parameters.
|
||||
* This method recalculates the edit operation before execution.
|
||||
* @param params Parameters for the edit operation
|
||||
* @returns Result of the edit operation
|
||||
*/
|
||||
async execute(params: EditToolParams): Promise<EditToolResult> {
|
||||
if (!this.validateParams(params)) {
|
||||
return {
|
||||
llmContent: 'Invalid parameters for file edit operation',
|
||||
returnDisplay: '**Error:** Invalid parameters for file edit operation',
|
||||
};
|
||||
}
|
||||
|
||||
let editData: CalculatedEdit;
|
||||
try {
|
||||
editData = this.calculateEdit(params);
|
||||
} catch (error) {
|
||||
return {
|
||||
llmContent: `Error preparing edit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
returnDisplay: 'Failed to prepare edit',
|
||||
};
|
||||
}
|
||||
|
||||
if (editData.error) {
|
||||
return {
|
||||
llmContent: editData.error.raw,
|
||||
returnDisplay: editData.error.display,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.ensureParentDirectoriesExist(params.file_path);
|
||||
fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
|
||||
|
||||
if (editData.isNewFile) {
|
||||
return {
|
||||
llmContent: `Created new file: ${params.file_path} with provided content.`,
|
||||
returnDisplay: `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
|
||||
};
|
||||
} else {
|
||||
const fileName = path.basename(params.file_path);
|
||||
const fileDiff = Diff.createPatch(
|
||||
fileName,
|
||||
calculatedEdit.currentContent ?? '',
|
||||
calculatedEdit.newContent,
|
||||
'Current',
|
||||
'Proposed',
|
||||
{ context: 3, ignoreWhitespace: true, }
|
||||
fileName,
|
||||
editData.currentContent ?? '',
|
||||
editData.newContent,
|
||||
'Current',
|
||||
'Proposed',
|
||||
{ context: 3, ignoreWhitespace: true },
|
||||
);
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
|
||||
fileName,
|
||||
fileDiff,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
this.shouldAlwaysEdit = true;
|
||||
}
|
||||
},
|
||||
return {
|
||||
llmContent: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`,
|
||||
returnDisplay: { fileDiff },
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
llmContent: `Error executing edit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
returnDisplay: `Failed to edit file`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(params: EditToolParams): string {
|
||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
||||
const oldStringSnippet = params.old_string.split('\n')[0].substring(0, 30) + (params.old_string.length > 30 ? '...' : '');
|
||||
const newStringSnippet = params.new_string.split('\n')[0].substring(0, 30) + (params.new_string.length > 30 ? '...' : '');
|
||||
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
|
||||
/**
|
||||
* Counts occurrences of a substring in a string
|
||||
* @param str String to search in
|
||||
* @param substr Substring to count
|
||||
* @returns Number of occurrences
|
||||
*/
|
||||
private countOccurrences(str: string, substr: string): number {
|
||||
if (substr === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the edit operation with the given parameters.
|
||||
* This method recalculates the edit operation before execution.
|
||||
* @param params Parameters for the edit operation
|
||||
* @returns Result of the edit operation
|
||||
*/
|
||||
async execute(params: EditToolParams): Promise<EditToolResult> {
|
||||
if (!this.validateParams(params)) {
|
||||
return {
|
||||
llmContent: 'Invalid parameters for file edit operation',
|
||||
returnDisplay: '**Error:** Invalid parameters for file edit operation'
|
||||
};
|
||||
}
|
||||
|
||||
let editData: CalculatedEdit;
|
||||
try {
|
||||
editData = this.calculateEdit(params);
|
||||
} catch (error) {
|
||||
return {
|
||||
llmContent: `Error preparing edit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
returnDisplay: 'Failed to prepare edit'
|
||||
};
|
||||
}
|
||||
|
||||
if (editData.error) {
|
||||
return {
|
||||
llmContent: editData.error.raw,
|
||||
returnDisplay: editData.error.display
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.ensureParentDirectoriesExist(params.file_path);
|
||||
fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
|
||||
|
||||
if (editData.isNewFile) {
|
||||
return {
|
||||
llmContent: `Created new file: ${params.file_path} with provided content.`,
|
||||
returnDisplay: `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`
|
||||
};
|
||||
} else {
|
||||
const fileName = path.basename(params.file_path);
|
||||
const fileDiff = Diff.createPatch(
|
||||
fileName,
|
||||
editData.currentContent ?? '',
|
||||
editData.newContent,
|
||||
'Current',
|
||||
'Proposed',
|
||||
{ context: 3, ignoreWhitespace: true }
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`,
|
||||
returnDisplay: { fileDiff }
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
llmContent: `Error executing edit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
returnDisplay: `Failed to edit file`
|
||||
};
|
||||
}
|
||||
let count = 0;
|
||||
let pos = str.indexOf(substr);
|
||||
while (pos !== -1) {
|
||||
count++;
|
||||
pos = str.indexOf(substr, pos + substr.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts occurrences of a substring in a string
|
||||
* @param str String to search in
|
||||
* @param substr Substring to count
|
||||
* @returns Number of occurrences
|
||||
*/
|
||||
private countOccurrences(str: string, substr: string): number {
|
||||
if (substr === '') {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
let pos = str.indexOf(substr);
|
||||
while (pos !== -1) {
|
||||
count++;
|
||||
pos = str.indexOf(substr, pos + substr.length);
|
||||
}
|
||||
return count;
|
||||
/**
|
||||
* Replaces all occurrences of a substring in a string
|
||||
* @param str String to modify
|
||||
* @param find Substring to find
|
||||
* @param replace Replacement string
|
||||
* @returns Modified string
|
||||
*/
|
||||
private replaceAll(str: string, find: string, replace: string): string {
|
||||
if (find === '') {
|
||||
return str;
|
||||
}
|
||||
return str.split(find).join(replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all occurrences of a substring in a string
|
||||
* @param str String to modify
|
||||
* @param find Substring to find
|
||||
* @param replace Replacement string
|
||||
* @returns Modified string
|
||||
*/
|
||||
private replaceAll(str: string, find: string, replace: string): string {
|
||||
if (find === '') {
|
||||
return str;
|
||||
}
|
||||
return str.split(find).join(replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates parent directories if they don't exist
|
||||
* @param filePath Path to ensure parent directories exist
|
||||
*/
|
||||
private ensureParentDirectoriesExist(filePath: string): void {
|
||||
const dirName = path.dirname(filePath);
|
||||
if (!fs.existsSync(dirName)) {
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
}
|
||||
/**
|
||||
* Creates parent directories if they don't exist
|
||||
* @param filePath Path to ensure parent directories exist
|
||||
*/
|
||||
private ensureParentDirectoriesExist(filePath: string): void {
|
||||
const dirName = path.dirname(filePath);
|
||||
if (!fs.existsSync(dirName)) {
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ export interface GlobToolParams {
|
||||
/**
|
||||
* Result from the GlobTool
|
||||
*/
|
||||
export interface GlobToolResult extends ToolResult {
|
||||
}
|
||||
export interface GlobToolResult extends ToolResult {}
|
||||
|
||||
/**
|
||||
* Implementation of the GlobTool that finds files matching patterns,
|
||||
@@ -49,17 +48,19 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description: 'The glob pattern to match against (e.g., \'*.py\', \'src/**/*.js\', \'docs/*.md\').',
|
||||
type: 'string'
|
||||
description:
|
||||
"The glob pattern to match against (e.g., '*.py', 'src/**/*.js', 'docs/*.md').",
|
||||
type: 'string',
|
||||
},
|
||||
path: {
|
||||
description: 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
type: 'string'
|
||||
}
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object'
|
||||
}
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
|
||||
// Set the root directory
|
||||
@@ -84,7 +85,10 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
|
||||
// Check if it's the root itself or starts with the root path followed by a separator.
|
||||
// This ensures that we don't accidentally allow access to parent directories.
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep);
|
||||
return (
|
||||
normalizedPath === normalizedRoot ||
|
||||
normalizedPath.startsWith(rootWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +98,13 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
invalidParams(params: GlobToolParams): string | null {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
if (
|
||||
this.schema.parameters &&
|
||||
!SchemaValidator.validate(
|
||||
this.schema.parameters as Record<string, unknown>,
|
||||
params,
|
||||
)
|
||||
) {
|
||||
return "Parameters failed schema validation. Ensure 'pattern' is a string and 'path' (if provided) is a string.";
|
||||
}
|
||||
|
||||
@@ -121,8 +131,12 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
}
|
||||
|
||||
// Validate glob pattern (basic non-empty check)
|
||||
if (!params.pattern || typeof params.pattern !== 'string' || params.pattern.trim() === '') {
|
||||
return "The 'pattern' parameter cannot be empty.";
|
||||
if (
|
||||
!params.pattern ||
|
||||
typeof params.pattern !== 'string' ||
|
||||
params.pattern.trim() === ''
|
||||
) {
|
||||
return "The 'pattern' parameter cannot be empty.";
|
||||
}
|
||||
// Could add more sophisticated glob pattern validation if needed
|
||||
|
||||
@@ -156,7 +170,7 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
if (validationError) {
|
||||
return {
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
returnDisplay: `**Error:** Failed to execute tool.`
|
||||
returnDisplay: `**Error:** Failed to execute tool.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,10 +182,10 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
// We use fast-glob because it's performant and supports glob patterns.
|
||||
const entries = await fg(params.pattern, {
|
||||
cwd: searchDirAbsolute, // Search within this absolute directory
|
||||
absolute: true, // Return absolute paths
|
||||
onlyFiles: true, // Match only files
|
||||
stats: true, // Include file stats object for sorting
|
||||
dot: true, // Include files starting with a dot
|
||||
absolute: true, // Return absolute paths
|
||||
onlyFiles: true, // Match only files
|
||||
stats: true, // Include file stats object for sorting
|
||||
dot: true, // Include files starting with a dot
|
||||
ignore: ['**/node_modules/**', '**/.git/**'], // Common sensible default, adjust as needed
|
||||
followSymbolicLinks: false, // Avoid potential issues with symlinks unless specifically needed
|
||||
suppressErrors: true, // Suppress EACCES errors for individual files (we handle dir access in validation)
|
||||
@@ -181,7 +195,7 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
if (!entries || entries.length === 0) {
|
||||
return {
|
||||
llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`,
|
||||
returnDisplay: `No files found`
|
||||
returnDisplay: `No files found`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,30 +211,39 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
|
||||
});
|
||||
|
||||
// 5. Format Output
|
||||
const sortedAbsolutePaths = entries.map(entry => entry.path);
|
||||
const sortedAbsolutePaths = entries.map((entry) => entry.path);
|
||||
|
||||
// Convert absolute paths to relative paths (to rootDir) for clearer display
|
||||
const sortedRelativePaths = sortedAbsolutePaths.map(absPath => makeRelative(absPath, this.rootDirectory));
|
||||
const sortedRelativePaths = sortedAbsolutePaths.map((absPath) =>
|
||||
makeRelative(absPath, this.rootDirectory),
|
||||
);
|
||||
|
||||
// Construct the result message
|
||||
const fileListDescription = sortedRelativePaths.map(p => ` - ${shortenPath(p)}`).join('\n');
|
||||
const fileListDescription = sortedRelativePaths
|
||||
.map((p) => ` - ${shortenPath(p)}`)
|
||||
.join('\n');
|
||||
const fileCount = sortedRelativePaths.length;
|
||||
const relativeSearchDir = makeRelative(searchDirAbsolute, this.rootDirectory);
|
||||
const displayPath = shortenPath(relativeSearchDir === '.' ? 'root directory' : relativeSearchDir);
|
||||
const relativeSearchDir = makeRelative(
|
||||
searchDirAbsolute,
|
||||
this.rootDirectory,
|
||||
);
|
||||
const displayPath = shortenPath(
|
||||
relativeSearchDir === '.' ? 'root directory' : relativeSearchDir,
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${displayPath}, sorted by modification time (newest first):\n${fileListDescription}`,
|
||||
returnDisplay: `Found ${fileCount} matching file(s)`
|
||||
returnDisplay: `Found ${fileCount} matching file(s)`,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Catch unexpected errors during glob execution (less likely with suppressErrors=true, but possible)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`GlobTool execute Error: ${errorMessage}`, error);
|
||||
return {
|
||||
llmContent: `Error during glob search operation: ${errorMessage}`,
|
||||
returnDisplay: `**Error:** An unexpected error occurred.`
|
||||
};
|
||||
// Catch unexpected errors during glob execution (less likely with suppressErrors=true, but possible)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(`GlobTool execute Error: ${errorMessage}`, error);
|
||||
return {
|
||||
llmContent: `Error during glob search operation: ${errorMessage}`,
|
||||
returnDisplay: `**Error:** An unexpected error occurred.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,7 @@ interface GrepMatch {
|
||||
/**
|
||||
* Result from the GrepTool
|
||||
*/
|
||||
export interface GrepToolResult extends ToolResult {
|
||||
}
|
||||
export interface GrepToolResult extends ToolResult {}
|
||||
|
||||
// --- GrepTool Class ---
|
||||
|
||||
@@ -65,21 +64,24 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description: 'The regular expression (regex) pattern to search for within file contents (e.g., \'function\\s+myFunction\', \'import\\s+\\{.*\\}\\s+from\\s+.*\').',
|
||||
type: 'string'
|
||||
description:
|
||||
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
type: 'string',
|
||||
},
|
||||
path: {
|
||||
description: 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string'
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string',
|
||||
},
|
||||
include: {
|
||||
description: 'Optional: A glob pattern to filter which files are searched (e.g., \'*.js\', \'*.{ts,tsx}\', \'src/**\'). If omitted, searches all files (respecting potential global ignores).',
|
||||
type: 'string'
|
||||
}
|
||||
description:
|
||||
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object'
|
||||
}
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
// Ensure rootDirectory is absolute and normalized
|
||||
this.rootDirectory = path.resolve(rootDirectory);
|
||||
@@ -97,8 +99,13 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
const targetPath = path.resolve(this.rootDirectory, relativePath || '.');
|
||||
|
||||
// Security Check: Ensure the resolved path is still within the root directory.
|
||||
if (!targetPath.startsWith(this.rootDirectory) && targetPath !== this.rootDirectory) {
|
||||
throw new Error(`Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`);
|
||||
if (
|
||||
!targetPath.startsWith(this.rootDirectory) &&
|
||||
targetPath !== this.rootDirectory
|
||||
) {
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
@@ -111,7 +118,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(`Failed to access path stats for ${targetPath}: ${err.message}`);
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
@@ -123,8 +132,14 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
invalidParams(params: GrepToolParams): string | null {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
return "Parameters failed schema validation.";
|
||||
if (
|
||||
this.schema.parameters &&
|
||||
!SchemaValidator.validate(
|
||||
this.schema.parameters as Record<string, unknown>,
|
||||
params,
|
||||
)
|
||||
) {
|
||||
return 'Parameters failed schema validation.';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -142,7 +157,6 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
return null; // Parameters are valid
|
||||
}
|
||||
|
||||
|
||||
// --- Core Execution ---
|
||||
|
||||
/**
|
||||
@@ -156,7 +170,7 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
console.error(`GrepTool Parameter Validation Failed: ${validationError}`);
|
||||
return {
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
returnDisplay: `**Error:** Failed to execute tool.`
|
||||
returnDisplay: `**Error:** Failed to execute tool.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,40 +191,49 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
return { llmContent: noMatchMsg, returnDisplay: noMatchUser };
|
||||
}
|
||||
|
||||
const matchesByFile = matches.reduce((acc, match) => {
|
||||
const relativeFilePath = path.relative(searchDirAbs, path.resolve(searchDirAbs, match.filePath)) || path.basename(match.filePath);
|
||||
if (!acc[relativeFilePath]) {
|
||||
acc[relativeFilePath] = [];
|
||||
}
|
||||
acc[relativeFilePath].push(match);
|
||||
acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber);
|
||||
return acc;
|
||||
}, {} as Record<string, GrepMatch[]>);
|
||||
const matchesByFile = matches.reduce(
|
||||
(acc, match) => {
|
||||
const relativeFilePath =
|
||||
path.relative(
|
||||
searchDirAbs,
|
||||
path.resolve(searchDirAbs, match.filePath),
|
||||
) || path.basename(match.filePath);
|
||||
if (!acc[relativeFilePath]) {
|
||||
acc[relativeFilePath] = [];
|
||||
}
|
||||
acc[relativeFilePath].push(match);
|
||||
acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, GrepMatch[]>,
|
||||
);
|
||||
|
||||
let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`;
|
||||
|
||||
for (const filePath in matchesByFile) {
|
||||
llmContent += `File: ${filePath}\n`;
|
||||
matchesByFile[filePath].forEach(match => {
|
||||
matchesByFile[filePath].forEach((match) => {
|
||||
const trimmedLine = match.line.trim();
|
||||
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
|
||||
});
|
||||
llmContent += '---\n';
|
||||
}
|
||||
|
||||
return { llmContent: llmContent.trim(), returnDisplay: `Found ${matches.length} matche(s)` };
|
||||
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${matches.length} matche(s)`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error during GrepTool execution: ${error}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: `Error during grep search operation: ${errorMessage}`,
|
||||
returnDisplay: errorMessage
|
||||
returnDisplay: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Inlined Grep Logic and Helpers ---
|
||||
|
||||
/**
|
||||
@@ -221,9 +244,13 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
private isCommandAvailable(command: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
|
||||
const checkArgs = process.platform === 'win32' ? [command] : ['-v', command];
|
||||
const checkArgs =
|
||||
process.platform === 'win32' ? [command] : ['-v', command];
|
||||
try {
|
||||
const child = spawn(checkCommand, checkArgs, { stdio: 'ignore', shell: process.platform === 'win32' });
|
||||
const child = spawn(checkCommand, checkArgs, {
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
} catch (e) {
|
||||
@@ -252,7 +279,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error(`Error checking for .git in ${currentPath}: ${err.message}`);
|
||||
console.error(
|
||||
`Error checking for .git in ${currentPath}: ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -263,19 +292,21 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
currentPath = path.dirname(currentPath);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error traversing directory structure upwards from ${dirPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Error traversing directory structure upwards from ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the standard output of grep-like commands (git grep, system grep).
|
||||
* Expects format: filePath:lineNumber:lineContent
|
||||
* Handles colons within file paths and line content correctly.
|
||||
* @param {string} output The raw stdout string.
|
||||
* @param {string} basePath The absolute directory the search was run from, for relative paths.
|
||||
* @returns {GrepMatch[]} Array of match objects.
|
||||
*/
|
||||
* Parses the standard output of grep-like commands (git grep, system grep).
|
||||
* Expects format: filePath:lineNumber:lineContent
|
||||
* Handles colons within file paths and line content correctly.
|
||||
* @param {string} output The raw stdout string.
|
||||
* @param {string} basePath The absolute directory the search was run from, for relative paths.
|
||||
* @returns {GrepMatch[]} Array of match objects.
|
||||
*/
|
||||
private parseGrepOutput(output: string, basePath: string): GrepMatch[] {
|
||||
const results: GrepMatch[] = [];
|
||||
if (!output) return results;
|
||||
@@ -302,7 +333,10 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
|
||||
// Extract parts based on the found colon indices
|
||||
const filePathRaw = line.substring(0, firstColonIndex);
|
||||
const lineNumberStr = line.substring(firstColonIndex + 1, secondColonIndex);
|
||||
const lineNumberStr = line.substring(
|
||||
firstColonIndex + 1,
|
||||
secondColonIndex,
|
||||
);
|
||||
// The rest of the line, starting after the second colon, is the content.
|
||||
const lineContent = line.substring(secondColonIndex + 1);
|
||||
|
||||
@@ -327,10 +361,10 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a description of the grep operation
|
||||
* @param params Parameters for the grep operation
|
||||
* @returns A string describing the grep
|
||||
*/
|
||||
* Gets a description of the grep operation
|
||||
* @param params Parameters for the grep operation
|
||||
* @returns A string describing the grep
|
||||
*/
|
||||
getDescription(params: GrepToolParams): string {
|
||||
let description = `'${params.pattern}'`;
|
||||
|
||||
@@ -363,37 +397,59 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
try {
|
||||
// --- Strategy 1: git grep ---
|
||||
const isGit = await this.isGitRepository(absolutePath);
|
||||
const gitAvailable = isGit && await this.isCommandAvailable('git');
|
||||
const gitAvailable = isGit && (await this.isCommandAvailable('git'));
|
||||
|
||||
if (gitAvailable) {
|
||||
strategyUsed = 'git grep';
|
||||
const gitArgs = ['grep', '--untracked', '-n', '-E', '--ignore-case', pattern];
|
||||
const gitArgs = [
|
||||
'grep',
|
||||
'--untracked',
|
||||
'-n',
|
||||
'-E',
|
||||
'--ignore-case',
|
||||
pattern,
|
||||
];
|
||||
if (include) {
|
||||
gitArgs.push('--', include);
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn('git', gitArgs, { cwd: absolutePath, windowsHide: true });
|
||||
const child = spawn('git', gitArgs, {
|
||||
cwd: absolutePath,
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); });
|
||||
child.stderr.on('data', (chunk) => { stderrChunks.push(chunk); });
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrChunks.push(chunk);
|
||||
});
|
||||
|
||||
child.on('error', (err) => reject(new Error(`Failed to start git grep: ${err.message}`)));
|
||||
child.on('error', (err) =>
|
||||
reject(new Error(`Failed to start git grep: ${err.message}`)),
|
||||
);
|
||||
|
||||
child.on('close', (code) => {
|
||||
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
|
||||
const stderrData = Buffer.concat(stderrChunks).toString('utf8');
|
||||
if (code === 0) resolve(stdoutData);
|
||||
else if (code === 1) resolve(''); // No matches is not an error
|
||||
else reject(new Error(`git grep exited with code ${code}: ${stderrData}`));
|
||||
else if (code === 1)
|
||||
resolve(''); // No matches is not an error
|
||||
else
|
||||
reject(
|
||||
new Error(`git grep exited with code ${code}: ${stderrData}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
return this.parseGrepOutput(output, absolutePath);
|
||||
} catch (gitError: any) {
|
||||
console.error(`GrepTool: git grep strategy failed: ${gitError.message}. Falling back...`);
|
||||
console.error(
|
||||
`GrepTool: git grep strategy failed: ${gitError.message}. Falling back...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +459,7 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
strategyUsed = 'system grep';
|
||||
const grepArgs = ['-r', '-n', '-H', '-E'];
|
||||
const commonExcludes = ['.git', 'node_modules', 'bower_components'];
|
||||
commonExcludes.forEach(dir => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
if (include) {
|
||||
grepArgs.push(`--include=${include}`);
|
||||
}
|
||||
@@ -412,41 +468,67 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
|
||||
try {
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn('grep', grepArgs, { cwd: absolutePath, windowsHide: true });
|
||||
const child = spawn('grep', grepArgs, {
|
||||
cwd: absolutePath,
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); });
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const stderrStr = chunk.toString();
|
||||
if (!stderrStr.includes('Permission denied') && !/grep:.*: Is a directory/i.test(stderrStr)) {
|
||||
if (
|
||||
!stderrStr.includes('Permission denied') &&
|
||||
!/grep:.*: Is a directory/i.test(stderrStr)
|
||||
) {
|
||||
stderrChunks.push(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => reject(new Error(`Failed to start system grep: ${err.message}`)));
|
||||
child.on('error', (err) =>
|
||||
reject(new Error(`Failed to start system grep: ${err.message}`)),
|
||||
);
|
||||
|
||||
child.on('close', (code) => {
|
||||
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
|
||||
const stderrData = Buffer.concat(stderrChunks).toString('utf8').trim();
|
||||
const stderrData = Buffer.concat(stderrChunks)
|
||||
.toString('utf8')
|
||||
.trim();
|
||||
if (code === 0) resolve(stdoutData);
|
||||
else if (code === 1) resolve(''); // No matches
|
||||
else if (code === 1)
|
||||
resolve(''); // No matches
|
||||
else {
|
||||
if (stderrData) reject(new Error(`System grep exited with code ${code}: ${stderrData}`));
|
||||
if (stderrData)
|
||||
reject(
|
||||
new Error(
|
||||
`System grep exited with code ${code}: ${stderrData}`,
|
||||
),
|
||||
);
|
||||
else resolve('');
|
||||
}
|
||||
});
|
||||
});
|
||||
return this.parseGrepOutput(output, absolutePath);
|
||||
} catch (grepError: any) {
|
||||
console.error(`GrepTool: System grep strategy failed: ${grepError.message}. Falling back...`);
|
||||
console.error(
|
||||
`GrepTool: System grep strategy failed: ${grepError.message}. Falling back...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 3: Pure JavaScript Fallback ---
|
||||
strategyUsed = 'javascript fallback';
|
||||
const globPattern = include ? include : '**/*';
|
||||
const ignorePatterns = ['.git', 'node_modules', 'bower_components', '.svn', '.hg'];
|
||||
const ignorePatterns = [
|
||||
'.git',
|
||||
'node_modules',
|
||||
'bower_components',
|
||||
'.svn',
|
||||
'.hg',
|
||||
];
|
||||
|
||||
const filesStream = fastGlob.stream(globPattern, {
|
||||
cwd: absolutePath,
|
||||
@@ -469,7 +551,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
lines.forEach((line, index) => {
|
||||
if (regex.test(line)) {
|
||||
allMatches.push({
|
||||
filePath: path.relative(absolutePath, fileAbsolutePath) || path.basename(fileAbsolutePath),
|
||||
filePath:
|
||||
path.relative(absolutePath, fileAbsolutePath) ||
|
||||
path.basename(fileAbsolutePath),
|
||||
lineNumber: index + 1,
|
||||
line: line,
|
||||
});
|
||||
@@ -477,16 +561,19 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
|
||||
});
|
||||
} catch (readError: any) {
|
||||
if (readError.code !== 'ENOENT') {
|
||||
console.error(`GrepTool: Could not read or process file ${fileAbsolutePath}: ${readError.message}`);
|
||||
console.error(
|
||||
`GrepTool: Could not read or process file ${fileAbsolutePath}: ${readError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allMatches;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`GrepTool: Error during performGrepSearch (Strategy: ${strategyUsed}): ${error.message}`);
|
||||
console.error(
|
||||
`GrepTool: Error during performGrepSearch (Strategy: ${strategyUsed}): ${error.message}`,
|
||||
);
|
||||
throw error; // Re-throw to be caught by the execute method's handler
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,20 +91,21 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
{
|
||||
properties: {
|
||||
path: {
|
||||
description: 'The absolute path to the directory to list (must be absolute, not relative)',
|
||||
type: 'string'
|
||||
description:
|
||||
'The absolute path to the directory to list (must be absolute, not relative)',
|
||||
type: 'string',
|
||||
},
|
||||
ignore: {
|
||||
description: 'List of glob patterns to ignore',
|
||||
items: {
|
||||
type: 'string'
|
||||
type: 'string',
|
||||
},
|
||||
type: 'array'
|
||||
}
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
type: 'object'
|
||||
}
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
|
||||
// Set the root directory
|
||||
@@ -123,7 +124,10 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep);
|
||||
return (
|
||||
normalizedPath === normalizedRoot ||
|
||||
normalizedPath.startsWith(rootWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,8 +136,14 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
invalidParams(params: LSToolParams): string | null {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
return "Parameters failed schema validation.";
|
||||
if (
|
||||
this.schema.parameters &&
|
||||
!SchemaValidator.validate(
|
||||
this.schema.parameters as Record<string, unknown>,
|
||||
params,
|
||||
)
|
||||
) {
|
||||
return 'Parameters failed schema validation.';
|
||||
}
|
||||
// Ensure path is absolute
|
||||
if (!path.isAbsolute(params.path)) {
|
||||
@@ -194,7 +204,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
listedPath: params.path,
|
||||
totalEntries: 0,
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
returnDisplay: "**Error:** Failed to execute tool."
|
||||
returnDisplay: '**Error:** Failed to execute tool.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,7 +216,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
listedPath: params.path,
|
||||
totalEntries: 0,
|
||||
llmContent: `Directory does not exist: ${params.path}`,
|
||||
returnDisplay: `Directory does not exist`
|
||||
returnDisplay: `Directory does not exist`,
|
||||
};
|
||||
}
|
||||
// Check if path is a directory
|
||||
@@ -217,7 +227,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
listedPath: params.path,
|
||||
totalEntries: 0,
|
||||
llmContent: `Path is not a directory: ${params.path}`,
|
||||
returnDisplay: `Path is not a directory`
|
||||
returnDisplay: `Path is not a directory`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +240,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
listedPath: params.path,
|
||||
totalEntries: 0,
|
||||
llmContent: `Directory is empty: ${params.path}`,
|
||||
returnDisplay: `Directory is empty.`
|
||||
returnDisplay: `Directory is empty.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,7 +258,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
path: fullPath,
|
||||
isDirectory: isDir,
|
||||
size: isDir ? 0 : stats.size,
|
||||
modifiedTime: stats.mtime
|
||||
modifiedTime: stats.mtime,
|
||||
});
|
||||
} catch (error) {
|
||||
// Skip entries that can't be accessed
|
||||
@@ -264,18 +274,20 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
});
|
||||
|
||||
// Create formatted content for display
|
||||
const directoryContent = entries.map(entry => {
|
||||
const typeIndicator = entry.isDirectory ? 'd' : '-';
|
||||
const sizeInfo = entry.isDirectory ? '' : ` (${entry.size} bytes)`;
|
||||
return `${typeIndicator} ${entry.name}${sizeInfo}`;
|
||||
}).join('\n');
|
||||
|
||||
const directoryContent = entries
|
||||
.map((entry) => {
|
||||
const typeIndicator = entry.isDirectory ? 'd' : '-';
|
||||
const sizeInfo = entry.isDirectory ? '' : ` (${entry.size} bytes)`;
|
||||
return `${typeIndicator} ${entry.name}${sizeInfo}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
entries,
|
||||
listedPath: params.path,
|
||||
totalEntries: entries.length,
|
||||
llmContent: `Directory listing for ${params.path}:\n${directoryContent}`,
|
||||
returnDisplay: `Found ${entries.length} item(s).`
|
||||
returnDisplay: `Found ${entries.length} item(s).`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
|
||||
@@ -284,8 +296,8 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
|
||||
listedPath: params.path,
|
||||
totalEntries: 0,
|
||||
llmContent: errorMessage,
|
||||
returnDisplay: `**Error:** ${errorMessage}`
|
||||
returnDisplay: `**Error:** ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,15 @@ export interface ReadFileToolParams {
|
||||
/**
|
||||
* Standardized result from the ReadFile tool
|
||||
*/
|
||||
export interface ReadFileToolResult extends ToolResult {
|
||||
}
|
||||
export interface ReadFileToolResult extends ToolResult {}
|
||||
|
||||
/**
|
||||
* Implementation of the ReadFile tool that reads files from the filesystem
|
||||
*/
|
||||
export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResult> {
|
||||
export class ReadFileTool extends BaseTool<
|
||||
ReadFileToolParams,
|
||||
ReadFileToolResult
|
||||
> {
|
||||
public static readonly Name: string = 'read_file';
|
||||
|
||||
// Maximum number of lines to read by default
|
||||
@@ -60,21 +62,24 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The absolute path to the file to read (e.g., \'/home/user/project/file.txt\'). Relative paths are not supported.',
|
||||
type: 'string'
|
||||
description:
|
||||
"The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
|
||||
type: 'string',
|
||||
},
|
||||
offset: {
|
||||
description: 'Optional: The 0-based line number to start reading from. Requires \'limit\' to be set. Use for paginating through large files.',
|
||||
type: 'number'
|
||||
description:
|
||||
"Optional: The 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
|
||||
type: 'number',
|
||||
},
|
||||
limit: {
|
||||
description: 'Optional: Maximum number of lines to read. Use with \'offset\' to paginate through large files. If omitted, reads the entire file (if feasible).',
|
||||
type: 'number'
|
||||
}
|
||||
description:
|
||||
"Optional: Maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible).",
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
type: 'object'
|
||||
}
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
|
||||
// Set the root directory
|
||||
@@ -95,7 +100,10 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep);
|
||||
return (
|
||||
normalizedPath === normalizedRoot ||
|
||||
normalizedPath.startsWith(rootWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,8 +112,14 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
* @returns True if parameters are valid, false otherwise
|
||||
*/
|
||||
invalidParams(params: ReadFileToolParams): string | null {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
return "Parameters failed schema validation.";
|
||||
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)) {
|
||||
@@ -151,7 +165,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
}
|
||||
|
||||
// If more than 30% are non-printable, likely binary
|
||||
return (nonPrintableCount / bytesRead) > 0.3;
|
||||
return nonPrintableCount / bytesRead > 0.3;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@@ -166,7 +180,9 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Common image formats
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)) {
|
||||
if (
|
||||
['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)
|
||||
) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
@@ -189,8 +205,8 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
* @returns A string describing the file being read
|
||||
*/
|
||||
getDescription(params: ReadFileToolParams): string {
|
||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
||||
return shortenPath(relativePath);
|
||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
||||
return shortenPath(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +220,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
if (validationError) {
|
||||
return {
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
returnDisplay: "**Error:** Failed to execute tool."
|
||||
returnDisplay: '**Error:** Failed to execute tool.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,14 +261,15 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
const formattedLines = selectedLines.map((line) => {
|
||||
let processedLine = line;
|
||||
if (line.length > ReadFileTool.MAX_LINE_LENGTH) {
|
||||
processedLine = line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]';
|
||||
processedLine =
|
||||
line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]';
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return processedLine;
|
||||
});
|
||||
|
||||
const contentTruncated = (endLine < lines.length) || truncated;
|
||||
const contentTruncated = endLine < lines.length || truncated;
|
||||
|
||||
let llmContent = '';
|
||||
if (contentTruncated) {
|
||||
@@ -273,4 +290,4 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,56 +2,58 @@ import { ToolListUnion, FunctionDeclaration } from '@google/genai';
|
||||
import { Tool } from './tools.js';
|
||||
|
||||
class ToolRegistry {
|
||||
private tools: Map<string, Tool> = new Map();
|
||||
private tools: Map<string, Tool> = new Map();
|
||||
|
||||
/**
|
||||
* Registers a tool definition.
|
||||
* @param tool - The tool object containing schema and execution logic.
|
||||
*/
|
||||
registerTool(tool: Tool): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
// Decide on behavior: throw error, log warning, or allow overwrite
|
||||
console.warn(`Tool with name "${tool.name}" is already registered. Overwriting.`);
|
||||
}
|
||||
this.tools.set(tool.name, tool);
|
||||
/**
|
||||
* Registers a tool definition.
|
||||
* @param tool - The tool object containing schema and execution logic.
|
||||
*/
|
||||
registerTool(tool: Tool): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
// Decide on behavior: throw error, log warning, or allow overwrite
|
||||
console.warn(
|
||||
`Tool with name "${tool.name}" is already registered. Overwriting.`,
|
||||
);
|
||||
}
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of tool schemas in the format required by Gemini.
|
||||
* @returns A ToolListUnion containing the function declarations.
|
||||
*/
|
||||
getToolSchemas(): ToolListUnion {
|
||||
const declarations: FunctionDeclaration[] = [];
|
||||
this.tools.forEach(tool => {
|
||||
declarations.push(tool.schema);
|
||||
});
|
||||
/**
|
||||
* Retrieves the list of tool schemas in the format required by Gemini.
|
||||
* @returns A ToolListUnion containing the function declarations.
|
||||
*/
|
||||
getToolSchemas(): ToolListUnion {
|
||||
const declarations: FunctionDeclaration[] = [];
|
||||
this.tools.forEach((tool) => {
|
||||
declarations.push(tool.schema);
|
||||
});
|
||||
|
||||
// Return Gemini's expected format. Handle the case of no tools.
|
||||
if (declarations.length === 0) {
|
||||
// Depending on the SDK version, you might need `undefined`, `[]`, or `[{ functionDeclarations: [] }]`
|
||||
// Check the documentation for your @google/genai version.
|
||||
// Let's assume an empty array works or signifies no tools.
|
||||
return [];
|
||||
// Or if it requires the structure:
|
||||
// return [{ functionDeclarations: [] }];
|
||||
}
|
||||
return [{ functionDeclarations: declarations }];
|
||||
// Return Gemini's expected format. Handle the case of no tools.
|
||||
if (declarations.length === 0) {
|
||||
// Depending on the SDK version, you might need `undefined`, `[]`, or `[{ functionDeclarations: [] }]`
|
||||
// Check the documentation for your @google/genai version.
|
||||
// Let's assume an empty array works or signifies no tools.
|
||||
return [];
|
||||
// Or if it requires the structure:
|
||||
// return [{ functionDeclarations: [] }];
|
||||
}
|
||||
return [{ functionDeclarations: declarations }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Get a list of registered tool names.
|
||||
*/
|
||||
listAvailableTools(): string[] {
|
||||
return Array.from(this.tools.keys());
|
||||
}
|
||||
/**
|
||||
* Optional: Get a list of registered tool names.
|
||||
*/
|
||||
listAvailableTools(): string[] {
|
||||
return Array.from(this.tools.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the definition of a specific tool.
|
||||
*/
|
||||
getTool(name: string): Tool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
/**
|
||||
* Get the definition of a specific tool.
|
||||
*/
|
||||
getTool(name: string): Tool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance of the registry
|
||||
export const toolRegistry = new ToolRegistry();
|
||||
export const toolRegistry = new ToolRegistry();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { FunctionDeclaration, Schema } from "@google/genai";
|
||||
import { ToolCallConfirmationDetails } from "../ui/types.js";
|
||||
import { FunctionDeclaration, Schema } from '@google/genai';
|
||||
import { ToolCallConfirmationDetails } from '../ui/types.js';
|
||||
|
||||
/**
|
||||
* Interface representing the base Tool functionality
|
||||
*/
|
||||
export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult> {
|
||||
export interface Tool<
|
||||
TParams = unknown,
|
||||
TResult extends ToolResult = ToolResult,
|
||||
> {
|
||||
/**
|
||||
* The internal name of the tool (used for API calls)
|
||||
*/
|
||||
@@ -45,7 +48,9 @@ export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult
|
||||
* @param params Parameters for the tool execution
|
||||
* @returns Whether execute should be confirmed.
|
||||
*/
|
||||
shouldConfirmExecute(params: TParams): Promise<ToolCallConfirmationDetails | false>;
|
||||
shouldConfirmExecute(
|
||||
params: TParams,
|
||||
): Promise<ToolCallConfirmationDetails | false>;
|
||||
|
||||
/**
|
||||
* Executes the tool with the given parameters
|
||||
@@ -55,11 +60,14 @@ export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult
|
||||
execute(params: TParams): Promise<TResult>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Base implementation for tools with common functionality
|
||||
*/
|
||||
export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = ToolResult> implements Tool<TParams, TResult> {
|
||||
export abstract class BaseTool<
|
||||
TParams = unknown,
|
||||
TResult extends ToolResult = ToolResult,
|
||||
> implements Tool<TParams, TResult>
|
||||
{
|
||||
/**
|
||||
* Creates a new instance of BaseTool
|
||||
* @param name Internal name of the tool (used for API calls)
|
||||
@@ -71,7 +79,7 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
|
||||
public readonly name: string,
|
||||
public readonly displayName: string,
|
||||
public readonly description: string,
|
||||
public readonly parameterSchema: Record<string, unknown>
|
||||
public readonly parameterSchema: Record<string, unknown>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -81,7 +89,7 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameterSchema as Schema
|
||||
parameters: this.parameterSchema as Schema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +120,9 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
|
||||
* @param params Parameters for the tool execution
|
||||
* @returns Whether or not execute should be confirmed by the user.
|
||||
*/
|
||||
shouldConfirmExecute(params: TParams): Promise<ToolCallConfirmationDetails | false> {
|
||||
shouldConfirmExecute(
|
||||
params: TParams,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
@@ -125,7 +135,6 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
|
||||
abstract execute(params: TParams): Promise<TResult>;
|
||||
}
|
||||
|
||||
|
||||
export interface ToolResult {
|
||||
/**
|
||||
* Content meant to be included in LLM history.
|
||||
@@ -143,5 +152,5 @@ export interface ToolResult {
|
||||
export type ToolResultDisplay = string | FileDiff;
|
||||
|
||||
export interface FileDiff {
|
||||
fileDiff: string
|
||||
fileDiff: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import path from 'path';
|
||||
import { BaseTool, ToolResult } from './tools.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails } from '../ui/types.js';
|
||||
import {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolEditConfirmationDetails,
|
||||
} from '../ui/types.js';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
/**
|
||||
@@ -24,13 +28,15 @@ export interface WriteFileToolParams {
|
||||
/**
|
||||
* Standardized result from the WriteFile tool
|
||||
*/
|
||||
export interface WriteFileToolResult extends ToolResult {
|
||||
}
|
||||
export interface WriteFileToolResult extends ToolResult {}
|
||||
|
||||
/**
|
||||
* Implementation of the WriteFile tool that writes files to the filesystem
|
||||
*/
|
||||
export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolResult> {
|
||||
export class WriteFileTool extends BaseTool<
|
||||
WriteFileToolParams,
|
||||
WriteFileToolResult
|
||||
> {
|
||||
public static readonly Name: string = 'write_file';
|
||||
private shouldAlwaysWrite = false;
|
||||
|
||||
@@ -52,17 +58,18 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
{
|
||||
properties: {
|
||||
filePath: {
|
||||
description: 'The absolute path to the file to write to (e.g., \'/home/user/project/file.txt\'). Relative paths are not supported.',
|
||||
type: 'string'
|
||||
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'
|
||||
}
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'content'],
|
||||
type: 'object'
|
||||
}
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
|
||||
// Set the root directory
|
||||
@@ -83,7 +90,10 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep);
|
||||
return (
|
||||
normalizedPath === normalizedRoot ||
|
||||
normalizedPath.startsWith(rootWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +102,13 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
* @returns True if parameters are valid, false otherwise
|
||||
*/
|
||||
invalidParams(params: WriteFileToolParams): string | null {
|
||||
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) {
|
||||
if (
|
||||
this.schema.parameters &&
|
||||
!SchemaValidator.validate(
|
||||
this.schema.parameters as Record<string, unknown>,
|
||||
params,
|
||||
)
|
||||
) {
|
||||
return 'Parameters failed schema validation.';
|
||||
}
|
||||
|
||||
@@ -114,7 +130,9 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
* @param params Parameters for the tool execution
|
||||
* @returns Whether or not execute should be confirmed by the user.
|
||||
*/
|
||||
async shouldConfirmExecute(params: WriteFileToolParams): Promise<ToolCallConfirmationDetails | false> {
|
||||
async shouldConfirmExecute(
|
||||
params: WriteFileToolParams,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.shouldAlwaysWrite) {
|
||||
return false;
|
||||
}
|
||||
@@ -135,7 +153,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
params.content,
|
||||
'Current',
|
||||
'Proposed',
|
||||
{ context: 3, ignoreWhitespace: true}
|
||||
{ context: 3, ignoreWhitespace: true },
|
||||
);
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
@@ -171,7 +189,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
if (validationError) {
|
||||
return {
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
returnDisplay: '**Error:** Failed to execute tool.'
|
||||
returnDisplay: '**Error:** Failed to execute tool.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,13 +205,13 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
|
||||
|
||||
return {
|
||||
llmContent: `Successfully wrote to file: ${params.file_path}`,
|
||||
returnDisplay: `Wrote to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`
|
||||
returnDisplay: `Wrote to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
|
||||
};
|
||||
} 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: `Failed to write to file: ${errorMsg}`
|
||||
returnDisplay: `Failed to write to file: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user