feat: migrate tools to use parametersJsonSchema. (#5330)

This commit is contained in:
Wanlin Du 2025-08-11 16:12:41 -07:00 committed by GitHub
parent f52d073dfb
commit d9fb08c9da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 141 additions and 423 deletions

View File

@ -19,7 +19,6 @@ import {
ToolResultDisplay, ToolResultDisplay,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js'; import { isNodeError } from '../utils/errors.js';
@ -443,27 +442,27 @@ Expectation for required parameters:
file_path: { file_path: {
description: description:
"The absolute path to the file to modify. Must start with '/'.", "The absolute path to the file to modify. Must start with '/'.",
type: Type.STRING, type: 'string',
}, },
old_string: { old_string: {
description: description:
'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
type: Type.STRING, type: 'string',
}, },
new_string: { new_string: {
description: description:
'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
type: Type.STRING, type: 'string',
}, },
expected_replacements: { expected_replacements: {
type: Type.NUMBER, type: 'number',
description: description:
'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.',
minimum: 1, minimum: 1,
}, },
}, },
required: ['file_path', 'old_string', 'new_string'], required: ['file_path', 'old_string', 'new_string'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
@ -474,7 +473,10 @@ Expectation for required parameters:
* @returns Error message string or null if valid * @returns Error message string or null if valid
*/ */
validateToolParams(params: EditToolParams): string | null { validateToolParams(params: EditToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -15,7 +15,6 @@ import {
ToolInvocation, ToolInvocation,
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai';
import { shortenPath, makeRelative } from '../utils/paths.js'; import { shortenPath, makeRelative } from '../utils/paths.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
@ -255,26 +254,26 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
pattern: { pattern: {
description: description:
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').", "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
type: Type.STRING, type: 'string',
}, },
path: { path: {
description: description:
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.', 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
type: Type.STRING, type: 'string',
}, },
case_sensitive: { case_sensitive: {
description: description:
'Optional: Whether the search should be case-sensitive. Defaults to false.', 'Optional: Whether the search should be case-sensitive. Defaults to false.',
type: Type.BOOLEAN, type: 'boolean',
}, },
respect_git_ignore: { respect_git_ignore: {
description: description:
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.', 'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN, type: 'boolean',
}, },
}, },
required: ['pattern'], required: ['pattern'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
@ -283,7 +282,10 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
* Validates the parameters for the tool. * Validates the parameters for the tool.
*/ */
validateToolParams(params: GlobToolParams): string | null { validateToolParams(params: GlobToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -17,7 +17,6 @@ import {
ToolInvocation, ToolInvocation,
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
@ -550,21 +549,21 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
pattern: { pattern: {
description: description:
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
type: Type.STRING, type: 'string',
}, },
path: { path: {
description: description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: Type.STRING, type: 'string',
}, },
include: { include: {
description: description:
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
type: Type.STRING, type: 'string',
}, },
}, },
required: ['pattern'], required: ['pattern'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
@ -616,7 +615,10 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
validateToolParams(params: GrepToolParams): string | null { validateToolParams(params: GrepToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -7,7 +7,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { BaseTool, Icon, ToolResult } from './tools.js'; import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
@ -82,35 +81,35 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
path: { path: {
description: description:
'The absolute path to the directory to list (must be absolute, not relative)', 'The absolute path to the directory to list (must be absolute, not relative)',
type: Type.STRING, type: 'string',
}, },
ignore: { ignore: {
description: 'List of glob patterns to ignore', description: 'List of glob patterns to ignore',
items: { items: {
type: Type.STRING, type: 'string',
}, },
type: Type.ARRAY, type: 'array',
}, },
file_filtering_options: { file_filtering_options: {
description: description:
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore', 'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
type: Type.OBJECT, type: 'object',
properties: { properties: {
respect_git_ignore: { respect_git_ignore: {
description: description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN, type: 'boolean',
}, },
respect_gemini_ignore: { respect_gemini_ignore: {
description: description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.', 'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: Type.BOOLEAN, type: 'boolean',
}, },
}, },
}, },
}, },
required: ['path'], required: ['path'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
@ -121,7 +120,10 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
validateToolParams(params: LSToolParams): string | null { validateToolParams(params: LSToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -12,13 +12,7 @@ import {
ToolMcpConfirmationDetails, ToolMcpConfirmationDetails,
Icon, Icon,
} from './tools.js'; } from './tools.js';
import { import { CallableTool, Part, FunctionCall } from '@google/genai';
CallableTool,
Part,
FunctionCall,
FunctionDeclaration,
Type,
} from '@google/genai';
type ToolParams = Record<string, unknown>; type ToolParams = Record<string, unknown>;
@ -64,7 +58,7 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
readonly serverName: string, readonly serverName: string,
readonly serverToolName: string, readonly serverToolName: string,
description: string, description: string,
readonly parameterSchemaJson: unknown, readonly parameterSchema: unknown,
readonly timeout?: number, readonly timeout?: number,
readonly trust?: boolean, readonly trust?: boolean,
nameOverride?: string, nameOverride?: string,
@ -74,7 +68,7 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
`${serverToolName} (${serverName} MCP Server)`, `${serverToolName} (${serverName} MCP Server)`,
description, description,
Icon.Hammer, Icon.Hammer,
{ type: Type.OBJECT }, // this is a dummy Schema for MCP, will be not be used to construct the FunctionDeclaration parameterSchema,
true, // isOutputMarkdown true, // isOutputMarkdown
false, // canUpdateOutput false, // canUpdateOutput
); );
@ -86,25 +80,13 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
this.serverName, this.serverName,
this.serverToolName, this.serverToolName,
this.description, this.description,
this.parameterSchemaJson, this.parameterSchema,
this.timeout, this.timeout,
this.trust, this.trust,
`${this.serverName}__${this.serverToolName}`, `${this.serverName}__${this.serverToolName}`,
); );
} }
/**
* Overrides the base schema to use parametersJsonSchema when building
* FunctionDeclaration
*/
override get schema(): FunctionDeclaration {
return {
name: this.name,
description: this.description,
parametersJsonSchema: this.parameterSchemaJson,
};
}
async shouldConfirmExecute( async shouldConfirmExecute(
_params: ToolParams, _params: ToolParams,
_abortSignal: AbortSignal, _abortSignal: AbortSignal,

View File

@ -203,7 +203,17 @@ describe('MemoryTool', () => {
); );
expect(memoryTool.schema).toBeDefined(); expect(memoryTool.schema).toBeDefined();
expect(memoryTool.schema.name).toBe('save_memory'); expect(memoryTool.schema.name).toBe('save_memory');
expect(memoryTool.schema.parameters?.properties?.fact).toBeDefined(); expect(memoryTool.schema.parametersJsonSchema).toStrictEqual({
type: 'object',
properties: {
fact: {
type: 'string',
description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
},
},
required: ['fact'],
});
}); });
it('should call performAddMemoryEntry with correct parameters and return success', async () => { it('should call performAddMemoryEntry with correct parameters and return success', async () => {

View File

@ -11,7 +11,7 @@ import {
ToolConfirmationOutcome, ToolConfirmationOutcome,
Icon, Icon,
} from './tools.js'; } from './tools.js';
import { FunctionDeclaration, Type } from '@google/genai'; import { FunctionDeclaration } from '@google/genai';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
@ -24,11 +24,11 @@ const memoryToolSchemaData: FunctionDeclaration = {
name: 'save_memory', name: 'save_memory',
description: description:
'Saves a specific piece of information or fact to your long-term memory. Use this when the user explicitly asks you to remember something, or when they state a clear, concise fact that seems important to retain for future interactions.', 'Saves a specific piece of information or fact to your long-term memory. Use this when the user explicitly asks you to remember something, or when they state a clear, concise fact that seems important to retain for future interactions.',
parameters: { parametersJsonSchema: {
type: Type.OBJECT, type: 'object',
properties: { properties: {
fact: { fact: {
type: Type.STRING, type: 'string',
description: description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.', 'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
}, },
@ -123,7 +123,7 @@ export class MemoryTool
'Save Memory', 'Save Memory',
memoryToolDescription, memoryToolDescription,
Icon.LightBulb, Icon.LightBulb,
memoryToolSchemaData.parameters as Record<string, unknown>, memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,
); );
} }

View File

@ -16,7 +16,7 @@ import {
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { PartUnion, Type } from '@google/genai'; import { PartUnion } from '@google/genai';
import { import {
processSingleFileContent, processSingleFileContent,
getSpecificMimeType, getSpecificMimeType,
@ -179,27 +179,30 @@ export class ReadFileTool extends BaseDeclarativeTool<
absolute_path: { absolute_path: {
description: description:
"The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported. You must provide an absolute path.", "The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported. You must provide an absolute path.",
type: Type.STRING, type: 'string',
}, },
offset: { offset: {
description: description:
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
type: Type.NUMBER, type: 'number',
}, },
limit: { limit: {
description: description:
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
type: Type.NUMBER, type: 'number',
}, },
}, },
required: ['absolute_path'], required: ['absolute_path'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
protected validateToolParams(params: ReadFileToolParams): string | null { protected validateToolParams(params: ReadFileToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -16,7 +16,7 @@ import {
DEFAULT_ENCODING, DEFAULT_ENCODING,
getSpecificMimeType, getSpecificMimeType,
} from '../utils/fileUtils.js'; } from '../utils/fileUtils.js';
import { PartListUnion, Schema, Type } from '@google/genai'; import { PartListUnion } from '@google/genai';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { import {
recordFileOperationMetric, recordFileOperationMetric,
@ -150,47 +150,47 @@ export class ReadManyFilesTool extends BaseTool<
static readonly Name: string = 'read_many_files'; static readonly Name: string = 'read_many_files';
constructor(private config: Config) { constructor(private config: Config) {
const parameterSchema: Schema = { const parameterSchema = {
type: Type.OBJECT, type: 'object',
properties: { properties: {
paths: { paths: {
type: Type.ARRAY, type: 'array',
items: { items: {
type: Type.STRING, type: 'string',
minLength: '1', minLength: 1,
}, },
minItems: '1', minItems: 1,
description: description:
"Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']", "Required. An array of glob patterns or paths relative to the tool's target directory. Examples: ['src/**/*.ts'], ['README.md', 'docs/']",
}, },
include: { include: {
type: Type.ARRAY, type: 'array',
items: { items: {
type: Type.STRING, type: 'string',
minLength: '1', minLength: 1,
}, },
description: 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.', '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: [], default: [],
}, },
exclude: { exclude: {
type: Type.ARRAY, type: 'array',
items: { items: {
type: Type.STRING, type: 'string',
minLength: '1', minLength: 1,
}, },
description: description:
'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: ["**/*.log", "temp/"]', 'Optional. Glob patterns for files/directories to exclude. Added to default excludes if useDefaultExcludes is true. Example: ["**/*.log", "temp/"]',
default: [], default: [],
}, },
recursive: { recursive: {
type: Type.BOOLEAN, type: 'boolean',
description: description:
'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.', 'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.',
default: true, default: true,
}, },
useDefaultExcludes: { useDefaultExcludes: {
type: Type.BOOLEAN, type: 'boolean',
description: description:
'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.', 'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
default: true, default: true,
@ -198,17 +198,17 @@ export class ReadManyFilesTool extends BaseTool<
file_filtering_options: { file_filtering_options: {
description: description:
'Whether to respect ignore patterns from .gitignore or .geminiignore', 'Whether to respect ignore patterns from .gitignore or .geminiignore',
type: Type.OBJECT, type: 'object',
properties: { properties: {
respect_git_ignore: { respect_git_ignore: {
description: description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN, type: 'boolean',
}, },
respect_gemini_ignore: { respect_gemini_ignore: {
description: description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.', 'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: Type.BOOLEAN, type: 'boolean',
}, },
}, },
}, },
@ -235,7 +235,10 @@ Use this tool when the user's query implies needing the content of several files
} }
validateParams(params: ReadManyFilesParams): string | null { validateParams(params: ReadManyFilesParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -18,7 +18,6 @@ import {
Icon, Icon,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js'; import { summarizeToolOutput } from '../utils/summarizer.js';
@ -64,19 +63,19 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
Process Group PGID: Process group started or \`(none)\``, Process Group PGID: Process group started or \`(none)\``,
Icon.Terminal, Icon.Terminal,
{ {
type: Type.OBJECT, type: 'object',
properties: { properties: {
command: { command: {
type: Type.STRING, type: 'string',
description: 'Exact bash command to execute as `bash -c <command>`', description: 'Exact bash command to execute as `bash -c <command>`',
}, },
description: { description: {
type: Type.STRING, type: 'string',
description: 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.', '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: { directory: {
type: Type.STRING, type: 'string',
description: 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.', '(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.',
}, },
@ -113,7 +112,10 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
} }
return commandCheck.reason; return commandCheck.reason;
} }
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -15,19 +15,9 @@ import {
Mocked, Mocked,
} from 'vitest'; } from 'vitest';
import { Config, ConfigParameters, ApprovalMode } from '../config/config.js'; import { Config, ConfigParameters, ApprovalMode } from '../config/config.js';
import { import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
ToolRegistry,
DiscoveredTool,
sanitizeParameters,
} from './tool-registry.js';
import { DiscoveredMCPTool } from './mcp-tool.js'; import { DiscoveredMCPTool } from './mcp-tool.js';
import { import { FunctionDeclaration, CallableTool, mcpToTool } from '@google/genai';
FunctionDeclaration,
CallableTool,
mcpToTool,
Type,
Schema,
} from '@google/genai';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@ -254,18 +244,18 @@ describe('ToolRegistry', () => {
}); });
describe('discoverTools', () => { describe('discoverTools', () => {
it('should sanitize tool parameters during discovery from command', async () => { it('should will preserve tool parametersJsonSchema during discovery from command', async () => {
const discoveryCommand = 'my-discovery-command'; const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand); mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
const unsanitizedToolDeclaration: FunctionDeclaration = { const unsanitizedToolDeclaration: FunctionDeclaration = {
name: 'tool-with-bad-format', name: 'tool-with-bad-format',
description: 'A tool with an invalid format property', description: 'A tool with an invalid format property',
parameters: { parametersJsonSchema: {
type: Type.OBJECT, type: 'object',
properties: { properties: {
some_string: { some_string: {
type: Type.STRING, type: 'string',
format: 'uuid', // This is an unsupported format format: 'uuid', // This is an unsupported format
}, },
}, },
@ -308,12 +298,16 @@ describe('ToolRegistry', () => {
expect(discoveredTool).toBeDefined(); expect(discoveredTool).toBeDefined();
const registeredParams = (discoveredTool as DiscoveredTool).schema const registeredParams = (discoveredTool as DiscoveredTool).schema
.parameters as Schema; .parametersJsonSchema;
expect(registeredParams.properties?.['some_string']).toBeDefined(); expect(registeredParams).toStrictEqual({
expect(registeredParams.properties?.['some_string']).toHaveProperty( type: 'object',
'format', properties: {
undefined, some_string: {
); type: 'string',
format: 'uuid',
},
},
});
}); });
it('should discover tools using MCP servers defined in getMcpServers', async () => { it('should discover tools using MCP servers defined in getMcpServers', async () => {
@ -365,187 +359,3 @@ describe('ToolRegistry', () => {
}); });
}); });
}); });
describe('sanitizeParameters', () => {
it('should remove default when anyOf is present', () => {
const schema: Schema = {
anyOf: [{ type: Type.STRING }, { type: Type.NUMBER }],
default: 'hello',
};
sanitizeParameters(schema);
expect(schema.default).toBeUndefined();
});
it('should recursively sanitize items in anyOf', () => {
const schema: Schema = {
anyOf: [
{
anyOf: [{ type: Type.STRING }],
default: 'world',
},
{ type: Type.NUMBER },
],
};
sanitizeParameters(schema);
expect(schema.anyOf![0].default).toBeUndefined();
});
it('should recursively sanitize items in items', () => {
const schema: Schema = {
items: {
anyOf: [{ type: Type.STRING }],
default: 'world',
},
};
sanitizeParameters(schema);
expect(schema.items!.default).toBeUndefined();
});
it('should recursively sanitize items in properties', () => {
const schema: Schema = {
properties: {
prop1: {
anyOf: [{ type: Type.STRING }],
default: 'world',
},
},
};
sanitizeParameters(schema);
expect(schema.properties!.prop1.default).toBeUndefined();
});
it('should handle complex nested schemas', () => {
const schema: Schema = {
properties: {
prop1: {
items: {
anyOf: [{ type: Type.STRING }],
default: 'world',
},
},
prop2: {
anyOf: [
{
properties: {
nestedProp: {
anyOf: [{ type: Type.NUMBER }],
default: 123,
},
},
},
],
},
},
};
sanitizeParameters(schema);
expect(schema.properties!.prop1.items!.default).toBeUndefined();
const nestedProp =
schema.properties!.prop2.anyOf![0].properties!.nestedProp;
expect(nestedProp?.default).toBeUndefined();
});
it('should remove unsupported format from a simple string property', () => {
const schema: Schema = {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
id: { type: Type.STRING, format: 'uuid' },
},
};
sanitizeParameters(schema);
expect(schema.properties?.['id']).toHaveProperty('format', undefined);
expect(schema.properties?.['name']).not.toHaveProperty('format');
});
it('should NOT remove supported format values', () => {
const schema: Schema = {
type: Type.OBJECT,
properties: {
date: { type: Type.STRING, format: 'date-time' },
role: {
type: Type.STRING,
format: 'enum',
enum: ['admin', 'user'],
},
},
};
const originalSchema = JSON.parse(JSON.stringify(schema));
sanitizeParameters(schema);
expect(schema).toEqual(originalSchema);
});
it('should handle arrays of objects', () => {
const schema: Schema = {
type: Type.OBJECT,
properties: {
items: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
itemId: { type: Type.STRING, format: 'uuid' },
},
},
},
},
};
sanitizeParameters(schema);
expect(
(schema.properties?.['items']?.items as Schema)?.properties?.['itemId'],
).toHaveProperty('format', undefined);
});
it('should handle schemas with no properties to sanitize', () => {
const schema: Schema = {
type: Type.OBJECT,
properties: {
count: { type: Type.NUMBER },
isActive: { type: Type.BOOLEAN },
},
};
const originalSchema = JSON.parse(JSON.stringify(schema));
sanitizeParameters(schema);
expect(schema).toEqual(originalSchema);
});
it('should not crash on an empty or undefined schema', () => {
expect(() => sanitizeParameters({})).not.toThrow();
expect(() => sanitizeParameters(undefined)).not.toThrow();
});
it('should handle complex nested schemas with cycles', () => {
const userNode: any = {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING, format: 'uuid' },
name: { type: Type.STRING },
manager: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING, format: 'uuid' },
},
},
},
};
userNode.properties.reports = {
type: Type.ARRAY,
items: userNode,
};
const schema: Schema = {
type: Type.OBJECT,
properties: {
ceo: userNode,
},
};
expect(() => sanitizeParameters(schema)).not.toThrow();
expect(schema.properties?.['ceo']?.properties?.['id']).toHaveProperty(
'format',
undefined,
);
expect(
schema.properties?.['ceo']?.properties?.['manager']?.properties?.['id'],
).toHaveProperty('format', undefined);
});
});

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { FunctionDeclaration, Schema, Type } from '@google/genai'; import { FunctionDeclaration } from '@google/genai';
import { AnyDeclarativeTool, Icon, ToolResult, BaseTool } from './tools.js'; import { AnyDeclarativeTool, Icon, ToolResult, BaseTool } from './tools.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@ -331,14 +331,12 @@ export class ToolRegistry {
console.warn('Discovered a tool with no name. Skipping.'); console.warn('Discovered a tool with no name. Skipping.');
continue; continue;
} }
// Sanitize the parameters before registering the tool.
const parameters = const parameters =
func.parameters && func.parametersJsonSchema &&
typeof func.parameters === 'object' && typeof func.parametersJsonSchema === 'object' &&
!Array.isArray(func.parameters) !Array.isArray(func.parametersJsonSchema)
? (func.parameters as Schema) ? func.parametersJsonSchema
: {}; : {};
sanitizeParameters(parameters);
this.registerTool( this.registerTool(
new DiscoveredTool( new DiscoveredTool(
this.config, this.config,
@ -413,75 +411,3 @@ export class ToolRegistry {
return this.tools.get(name); return this.tools.get(name);
} }
} }
/**
* Sanitizes a schema object in-place to ensure compatibility with the Gemini API.
*
* NOTE: This function mutates the passed schema object.
*
* It performs the following actions:
* - Removes the `default` property when `anyOf` is present.
* - Removes unsupported `format` values from string properties, keeping only 'enum' and 'date-time'.
* - Recursively sanitizes nested schemas within `anyOf`, `items`, and `properties`.
* - Handles circular references within the schema to prevent infinite loops.
*
* @param schema The schema object to sanitize. It will be modified directly.
*/
export function sanitizeParameters(schema?: Schema) {
_sanitizeParameters(schema, new Set<Schema>());
}
/**
* Internal recursive implementation for sanitizeParameters.
* @param schema The schema object to sanitize.
* @param visited A set used to track visited schema objects during recursion.
*/
function _sanitizeParameters(schema: Schema | undefined, visited: Set<Schema>) {
if (!schema || visited.has(schema)) {
return;
}
visited.add(schema);
if (schema.anyOf) {
// Vertex AI gets confused if both anyOf and default are set.
schema.default = undefined;
for (const item of schema.anyOf) {
if (typeof item !== 'boolean') {
_sanitizeParameters(item, visited);
}
}
}
if (schema.items && typeof schema.items !== 'boolean') {
_sanitizeParameters(schema.items, visited);
}
if (schema.properties) {
for (const item of Object.values(schema.properties)) {
if (typeof item !== 'boolean') {
_sanitizeParameters(item, visited);
}
}
}
// Handle enum values - Gemini API only allows enum for STRING type
if (schema.enum && Array.isArray(schema.enum)) {
if (schema.type !== Type.STRING) {
// If enum is present but type is not STRING, convert type to STRING
schema.type = Type.STRING;
}
// Filter out null and undefined values, then convert remaining values to strings for Gemini API compatibility
schema.enum = schema.enum
.filter((value: unknown) => value !== null && value !== undefined)
.map((value: unknown) => String(value));
}
// Vertex AI only supports 'enum' and 'date-time' for STRING format.
if (schema.type === Type.STRING) {
if (
schema.format &&
schema.format !== 'enum' &&
schema.format !== 'date-time'
) {
schema.format = undefined;
}
}
}

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { FunctionDeclaration, PartListUnion, Schema } from '@google/genai'; import { FunctionDeclaration, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { DiffUpdateResult } from '../ide/ideContext.js'; import { DiffUpdateResult } from '../ide/ideContext.js';
@ -186,7 +186,7 @@ export abstract class DeclarativeTool<
readonly displayName: string, readonly displayName: string,
readonly description: string, readonly description: string,
readonly icon: Icon, readonly icon: Icon,
readonly parameterSchema: Schema, readonly parameterSchema: unknown,
readonly isOutputMarkdown: boolean = true, readonly isOutputMarkdown: boolean = true,
readonly canUpdateOutput: boolean = false, readonly canUpdateOutput: boolean = false,
) {} ) {}
@ -195,7 +195,7 @@ export abstract class DeclarativeTool<
return { return {
name: this.name, name: this.name,
description: this.description, description: this.description,
parameters: this.parameterSchema, parametersJsonSchema: this.parameterSchema,
}; };
} }
@ -281,14 +281,14 @@ export abstract class BaseTool<
* @param description Description of what the tool does * @param description Description of what the tool does
* @param isOutputMarkdown Whether the tool's output should be rendered as markdown * @param isOutputMarkdown Whether the tool's output should be rendered as markdown
* @param canUpdateOutput Whether the tool supports live (streaming) output * @param canUpdateOutput Whether the tool supports live (streaming) output
* @param parameterSchema Open API 3.0 Schema defining the parameters * @param parameterSchema JSON Schema defining the parameters
*/ */
constructor( constructor(
readonly name: string, readonly name: string,
readonly displayName: string, readonly displayName: string,
readonly description: string, readonly description: string,
readonly icon: Icon, readonly icon: Icon,
readonly parameterSchema: Schema, readonly parameterSchema: unknown,
readonly isOutputMarkdown: boolean = true, readonly isOutputMarkdown: boolean = true,
readonly canUpdateOutput: boolean = false, readonly canUpdateOutput: boolean = false,
) { ) {

View File

@ -12,7 +12,6 @@ import {
ToolConfirmationOutcome, ToolConfirmationOutcome,
Icon, Icon,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { Config, ApprovalMode } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js';
@ -77,11 +76,11 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
prompt: { prompt: {
description: description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.', 'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
type: Type.STRING, type: 'string',
}, },
}, },
required: ['prompt'], required: ['prompt'],
type: Type.OBJECT, type: 'object',
}, },
); );
const proxy = config.getProxy(); const proxy = config.getProxy();
@ -156,7 +155,10 @@ ${textContent}
} }
validateParams(params: WebFetchToolParams): string | null { validateParams(params: WebFetchToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -89,7 +89,10 @@ export class WebSearchTool extends BaseTool<
* @returns An error message string if validation fails, null if valid * @returns An error message string if validation fails, null if valid
*/ */
validateParams(params: WebSearchToolParams): string | null { validateParams(params: WebSearchToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -18,7 +18,6 @@ import {
Icon, Icon,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
@ -89,21 +88,24 @@ export class WriteFileTool
file_path: { file_path: {
description: description:
"The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.", "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
type: Type.STRING, type: 'string',
}, },
content: { content: {
description: 'The content to write to the file.', description: 'The content to write to the file.',
type: Type.STRING, type: 'string',
}, },
}, },
required: ['file_path', 'content'], required: ['file_path', 'content'],
type: Type.OBJECT, type: 'object',
}, },
); );
} }
validateToolParams(params: WriteFileToolParams): string | null { validateToolParams(params: WriteFileToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params); const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) { if (errors) {
return errors; return errors;
} }

View File

@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Schema } from '@google/genai';
import AjvPkg from 'ajv'; import AjvPkg from 'ajv';
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -19,50 +18,18 @@ export class SchemaValidator {
* Returns null if the data confroms to the schema described by schema (or if schema * Returns null if the data confroms to the schema described by schema (or if schema
* is null). Otherwise, returns a string describing the error. * is null). Otherwise, returns a string describing the error.
*/ */
static validate(schema: Schema | undefined, data: unknown): string | null { static validate(schema: unknown | undefined, data: unknown): string | null {
if (!schema) { if (!schema) {
return null; return null;
} }
if (typeof data !== 'object' || data === null) { if (typeof data !== 'object' || data === null) {
return 'Value of params must be an object'; return 'Value of params must be an object';
} }
const validate = ajValidator.compile(this.toObjectSchema(schema)); const validate = ajValidator.compile(schema);
const valid = validate(data); const valid = validate(data);
if (!valid && validate.errors) { if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
} }
return null; return null;
} }
/**
* Converts @google/genai's Schema to an object compatible with avj.
* This is necessary because it represents Types as an Enum (with
* UPPERCASE values) and minItems and minLength as strings, when they should be numbers.
*/
private static toObjectSchema(schema: Schema): object {
const newSchema: Record<string, unknown> = { ...schema };
if (newSchema.anyOf && Array.isArray(newSchema.anyOf)) {
newSchema.anyOf = newSchema.anyOf.map((v) => this.toObjectSchema(v));
}
if (newSchema.items) {
newSchema.items = this.toObjectSchema(newSchema.items);
}
if (newSchema.properties && typeof newSchema.properties === 'object') {
const newProperties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(newSchema.properties)) {
newProperties[key] = this.toObjectSchema(value as Schema);
}
newSchema.properties = newProperties;
}
if (newSchema.type) {
newSchema.type = String(newSchema.type).toLowerCase();
}
if (newSchema.minItems) {
newSchema.minItems = Number(newSchema.minItems);
}
if (newSchema.minLength) {
newSchema.minLength = Number(newSchema.minLength);
}
return newSchema;
}
} }