mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.1.21' of github.com:google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.21
This commit is contained in:
@@ -62,7 +62,6 @@ describe('EditTool', () => {
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
|
||||
getIdeClient: () => undefined,
|
||||
getIdeMode: () => false,
|
||||
getIdeModeFeature: () => false,
|
||||
// getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method
|
||||
// Add other properties/methods of Config if EditTool uses them
|
||||
// Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses:
|
||||
@@ -810,7 +809,6 @@ describe('EditTool', () => {
|
||||
}),
|
||||
};
|
||||
(mockConfig as any).getIdeMode = () => true;
|
||||
(mockConfig as any).getIdeModeFeature = () => true;
|
||||
(mockConfig as any).getIdeClient = () => ideClient;
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as path from 'path';
|
||||
import * as Diff from 'diff';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
Icon,
|
||||
Kind,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolEditConfirmationDetails,
|
||||
@@ -250,7 +250,6 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
);
|
||||
const ideClient = this.config.getIdeClient();
|
||||
const ideConfirmation =
|
||||
this.config.getIdeModeFeature() &&
|
||||
this.config.getIdeMode() &&
|
||||
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
? ideClient.openDiff(this.params.file_path, editData.newContent)
|
||||
@@ -436,7 +435,7 @@ Expectation for required parameters:
|
||||
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
|
||||
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 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.
|
||||
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`,
|
||||
Icon.Pencil,
|
||||
Kind.Edit,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
@@ -472,7 +471,7 @@ Expectation for required parameters:
|
||||
* @param params Parameters to validate
|
||||
* @returns Error message string or null if valid
|
||||
*/
|
||||
validateToolParams(params: EditToolParams): string | null {
|
||||
override validateToolParams(params: EditToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Icon,
|
||||
Kind,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
@@ -248,7 +248,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
GlobTool.Name,
|
||||
'FindFiles',
|
||||
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
|
||||
Icon.FileSearch,
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
@@ -281,7 +281,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
/**
|
||||
* Validates the parameters for the tool.
|
||||
*/
|
||||
validateToolParams(params: GlobToolParams): string | null {
|
||||
override validateToolParams(params: GlobToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { globStream } from 'glob';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Icon,
|
||||
Kind,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
@@ -549,7 +549,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
GrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
|
||||
Icon.Regex,
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
@@ -620,7 +620,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
* @param params Parameters to validate
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
validateToolParams(params: GrepToolParams): string | null {
|
||||
override validateToolParams(params: GrepToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
|
||||
@@ -74,9 +74,11 @@ describe('LSTool', () => {
|
||||
const params = {
|
||||
path: '/home/user/project/src',
|
||||
};
|
||||
|
||||
const error = lsTool.validateToolParams(params);
|
||||
expect(error).toBeNull();
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
const invocation = lsTool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject relative paths', () => {
|
||||
@@ -84,8 +86,9 @@ describe('LSTool', () => {
|
||||
path: './src',
|
||||
};
|
||||
|
||||
const error = lsTool.validateToolParams(params);
|
||||
expect(error).toBe('Path must be absolute: ./src');
|
||||
expect(() => lsTool.build(params)).toThrow(
|
||||
'Path must be absolute: ./src',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject paths outside workspace with clear error message', () => {
|
||||
@@ -93,8 +96,7 @@ describe('LSTool', () => {
|
||||
path: '/etc/passwd',
|
||||
};
|
||||
|
||||
const error = lsTool.validateToolParams(params);
|
||||
expect(error).toBe(
|
||||
expect(() => lsTool.build(params)).toThrow(
|
||||
'Path must be within one of the workspace directories: /home/user/project, /home/user/other-project',
|
||||
);
|
||||
});
|
||||
@@ -103,9 +105,11 @@ describe('LSTool', () => {
|
||||
const params = {
|
||||
path: '/home/user/other-project/lib',
|
||||
};
|
||||
|
||||
const error = lsTool.validateToolParams(params);
|
||||
expect(error).toBeNull();
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
const invocation = lsTool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,10 +137,8 @@ describe('LSTool', () => {
|
||||
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('[DIR] subdir');
|
||||
expect(result.llmContent).toContain('file1.ts');
|
||||
@@ -161,10 +163,8 @@ describe('LSTool', () => {
|
||||
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('module1.js');
|
||||
expect(result.llmContent).toContain('module2.js');
|
||||
@@ -179,10 +179,8 @@ describe('LSTool', () => {
|
||||
} as fs.Stats);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toBe(
|
||||
'Directory /home/user/project/empty is empty.',
|
||||
@@ -207,10 +205,11 @@ describe('LSTool', () => {
|
||||
});
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath, ignore: ['*.spec.js'] },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({
|
||||
path: testPath,
|
||||
ignore: ['*.spec.js'],
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('test.js');
|
||||
expect(result.llmContent).toContain('index.js');
|
||||
@@ -238,10 +237,8 @@ describe('LSTool', () => {
|
||||
(path: string) => path.includes('ignored.js'),
|
||||
);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('file1.js');
|
||||
expect(result.llmContent).toContain('file2.js');
|
||||
@@ -269,10 +266,8 @@ describe('LSTool', () => {
|
||||
(path: string) => path.includes('private.js'),
|
||||
);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('file1.js');
|
||||
expect(result.llmContent).toContain('file2.js');
|
||||
@@ -287,10 +282,8 @@ describe('LSTool', () => {
|
||||
isDirectory: () => false,
|
||||
} as fs.Stats);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Path is not a directory');
|
||||
expect(result.returnDisplay).toBe('Error: Path is not a directory.');
|
||||
@@ -303,10 +296,8 @@ describe('LSTool', () => {
|
||||
throw new Error('ENOENT: no such file or directory');
|
||||
});
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Error listing directory');
|
||||
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
|
||||
@@ -336,10 +327,8 @@ describe('LSTool', () => {
|
||||
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
const lines = (
|
||||
typeof result.llmContent === 'string' ? result.llmContent : ''
|
||||
@@ -361,24 +350,18 @@ describe('LSTool', () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
});
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Error listing directory');
|
||||
expect(result.llmContent).toContain('permission denied');
|
||||
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
|
||||
});
|
||||
|
||||
it('should validate parameters and return error for invalid params', async () => {
|
||||
const result = await lsTool.execute(
|
||||
{ path: '../outside' },
|
||||
new AbortController().signal,
|
||||
it('should throw for invalid params at build time', async () => {
|
||||
expect(() => lsTool.build({ path: '../outside' })).toThrow(
|
||||
'Path must be absolute: ../outside',
|
||||
);
|
||||
|
||||
expect(result.llmContent).toContain('Invalid parameters provided');
|
||||
expect(result.returnDisplay).toBe('Error: Failed to execute tool.');
|
||||
});
|
||||
|
||||
it('should handle errors accessing individual files during listing', async () => {
|
||||
@@ -406,10 +389,8 @@ describe('LSTool', () => {
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// Should still list the accessible file
|
||||
expect(result.llmContent).toContain('accessible.ts');
|
||||
@@ -428,19 +409,25 @@ describe('LSTool', () => {
|
||||
describe('getDescription', () => {
|
||||
it('should return shortened relative path', () => {
|
||||
const params = {
|
||||
path: path.join(mockPrimaryDir, 'deeply', 'nested', 'directory'),
|
||||
path: `${mockPrimaryDir}/deeply/nested/directory`,
|
||||
};
|
||||
|
||||
const description = lsTool.getDescription(params);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
const invocation = lsTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
expect(description).toBe(path.join('deeply', 'nested', 'directory'));
|
||||
});
|
||||
|
||||
it('should handle paths in secondary workspace', () => {
|
||||
const params = {
|
||||
path: path.join(mockSecondaryDir, 'lib'),
|
||||
path: `${mockSecondaryDir}/lib`,
|
||||
};
|
||||
|
||||
const description = lsTool.getDescription(params);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
const invocation = lsTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
expect(description).toBe(path.join('..', 'other-project', 'lib'));
|
||||
});
|
||||
});
|
||||
@@ -448,22 +435,25 @@ describe('LSTool', () => {
|
||||
describe('workspace boundary validation', () => {
|
||||
it('should accept paths in primary workspace directory', () => {
|
||||
const params = { path: `${mockPrimaryDir}/src` };
|
||||
expect(lsTool.validateToolParams(params)).toBeNull();
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
expect(lsTool.build(params)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept paths in secondary workspace directory', () => {
|
||||
const params = { path: `${mockSecondaryDir}/lib` };
|
||||
expect(lsTool.validateToolParams(params)).toBeNull();
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
expect(lsTool.build(params)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject paths outside all workspace directories', () => {
|
||||
const params = { path: '/etc/passwd' };
|
||||
const error = lsTool.validateToolParams(params);
|
||||
expect(error).toContain(
|
||||
expect(() => lsTool.build(params)).toThrow(
|
||||
'Path must be within one of the workspace directories',
|
||||
);
|
||||
expect(error).toContain(mockPrimaryDir);
|
||||
expect(error).toContain(mockSecondaryDir);
|
||||
});
|
||||
|
||||
it('should list files from secondary workspace directory', async () => {
|
||||
@@ -483,10 +473,8 @@ describe('LSTool', () => {
|
||||
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any);
|
||||
|
||||
const result = await lsTool.execute(
|
||||
{ path: testPath },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const invocation = lsTool.build({ path: testPath });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('test1.spec.ts');
|
||||
expect(result.llmContent).toContain('test2.spec.ts');
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { BaseTool, Icon, ToolResult } from './tools.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
|
||||
@@ -64,79 +70,12 @@ export interface FileEntry {
|
||||
modifiedTime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the LS tool logic
|
||||
*/
|
||||
export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
static readonly Name = 'list_directory';
|
||||
|
||||
constructor(private config: Config) {
|
||||
super(
|
||||
LSTool.Name,
|
||||
'ReadFolder',
|
||||
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
|
||||
Icon.Folder,
|
||||
{
|
||||
properties: {
|
||||
path: {
|
||||
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: 'array',
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
validateToolParams(params: LSToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
if (!path.isAbsolute(params.path)) {
|
||||
return `Path must be absolute: ${params.path}`;
|
||||
}
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(params.path)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `Path must be within one of the workspace directories: ${directories.join(', ')}`;
|
||||
}
|
||||
return null;
|
||||
class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
params: LSToolParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,11 +104,13 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
|
||||
/**
|
||||
* Gets a description of the file reading operation
|
||||
* @param params Parameters for the file reading
|
||||
* @returns A string describing the file being read
|
||||
*/
|
||||
getDescription(params: LSToolParams): string {
|
||||
const relativePath = makeRelative(params.path, this.config.getTargetDir());
|
||||
getDescription(): string {
|
||||
const relativePath = makeRelative(
|
||||
this.params.path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
return shortenPath(relativePath);
|
||||
}
|
||||
|
||||
@@ -184,49 +125,37 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
|
||||
/**
|
||||
* Executes the LS operation with the given parameters
|
||||
* @param params Parameters for the LS operation
|
||||
* @returns Result of the LS operation
|
||||
*/
|
||||
async execute(
|
||||
params: LSToolParams,
|
||||
_signal: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
const validationError = this.validateToolParams(params);
|
||||
if (validationError) {
|
||||
return this.errorResult(
|
||||
`Error: Invalid parameters provided. Reason: ${validationError}`,
|
||||
`Failed to execute tool.`,
|
||||
);
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const stats = fs.statSync(params.path);
|
||||
const stats = fs.statSync(this.params.path);
|
||||
if (!stats) {
|
||||
// fs.statSync throws on non-existence, so this check might be redundant
|
||||
// but keeping for clarity. Error message adjusted.
|
||||
return this.errorResult(
|
||||
`Error: Directory not found or inaccessible: ${params.path}`,
|
||||
`Error: Directory not found or inaccessible: ${this.params.path}`,
|
||||
`Directory not found or inaccessible.`,
|
||||
);
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return this.errorResult(
|
||||
`Error: Path is not a directory: ${params.path}`,
|
||||
`Error: Path is not a directory: ${this.params.path}`,
|
||||
`Path is not a directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(params.path);
|
||||
const files = fs.readdirSync(this.params.path);
|
||||
|
||||
const defaultFileIgnores =
|
||||
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
const fileFilteringOptions = {
|
||||
respectGitIgnore:
|
||||
params.file_filtering_options?.respect_git_ignore ??
|
||||
this.params.file_filtering_options?.respect_git_ignore ??
|
||||
defaultFileIgnores.respectGitIgnore,
|
||||
respectGeminiIgnore:
|
||||
params.file_filtering_options?.respect_gemini_ignore ??
|
||||
this.params.file_filtering_options?.respect_gemini_ignore ??
|
||||
defaultFileIgnores.respectGeminiIgnore,
|
||||
};
|
||||
|
||||
@@ -241,17 +170,17 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
if (files.length === 0) {
|
||||
// Changed error message to be more neutral for LLM
|
||||
return {
|
||||
llmContent: `Directory ${params.path} is empty.`,
|
||||
llmContent: `Directory ${this.params.path} is empty.`,
|
||||
returnDisplay: `Directory is empty.`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (this.shouldIgnore(file, params.ignore)) {
|
||||
if (this.shouldIgnore(file, this.params.ignore)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(params.path, file);
|
||||
const fullPath = path.join(this.params.path, file);
|
||||
const relativePath = path.relative(
|
||||
this.config.getTargetDir(),
|
||||
fullPath,
|
||||
@@ -301,7 +230,7 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
.map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`)
|
||||
.join('\n');
|
||||
|
||||
let resultMessage = `Directory listing for ${params.path}:\n${directoryContent}`;
|
||||
let resultMessage = `Directory listing for ${this.params.path}:\n${directoryContent}`;
|
||||
const ignoredMessages = [];
|
||||
if (gitIgnoredCount > 0) {
|
||||
ignoredMessages.push(`${gitIgnoredCount} git-ignored`);
|
||||
@@ -329,3 +258,87 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the LS tool logic
|
||||
*/
|
||||
export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
|
||||
static readonly Name = 'list_directory';
|
||||
|
||||
constructor(private config: Config) {
|
||||
super(
|
||||
LSTool.Name,
|
||||
'ReadFolder',
|
||||
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
path: {
|
||||
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: 'array',
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
type: 'object',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
override validateToolParams(params: LSToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
if (!path.isAbsolute(params.path)) {
|
||||
return `Path must be absolute: ${params.path}`;
|
||||
}
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(params.path)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `Path must be within one of the workspace directories: ${directories.join(
|
||||
', ',
|
||||
)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: LSToolParams,
|
||||
): ToolInvocation<LSToolParams, ToolResult> {
|
||||
return new LSToolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +73,21 @@ describe('DiscoveredMCPTool', () => {
|
||||
required: ['param'],
|
||||
};
|
||||
|
||||
let tool: DiscoveredMCPTool;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallTool.mockClear();
|
||||
mockToolMethod.mockClear();
|
||||
tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
// Clear allowlist before each relevant test, especially for shouldConfirmExecute
|
||||
(DiscoveredMCPTool as any).allowlist.clear();
|
||||
const invocation = tool.build({}) as any;
|
||||
invocation.constructor.allowlist.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,14 +96,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should set properties correctly', () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
|
||||
expect(tool.name).toBe(serverToolName);
|
||||
expect(tool.schema.name).toBe(serverToolName);
|
||||
expect(tool.schema.description).toBe(baseDescription);
|
||||
@@ -105,7 +107,7 @@ describe('DiscoveredMCPTool', () => {
|
||||
|
||||
it('should accept and store a custom timeout', () => {
|
||||
const customTimeout = 5000;
|
||||
const tool = new DiscoveredMCPTool(
|
||||
const toolWithTimeout = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
@@ -113,19 +115,12 @@ describe('DiscoveredMCPTool', () => {
|
||||
inputSchema,
|
||||
customTimeout,
|
||||
);
|
||||
expect(tool.timeout).toBe(customTimeout);
|
||||
expect(toolWithTimeout.timeout).toBe(customTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should call mcpTool.callTool with correct parameters and format display output', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { param: 'testValue' };
|
||||
const mockToolSuccessResultObject = {
|
||||
success: true,
|
||||
@@ -147,7 +142,10 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(mockMcpToolResponseParts);
|
||||
|
||||
const toolResult: ToolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult: ToolResult = await invocation.execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith([
|
||||
{ name: serverToolName, args: params },
|
||||
@@ -163,17 +161,13 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle empty result from getStringifiedResultForDisplay', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { param: 'testValue' };
|
||||
const mockMcpToolResponsePartsEmpty: Part[] = [];
|
||||
mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty);
|
||||
const toolResult: ToolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult: ToolResult = await invocation.execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(toolResult.returnDisplay).toBe('```json\n[]\n```');
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: '[Error: Could not parse tool response]' },
|
||||
@@ -181,28 +175,17 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should propagate rejection if mcpTool.callTool rejects', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { param: 'failCase' };
|
||||
const expectedError = new Error('MCP call failed');
|
||||
mockCallTool.mockRejectedValue(expectedError);
|
||||
|
||||
await expect(tool.execute(params)).rejects.toThrow(expectedError);
|
||||
const invocation = tool.build(params);
|
||||
await expect(
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(expectedError);
|
||||
});
|
||||
|
||||
it('should handle a simple text response correctly', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { query: 'test' };
|
||||
const successMessage = 'This is a success message.';
|
||||
|
||||
@@ -221,7 +204,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// 1. Assert that the llmContent sent to the scheduler is a clean Part array.
|
||||
expect(toolResult.llmContent).toEqual([{ text: successMessage }]);
|
||||
@@ -236,13 +220,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle an AudioBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'play' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -262,7 +239,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
@@ -279,13 +257,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle a ResourceLinkBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -306,7 +277,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
@@ -319,13 +291,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle an embedded text ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -348,7 +313,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'This is the text content.' },
|
||||
@@ -357,13 +323,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle an embedded binary ResourceBlock response', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { resource: 'get' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -386,7 +345,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{
|
||||
@@ -405,13 +365,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle a mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -433,7 +386,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'First part.' },
|
||||
@@ -454,13 +408,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should ignore unknown content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'test' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -477,7 +424,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]);
|
||||
expect(toolResult.returnDisplay).toBe(
|
||||
@@ -486,13 +434,6 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle a complex mix of content block types', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const params = { action: 'super-complex' };
|
||||
const sdkResponse: Part[] = [
|
||||
{
|
||||
@@ -527,7 +468,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
];
|
||||
mockCallTool.mockResolvedValue(sdkResponse);
|
||||
|
||||
const toolResult = await tool.execute(params);
|
||||
const invocation = tool.build(params);
|
||||
const toolResult = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(toolResult.llmContent).toEqual([
|
||||
{ text: 'Here is a resource.' },
|
||||
@@ -552,10 +494,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
// beforeEach is already clearing allowlist
|
||||
|
||||
it('should return false if trust is true', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
const trustedTool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
@@ -564,50 +504,32 @@ describe('DiscoveredMCPTool', () => {
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const invocation = trustedTool.build({});
|
||||
expect(
|
||||
await tool.shouldConfirmExecute({}, new AbortController().signal),
|
||||
await invocation.shouldConfirmExecute(new AbortController().signal),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if server is allowlisted', async () => {
|
||||
(DiscoveredMCPTool as any).allowlist.add(serverName);
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const invocation = tool.build({}) as any;
|
||||
invocation.constructor.allowlist.add(serverName);
|
||||
expect(
|
||||
await tool.shouldConfirmExecute({}, new AbortController().signal),
|
||||
await invocation.shouldConfirmExecute(new AbortController().signal),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if tool is allowlisted', async () => {
|
||||
const toolAllowlistKey = `${serverName}.${serverToolName}`;
|
||||
(DiscoveredMCPTool as any).allowlist.add(toolAllowlistKey);
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const invocation = tool.build({}) as any;
|
||||
invocation.constructor.allowlist.add(toolAllowlistKey);
|
||||
expect(
|
||||
await tool.shouldConfirmExecute({}, new AbortController().signal),
|
||||
await invocation.shouldConfirmExecute(new AbortController().signal),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return confirmation details if not trusted and not allowlisted', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const confirmation = await tool.shouldConfirmExecute(
|
||||
{},
|
||||
const invocation = tool.build({});
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
@@ -629,15 +551,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should add server to allowlist on ProceedAlwaysServer', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const confirmation = await tool.shouldConfirmExecute(
|
||||
{},
|
||||
const invocation = tool.build({}) as any;
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
@@ -650,7 +565,7 @@ describe('DiscoveredMCPTool', () => {
|
||||
await confirmation.onConfirm(
|
||||
ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
);
|
||||
expect((DiscoveredMCPTool as any).allowlist.has(serverName)).toBe(true);
|
||||
expect(invocation.constructor.allowlist.has(serverName)).toBe(true);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Confirmation details or onConfirm not in expected format',
|
||||
@@ -659,16 +574,9 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should add tool to allowlist on ProceedAlwaysTool', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const toolAllowlistKey = `${serverName}.${serverToolName}`;
|
||||
const confirmation = await tool.shouldConfirmExecute(
|
||||
{},
|
||||
const invocation = tool.build({}) as any;
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
@@ -679,7 +587,7 @@ describe('DiscoveredMCPTool', () => {
|
||||
typeof confirmation.onConfirm === 'function'
|
||||
) {
|
||||
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlwaysTool);
|
||||
expect((DiscoveredMCPTool as any).allowlist.has(toolAllowlistKey)).toBe(
|
||||
expect(invocation.constructor.allowlist.has(toolAllowlistKey)).toBe(
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
@@ -690,15 +598,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle Cancel confirmation outcome', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const confirmation = await tool.shouldConfirmExecute(
|
||||
{},
|
||||
const invocation = tool.build({}) as any;
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
@@ -710,11 +611,9 @@ describe('DiscoveredMCPTool', () => {
|
||||
) {
|
||||
// Cancel should not add anything to allowlist
|
||||
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
expect((DiscoveredMCPTool as any).allowlist.has(serverName)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(invocation.constructor.allowlist.has(serverName)).toBe(false);
|
||||
expect(
|
||||
(DiscoveredMCPTool as any).allowlist.has(
|
||||
invocation.constructor.allowlist.has(
|
||||
`${serverName}.${serverToolName}`,
|
||||
),
|
||||
).toBe(false);
|
||||
@@ -726,15 +625,8 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
|
||||
it('should handle ProceedOnce confirmation outcome', async () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
serverName,
|
||||
serverToolName,
|
||||
baseDescription,
|
||||
inputSchema,
|
||||
);
|
||||
const confirmation = await tool.shouldConfirmExecute(
|
||||
{},
|
||||
const invocation = tool.build({}) as any;
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
@@ -746,11 +638,9 @@ describe('DiscoveredMCPTool', () => {
|
||||
) {
|
||||
// ProceedOnce should not add anything to allowlist
|
||||
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
expect((DiscoveredMCPTool as any).allowlist.has(serverName)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(invocation.constructor.allowlist.has(serverName)).toBe(false);
|
||||
expect(
|
||||
(DiscoveredMCPTool as any).allowlist.has(
|
||||
invocation.constructor.allowlist.has(
|
||||
`${serverName}.${serverToolName}`,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseTool,
|
||||
ToolResult,
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolInvocation,
|
||||
ToolMcpConfirmationDetails,
|
||||
Icon,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { CallableTool, Part, FunctionCall } from '@google/genai';
|
||||
import { CallableTool, FunctionCall, Part } from '@google/genai';
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
||||
@@ -50,15 +52,90 @@ type McpContentBlock =
|
||||
| McpResourceBlock
|
||||
| McpResourceLinkBlock;
|
||||
|
||||
export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
ToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
|
||||
constructor(
|
||||
private readonly mcpTool: CallableTool,
|
||||
readonly serverName: string,
|
||||
readonly serverToolName: string,
|
||||
readonly displayName: string,
|
||||
readonly timeout?: number,
|
||||
readonly trust?: boolean,
|
||||
params: ToolParams = {},
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
override async shouldConfirmExecute(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
const serverAllowListKey = this.serverName;
|
||||
const toolAllowListKey = `${this.serverName}.${this.serverToolName}`;
|
||||
|
||||
if (this.trust) {
|
||||
return false; // server is trusted, no confirmation needed
|
||||
}
|
||||
|
||||
if (
|
||||
DiscoveredMCPToolInvocation.allowlist.has(serverAllowListKey) ||
|
||||
DiscoveredMCPToolInvocation.allowlist.has(toolAllowListKey)
|
||||
) {
|
||||
return false; // server and/or tool already allowlisted
|
||||
}
|
||||
|
||||
const confirmationDetails: ToolMcpConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool Execution',
|
||||
serverName: this.serverName,
|
||||
toolName: this.serverToolName, // Display original tool name in confirmation
|
||||
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
|
||||
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
|
||||
} else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {
|
||||
DiscoveredMCPToolInvocation.allowlist.add(toolAllowListKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const functionCalls: FunctionCall[] = [
|
||||
{
|
||||
name: this.serverToolName,
|
||||
args: this.params,
|
||||
},
|
||||
];
|
||||
|
||||
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
|
||||
const transformedParts = transformMcpContentToParts(rawResponseParts);
|
||||
|
||||
return {
|
||||
llmContent: transformedParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
|
||||
};
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
ToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly mcpTool: CallableTool,
|
||||
readonly serverName: string,
|
||||
readonly serverToolName: string,
|
||||
description: string,
|
||||
readonly parameterSchema: unknown,
|
||||
override readonly parameterSchema: unknown,
|
||||
readonly timeout?: number,
|
||||
readonly trust?: boolean,
|
||||
nameOverride?: string,
|
||||
@@ -67,7 +144,7 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
nameOverride ?? generateValidName(serverToolName),
|
||||
`${serverToolName} (${serverName} MCP Server)`,
|
||||
description,
|
||||
Icon.Hammer,
|
||||
Kind.Other,
|
||||
parameterSchema,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
@@ -87,56 +164,18 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
|
||||
);
|
||||
}
|
||||
|
||||
async shouldConfirmExecute(
|
||||
_params: ToolParams,
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
const serverAllowListKey = this.serverName;
|
||||
const toolAllowListKey = `${this.serverName}.${this.serverToolName}`;
|
||||
|
||||
if (this.trust) {
|
||||
return false; // server is trusted, no confirmation needed
|
||||
}
|
||||
|
||||
if (
|
||||
DiscoveredMCPTool.allowlist.has(serverAllowListKey) ||
|
||||
DiscoveredMCPTool.allowlist.has(toolAllowListKey)
|
||||
) {
|
||||
return false; // server and/or tool already allowlisted
|
||||
}
|
||||
|
||||
const confirmationDetails: ToolMcpConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool Execution',
|
||||
serverName: this.serverName,
|
||||
toolName: this.serverToolName, // Display original tool name in confirmation
|
||||
toolDisplayName: this.name, // Display global registry name exposed to model and user
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
|
||||
DiscoveredMCPTool.allowlist.add(serverAllowListKey);
|
||||
} else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {
|
||||
DiscoveredMCPTool.allowlist.add(toolAllowListKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
async execute(params: ToolParams): Promise<ToolResult> {
|
||||
const functionCalls: FunctionCall[] = [
|
||||
{
|
||||
name: this.serverToolName,
|
||||
args: params,
|
||||
},
|
||||
];
|
||||
|
||||
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
|
||||
const transformedParts = transformMcpContentToParts(rawResponseParts);
|
||||
|
||||
return {
|
||||
llmContent: transformedParts,
|
||||
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
|
||||
};
|
||||
protected createInvocation(
|
||||
params: ToolParams,
|
||||
): ToolInvocation<ToolParams, ToolResult> {
|
||||
return new DiscoveredMCPToolInvocation(
|
||||
this.mcpTool,
|
||||
this.serverName,
|
||||
this.serverToolName,
|
||||
this.displayName,
|
||||
this.timeout,
|
||||
this.trust,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,9 +202,11 @@ describe('MemoryTool', () => {
|
||||
expect(memoryTool.schema.parametersJsonSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call performAddMemoryEntry with correct parameters and return success', async () => {
|
||||
it('should call performAddMemoryEntry with correct parameters and return success for global scope', async () => {
|
||||
const params = { fact: 'The sky is blue', scope: 'global' as const };
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.execute(mockAbortSignal);
|
||||
|
||||
// Use getCurrentGeminiMdFilename for the default expectation before any setGeminiMdFilename calls in a test
|
||||
const expectedFilePath = path.join(
|
||||
os.homedir(),
|
||||
@@ -231,16 +233,44 @@ describe('MemoryTool', () => {
|
||||
expect(result.returnDisplay).toBe(successMessage);
|
||||
});
|
||||
|
||||
it('should call performAddMemoryEntry with correct parameters and return success for project scope', async () => {
|
||||
const params = { fact: 'The sky is blue', scope: 'project' as const };
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.execute(mockAbortSignal);
|
||||
|
||||
// For project scope, expect the file to be in current working directory
|
||||
const expectedFilePath = path.join(
|
||||
process.cwd(),
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
// For this test, we expect the actual fs methods to be passed
|
||||
const expectedFsArgument = {
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
mkdir: fs.mkdir,
|
||||
};
|
||||
|
||||
expect(performAddMemoryEntrySpy).toHaveBeenCalledWith(
|
||||
params.fact,
|
||||
expectedFilePath,
|
||||
expectedFsArgument,
|
||||
);
|
||||
const successMessage = `Okay, I've remembered that in project memory: "${params.fact}"`;
|
||||
expect(result.llmContent).toBe(
|
||||
JSON.stringify({ success: true, message: successMessage }),
|
||||
);
|
||||
expect(result.returnDisplay).toBe(successMessage);
|
||||
});
|
||||
|
||||
it('should return an error if fact is empty', async () => {
|
||||
const params = { fact: ' ' }; // Empty fact
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
const errorMessage = 'Parameter "fact" must be a non-empty string.';
|
||||
|
||||
expect(performAddMemoryEntrySpy).not.toHaveBeenCalled();
|
||||
expect(result.llmContent).toBe(
|
||||
JSON.stringify({ success: false, error: errorMessage }),
|
||||
expect(memoryTool.validateToolParams(params)).toBe(
|
||||
'Parameter "fact" must be a non-empty string.',
|
||||
);
|
||||
expect(() => memoryTool.build(params)).toThrow(
|
||||
'Parameter "fact" must be a non-empty string.',
|
||||
);
|
||||
expect(result.returnDisplay).toBe(`Error: ${errorMessage}`);
|
||||
});
|
||||
|
||||
it('should handle errors from performAddMemoryEntry', async () => {
|
||||
@@ -250,7 +280,8 @@ describe('MemoryTool', () => {
|
||||
);
|
||||
performAddMemoryEntrySpy.mockRejectedValue(underlyingError);
|
||||
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.execute(mockAbortSignal);
|
||||
|
||||
expect(result.llmContent).toBe(
|
||||
JSON.stringify({
|
||||
@@ -262,6 +293,18 @@ describe('MemoryTool', () => {
|
||||
`Error saving memory: ${underlyingError.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when executing without scope parameter', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.execute(mockAbortSignal);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'Please specify where to save this memory',
|
||||
);
|
||||
expect(result.returnDisplay).toContain('Global:');
|
||||
expect(result.returnDisplay).toContain('Project:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
@@ -269,18 +312,14 @@ describe('MemoryTool', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
memoryTool = new MemoryTool();
|
||||
// Clear the allowlist before each test
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.clear();
|
||||
// Mock fs.readFile to return empty string (file doesn't exist)
|
||||
vi.mocked(fs.readFile).mockResolvedValue('');
|
||||
});
|
||||
|
||||
it('should return confirmation details when memory file is not allowlisted', async () => {
|
||||
it('should return confirmation details when memory file is not allowlisted for global scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
@@ -301,7 +340,30 @@ describe('MemoryTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false when memory file is already allowlisted', async () => {
|
||||
it('should return confirmation details when memory file is not allowlisted for project scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'project' as const };
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
const expectedPath = path.join(process.cwd(), 'QWEN.md');
|
||||
expect(result.title).toBe(
|
||||
`Confirm Memory Save: ${expectedPath} (project)`,
|
||||
);
|
||||
expect(result.fileName).toBe(expectedPath);
|
||||
expect(result.fileDiff).toContain('Index: QWEN.md');
|
||||
expect(result.fileDiff).toContain('+## Qwen Added Memories');
|
||||
expect(result.fileDiff).toContain('+- Test fact');
|
||||
expect(result.originalContent).toBe('');
|
||||
expect(result.newContent).toContain('## Qwen Added Memories');
|
||||
expect(result.newContent).toContain('- Test fact');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false when memory file is already allowlisted for global scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const memoryFilePath = path.join(
|
||||
os.homedir(),
|
||||
@@ -309,20 +371,36 @@ describe('MemoryTool', () => {
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
// Add the memory file to the allowlist with the new key format
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.add(
|
||||
`${memoryFilePath}_global`,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
// Add the memory file to the allowlist with the scope-specific key format
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation.constructor as any).allowlist.add(`${memoryFilePath}_global`);
|
||||
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should add memory file to allowlist when ProceedAlways is confirmed', async () => {
|
||||
it('should return false when memory file is already allowlisted for project scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'project' as const };
|
||||
const memoryFilePath = path.join(
|
||||
process.cwd(),
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
const invocation = memoryTool.build(params);
|
||||
// Add the memory file to the allowlist with the scope-specific key format
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation.constructor as any).allowlist.add(
|
||||
`${memoryFilePath}_project`,
|
||||
);
|
||||
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should add memory file to allowlist when ProceedAlways is confirmed for global scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const memoryFilePath = path.join(
|
||||
os.homedir(),
|
||||
@@ -330,10 +408,8 @@ describe('MemoryTool', () => {
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
@@ -342,27 +418,53 @@ describe('MemoryTool', () => {
|
||||
// Simulate the onConfirm callback
|
||||
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
|
||||
|
||||
// Check that the memory file was added to the allowlist with the new key format
|
||||
// Check that the memory file was added to the allowlist with the scope-specific key format
|
||||
expect(
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.has(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation.constructor as any).allowlist.has(
|
||||
`${memoryFilePath}_global`,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should add memory file to allowlist when ProceedAlways is confirmed for project scope', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'project' as const };
|
||||
const memoryFilePath = path.join(
|
||||
process.cwd(),
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
// Simulate the onConfirm callback
|
||||
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
|
||||
|
||||
// Check that the memory file was added to the allowlist with the scope-specific key format
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation.constructor as any).allowlist.has(
|
||||
`${memoryFilePath}_project`,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not add memory file to allowlist when other outcomes are confirmed', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const memoryFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.qwen',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
@@ -370,22 +472,16 @@ describe('MemoryTool', () => {
|
||||
if (result && result.type === 'edit') {
|
||||
// Simulate the onConfirm callback with different outcomes
|
||||
await result.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
expect(
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.has(
|
||||
memoryFilePath,
|
||||
),
|
||||
).toBe(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allowlist = (invocation.constructor as any).allowlist;
|
||||
expect(allowlist.has(`${memoryFilePath}_global`)).toBe(false);
|
||||
|
||||
await result.onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
expect(
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.has(
|
||||
memoryFilePath,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(allowlist.has(`${memoryFilePath}_global`)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle existing memory file with content', async () => {
|
||||
it('should handle existing memory file with content for global scope', async () => {
|
||||
const params = { fact: 'New fact', scope: 'global' as const };
|
||||
const existingContent =
|
||||
'Some existing content.\n\n## Qwen Added Memories\n- Old fact\n';
|
||||
@@ -393,10 +489,8 @@ describe('MemoryTool', () => {
|
||||
// Mock fs.readFile to return existing content
|
||||
vi.mocked(fs.readFile).mockResolvedValue(existingContent);
|
||||
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
@@ -416,10 +510,8 @@ describe('MemoryTool', () => {
|
||||
|
||||
it('should prompt for scope selection when scope is not specified', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
@@ -435,15 +527,58 @@ describe('MemoryTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when executing without scope parameter', async () => {
|
||||
it('should show correct file paths in scope selection prompt', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
const invocation = memoryTool.build(params);
|
||||
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'Please specify where to save this memory',
|
||||
);
|
||||
expect(result.returnDisplay).toContain('Global:');
|
||||
expect(result.returnDisplay).toContain('Project:');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
const globalPath = path.join('~', '.qwen', 'QWEN.md');
|
||||
const projectPath = path.join(process.cwd(), 'QWEN.md');
|
||||
|
||||
expect(result.fileDiff).toContain(`Global: ${globalPath}`);
|
||||
expect(result.fileDiff).toContain(`Project: ${projectPath}`);
|
||||
expect(result.fileDiff).toContain('(shared across all projects)');
|
||||
expect(result.fileDiff).toContain('(current project only)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
let memoryTool: MemoryTool;
|
||||
|
||||
beforeEach(() => {
|
||||
memoryTool = new MemoryTool();
|
||||
});
|
||||
|
||||
it('should return correct description for global scope', () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const invocation = memoryTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
|
||||
expect(description).toBe(`${expectedPath} (global)`);
|
||||
});
|
||||
|
||||
it('should return correct description for project scope', () => {
|
||||
const params = { fact: 'Test fact', scope: 'project' as const };
|
||||
const invocation = memoryTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
const expectedPath = path.join(process.cwd(), 'QWEN.md');
|
||||
expect(description).toBe(`${expectedPath} (project)`);
|
||||
});
|
||||
|
||||
it('should default to global scope when scope is not specified', () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const invocation = memoryTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
|
||||
expect(description).toBe(`${expectedPath} (global)`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseTool,
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
ToolResult,
|
||||
ToolEditConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
Icon,
|
||||
} from './tools.js';
|
||||
import { FunctionDeclaration } from '@google/genai';
|
||||
import * as fs from 'fs/promises';
|
||||
@@ -19,6 +20,7 @@ import * as Diff from 'diff';
|
||||
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
|
||||
import { tildeifyPath } from '../utils/paths.js';
|
||||
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
|
||||
const memoryToolSchemaData: FunctionDeclaration = {
|
||||
name: 'save_memory',
|
||||
@@ -131,94 +133,82 @@ function ensureNewlineSeparation(currentContent: string): string {
|
||||
return '\n\n';
|
||||
}
|
||||
|
||||
export class MemoryTool
|
||||
extends BaseTool<SaveMemoryParams, ToolResult>
|
||||
implements ModifiableDeclarativeTool<SaveMemoryParams>
|
||||
{
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
/**
|
||||
* Reads the current content of the memory file
|
||||
*/
|
||||
async function readMemoryFileContent(
|
||||
scope: 'global' | 'project' = 'global',
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(getMemoryFilePath(scope), 'utf-8');
|
||||
} catch (err) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (!(error instanceof Error) || error.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static readonly Name: string = memoryToolSchemaData.name!;
|
||||
constructor() {
|
||||
super(
|
||||
MemoryTool.Name,
|
||||
'Save Memory',
|
||||
memoryToolDescription,
|
||||
Icon.LightBulb,
|
||||
memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
||||
/**
|
||||
* Computes the new content that would result from adding a memory entry
|
||||
*/
|
||||
function computeNewContent(currentContent: string, fact: string): string {
|
||||
let processedText = fact.trim();
|
||||
processedText = processedText.replace(/^(-+\s*)+/, '').trim();
|
||||
const newMemoryItem = `- ${processedText}`;
|
||||
|
||||
const headerIndex = currentContent.indexOf(MEMORY_SECTION_HEADER);
|
||||
|
||||
if (headerIndex === -1) {
|
||||
// Header not found, append header and then the entry
|
||||
const separator = ensureNewlineSeparation(currentContent);
|
||||
return (
|
||||
currentContent +
|
||||
`${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n`
|
||||
);
|
||||
} else {
|
||||
// Header found, find where to insert the new memory entry
|
||||
const startOfSectionContent = headerIndex + MEMORY_SECTION_HEADER.length;
|
||||
let endOfSectionIndex = currentContent.indexOf(
|
||||
'\n## ',
|
||||
startOfSectionContent,
|
||||
);
|
||||
if (endOfSectionIndex === -1) {
|
||||
endOfSectionIndex = currentContent.length; // End of file
|
||||
}
|
||||
|
||||
const beforeSectionMarker = currentContent
|
||||
.substring(0, startOfSectionContent)
|
||||
.trimEnd();
|
||||
let sectionContent = currentContent
|
||||
.substring(startOfSectionContent, endOfSectionIndex)
|
||||
.trimEnd();
|
||||
const afterSectionMarker = currentContent.substring(endOfSectionIndex);
|
||||
|
||||
sectionContent += `\n${newMemoryItem}`;
|
||||
return (
|
||||
`${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(params: SaveMemoryParams): string {
|
||||
const scope = params.scope || 'global';
|
||||
class MemoryToolInvocation extends BaseToolInvocation<
|
||||
SaveMemoryParams,
|
||||
ToolResult
|
||||
> {
|
||||
private static readonly allowlist: Set<string> = new Set();
|
||||
|
||||
getDescription(): string {
|
||||
const scope = this.params.scope || 'global';
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
return `in ${tildeifyPath(memoryFilePath)} (${scope})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current content of the memory file
|
||||
*/
|
||||
private async readMemoryFileContent(
|
||||
scope: 'global' | 'project' = 'global',
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(getMemoryFilePath(scope), 'utf-8');
|
||||
} catch (err) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (!(error instanceof Error) || error.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the new content that would result from adding a memory entry
|
||||
*/
|
||||
private computeNewContent(currentContent: string, fact: string): string {
|
||||
let processedText = fact.trim();
|
||||
processedText = processedText.replace(/^(-+\s*)+/, '').trim();
|
||||
const newMemoryItem = `- ${processedText}`;
|
||||
|
||||
const headerIndex = currentContent.indexOf(MEMORY_SECTION_HEADER);
|
||||
|
||||
if (headerIndex === -1) {
|
||||
// Header not found, append header and then the entry
|
||||
const separator = ensureNewlineSeparation(currentContent);
|
||||
return (
|
||||
currentContent +
|
||||
`${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n`
|
||||
);
|
||||
} else {
|
||||
// Header found, find where to insert the new memory entry
|
||||
const startOfSectionContent = headerIndex + MEMORY_SECTION_HEADER.length;
|
||||
let endOfSectionIndex = currentContent.indexOf(
|
||||
'\n## ',
|
||||
startOfSectionContent,
|
||||
);
|
||||
if (endOfSectionIndex === -1) {
|
||||
endOfSectionIndex = currentContent.length; // End of file
|
||||
}
|
||||
|
||||
const beforeSectionMarker = currentContent
|
||||
.substring(0, startOfSectionContent)
|
||||
.trimEnd();
|
||||
let sectionContent = currentContent
|
||||
.substring(startOfSectionContent, endOfSectionIndex)
|
||||
.trimEnd();
|
||||
const afterSectionMarker = currentContent.substring(endOfSectionIndex);
|
||||
|
||||
sectionContent += `\n${newMemoryItem}`;
|
||||
return (
|
||||
`${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async shouldConfirmExecute(
|
||||
params: SaveMemoryParams,
|
||||
override async shouldConfirmExecute(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolEditConfirmationDetails | false> {
|
||||
// If scope is not specified, prompt the user to choose
|
||||
if (!params.scope) {
|
||||
if (!this.params.scope) {
|
||||
const globalPath = tildeifyPath(getMemoryFilePath('global'));
|
||||
const projectPath = tildeifyPath(getMemoryFilePath('project'));
|
||||
|
||||
@@ -227,9 +217,9 @@ export class MemoryTool
|
||||
title: `Choose Memory Storage Location`,
|
||||
fileName: 'Memory Storage Options',
|
||||
filePath: '',
|
||||
fileDiff: `Choose where to save this memory:\n\n"${params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
|
||||
fileDiff: `Choose where to save this memory:\n\n"${this.params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
|
||||
originalContent: '',
|
||||
newContent: `Memory to save: ${params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
|
||||
newContent: `Memory to save: ${this.params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
|
||||
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
|
||||
// This will be handled by the execution flow
|
||||
},
|
||||
@@ -237,19 +227,19 @@ export class MemoryTool
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
const scope = params.scope;
|
||||
const scope = this.params.scope;
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
const allowlistKey = `${memoryFilePath}_${scope}`;
|
||||
|
||||
if (MemoryTool.allowlist.has(allowlistKey)) {
|
||||
if (MemoryToolInvocation.allowlist.has(allowlistKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read current content of the memory file
|
||||
const currentContent = await this.readMemoryFileContent(scope);
|
||||
const currentContent = await readMemoryFileContent(scope);
|
||||
|
||||
// Calculate the new content that will be written to the memory file
|
||||
const newContent = this.computeNewContent(currentContent, params.fact);
|
||||
const newContent = computeNewContent(currentContent, this.params.fact);
|
||||
|
||||
const fileName = path.basename(memoryFilePath);
|
||||
const fileDiff = Diff.createPatch(
|
||||
@@ -271,13 +261,120 @@ export class MemoryTool
|
||||
newContent,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
MemoryTool.allowlist.add(allowlistKey);
|
||||
MemoryToolInvocation.allowlist.add(allowlistKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const { fact, modified_by_user, modified_content } = this.params;
|
||||
|
||||
if (!fact || typeof fact !== 'string' || fact.trim() === '') {
|
||||
const errorMessage = 'Parameter "fact" must be a non-empty string.';
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// If scope is not specified, prompt the user to choose
|
||||
if (!this.params.scope) {
|
||||
const errorMessage =
|
||||
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scope = this.params.scope;
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
|
||||
try {
|
||||
if (modified_by_user && modified_content !== undefined) {
|
||||
// User modified the content in external editor, write it directly
|
||||
await fs.mkdir(path.dirname(memoryFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(memoryFilePath, modified_content, 'utf-8');
|
||||
const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
}),
|
||||
returnDisplay: successMessage,
|
||||
};
|
||||
} else {
|
||||
// Use the normal memory entry logic
|
||||
await MemoryTool.performAddMemoryEntry(fact, memoryFilePath, {
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
mkdir: fs.mkdir,
|
||||
});
|
||||
const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
}),
|
||||
returnDisplay: successMessage,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`,
|
||||
);
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to save memory. Detail: ${errorMessage}`,
|
||||
}),
|
||||
returnDisplay: `Error saving memory: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryTool
|
||||
extends BaseDeclarativeTool<SaveMemoryParams, ToolResult>
|
||||
implements ModifiableDeclarativeTool<SaveMemoryParams>
|
||||
{
|
||||
static readonly Name: string = memoryToolSchemaData.name!;
|
||||
constructor() {
|
||||
super(
|
||||
MemoryTool.Name,
|
||||
'Save Memory',
|
||||
memoryToolDescription,
|
||||
Kind.Think,
|
||||
memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
override validateToolParams(params: SaveMemoryParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (params.fact.trim() === '') {
|
||||
return 'Parameter "fact" must be a non-empty string.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(params: SaveMemoryParams) {
|
||||
return new MemoryToolInvocation(params);
|
||||
}
|
||||
|
||||
static async performAddMemoryEntry(
|
||||
text: string,
|
||||
memoryFilePath: string,
|
||||
@@ -348,90 +445,16 @@ export class MemoryTool
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: SaveMemoryParams,
|
||||
_signal: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
const { fact, modified_by_user, modified_content } = params;
|
||||
|
||||
if (!fact || typeof fact !== 'string' || fact.trim() === '') {
|
||||
const errorMessage = 'Parameter "fact" must be a non-empty string.';
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// If scope is not specified, prompt the user to choose
|
||||
if (!params.scope) {
|
||||
const errorMessage =
|
||||
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scope = params.scope;
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
|
||||
try {
|
||||
if (modified_by_user && modified_content !== undefined) {
|
||||
// User modified the content in external editor, write it directly
|
||||
await fs.mkdir(path.dirname(memoryFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(memoryFilePath, modified_content, 'utf-8');
|
||||
const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
}),
|
||||
returnDisplay: successMessage,
|
||||
};
|
||||
} else {
|
||||
// Use the normal memory entry logic
|
||||
await MemoryTool.performAddMemoryEntry(fact, memoryFilePath, {
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
mkdir: fs.mkdir,
|
||||
});
|
||||
const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
}),
|
||||
returnDisplay: successMessage,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`,
|
||||
);
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to save memory. Detail: ${errorMessage}`,
|
||||
}),
|
||||
returnDisplay: `Error saving memory: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
|
||||
return {
|
||||
getFilePath: (params: SaveMemoryParams) =>
|
||||
getMemoryFilePath(params.scope || 'global'),
|
||||
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
|
||||
this.readMemoryFileContent(params.scope || 'global'),
|
||||
readMemoryFileContent(params.scope || 'global'),
|
||||
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
|
||||
const scope = params.scope || 'global';
|
||||
const currentContent = await this.readMemoryFileContent(scope);
|
||||
return this.computeNewContent(currentContent, params.fact);
|
||||
const currentContent = await readMemoryFileContent(scope);
|
||||
return computeNewContent(currentContent, params.fact);
|
||||
},
|
||||
createUpdatedParams: (
|
||||
_oldContent: string,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Icon,
|
||||
Kind,
|
||||
ToolInvocation,
|
||||
ToolLocation,
|
||||
ToolResult,
|
||||
@@ -173,7 +173,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
ReadFileTool.Name,
|
||||
'ReadFile',
|
||||
`Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.`,
|
||||
Icon.FileSearch,
|
||||
Kind.Read,
|
||||
{
|
||||
properties: {
|
||||
absolute_path: {
|
||||
@@ -198,7 +198,9 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
protected validateToolParams(params: ReadFileToolParams): string | null {
|
||||
protected override validateToolParams(
|
||||
params: ReadFileToolParams,
|
||||
): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
|
||||
@@ -121,66 +121,71 @@ describe('ReadManyFilesTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateParams', () => {
|
||||
it('should return null for valid relative paths within root', () => {
|
||||
describe('build', () => {
|
||||
it('should return an invocation for valid relative paths within root', () => {
|
||||
const params = { paths: ['file1.txt', 'subdir/file2.txt'] };
|
||||
expect(tool.validateParams(params)).toBeNull();
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for valid glob patterns within root', () => {
|
||||
it('should return an invocation for valid glob patterns within root', () => {
|
||||
const params = { paths: ['*.txt', 'subdir/**/*.js'] };
|
||||
expect(tool.validateParams(params)).toBeNull();
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for paths trying to escape the root (e.g., ../) as execute handles this', () => {
|
||||
it('should return an invocation for paths trying to escape the root (e.g., ../) as execute handles this', () => {
|
||||
const params = { paths: ['../outside.txt'] };
|
||||
expect(tool.validateParams(params)).toBeNull();
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for absolute paths as execute handles this', () => {
|
||||
it('should return an invocation for absolute paths as execute handles this', () => {
|
||||
const params = { paths: [path.join(tempDirOutsideRoot, 'absolute.txt')] };
|
||||
expect(tool.validateParams(params)).toBeNull();
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error if paths array is empty', () => {
|
||||
it('should throw error if paths array is empty', () => {
|
||||
const params = { paths: [] };
|
||||
expect(tool.validateParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'params/paths must NOT have fewer than 1 items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for valid exclude and include patterns', () => {
|
||||
it('should return an invocation for valid exclude and include patterns', () => {
|
||||
const params = {
|
||||
paths: ['src/**/*.ts'],
|
||||
exclude: ['**/*.test.ts'],
|
||||
include: ['src/utils/*.ts'],
|
||||
};
|
||||
expect(tool.validateParams(params)).toBeNull();
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error if paths array contains an empty string', () => {
|
||||
it('should throw error if paths array contains an empty string', () => {
|
||||
const params = { paths: ['file1.txt', ''] };
|
||||
expect(tool.validateParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'params/paths/1 must NOT have fewer than 1 characters',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if include array contains non-string elements', () => {
|
||||
it('should throw error if include array contains non-string elements', () => {
|
||||
const params = {
|
||||
paths: ['file1.txt'],
|
||||
include: ['*.ts', 123] as string[],
|
||||
};
|
||||
expect(tool.validateParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'params/include/1 must be string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if exclude array contains non-string elements', () => {
|
||||
it('should throw error if exclude array contains non-string elements', () => {
|
||||
const params = {
|
||||
paths: ['file1.txt'],
|
||||
exclude: ['*.log', {}] as string[],
|
||||
};
|
||||
expect(tool.validateParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'params/exclude/1 must be string',
|
||||
);
|
||||
});
|
||||
@@ -201,7 +206,8 @@ describe('ReadManyFilesTool', () => {
|
||||
it('should read a single specified file', async () => {
|
||||
createFile('file1.txt', 'Content of file1');
|
||||
const params = { paths: ['file1.txt'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const expectedPath = path.join(tempRootDir, 'file1.txt');
|
||||
expect(result.llmContent).toEqual([
|
||||
`--- ${expectedPath} ---\n\nContent of file1\n\n`,
|
||||
@@ -215,7 +221,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('file1.txt', 'Content1');
|
||||
createFile('subdir/file2.js', 'Content2');
|
||||
const params = { paths: ['file1.txt', 'subdir/file2.js'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath1 = path.join(tempRootDir, 'file1.txt');
|
||||
const expectedPath2 = path.join(tempRootDir, 'subdir/file2.js');
|
||||
@@ -239,7 +246,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('another.txt', 'Another text');
|
||||
createFile('sub/data.json', '{}');
|
||||
const params = { paths: ['*.txt'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath1 = path.join(tempRootDir, 'file.txt');
|
||||
const expectedPath2 = path.join(tempRootDir, 'another.txt');
|
||||
@@ -263,7 +271,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('src/main.ts', 'Main content');
|
||||
createFile('src/main.test.ts', 'Test content');
|
||||
const params = { paths: ['src/**/*.ts'], exclude: ['**/*.test.ts'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath = path.join(tempRootDir, 'src/main.ts');
|
||||
expect(content).toEqual([`--- ${expectedPath} ---\n\nMain content\n\n`]);
|
||||
@@ -277,7 +286,8 @@ describe('ReadManyFilesTool', () => {
|
||||
|
||||
it('should handle nonexistent specific files gracefully', async () => {
|
||||
const params = { paths: ['nonexistent-file.txt'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
'No files matching the criteria were found or all were skipped.',
|
||||
]);
|
||||
@@ -290,7 +300,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('node_modules/some-lib/index.js', 'lib code');
|
||||
createFile('src/app.js', 'app code');
|
||||
const params = { paths: ['**/*.js'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath = path.join(tempRootDir, 'src/app.js');
|
||||
expect(content).toEqual([`--- ${expectedPath} ---\n\napp code\n\n`]);
|
||||
@@ -306,7 +317,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('node_modules/some-lib/index.js', 'lib code');
|
||||
createFile('src/app.js', 'app code');
|
||||
const params = { paths: ['**/*.js'], useDefaultExcludes: false };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath1 = path.join(
|
||||
tempRootDir,
|
||||
@@ -334,7 +346,8 @@ describe('ReadManyFilesTool', () => {
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
);
|
||||
const params = { paths: ['*.png'] }; // Explicitly requesting .png
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
@@ -356,7 +369,8 @@ describe('ReadManyFilesTool', () => {
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
);
|
||||
const params = { paths: ['myExactImage.png'] }; // Explicitly requesting by full name
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
@@ -373,7 +387,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createBinaryFile('document.pdf', Buffer.from('%PDF-1.4...'));
|
||||
createFile('notes.txt', 'text notes');
|
||||
const params = { paths: ['*'] }; // Generic glob, not specific to .pdf
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath = path.join(tempRootDir, 'notes.txt');
|
||||
expect(
|
||||
@@ -392,7 +407,8 @@ describe('ReadManyFilesTool', () => {
|
||||
it('should include PDF files as inlineData parts if explicitly requested by extension', async () => {
|
||||
createBinaryFile('important.pdf', Buffer.from('%PDF-1.4...'));
|
||||
const params = { paths: ['*.pdf'] }; // Explicitly requesting .pdf files
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
@@ -406,7 +422,8 @@ describe('ReadManyFilesTool', () => {
|
||||
it('should include PDF files as inlineData parts if explicitly requested by name', async () => {
|
||||
createBinaryFile('report-final.pdf', Buffer.from('%PDF-1.4...'));
|
||||
const params = { paths: ['report-final.pdf'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
@@ -422,7 +439,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('bar.ts', '');
|
||||
createFile('foo.quux', '');
|
||||
const params = { paths: ['foo.bar', 'bar.ts', 'foo.quux'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.returnDisplay).not.toContain('foo.bar');
|
||||
expect(result.returnDisplay).not.toContain('foo.quux');
|
||||
expect(result.returnDisplay).toContain('bar.ts');
|
||||
@@ -451,7 +469,8 @@ describe('ReadManyFilesTool', () => {
|
||||
fs.writeFileSync(path.join(tempDir2, 'file2.txt'), 'Content2');
|
||||
|
||||
const params = { paths: ['*.txt'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`llmContent is not an array: ${content}`);
|
||||
@@ -486,7 +505,8 @@ describe('ReadManyFilesTool', () => {
|
||||
createFile('large-file.txt', longContent);
|
||||
|
||||
const params = { paths: ['*.txt'] };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
|
||||
const normalFileContent = content.find((c) => c.includes('file1.txt'));
|
||||
@@ -541,7 +561,8 @@ describe('ReadManyFilesTool', () => {
|
||||
});
|
||||
|
||||
const params = { paths: files };
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// Verify all files were processed
|
||||
const content = result.llmContent as string[];
|
||||
@@ -569,7 +590,8 @@ describe('ReadManyFilesTool', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const result = await tool.execute(params, new AbortController().signal);
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const content = result.llmContent as string[];
|
||||
|
||||
// Should successfully process valid files despite one failure
|
||||
@@ -606,7 +628,8 @@ describe('ReadManyFilesTool', () => {
|
||||
return 'text';
|
||||
});
|
||||
|
||||
await tool.execute({ paths: files }, new AbortController().signal);
|
||||
const invocation = tool.build({ paths: files });
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
console.log('Execution order:', executionOrder);
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseTool, Icon, ToolResult } from './tools.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import * as path from 'path';
|
||||
@@ -138,120 +144,28 @@ const DEFAULT_EXCLUDES: string[] = [
|
||||
|
||||
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
|
||||
|
||||
/**
|
||||
* Tool implementation for finding and reading multiple text files from the local filesystem
|
||||
* within a specified target directory. The content is concatenated.
|
||||
* It is intended to run in an environment with access to the local file system (e.g., a Node.js backend).
|
||||
*/
|
||||
export class ReadManyFilesTool extends BaseTool<
|
||||
class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
||||
ReadManyFilesParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name: string = 'read_many_files';
|
||||
|
||||
constructor(private config: Config) {
|
||||
const parameterSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
paths: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
minItems: 1,
|
||||
description:
|
||||
"Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']",
|
||||
},
|
||||
include: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
description:
|
||||
'Optional. Additional glob patterns to include. These are merged with `paths`. Example: ["*.test.ts"] to specifically add test files if they were broadly excluded.',
|
||||
default: [],
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
description:
|
||||
'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: ["**/*.log", "temp/"]',
|
||||
default: [],
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.',
|
||||
default: true,
|
||||
},
|
||||
useDefaultExcludes: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
|
||||
default: true,
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['paths'],
|
||||
};
|
||||
|
||||
super(
|
||||
ReadManyFilesTool.Name,
|
||||
'ReadManyFiles',
|
||||
`Reads content from multiple files specified by paths or glob patterns within a configured target directory. For text files, it concatenates their content into a single string. It is primarily designed for text-based files. However, it can also process image (e.g., .png, .jpg) and PDF (.pdf) files if their file names or extensions are explicitly included in the 'paths' argument. For these explicitly requested non-text files, their data is read and included in a format suitable for model consumption (e.g., base64 encoded).
|
||||
|
||||
This tool is useful when you need to understand or analyze a collection of files, such as:
|
||||
- Getting an overview of a codebase or parts of it (e.g., all TypeScript files in the 'src' directory).
|
||||
- Finding where specific functionality is implemented if the user asks broad questions about code.
|
||||
- Reviewing documentation files (e.g., all Markdown files in the 'docs' directory).
|
||||
- Gathering context from multiple configuration files.
|
||||
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
||||
|
||||
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
||||
Icon.FileSearch,
|
||||
parameterSchema,
|
||||
);
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
params: ReadManyFilesParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
validateParams(params: ReadManyFilesParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getDescription(params: ReadManyFilesParams): string {
|
||||
const allPatterns = [...params.paths, ...(params.include || [])];
|
||||
const pathDesc = `using patterns: \`${allPatterns.join('`, `')}\` (within target directory: \`${this.config.getTargetDir()}\`)`;
|
||||
getDescription(): string {
|
||||
const allPatterns = [...this.params.paths, ...(this.params.include || [])];
|
||||
const pathDesc = `using patterns:
|
||||
${allPatterns.join('`, `')}
|
||||
(within target directory:
|
||||
${this.config.getTargetDir()}
|
||||
) `;
|
||||
|
||||
// Determine the final list of exclusion patterns exactly as in execute method
|
||||
const paramExcludes = params.exclude || [];
|
||||
const paramUseDefaultExcludes = params.useDefaultExcludes !== false;
|
||||
const paramExcludes = this.params.exclude || [];
|
||||
const paramUseDefaultExcludes = this.params.useDefaultExcludes !== false;
|
||||
const geminiIgnorePatterns = this.config
|
||||
.getFileService()
|
||||
.getGeminiIgnorePatterns();
|
||||
@@ -260,7 +174,16 @@ Use this tool when the user's query implies needing the content of several files
|
||||
? [...DEFAULT_EXCLUDES, ...paramExcludes, ...geminiIgnorePatterns]
|
||||
: [...paramExcludes, ...geminiIgnorePatterns];
|
||||
|
||||
let excludeDesc = `Excluding: ${finalExclusionPatternsForDescription.length > 0 ? `patterns like \`${finalExclusionPatternsForDescription.slice(0, 2).join('`, `')}${finalExclusionPatternsForDescription.length > 2 ? '...`' : '`'}` : 'none specified'}`;
|
||||
let excludeDesc = `Excluding: ${
|
||||
finalExclusionPatternsForDescription.length > 0
|
||||
? `patterns like
|
||||
${finalExclusionPatternsForDescription
|
||||
.slice(0, 2)
|
||||
.join(
|
||||
'`, `',
|
||||
)}${finalExclusionPatternsForDescription.length > 2 ? '...`' : '`'}`
|
||||
: 'none specified'
|
||||
}`;
|
||||
|
||||
// Add a note if .geminiignore patterns contributed to the final list of exclusions
|
||||
if (geminiIgnorePatterns.length > 0) {
|
||||
@@ -272,37 +195,29 @@ Use this tool when the user's query implies needing the content of several files
|
||||
}
|
||||
}
|
||||
|
||||
return `Will attempt to read and concatenate files ${pathDesc}. ${excludeDesc}. File encoding: ${DEFAULT_ENCODING}. Separator: "${DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace('{filePath}', 'path/to/file.ext')}".`;
|
||||
return `Will attempt to read and concatenate files ${pathDesc}. ${excludeDesc}. File encoding: ${DEFAULT_ENCODING}. Separator: "${DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
|
||||
'{filePath}',
|
||||
'path/to/file.ext',
|
||||
)}".`;
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: ReadManyFilesParams,
|
||||
signal: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
const validationError = this.validateParams(params);
|
||||
if (validationError) {
|
||||
return {
|
||||
llmContent: `Error: Invalid parameters for ${this.displayName}. Reason: ${validationError}`,
|
||||
returnDisplay: `## Parameter Error\n\n${validationError}`,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
const {
|
||||
paths: inputPatterns,
|
||||
include = [],
|
||||
exclude = [],
|
||||
useDefaultExcludes = true,
|
||||
} = params;
|
||||
} = this.params;
|
||||
|
||||
const defaultFileIgnores =
|
||||
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
const fileFilteringOptions = {
|
||||
respectGitIgnore:
|
||||
params.file_filtering_options?.respect_git_ignore ??
|
||||
this.params.file_filtering_options?.respect_git_ignore ??
|
||||
defaultFileIgnores.respectGitIgnore, // Use the property from the returned object
|
||||
respectGeminiIgnore:
|
||||
params.file_filtering_options?.respect_gemini_ignore ??
|
||||
this.params.file_filtering_options?.respect_gemini_ignore ??
|
||||
defaultFileIgnores.respectGeminiIgnore, // Use the property from the returned object
|
||||
};
|
||||
// Get centralized file discovery service
|
||||
@@ -614,3 +529,119 @@ Use this tool when the user's query implies needing the content of several files
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool implementation for finding and reading multiple text files from the local filesystem
|
||||
* within a specified target directory. The content is concatenated.
|
||||
* It is intended to run in an environment with access to the local file system (e.g., a Node.js backend).
|
||||
*/
|
||||
export class ReadManyFilesTool extends BaseDeclarativeTool<
|
||||
ReadManyFilesParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name: string = 'read_many_files';
|
||||
|
||||
constructor(private config: Config) {
|
||||
const parameterSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
paths: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
minItems: 1,
|
||||
description:
|
||||
"Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']",
|
||||
},
|
||||
include: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
description:
|
||||
'Optional. Additional glob patterns to include. These are merged with `paths`. Example: "*.test.ts" to specifically add test files if they were broadly excluded.',
|
||||
default: [],
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
description:
|
||||
'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: "**/*.log", "temp/"',
|
||||
default: [],
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.',
|
||||
default: true,
|
||||
},
|
||||
useDefaultExcludes: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
|
||||
default: true,
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['paths'],
|
||||
};
|
||||
|
||||
super(
|
||||
ReadManyFilesTool.Name,
|
||||
'ReadManyFiles',
|
||||
`Reads content from multiple files specified by paths or glob patterns within a configured target directory. For text files, it concatenates their content into a single string. It is primarily designed for text-based files. However, it can also process image (e.g., .png, .jpg) and PDF (.pdf) files if their file names or extensions are explicitly included in the 'paths' argument. For these explicitly requested non-text files, their data is read and included in a format suitable for model consumption (e.g., base64 encoded).
|
||||
|
||||
This tool is useful when you need to understand or analyze a collection of files, such as:
|
||||
- Getting an overview of a codebase or parts of it (e.g., all TypeScript files in the 'src' directory).
|
||||
- Finding where specific functionality is implemented if the user asks broad questions about code.
|
||||
- Reviewing documentation files (e.g., all Markdown files in the 'docs' directory).
|
||||
- Gathering context from multiple configuration files.
|
||||
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
||||
|
||||
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
||||
Kind.Read,
|
||||
parameterSchema,
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParams(
|
||||
params: ReadManyFilesParams,
|
||||
): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ReadManyFilesParams,
|
||||
): ToolInvocation<ReadManyFilesParams, ToolResult> {
|
||||
return new ReadManyFilesToolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ vi.mock('../utils/summarizer.js');
|
||||
|
||||
import { isCommandAllowed } from '../utils/shell-utils.js';
|
||||
import { ShellTool } from './shell.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import {
|
||||
type ShellExecutionResult,
|
||||
@@ -98,22 +97,25 @@ describe('ShellTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should return null for a valid command', () => {
|
||||
expect(shellTool.validateToolParams({ command: 'ls -l' })).toBeNull();
|
||||
describe('build', () => {
|
||||
it('should return an invocation for a valid command', () => {
|
||||
const invocation = shellTool.build({ command: 'ls -l' });
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return an error for an empty command', () => {
|
||||
expect(shellTool.validateToolParams({ command: ' ' })).toBe(
|
||||
it('should throw an error for an empty command', () => {
|
||||
expect(() => shellTool.build({ command: ' ' })).toThrow(
|
||||
'Command cannot be empty.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an error for a non-existent directory', () => {
|
||||
it('should throw an error for a non-existent directory', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
expect(
|
||||
shellTool.validateToolParams({ command: 'ls', directory: 'rel/path' }),
|
||||
).toBe("Directory 'rel/path' is not a registered workspace directory.");
|
||||
expect(() =>
|
||||
shellTool.build({ command: 'ls', directory: 'rel/path' }),
|
||||
).toThrow(
|
||||
"Directory 'rel/path' is not a registered workspace directory.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,10 +141,8 @@ describe('ShellTool', () => {
|
||||
};
|
||||
|
||||
it('should wrap command on linux and parse pgrep output', async () => {
|
||||
const promise = shellTool.execute(
|
||||
{ command: 'my-command &' },
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = shellTool.build({ command: 'my-command &' });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
@@ -164,8 +164,9 @@ describe('ShellTool', () => {
|
||||
|
||||
it('should not wrap command on windows', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const promise = shellTool.execute({ command: 'dir' }, mockAbortSignal);
|
||||
resolveExecutionPromise({
|
||||
const invocation = shellTool.build({ command: 'dir' });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
stdout: '',
|
||||
@@ -187,10 +188,8 @@ describe('ShellTool', () => {
|
||||
|
||||
it('should format error messages correctly', async () => {
|
||||
const error = new Error('wrapped command failed');
|
||||
const promise = shellTool.execute(
|
||||
{ command: 'user-command' },
|
||||
mockAbortSignal,
|
||||
);
|
||||
const invocation = shellTool.build({ command: 'user-command' });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({
|
||||
error,
|
||||
exitCode: 1,
|
||||
@@ -209,40 +208,19 @@ describe('ShellTool', () => {
|
||||
expect(result.llmContent).not.toContain('pgrep');
|
||||
});
|
||||
|
||||
it('should return error with error property for invalid parameters', async () => {
|
||||
const result = await shellTool.execute(
|
||||
{ command: '' }, // Empty command is invalid
|
||||
mockAbortSignal,
|
||||
it('should throw an error for invalid parameters', () => {
|
||||
expect(() => shellTool.build({ command: '' })).toThrow(
|
||||
'Command cannot be empty.',
|
||||
);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'Could not execute command due to invalid parameters:',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('Command cannot be empty.');
|
||||
expect(result.error).toEqual({
|
||||
message: 'Command cannot be empty.',
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error with error property for invalid directory', async () => {
|
||||
it('should throw an error for invalid directory', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const result = await shellTool.execute(
|
||||
{ command: 'ls', directory: 'nonexistent' },
|
||||
mockAbortSignal,
|
||||
expect(() =>
|
||||
shellTool.build({ command: 'ls', directory: 'nonexistent' }),
|
||||
).toThrow(
|
||||
`Directory 'nonexistent' is not a registered workspace directory.`,
|
||||
);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'Could not execute command due to invalid parameters:',
|
||||
);
|
||||
expect(result.returnDisplay).toBe(
|
||||
"Directory 'nonexistent' is not a registered workspace directory.",
|
||||
);
|
||||
expect(result.error).toEqual({
|
||||
message:
|
||||
"Directory 'nonexistent' is not a registered workspace directory.",
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
});
|
||||
});
|
||||
|
||||
it('should summarize output when configured', async () => {
|
||||
@@ -253,7 +231,8 @@ describe('ShellTool', () => {
|
||||
'summarized output',
|
||||
);
|
||||
|
||||
const promise = shellTool.execute({ command: 'ls' }, mockAbortSignal);
|
||||
const invocation = shellTool.build({ command: 'ls' });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveExecutionPromise({
|
||||
output: 'long output',
|
||||
rawOutput: Buffer.from('long output'),
|
||||
@@ -285,9 +264,8 @@ describe('ShellTool', () => {
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
|
||||
|
||||
await expect(
|
||||
shellTool.execute({ command: 'a-command' }, mockAbortSignal),
|
||||
).rejects.toThrow(error);
|
||||
const invocation = shellTool.build({ command: 'a-command' });
|
||||
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
@@ -304,11 +282,8 @@ describe('ShellTool', () => {
|
||||
});
|
||||
|
||||
it('should throttle text output updates', async () => {
|
||||
const promise = shellTool.execute(
|
||||
{ command: 'stream' },
|
||||
mockAbortSignal,
|
||||
updateOutputMock,
|
||||
);
|
||||
const invocation = shellTool.build({ command: 'stream' });
|
||||
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
||||
|
||||
// First chunk, should be throttled.
|
||||
mockShellOutputCallback({
|
||||
@@ -347,11 +322,8 @@ describe('ShellTool', () => {
|
||||
});
|
||||
|
||||
it('should immediately show binary detection message and throttle progress', async () => {
|
||||
const promise = shellTool.execute(
|
||||
{ command: 'cat img' },
|
||||
mockAbortSignal,
|
||||
updateOutputMock,
|
||||
);
|
||||
const invocation = shellTool.build({ command: 'cat img' });
|
||||
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
||||
|
||||
mockShellOutputCallback({ type: 'binary_detected' });
|
||||
expect(updateOutputMock).toHaveBeenCalledOnce();
|
||||
@@ -399,8 +371,8 @@ describe('ShellTool', () => {
|
||||
describe('shouldConfirmExecute', () => {
|
||||
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
|
||||
const params = { command: 'npm install' };
|
||||
const confirmation = await shellTool.shouldConfirmExecute(
|
||||
params,
|
||||
const invocation = shellTool.build(params);
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
@@ -413,19 +385,15 @@ describe('ShellTool', () => {
|
||||
);
|
||||
|
||||
// Should now be whitelisted
|
||||
const secondConfirmation = await shellTool.shouldConfirmExecute(
|
||||
{ command: 'npm test' },
|
||||
const secondInvocation = shellTool.build({ command: 'npm test' });
|
||||
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(secondConfirmation).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip confirmation if validation fails', async () => {
|
||||
const confirmation = await shellTool.shouldConfirmExecute(
|
||||
{ command: '' },
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).toBe(false);
|
||||
it('should throw an error if validation fails', () => {
|
||||
expect(() => shellTool.build({ command: '' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -581,8 +549,8 @@ describe('validateToolParams', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should return null for valid directory', () => {
|
||||
describe('build', () => {
|
||||
it('should return an invocation for valid directory', () => {
|
||||
const config = {
|
||||
getCoreTools: () => undefined,
|
||||
getExcludeTools: () => undefined,
|
||||
@@ -591,14 +559,14 @@ describe('validateToolParams', () => {
|
||||
createMockWorkspaceContext('/root', ['/users/test']),
|
||||
} as unknown as Config;
|
||||
const shellTool = new ShellTool(config);
|
||||
const result = shellTool.validateToolParams({
|
||||
const invocation = shellTool.build({
|
||||
command: 'ls',
|
||||
directory: 'test',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(invocation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error for directory outside workspace', () => {
|
||||
it('should throw an error for directory outside workspace', () => {
|
||||
const config = {
|
||||
getCoreTools: () => undefined,
|
||||
getExcludeTools: () => undefined,
|
||||
@@ -607,10 +575,11 @@ describe('validateToolParams', () => {
|
||||
createMockWorkspaceContext('/root', ['/users/test']),
|
||||
} as unknown as Config;
|
||||
const shellTool = new ShellTool(config);
|
||||
const result = shellTool.validateToolParams({
|
||||
command: 'ls',
|
||||
directory: 'test2',
|
||||
});
|
||||
expect(result).toContain('is not a registered workspace directory');
|
||||
expect(() =>
|
||||
shellTool.build({
|
||||
command: 'ls',
|
||||
directory: 'test2',
|
||||
}),
|
||||
).toThrow('is not a registered workspace directory');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,14 +10,15 @@ import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { Config } from '../config/config.js';
|
||||
import {
|
||||
BaseTool,
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
Icon,
|
||||
Kind,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { summarizeToolOutput } from '../utils/summarizer.js';
|
||||
@@ -40,120 +41,36 @@ export interface ShellToolParams {
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
static Name: string = 'run_shell_command';
|
||||
private allowlist: Set<string> = new Set();
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
ShellTool.Name,
|
||||
'Shell',
|
||||
`This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
||||
|
||||
The following information is returned:
|
||||
|
||||
Command: Executed command.
|
||||
Directory: Directory (relative to project root) where command was executed, or \`(root)\`.
|
||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
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.
|
||||
Background PIDs: List of background processes started or \`(none)\`.
|
||||
Process Group PGID: Process group started or \`(none)\``,
|
||||
Icon.Terminal,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'Exact bash command to execute as `bash -c <command>`',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description:
|
||||
'(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
false, // output is not markdown
|
||||
true, // output can be updated
|
||||
);
|
||||
class ShellToolInvocation extends BaseToolInvocation<
|
||||
ShellToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
params: ShellToolParams,
|
||||
private readonly allowlist: Set<string>,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(params: ShellToolParams): string {
|
||||
let description = `${params.command}`;
|
||||
getDescription(): string {
|
||||
let description = `${this.params.command}`;
|
||||
// append optional [in directory]
|
||||
// note description is needed even if validation fails due to absolute path
|
||||
if (params.directory) {
|
||||
description += ` [in ${params.directory}]`;
|
||||
if (this.params.directory) {
|
||||
description += ` [in ${this.params.directory}]`;
|
||||
}
|
||||
// append optional (description), replacing any line breaks with spaces
|
||||
if (params.description) {
|
||||
description += ` (${params.description.replace(/\n/g, ' ')})`;
|
||||
if (this.params.description) {
|
||||
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
validateToolParams(params: ShellToolParams): string | null {
|
||||
const commandCheck = isCommandAllowed(params.command, this.config);
|
||||
if (!commandCheck.allowed) {
|
||||
if (!commandCheck.reason) {
|
||||
console.error(
|
||||
'Unexpected: isCommandAllowed returned false without a reason',
|
||||
);
|
||||
return `Command is not allowed: ${params.command}`;
|
||||
}
|
||||
return commandCheck.reason;
|
||||
}
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
if (!params.command.trim()) {
|
||||
return 'Command cannot be empty.';
|
||||
}
|
||||
if (getCommandRoots(params.command).length === 0) {
|
||||
return 'Could not identify command root to obtain permission from user.';
|
||||
}
|
||||
if (params.directory) {
|
||||
if (path.isAbsolute(params.directory)) {
|
||||
return 'Directory cannot be absolute. Please refer to workspace directories by their name.';
|
||||
}
|
||||
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
||||
const matchingDirs = workspaceDirs.filter(
|
||||
(dir) => path.basename(dir) === params.directory,
|
||||
);
|
||||
|
||||
if (matchingDirs.length === 0) {
|
||||
return `Directory '${params.directory}' is not a registered workspace directory.`;
|
||||
}
|
||||
|
||||
if (matchingDirs.length > 1) {
|
||||
return `Directory name '${params.directory}' is ambiguous as it matches multiple workspace directories.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async shouldConfirmExecute(
|
||||
params: ShellToolParams,
|
||||
override async shouldConfirmExecute(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.validateToolParams(params)) {
|
||||
return false; // skip confirmation, execute call will fail immediately
|
||||
}
|
||||
|
||||
const command = stripShellWrapper(params.command);
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const rootCommands = [...new Set(getCommandRoots(command))];
|
||||
const commandsToConfirm = rootCommands.filter(
|
||||
(command) => !this.allowlist.has(command),
|
||||
@@ -166,7 +83,7 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
const confirmationDetails: ToolExecuteConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Shell Command',
|
||||
command: params.command,
|
||||
command: this.params.command,
|
||||
rootCommand: commandsToConfirm.join(', '),
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
@@ -178,25 +95,10 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: ShellToolParams,
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: string) => void,
|
||||
): Promise<ToolResult> {
|
||||
const strippedCommand = stripShellWrapper(params.command);
|
||||
const validationError = this.validateToolParams({
|
||||
...params,
|
||||
command: strippedCommand,
|
||||
});
|
||||
if (validationError) {
|
||||
return {
|
||||
llmContent: `Could not execute command due to invalid parameters: ${validationError}`,
|
||||
returnDisplay: validationError,
|
||||
error: {
|
||||
message: validationError,
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
const strippedCommand = stripShellWrapper(this.params.command);
|
||||
|
||||
if (signal.aborted) {
|
||||
return {
|
||||
@@ -227,7 +129,7 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
|
||||
const cwd = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.directory || '',
|
||||
this.params.directory || '',
|
||||
);
|
||||
|
||||
let cumulativeStdout = '';
|
||||
@@ -327,12 +229,12 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
// Create a formatted error string for display, replacing the wrapper command
|
||||
// with the user-facing command.
|
||||
const finalError = result.error
|
||||
? result.error.message.replace(commandToExecute, params.command)
|
||||
? result.error.message.replace(commandToExecute, this.params.command)
|
||||
: '(none)';
|
||||
|
||||
llmContent = [
|
||||
`Command: ${params.command}`,
|
||||
`Directory: ${params.directory || '(root)'}`,
|
||||
`Command: ${this.params.command}`,
|
||||
`Directory: ${this.params.directory || '(root)'}`,
|
||||
`Stdout: ${result.stdout || '(empty)'}`,
|
||||
`Stderr: ${result.stderr || '(empty)'}`,
|
||||
`Error: ${finalError}`, // Use the cleaned error string.
|
||||
@@ -369,12 +271,12 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
}
|
||||
|
||||
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
|
||||
if (summarizeConfig && summarizeConfig[this.name]) {
|
||||
if (summarizeConfig && summarizeConfig[ShellTool.Name]) {
|
||||
const summary = await summarizeToolOutput(
|
||||
llmContent,
|
||||
this.config.getGeminiClient(),
|
||||
signal,
|
||||
summarizeConfig[this.name].tokenBudget,
|
||||
summarizeConfig[ShellTool.Name].tokenBudget,
|
||||
);
|
||||
return {
|
||||
llmContent: summary,
|
||||
@@ -429,3 +331,106 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
export class ShellTool extends BaseDeclarativeTool<
|
||||
ShellToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
static Name: string = 'run_shell_command';
|
||||
private allowlist: Set<string> = new Set();
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
ShellTool.Name,
|
||||
'Shell',
|
||||
`This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
||||
|
||||
The following information is returned:
|
||||
|
||||
Command: Executed command.
|
||||
Directory: Directory (relative to project root) where command was executed, or \`(root)\`.
|
||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
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.
|
||||
Background PIDs: List of background processes started or \`(none)\`.
|
||||
Process Group PGID: Process group started or \`(none)\``,
|
||||
Kind.Execute,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'Exact bash command to execute as `bash -c <command>`',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description:
|
||||
'(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
false, // output is not markdown
|
||||
true, // output can be updated
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParams(
|
||||
params: ShellToolParams,
|
||||
): string | null {
|
||||
const commandCheck = isCommandAllowed(params.command, this.config);
|
||||
if (!commandCheck.allowed) {
|
||||
if (!commandCheck.reason) {
|
||||
console.error(
|
||||
'Unexpected: isCommandAllowed returned false without a reason',
|
||||
);
|
||||
return `Command is not allowed: ${params.command}`;
|
||||
}
|
||||
return commandCheck.reason;
|
||||
}
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
if (!params.command.trim()) {
|
||||
return 'Command cannot be empty.';
|
||||
}
|
||||
if (getCommandRoots(params.command).length === 0) {
|
||||
return 'Could not identify command root to obtain permission from user.';
|
||||
}
|
||||
if (params.directory) {
|
||||
if (path.isAbsolute(params.directory)) {
|
||||
return 'Directory cannot be absolute. Please refer to workspace directories by their name.';
|
||||
}
|
||||
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
||||
const matchingDirs = workspaceDirs.filter(
|
||||
(dir) => path.basename(dir) === params.directory,
|
||||
);
|
||||
|
||||
if (matchingDirs.length === 0) {
|
||||
return `Directory '${params.directory}' is not a registered workspace directory.`;
|
||||
}
|
||||
|
||||
if (matchingDirs.length > 1) {
|
||||
return `Directory name '${params.directory}' is ambiguous as it matches multiple workspace directories.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ShellToolParams,
|
||||
): ToolInvocation<ShellToolParams, ToolResult> {
|
||||
return new ShellToolInvocation(this.config, params, this.allowlist);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FunctionDeclaration } from '@google/genai';
|
||||
import { AnyDeclarativeTool, Icon, ToolResult, BaseTool } from './tools.js';
|
||||
import { AnyDeclarativeTool, Kind, ToolResult, BaseTool } from './tools.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
@@ -19,8 +19,8 @@ export class DiscoveredTool extends BaseTool<ToolParams, ToolResult> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
name: string,
|
||||
readonly description: string,
|
||||
readonly parameterSchema: Record<string, unknown>,
|
||||
override readonly description: string,
|
||||
override readonly parameterSchema: Record<string, unknown>,
|
||||
) {
|
||||
const discoveryCmd = config.getToolDiscoveryCommand()!;
|
||||
const callCommand = config.getToolCallCommand()!;
|
||||
@@ -44,7 +44,7 @@ Signal: Signal number or \`(none)\` if no signal was received.
|
||||
name,
|
||||
name,
|
||||
description,
|
||||
Icon.Hammer,
|
||||
Kind.Other,
|
||||
parameterSchema,
|
||||
false, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
@@ -158,6 +158,18 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all tools from a specific MCP server.
|
||||
* @param serverName The name of the server to remove tools from.
|
||||
*/
|
||||
removeMcpToolsByServer(serverName: string): void {
|
||||
for (const [name, tool] of this.tools.entries()) {
|
||||
if (tool instanceof DiscoveredMCPTool && tool.serverName === serverName) {
|
||||
this.tools.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers tools from project (if available and configured).
|
||||
* Can be called multiple times to update discovered tools.
|
||||
|
||||
@@ -145,9 +145,9 @@ export interface ToolBuilder<
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The icon to display when interacting via ACP.
|
||||
* The kind of tool for categorization and permissions
|
||||
*/
|
||||
icon: Icon;
|
||||
kind: Kind;
|
||||
|
||||
/**
|
||||
* Function declaration schema from @google/genai.
|
||||
@@ -185,7 +185,7 @@ export abstract class DeclarativeTool<
|
||||
readonly name: string,
|
||||
readonly displayName: string,
|
||||
readonly description: string,
|
||||
readonly icon: Icon,
|
||||
readonly kind: Kind,
|
||||
readonly parameterSchema: unknown,
|
||||
readonly isOutputMarkdown: boolean = true,
|
||||
readonly canUpdateOutput: boolean = false,
|
||||
@@ -284,19 +284,19 @@ export abstract class BaseTool<
|
||||
* @param parameterSchema JSON Schema defining the parameters
|
||||
*/
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly displayName: string,
|
||||
readonly description: string,
|
||||
readonly icon: Icon,
|
||||
readonly parameterSchema: unknown,
|
||||
readonly isOutputMarkdown: boolean = true,
|
||||
readonly canUpdateOutput: boolean = false,
|
||||
override readonly name: string,
|
||||
override readonly displayName: string,
|
||||
override readonly description: string,
|
||||
override readonly kind: Kind,
|
||||
override readonly parameterSchema: unknown,
|
||||
override readonly isOutputMarkdown: boolean = true,
|
||||
override readonly canUpdateOutput: boolean = false,
|
||||
) {
|
||||
super(
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
icon,
|
||||
kind,
|
||||
parameterSchema,
|
||||
isOutputMarkdown,
|
||||
canUpdateOutput,
|
||||
@@ -320,7 +320,7 @@ export abstract class BaseTool<
|
||||
* @returns An error message string if invalid, null otherwise
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
validateToolParams(params: TParams): string | null {
|
||||
override validateToolParams(params: TParams): string | null {
|
||||
// Implementation would typically use a JSON Schema validator
|
||||
// This is a placeholder that should be implemented by derived classes
|
||||
return null;
|
||||
@@ -570,15 +570,16 @@ export enum ToolConfirmationOutcome {
|
||||
Cancel = 'cancel',
|
||||
}
|
||||
|
||||
export enum Icon {
|
||||
FileSearch = 'fileSearch',
|
||||
Folder = 'folder',
|
||||
Globe = 'globe',
|
||||
Hammer = 'hammer',
|
||||
LightBulb = 'lightBulb',
|
||||
Pencil = 'pencil',
|
||||
Regex = 'regex',
|
||||
Terminal = 'terminal',
|
||||
export enum Kind {
|
||||
Read = 'read',
|
||||
Edit = 'edit',
|
||||
Delete = 'delete',
|
||||
Move = 'move',
|
||||
Search = 'search',
|
||||
Execute = 'execute',
|
||||
Think = 'think',
|
||||
Fetch = 'fetch',
|
||||
Other = 'other',
|
||||
}
|
||||
|
||||
export interface ToolLocation {
|
||||
|
||||
@@ -23,7 +23,10 @@ describe('WebFetchTool', () => {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(confirmationDetails).toEqual({
|
||||
type: 'info',
|
||||
@@ -41,7 +44,10 @@ describe('WebFetchTool', () => {
|
||||
url: 'https://github.com/google/gemini-react/blob/main/README.md',
|
||||
prompt: 'summarize the README',
|
||||
};
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(confirmationDetails).toEqual({
|
||||
type: 'info',
|
||||
@@ -62,7 +68,10 @@ describe('WebFetchTool', () => {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(confirmationDetails).toBe(false);
|
||||
});
|
||||
@@ -77,7 +86,10 @@ describe('WebFetchTool', () => {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
if (
|
||||
confirmationDetails &&
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseTool, Icon, ToolResult } from './tools.js';
|
||||
import { BaseTool, Kind, ToolResult } from './tools.js';
|
||||
import { Type } from '@google/genai';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -55,7 +55,7 @@ export class WebSearchTool extends BaseTool<
|
||||
WebSearchTool.Name,
|
||||
'TavilySearch',
|
||||
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
|
||||
Icon.Globe,
|
||||
Kind.Search,
|
||||
{
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
@@ -89,7 +89,7 @@ export class WebSearchTool extends BaseTool<
|
||||
return null;
|
||||
}
|
||||
|
||||
getDescription(params: WebSearchToolParams): string {
|
||||
override getDescription(params: WebSearchToolParams): string {
|
||||
return `Searching the web for: "${params.query}"`;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ const mockConfigInternal = {
|
||||
getGeminiClient: vi.fn(), // Initialize as a plain mock function
|
||||
getIdeClient: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getIdeModeFeature: vi.fn(() => false),
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
|
||||
getApiKey: () => 'test-key',
|
||||
getModel: () => 'test-model',
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
ToolEditConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolCallConfirmationDetails,
|
||||
Icon,
|
||||
Kind,
|
||||
ToolLocation,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
@@ -82,7 +83,7 @@ export class WriteFileTool
|
||||
`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.`,
|
||||
Icon.Pencil,
|
||||
Kind.Edit,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
@@ -101,7 +102,11 @@ export class WriteFileTool
|
||||
);
|
||||
}
|
||||
|
||||
validateToolParams(params: WriteFileToolParams): string | null {
|
||||
override toolLocations(params: WriteFileToolParams): ToolLocation[] {
|
||||
return [{ path: params.file_path }];
|
||||
}
|
||||
|
||||
override validateToolParams(params: WriteFileToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(
|
||||
this.schema.parametersJsonSchema,
|
||||
params,
|
||||
@@ -139,7 +144,7 @@ export class WriteFileTool
|
||||
return null;
|
||||
}
|
||||
|
||||
getDescription(params: WriteFileToolParams): string {
|
||||
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"`;
|
||||
}
|
||||
@@ -153,7 +158,7 @@ export class WriteFileTool
|
||||
/**
|
||||
* Handles the confirmation prompt for the WriteFile tool.
|
||||
*/
|
||||
async shouldConfirmExecute(
|
||||
override async shouldConfirmExecute(
|
||||
params: WriteFileToolParams,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
@@ -195,7 +200,6 @@ export class WriteFileTool
|
||||
|
||||
const ideClient = this.config.getIdeClient();
|
||||
const ideConfirmation =
|
||||
this.config.getIdeModeFeature() &&
|
||||
this.config.getIdeMode() &&
|
||||
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
? ideClient.openDiff(params.file_path, correctedContent)
|
||||
|
||||
Reference in New Issue
Block a user