diff --git a/package-lock.json b/package-lock.json index 7579b5b9..bf344367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5205,6 +5205,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -8683,6 +8699,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", @@ -11375,6 +11400,7 @@ "@opentelemetry/sdk-node": "^0.52.0", "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", + "ajv": "^8.17.1", "diff": "^7.0.0", "dotenv": "^16.6.1", "gaxios": "^6.1.1", @@ -11403,6 +11429,22 @@ "node": ">=20" } }, + "packages/core/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "packages/core/node_modules/ignore": { "version": "7.0.5", "license": "MIT", @@ -11410,6 +11452,12 @@ "node": ">= 4" } }, + "packages/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "packages/core/node_modules/ws": { "version": "8.18.2", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index d5247fc3..1d6dfd76 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "@opentelemetry/sdk-node": "^0.52.0", "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", + "ajv": "^8.17.1", "diff": "^7.0.0", "dotenv": "^16.6.1", "gaxios": "^6.1.1", diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index f388b9f5..45e74e93 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -15,6 +15,7 @@ import { ToolResult, ToolResultDisplay, } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { isNodeError } from '../utils/errors.js'; @@ -97,27 +98,27 @@ Expectation for required parameters: file_path: { description: "The absolute path to the file to modify. Must start with '/'.", - type: 'string', + type: Type.STRING, }, old_string: { 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.', - type: 'string', + type: Type.STRING, }, new_string: { description: 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', - type: 'string', + type: Type.STRING, }, expected_replacements: { - type: 'number', + type: Type.NUMBER, description: 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', minimum: 1, }, }, required: ['file_path', 'old_string', 'new_string'], - type: 'object', + type: Type.OBJECT, }, ); this.rootDirectory = path.resolve(this.config.getTargetDir()); @@ -146,14 +147,9 @@ Expectation for required parameters: * @returns Error message string or null if valid */ validateToolParams(params: EditToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } if (!path.isAbsolute(params.file_path)) { diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index a4bc99a9..acda3729 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -181,8 +181,8 @@ describe('GlobTool', () => { // Need to correctly define this as an object without pattern const params = { path: '.' }; // @ts-expect-error - We're intentionally creating invalid params for testing - expect(globTool.validateToolParams(params)).toContain( - 'Parameters failed schema validation', + expect(globTool.validateToolParams(params)).toBe( + `params must have required property 'pattern'`, ); }); @@ -206,8 +206,8 @@ describe('GlobTool', () => { path: 123, }; // @ts-expect-error - We're intentionally creating invalid params for testing - expect(globTool.validateToolParams(params)).toContain( - 'Parameters failed schema validation', + expect(globTool.validateToolParams(params)).toBe( + 'params/path must be string', ); }); @@ -217,8 +217,8 @@ describe('GlobTool', () => { case_sensitive: 'true', }; // @ts-expect-error - We're intentionally creating invalid params for testing - expect(globTool.validateToolParams(params)).toContain( - 'Parameters failed schema validation', + expect(globTool.validateToolParams(params)).toBe( + 'params/case_sensitive must be boolean', ); }); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 22dacc83..23f05c2e 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -9,6 +9,7 @@ import path from 'path'; import { glob } from 'glob'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { BaseTool, ToolResult } from './tools.js'; +import { Type } from '@google/genai'; import { shortenPath, makeRelative } from '../utils/paths.js'; import { Config } from '../config/config.js'; @@ -95,26 +96,26 @@ export class GlobTool extends BaseTool { pattern: { description: "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').", - type: 'string', + type: Type.STRING, }, path: { description: 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.', - type: 'string', + type: Type.STRING, }, case_sensitive: { description: 'Optional: Whether the search should be case-sensitive. Defaults to false.', - type: 'boolean', + type: Type.BOOLEAN, }, respect_git_ignore: { description: 'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.', - type: 'boolean', + type: Type.BOOLEAN, }, }, required: ['pattern'], - type: 'object', + type: Type.OBJECT, }, ); @@ -145,14 +146,9 @@ export class GlobTool extends BaseTool { * Validates the parameters for the tool. */ validateToolParams(params: GlobToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return "Parameters failed schema validation. Ensure 'pattern' is a string, 'path' (if provided) is a string, and 'case_sensitive' (if provided) is a boolean."; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } const searchDirAbsolute = path.resolve( diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index ae629a52..75cf1d28 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -80,8 +80,8 @@ describe('GrepTool', () => { it('should return error if pattern is missing', () => { const params = { path: '.' } as unknown as GrepToolParams; - expect(grepTool.validateToolParams(params)).toContain( - 'Parameters failed schema validation', + expect(grepTool.validateToolParams(params)).toBe( + `params must have required property 'pattern'`, ); }); @@ -204,11 +204,11 @@ describe('GrepTool', () => { it('should return an error if params are invalid', async () => { const params = { path: '.' } as unknown as GrepToolParams; // Invalid: pattern missing const result = await grepTool.execute(params, abortSignal); - expect(result.llmContent).toContain( - 'Error: Invalid parameters provided. Reason: Parameters failed schema validation', + expect(result.llmContent).toBe( + "Error: Invalid parameters provided. Reason: params must have required property 'pattern'", ); - expect(result.returnDisplay).toContain( - 'Model provided invalid parameters. Error: Parameters failed schema validation', + expect(result.returnDisplay).toBe( + "Model provided invalid parameters. Error: params must have required property 'pattern'", ); }); }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 612291d5..b3c7a17b 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -11,6 +11,7 @@ import { EOL } from 'os'; import { spawn } from 'child_process'; import { globStream } from 'glob'; import { BaseTool, ToolResult } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; @@ -69,21 +70,21 @@ export class GrepTool extends BaseTool { pattern: { description: "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", - type: 'string', + type: Type.STRING, }, path: { description: 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', - type: 'string', + type: Type.STRING, }, include: { description: "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", - type: 'string', + type: Type.STRING, }, }, required: ['pattern'], - type: 'object', + type: Type.OBJECT, }, ); // Ensure rootDirectory is absolute and normalized @@ -135,14 +136,9 @@ export class GrepTool extends BaseTool { * @returns An error message string if invalid, null otherwise */ validateToolParams(params: GrepToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } try { diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 6e3869be..df09cc50 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import path from 'path'; import { BaseTool, ToolResult } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { Config } from '../config/config.js'; @@ -84,23 +85,23 @@ export class LSTool extends BaseTool { path: { description: 'The absolute path to the directory to list (must be absolute, not relative)', - type: 'string', + type: Type.STRING, }, ignore: { description: 'List of glob patterns to ignore', items: { - type: 'string', + type: Type.STRING, }, - type: 'array', + type: Type.ARRAY, }, respect_git_ignore: { description: 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', - type: 'boolean', + type: Type.BOOLEAN, }, }, required: ['path'], - type: 'object', + type: Type.OBJECT, }, ); @@ -132,14 +133,9 @@ export class LSTool extends BaseTool { * @returns An error message string if invalid, null otherwise */ validateToolParams(params: LSToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } if (!path.isAbsolute(params.path)) { return `Path must be absolute: ${params.path}`; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 6dca8cea..f2b7458b 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -14,7 +14,7 @@ import { import { parse } from 'shell-quote'; import { MCPServerConfig } from '../config/config.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; -import { CallableTool, FunctionDeclaration, mcpToTool } from '@google/genai'; +import { Type, mcpToTool } from '@google/genai'; import { sanitizeParameters, ToolRegistry } from './tool-registry.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -275,13 +275,10 @@ async function connectAndDiscover( } try { - const mcpCallableTool: CallableTool = mcpToTool(mcpClient); - const discoveredToolFunctions = await mcpCallableTool.tool(); + const mcpCallableTool = mcpToTool(mcpClient); + const tool = await mcpCallableTool.tool(); - if ( - !discoveredToolFunctions || - !Array.isArray(discoveredToolFunctions.functionDeclarations) - ) { + if (!tool || !Array.isArray(tool.functionDeclarations)) { console.error( `MCP server '${mcpServerName}' did not return valid tool function declarations. Skipping.`, ); @@ -297,7 +294,7 @@ async function connectAndDiscover( return; } - for (const funcDecl of discoveredToolFunctions.functionDeclarations) { + for (const funcDecl of tool.functionDeclarations) { if (!funcDecl.name) { console.warn( `Discovered a function declaration without a name from MCP server '${mcpServerName}'. Skipping.`, @@ -344,19 +341,13 @@ async function connectAndDiscover( sanitizeParameters(funcDecl.parameters); - // Ensure parameters is a valid JSON schema object, default to empty if not. - const parameterSchema: Record = - funcDecl.parameters && typeof funcDecl.parameters === 'object' - ? { ...(funcDecl.parameters as FunctionDeclaration) } - : { type: 'object', properties: {} }; - toolRegistry.registerTool( new DiscoveredMCPTool( mcpCallableTool, mcpServerName, toolNameForModel, funcDecl.description ?? '', - parameterSchema, + funcDecl.parameters ?? { type: Type.OBJECT, properties: {} }, funcDecl.name, mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, mcpServerConfig.trust, diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 086db763..cc4739a4 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -11,7 +11,7 @@ import { ToolConfirmationOutcome, ToolMcpConfirmationDetails, } from './tools.js'; -import { CallableTool, Part, FunctionCall } from '@google/genai'; +import { CallableTool, Part, FunctionCall, Schema } from '@google/genai'; type ToolParams = Record; @@ -23,7 +23,7 @@ export class DiscoveredMCPTool extends BaseTool { readonly serverName: string, readonly name: string, readonly description: string, - readonly parameterSchema: Record, + readonly parameterSchema: Schema, readonly serverToolName: string, readonly timeout?: number, readonly trust?: boolean, diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 2c6f41c8..b4a671b0 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -5,19 +5,20 @@ */ import { BaseTool, ToolResult } from './tools.js'; +import { FunctionDeclaration, Type } from '@google/genai'; import * as fs from 'fs/promises'; import * as path from 'path'; import { homedir } from 'os'; -const memoryToolSchemaData = { +const memoryToolSchemaData: FunctionDeclaration = { name: 'save_memory', 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.', parameters: { - type: 'object', + type: Type.OBJECT, properties: { fact: { - type: 'string', + type: Type.STRING, description: 'The specific fact or piece of information to remember. Should be a clear, self-contained statement.', }, @@ -98,7 +99,7 @@ function ensureNewlineSeparation(currentContent: string): string { } export class MemoryTool extends BaseTool { - static readonly Name: string = memoryToolSchemaData.name; + static readonly Name: string = memoryToolSchemaData.name!; constructor() { super( MemoryTool.Name, diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index bfb08a6c..e7ff822f 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -73,8 +73,8 @@ describe('ReadFileTool', () => { it('should return error for relative path', () => { const params: ReadFileToolParams = { absolute_path: 'test.txt' }; - expect(tool.validateToolParams(params)).toMatch( - /File path must be absolute/, + expect(tool.validateToolParams(params)).toBe( + `params/absolute_path must match pattern "^/"`, ); }); @@ -119,7 +119,7 @@ describe('ReadFileTool', () => { it('should return error for schema validation failure (e.g. missing path)', () => { const params = { offset: 0 } as unknown as ReadFileToolParams; expect(tool.validateToolParams(params)).toBe( - 'Parameters failed schema validation.', + `params must have required property 'absolute_path'`, ); }); }); @@ -143,8 +143,12 @@ describe('ReadFileTool', () => { it('should return validation error if params are invalid', async () => { const params: ReadFileToolParams = { absolute_path: 'relative/path.txt' }; const result = await tool.execute(params, abortSignal); - expect(result.llmContent).toMatch(/Error: Invalid parameters provided/); - expect(result.returnDisplay).toMatch(/File path must be absolute/); + expect(result.llmContent).toBe( + 'Error: Invalid parameters provided. Reason: params/absolute_path must match pattern "^/"', + ); + expect(result.returnDisplay).toBe( + 'params/absolute_path must match pattern "^/"', + ); }); it('should return error from processSingleFileContent if it fails', async () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 1060ac8d..a26f50ce 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -8,6 +8,7 @@ import path from 'path'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { BaseTool, ToolResult } from './tools.js'; +import { Type } from '@google/genai'; import { isWithinRoot, processSingleFileContent, @@ -58,37 +59,33 @@ export class ReadFileTool extends BaseTool { absolute_path: { 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.", - type: 'string', + type: Type.STRING, pattern: '^/', }, offset: { 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.", - type: 'number', + type: Type.NUMBER, }, limit: { 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).", - type: 'number', + type: Type.NUMBER, }, }, required: ['absolute_path'], - type: 'object', + type: Type.OBJECT, }, ); this.rootDirectory = path.resolve(rootDirectory); } validateToolParams(params: ReadFileToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } + const filePath = params.absolute_path; if (!path.isAbsolute(filePath)) { return `File path must be absolute, but was relative: ${filePath}. You must provide an absolute path.`; diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 697b7d1b..ef42d8b6 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -138,7 +138,7 @@ describe('ReadManyFilesTool', () => { it('should return error if paths array is empty', () => { const params = { paths: [] }; expect(tool.validateParams(params)).toBe( - 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.', + 'params/paths must NOT have fewer than 1 items', ); }); @@ -154,7 +154,7 @@ describe('ReadManyFilesTool', () => { it('should return error if paths array contains an empty string', () => { const params = { paths: ['file1.txt', ''] }; expect(tool.validateParams(params)).toBe( - 'Each item in "paths" must be a non-empty string/glob pattern.', + 'params/paths/1 must NOT have fewer than 1 characters', ); }); @@ -164,7 +164,7 @@ describe('ReadManyFilesTool', () => { include: ['*.ts', 123] as string[], }; expect(tool.validateParams(params)).toBe( - 'If provided, "include" must be an array of strings/glob patterns.', + 'params/include/1 must be string', ); }); @@ -174,7 +174,7 @@ describe('ReadManyFilesTool', () => { exclude: ['*.log', {}] as string[], }; expect(tool.validateParams(params)).toBe( - 'If provided, "exclude" must be an array of strings/glob patterns.', + 'params/exclude/1 must be string', ); }); }); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 4f8cb8fa..38cecc16 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -16,7 +16,7 @@ import { DEFAULT_ENCODING, getSpecificMimeType, } from '../utils/fileUtils.js'; -import { PartListUnion } from '@google/genai'; +import { PartListUnion, Schema, Type } from '@google/genai'; import { Config } from '../config/config.js'; import { recordFileOperationMetric, @@ -135,43 +135,53 @@ export class ReadManyFilesTool extends BaseTool< readonly targetDir: string, private config: Config, ) { - const parameterSchema: Record = { - type: 'object', + const parameterSchema: Schema = { + type: Type.OBJECT, properties: { paths: { - type: 'array', - items: { type: 'string' }, + type: Type.ARRAY, + items: { + type: 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' }, + type: Type.ARRAY, + items: { + type: 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' }, + type: Type.ARRAY, + items: { + type: 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', + type: Type.BOOLEAN, description: 'Optional. Whether to search recursively (primarily controlled by `**` in glob patterns). Defaults to true.', default: true, }, useDefaultExcludes: { - type: 'boolean', + type: 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, }, respect_git_ignore: { - type: 'boolean', + type: Type.BOOLEAN, description: 'Optional. Whether to respect .gitignore patterns when discovering files. Only available in git repositories. Defaults to true.', default: true, @@ -202,47 +212,9 @@ Use this tool when the user's query implies needing the content of several files } validateParams(params: ReadManyFilesParams): string | null { - if ( - !params.paths || - !Array.isArray(params.paths) || - params.paths.length === 0 - ) { - return 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.'; - } - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - if ( - !params.paths || - !Array.isArray(params.paths) || - params.paths.length === 0 - ) { - return 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.'; - } - return 'Parameters failed schema validation. Ensure "paths" is a non-empty array and other parameters match their expected types.'; - } - for (const p of params.paths) { - if (typeof p !== 'string' || p.trim() === '') { - return 'Each item in "paths" must be a non-empty string/glob pattern.'; - } - } - if ( - params.include && - (!Array.isArray(params.include) || - !params.include.every((item) => typeof item === 'string')) - ) { - return 'If provided, "include" must be an array of strings/glob patterns.'; - } - if ( - params.exclude && - (!Array.isArray(params.exclude) || - !params.exclude.every((item) => typeof item === 'string')) - ) { - return 'If provided, "exclude" must be an array of strings/glob patterns.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } return null; } diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index bdee190f..c8fa6ba7 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -16,6 +16,7 @@ import { ToolExecuteConfirmationDetails, ToolConfirmationOutcome, } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; import stripAnsi from 'strip-ansi'; @@ -51,19 +52,19 @@ 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)\``, { - type: 'object', + type: Type.OBJECT, properties: { command: { - type: 'string', + type: Type.STRING, description: 'Exact bash command to execute as `bash -c `', }, description: { - type: 'string', + type: 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', + type: 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.', }, @@ -223,13 +224,9 @@ Process Group PGID: Process group started or \`(none)\``, } return commandCheck.reason; } - if ( - !SchemaValidator.validate( - this.parameterSchema as Record, - params, - ) - ) { - return `Parameters failed schema validation.`; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } if (!params.command.trim()) { return 'Command cannot be empty.'; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 4d586d62..a8ca1f07 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -106,9 +106,9 @@ const createMockCallableTool = ( class MockTool extends BaseTool<{ param: string }, ToolResult> { constructor(name = 'mock-tool', description = 'A mock tool') { super(name, name, description, { - type: 'object', + type: Type.OBJECT, properties: { - param: { type: 'string' }, + param: { type: Type.STRING }, }, required: ['param'], }); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index cff969f8..5347caa0 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -103,7 +103,7 @@ export abstract class BaseTool< readonly name: string, readonly displayName: string, readonly description: string, - readonly parameterSchema: Record, + readonly parameterSchema: Schema, readonly isOutputMarkdown: boolean = true, readonly canUpdateOutput: boolean = false, ) {} @@ -115,7 +115,7 @@ export abstract class BaseTool< return { name: this.name, description: this.description, - parameters: this.parameterSchema as Schema, + parameters: this.parameterSchema, }; } diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 1fa0019f..0f5be969 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -11,6 +11,7 @@ import { ToolCallConfirmationDetails, ToolConfirmationOutcome, } from './tools.js'; +import { Type } from '@google/genai'; import { getErrorMessage } from '../utils/errors.js'; import { Config, ApprovalMode } from '../config/config.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js'; @@ -73,11 +74,11 @@ export class WebFetchTool extends BaseTool { prompt: { 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://.', - type: 'string', + type: Type.STRING, }, }, required: ['prompt'], - type: 'object', + type: Type.OBJECT, }, ); } @@ -148,14 +149,9 @@ ${textContent} } validateParams(params: WebFetchToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } if (!params.prompt || params.prompt.trim() === '') { return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions."; diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 5ee8e85c..98be1f30 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -6,6 +6,7 @@ import { GroundingMetadata } from '@google/genai'; import { BaseTool, ToolResult } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -69,10 +70,10 @@ export class WebSearchTool extends BaseTool< 'GoogleSearch', 'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.', { - type: 'object', + type: Type.OBJECT, properties: { query: { - type: 'string', + type: Type.STRING, description: 'The search query to find information on the web.', }, }, @@ -86,16 +87,12 @@ export class WebSearchTool extends BaseTool< * @param params The parameters to validate * @returns An error message string if validation fails, null if valid */ - validateToolParams(params: WebSearchToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return "Parameters failed schema validation. Ensure 'query' is a string."; + validateParams(params: WebSearchToolParams): string | null { + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } + if (!params.query || params.query.trim() === '') { return "The 'query' parameter cannot be empty."; } diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index ab30891b..e936ce0b 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -16,6 +16,7 @@ import { ToolConfirmationOutcome, ToolCallConfirmationDetails, } from './tools.js'; +import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; @@ -79,15 +80,15 @@ export class WriteFileTool file_path: { description: "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.", - type: 'string', + type: Type.STRING, }, content: { description: 'The content to write to the file.', - type: 'string', + type: Type.STRING, }, }, required: ['file_path', 'content'], - type: 'object', + type: Type.OBJECT, }, ); } @@ -112,15 +113,11 @@ export class WriteFileTool } validateToolParams(params: WriteFileToolParams): string | null { - if ( - this.schema.parameters && - !SchemaValidator.validate( - this.schema.parameters as Record, - params, - ) - ) { - return 'Parameters failed schema validation.'; + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; } + const filePath = params.file_path; if (!path.isAbsolute(filePath)) { return `File path must be absolute: ${filePath}`; diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 34ed5843..b2b1f853 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,55 +4,63 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Schema } from '@google/genai'; +import * as ajv from 'ajv'; + +const ajValidator = new ajv.Ajv(); + /** * Simple utility to validate objects against JSON Schemas */ export class SchemaValidator { /** - * Validates data against a JSON schema - * @param schema JSON Schema to validate against - * @param data Data to validate - * @returns True if valid, false otherwise + * Returns null if the data confroms to the schema described by schema (or if schema + * is null). Otherwise, returns a string describing the error. */ - static validate(schema: Record, data: unknown): boolean { - // This is a simplified implementation - // In a real application, you would use a library like Ajv for proper validation - - // Check for required fields - if (schema.required && Array.isArray(schema.required)) { - const required = schema.required as string[]; - const dataObj = data as Record; - - for (const field of required) { - if (dataObj[field] === undefined) { - console.error(`Missing required field: ${field}`); - return false; - } - } + static validate(schema: Schema | undefined, data: unknown): string | null { + if (!schema) { + return null; } - - // Check property types if properties are defined - if (schema.properties && typeof schema.properties === 'object') { - const properties = schema.properties as Record; - const dataObj = data as Record; - - for (const [key, prop] of Object.entries(properties)) { - if (dataObj[key] !== undefined && prop.type) { - const expectedType = prop.type; - const actualType = Array.isArray(dataObj[key]) - ? 'array' - : typeof dataObj[key]; - - if (expectedType !== actualType) { - console.error( - `Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`, - ); - return false; - } - } - } + if (typeof data !== 'object' || data === null) { + return 'Value of params must be an object'; } + const validate = ajValidator.compile(this.toObjectSchema(schema)); + const valid = validate(data); + if (!valid && validate.errors) { + return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); + } + return null; + } - return true; + /** + * Converts @google/genai's Schema to an object compatible with avj. + * This is necessry 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 = { ...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 = {}; + 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; } }