summaryrefslogtreecommitdiff
path: root/packages/core/src/tools
diff options
context:
space:
mode:
authorjoshualitt <[email protected]>2025-08-06 10:50:02 -0700
committerGitHub <[email protected]>2025-08-06 17:50:02 +0000
commit6133bea388a2de69c71a6be6f1450707f2ce4dfb (patch)
tree367de1d618069ea80e47d7e86c4fb8f82ad032a7 /packages/core/src/tools
parent882a97aff998b2f19731e9966d135f1db5a59914 (diff)
feat(core): Introduce `DeclarativeTool` and `ToolInvocation`. (#5613)
Diffstat (limited to 'packages/core/src/tools')
-rw-r--r--packages/core/src/tools/edit.ts4
-rw-r--r--packages/core/src/tools/memoryTool.ts4
-rw-r--r--packages/core/src/tools/modifiable-tool.test.ts12
-rw-r--r--packages/core/src/tools/modifiable-tool.ts18
-rw-r--r--packages/core/src/tools/read-file.test.ts310
-rw-r--r--packages/core/src/tools/read-file.ts138
-rw-r--r--packages/core/src/tools/tool-registry.test.ts24
-rw-r--r--packages/core/src/tools/tool-registry.ts14
-rw-r--r--packages/core/src/tools/tools.ts255
-rw-r--r--packages/core/src/tools/write-file.ts4
10 files changed, 470 insertions, 313 deletions
diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts
index 0d129e42..853ad4c1 100644
--- a/packages/core/src/tools/edit.ts
+++ b/packages/core/src/tools/edit.ts
@@ -26,7 +26,7 @@ import { Config, ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { ReadFileTool } from './read-file.js';
-import { ModifiableTool, ModifyContext } from './modifiable-tool.js';
+import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
/**
* Parameters for the Edit tool
@@ -72,7 +72,7 @@ interface CalculatedEdit {
*/
export class EditTool
extends BaseTool<EditToolParams, ToolResult>
- implements ModifiableTool<EditToolParams>
+ implements ModifiableDeclarativeTool<EditToolParams>
{
static readonly Name = 'replace';
diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts
index 847ea5cf..f3bf315b 100644
--- a/packages/core/src/tools/memoryTool.ts
+++ b/packages/core/src/tools/memoryTool.ts
@@ -18,7 +18,7 @@ import { homedir } from 'os';
import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { tildeifyPath } from '../utils/paths.js';
-import { ModifiableTool, ModifyContext } from './modifiable-tool.js';
+import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
const memoryToolSchemaData: FunctionDeclaration = {
name: 'save_memory',
@@ -112,7 +112,7 @@ function ensureNewlineSeparation(currentContent: string): string {
export class MemoryTool
extends BaseTool<SaveMemoryParams, ToolResult>
- implements ModifiableTool<SaveMemoryParams>
+ implements ModifiableDeclarativeTool<SaveMemoryParams>
{
private static readonly allowlist: Set<string> = new Set();
diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts
index eb7e8dbf..dc68640a 100644
--- a/packages/core/src/tools/modifiable-tool.test.ts
+++ b/packages/core/src/tools/modifiable-tool.test.ts
@@ -8,8 +8,8 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
modifyWithEditor,
ModifyContext,
- ModifiableTool,
- isModifiableTool,
+ ModifiableDeclarativeTool,
+ isModifiableDeclarativeTool,
} from './modifiable-tool.js';
import { EditorType } from '../utils/editor.js';
import fs from 'fs';
@@ -338,16 +338,16 @@ describe('isModifiableTool', () => {
const mockTool = {
name: 'test-tool',
getModifyContext: vi.fn(),
- } as unknown as ModifiableTool<TestParams>;
+ } as unknown as ModifiableDeclarativeTool<TestParams>;
- expect(isModifiableTool(mockTool)).toBe(true);
+ expect(isModifiableDeclarativeTool(mockTool)).toBe(true);
});
it('should return false for objects without getModifyContext method', () => {
const mockTool = {
name: 'test-tool',
- } as unknown as ModifiableTool<TestParams>;
+ } as unknown as ModifiableDeclarativeTool<TestParams>;
- expect(isModifiableTool(mockTool)).toBe(false);
+ expect(isModifiableDeclarativeTool(mockTool)).toBe(false);
});
});
diff --git a/packages/core/src/tools/modifiable-tool.ts b/packages/core/src/tools/modifiable-tool.ts
index 42de3eb6..25a2906b 100644
--- a/packages/core/src/tools/modifiable-tool.ts
+++ b/packages/core/src/tools/modifiable-tool.ts
@@ -11,13 +11,14 @@ import fs from 'fs';
import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { isNodeError } from '../utils/errors.js';
-import { Tool } from './tools.js';
+import { AnyDeclarativeTool, DeclarativeTool, ToolResult } from './tools.js';
/**
- * A tool that supports a modify operation.
+ * A declarative tool that supports a modify operation.
*/
-export interface ModifiableTool<ToolParams> extends Tool<ToolParams> {
- getModifyContext(abortSignal: AbortSignal): ModifyContext<ToolParams>;
+export interface ModifiableDeclarativeTool<TParams extends object>
+ extends DeclarativeTool<TParams, ToolResult> {
+ getModifyContext(abortSignal: AbortSignal): ModifyContext<TParams>;
}
export interface ModifyContext<ToolParams> {
@@ -39,9 +40,12 @@ export interface ModifyResult<ToolParams> {
updatedDiff: string;
}
-export function isModifiableTool<TParams>(
- tool: Tool<TParams>,
-): tool is ModifiableTool<TParams> {
+/**
+ * Type guard to check if a declarative tool is modifiable.
+ */
+export function isModifiableDeclarativeTool(
+ tool: AnyDeclarativeTool,
+): tool is ModifiableDeclarativeTool<object> {
return 'getModifyContext' in tool;
}
diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts
index fa1e458c..bb9317fd 100644
--- a/packages/core/src/tools/read-file.test.ts
+++ b/packages/core/src/tools/read-file.test.ts
@@ -13,6 +13,7 @@ import fsp from 'fs/promises';
import { Config } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
+import { ToolInvocation, ToolResult } from './tools.js';
describe('ReadFileTool', () => {
let tempRootDir: string;
@@ -40,57 +41,62 @@ describe('ReadFileTool', () => {
}
});
- describe('validateToolParams', () => {
- it('should return null for valid params (absolute path within root)', () => {
+ describe('build', () => {
+ it('should return an invocation for valid params (absolute path within root)', () => {
const params: ReadFileToolParams = {
absolute_path: path.join(tempRootDir, 'test.txt'),
};
- expect(tool.validateToolParams(params)).toBeNull();
+ const result = tool.build(params);
+ expect(result).not.toBeTypeOf('string');
+ expect(typeof result).toBe('object');
+ expect(
+ (result as ToolInvocation<ReadFileToolParams, ToolResult>).params,
+ ).toEqual(params);
});
- it('should return null for valid params with offset and limit', () => {
+ it('should return an invocation for valid params with offset and limit', () => {
const params: ReadFileToolParams = {
absolute_path: path.join(tempRootDir, 'test.txt'),
offset: 0,
limit: 10,
};
- expect(tool.validateToolParams(params)).toBeNull();
+ const result = tool.build(params);
+ expect(result).not.toBeTypeOf('string');
});
- it('should return error for relative path', () => {
+ it('should throw error for relative path', () => {
const params: ReadFileToolParams = { absolute_path: 'test.txt' };
- expect(tool.validateToolParams(params)).toBe(
+ expect(() => tool.build(params)).toThrow(
`File path must be absolute, but was relative: test.txt. You must provide an absolute path.`,
);
});
- it('should return error for path outside root', () => {
+ it('should throw error for path outside root', () => {
const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt');
const params: ReadFileToolParams = { absolute_path: outsidePath };
- const error = tool.validateToolParams(params);
- expect(error).toContain(
+ expect(() => tool.build(params)).toThrow(
'File path must be within one of the workspace directories',
);
});
- it('should return error for negative offset', () => {
+ it('should throw error for negative offset', () => {
const params: ReadFileToolParams = {
absolute_path: path.join(tempRootDir, 'test.txt'),
offset: -1,
limit: 10,
};
- expect(tool.validateToolParams(params)).toBe(
+ expect(() => tool.build(params)).toThrow(
'Offset must be a non-negative number',
);
});
- it('should return error for non-positive limit', () => {
+ it('should throw error for non-positive limit', () => {
const paramsZero: ReadFileToolParams = {
absolute_path: path.join(tempRootDir, 'test.txt'),
offset: 0,
limit: 0,
};
- expect(tool.validateToolParams(paramsZero)).toBe(
+ expect(() => tool.build(paramsZero)).toThrow(
'Limit must be a positive number',
);
const paramsNegative: ReadFileToolParams = {
@@ -98,168 +104,182 @@ describe('ReadFileTool', () => {
offset: 0,
limit: -5,
};
- expect(tool.validateToolParams(paramsNegative)).toBe(
+ expect(() => tool.build(paramsNegative)).toThrow(
'Limit must be a positive number',
);
});
- it('should return error for schema validation failure (e.g. missing path)', () => {
+ it('should throw error for schema validation failure (e.g. missing path)', () => {
const params = { offset: 0 } as unknown as ReadFileToolParams;
- expect(tool.validateToolParams(params)).toBe(
+ expect(() => tool.build(params)).toThrow(
`params must have required property 'absolute_path'`,
);
});
});
- describe('getDescription', () => {
- it('should return a shortened, relative path', () => {
- const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
- const params: ReadFileToolParams = { absolute_path: filePath };
- expect(tool.getDescription(params)).toBe(
- path.join('sub', 'dir', 'file.txt'),
- );
- });
-
- it('should return . if path is the root directory', () => {
- const params: ReadFileToolParams = { absolute_path: tempRootDir };
- expect(tool.getDescription(params)).toBe('.');
- });
- });
+ describe('ToolInvocation', () => {
+ describe('getDescription', () => {
+ it('should return a shortened, relative path', () => {
+ const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
+ const params: ReadFileToolParams = { absolute_path: filePath };
+ const invocation = tool.build(params);
+ expect(typeof invocation).not.toBe('string');
+ expect(
+ (
+ invocation as ToolInvocation<ReadFileToolParams, ToolResult>
+ ).getDescription(),
+ ).toBe(path.join('sub', 'dir', 'file.txt'));
+ });
- describe('execute', () => {
- it('should return validation error if params are invalid', async () => {
- const params: ReadFileToolParams = {
- absolute_path: 'relative/path.txt',
- };
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent:
- 'Error: Invalid parameters provided. Reason: File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.',
- returnDisplay:
- 'File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.',
+ it('should return . if path is the root directory', () => {
+ const params: ReadFileToolParams = { absolute_path: tempRootDir };
+ const invocation = tool.build(params);
+ expect(typeof invocation).not.toBe('string');
+ expect(
+ (
+ invocation as ToolInvocation<ReadFileToolParams, ToolResult>
+ ).getDescription(),
+ ).toBe('.');
});
});
- it('should return error if file does not exist', async () => {
- const filePath = path.join(tempRootDir, 'nonexistent.txt');
- const params: ReadFileToolParams = { absolute_path: filePath };
+ describe('execute', () => {
+ it('should return error if file does not exist', async () => {
+ const filePath = path.join(tempRootDir, 'nonexistent.txt');
+ const params: ReadFileToolParams = { absolute_path: filePath };
+ const invocation = tool.build(params) as ToolInvocation<
+ ReadFileToolParams,
+ ToolResult
+ >;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: `File not found: ${filePath}`,
- returnDisplay: 'File not found.',
+ expect(await invocation.execute(abortSignal)).toEqual({
+ llmContent: `File not found: ${filePath}`,
+ returnDisplay: 'File not found.',
+ });
});
- });
- it('should return success result for a text file', async () => {
- const filePath = path.join(tempRootDir, 'textfile.txt');
- const fileContent = 'This is a test file.';
- await fsp.writeFile(filePath, fileContent, 'utf-8');
- const params: ReadFileToolParams = { absolute_path: filePath };
+ it('should return success result for a text file', async () => {
+ const filePath = path.join(tempRootDir, 'textfile.txt');
+ const fileContent = 'This is a test file.';
+ await fsp.writeFile(filePath, fileContent, 'utf-8');
+ const params: ReadFileToolParams = { absolute_path: filePath };
+ const invocation = tool.build(params) as ToolInvocation<
+ ReadFileToolParams,
+ ToolResult
+ >;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: fileContent,
- returnDisplay: '',
+ expect(await invocation.execute(abortSignal)).toEqual({
+ llmContent: fileContent,
+ returnDisplay: '',
+ });
});
- });
- it('should return success result for an image file', async () => {
- // A minimal 1x1 transparent PNG file.
- const pngContent = Buffer.from([
- 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
- 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65,
- 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0, 0, 73,
- 69, 78, 68, 174, 66, 96, 130,
- ]);
- const filePath = path.join(tempRootDir, 'image.png');
- await fsp.writeFile(filePath, pngContent);
- const params: ReadFileToolParams = { absolute_path: filePath };
+ it('should return success result for an image file', async () => {
+ // A minimal 1x1 transparent PNG file.
+ const pngContent = Buffer.from([
+ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
+ 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68,
+ 65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0,
+ 0, 73, 69, 78, 68, 174, 66, 96, 130,
+ ]);
+ const filePath = path.join(tempRootDir, 'image.png');
+ await fsp.writeFile(filePath, pngContent);
+ const params: ReadFileToolParams = { absolute_path: filePath };
+ const invocation = tool.build(params) as ToolInvocation<
+ ReadFileToolParams,
+ ToolResult
+ >;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: {
- inlineData: {
- mimeType: 'image/png',
- data: pngContent.toString('base64'),
+ expect(await invocation.execute(abortSignal)).toEqual({
+ llmContent: {
+ inlineData: {
+ mimeType: 'image/png',
+ data: pngContent.toString('base64'),
+ },
},
- },
- returnDisplay: `Read image file: image.png`,
+ returnDisplay: `Read image file: image.png`,
+ });
});
- });
- it('should treat a non-image file with image extension as an image', async () => {
- const filePath = path.join(tempRootDir, 'fake-image.png');
- const fileContent = 'This is not a real png.';
- await fsp.writeFile(filePath, fileContent, 'utf-8');
- const params: ReadFileToolParams = { absolute_path: filePath };
+ it('should treat a non-image file with image extension as an image', async () => {
+ const filePath = path.join(tempRootDir, 'fake-image.png');
+ const fileContent = 'This is not a real png.';
+ await fsp.writeFile(filePath, fileContent, 'utf-8');
+ const params: ReadFileToolParams = { absolute_path: filePath };
+ const invocation = tool.build(params) as ToolInvocation<
+ ReadFileToolParams,
+ ToolResult
+ >;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: {
- inlineData: {
- mimeType: 'image/png',
- data: Buffer.from(fileContent).toString('base64'),
+ expect(await invocation.execute(abortSignal)).toEqual({
+ llmContent: {
+ inlineData: {
+ mimeType: 'image/png',
+ data: Buffer.from(fileContent).toString('base64'),
+ },
},
- },
- returnDisplay: `Read image file: fake-image.png`,
+ returnDisplay: `Read image file: fake-image.png`,
+ });
});
- });
- it('should pass offset and limit to read a slice of a text file', async () => {
- const filePath = path.join(tempRootDir, 'paginated.txt');
- const fileContent = Array.from(
- { length: 20 },
- (_, i) => `Line ${i + 1}`,
- ).join('\n');
- await fsp.writeFile(filePath, fileContent, 'utf-8');
+ it('should pass offset and limit to read a slice of a text file', async () => {
+ const filePath = path.join(tempRootDir, 'paginated.txt');
+ const fileContent = Array.from(
+ { length: 20 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+ await fsp.writeFile(filePath, fileContent, 'utf-8');
- const params: ReadFileToolParams = {
- absolute_path: filePath,
- offset: 5, // Start from line 6
- limit: 3,
- };
+ const params: ReadFileToolParams = {
+ absolute_path: filePath,
+ offset: 5, // Start from line 6
+ limit: 3,
+ };
+ const invocation = tool.build(params) as ToolInvocation<
+ ReadFileToolParams,
+ ToolResult
+ >;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: [
- '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]',
- 'Line 6',
- 'Line 7',
- 'Line 8',
- ].join('\n'),
- returnDisplay: 'Read lines 6-8 of 20 from paginated.txt',
+ expect(await invocation.execute(abortSignal)).toEqual({
+ llmContent: [
+ '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]',
+ 'Line 6',
+ 'Line 7',
+ 'Line 8',
+ ].join('\n'),
+ returnDisplay: 'Read lines 6-8 of 20 from paginated.txt',
+ });
});
- });
- describe('with .geminiignore', () => {
- beforeEach(async () => {
- await fsp.writeFile(
- path.join(tempRootDir, '.geminiignore'),
- ['foo.*', 'ignored/'].join('\n'),
- );
- });
+ describe('with .geminiignore', () => {
+ beforeEach(async () => {
+ await fsp.writeFile(
+ path.join(tempRootDir, '.geminiignore'),
+ ['foo.*', 'ignored/'].join('\n'),
+ );
+ });
- it('should return error if path is ignored by a .geminiignore pattern', async () => {
- const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
- await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
- const params: ReadFileToolParams = {
- absolute_path: ignoredFilePath,
- };
- const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`,
- returnDisplay: expectedError,
+ it('should throw error if path is ignored by a .geminiignore pattern', async () => {
+ const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
+ await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
+ const params: ReadFileToolParams = {
+ absolute_path: ignoredFilePath,
+ };
+ const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
+ expect(() => tool.build(params)).toThrow(expectedError);
});
- });
- it('should return error if path is in an ignored directory', async () => {
- const ignoredDirPath = path.join(tempRootDir, 'ignored');
- await fsp.mkdir(ignoredDirPath);
- const filePath = path.join(ignoredDirPath, 'somefile.txt');
- await fsp.writeFile(filePath, 'content', 'utf-8');
+ it('should throw error if path is in an ignored directory', async () => {
+ const ignoredDirPath = path.join(tempRootDir, 'ignored');
+ await fsp.mkdir(ignoredDirPath);
+ const filePath = path.join(ignoredDirPath, 'somefile.txt');
+ await fsp.writeFile(filePath, 'content', 'utf-8');
- const params: ReadFileToolParams = {
- absolute_path: filePath,
- };
- const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`;
- expect(await tool.execute(params, abortSignal)).toEqual({
- llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`,
- returnDisplay: expectedError,
+ const params: ReadFileToolParams = {
+ absolute_path: filePath,
+ };
+ const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`;
+ expect(() => tool.build(params)).toThrow(expectedError);
});
});
});
@@ -270,18 +290,16 @@ describe('ReadFileTool', () => {
const params: ReadFileToolParams = {
absolute_path: path.join(tempRootDir, 'file.txt'),
};
- expect(tool.validateToolParams(params)).toBeNull();
+ expect(() => tool.build(params)).not.toThrow();
});
it('should reject paths outside workspace root', () => {
const params: ReadFileToolParams = {
absolute_path: '/etc/passwd',
};
- const error = tool.validateToolParams(params);
- expect(error).toContain(
+ expect(() => tool.build(params)).toThrow(
'File path must be within one of the workspace directories',
);
- expect(error).toContain(tempRootDir);
});
it('should provide clear error message with workspace directories', () => {
@@ -289,11 +307,9 @@ describe('ReadFileTool', () => {
const params: ReadFileToolParams = {
absolute_path: outsidePath,
};
- const error = tool.validateToolParams(params);
- expect(error).toContain(
+ expect(() => tool.build(params)).toThrow(
'File path must be within one of the workspace directories',
);
- expect(error).toContain(tempRootDir);
});
});
});
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
index 31282c20..3a05da06 100644
--- a/packages/core/src/tools/read-file.ts
+++ b/packages/core/src/tools/read-file.ts
@@ -7,7 +7,13 @@
import path from 'path';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
-import { BaseTool, Icon, ToolLocation, ToolResult } from './tools.js';
+import {
+ BaseDeclarativeTool,
+ Icon,
+ ToolInvocation,
+ ToolLocation,
+ ToolResult,
+} from './tools.js';
import { Type } from '@google/genai';
import {
processSingleFileContent,
@@ -39,10 +45,72 @@ export interface ReadFileToolParams {
limit?: number;
}
+class ReadFileToolInvocation
+ implements ToolInvocation<ReadFileToolParams, ToolResult>
+{
+ constructor(
+ private config: Config,
+ public params: ReadFileToolParams,
+ ) {}
+
+ getDescription(): string {
+ const relativePath = makeRelative(
+ this.params.absolute_path,
+ this.config.getTargetDir(),
+ );
+ return shortenPath(relativePath);
+ }
+
+ toolLocations(): ToolLocation[] {
+ return [{ path: this.params.absolute_path, line: this.params.offset }];
+ }
+
+ shouldConfirmExecute(): Promise<false> {
+ return Promise.resolve(false);
+ }
+
+ async execute(): Promise<ToolResult> {
+ const result = await processSingleFileContent(
+ this.params.absolute_path,
+ this.config.getTargetDir(),
+ this.params.offset,
+ this.params.limit,
+ );
+
+ if (result.error) {
+ return {
+ llmContent: result.error, // The detailed error for LLM
+ returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error
+ };
+ }
+
+ const lines =
+ typeof result.llmContent === 'string'
+ ? result.llmContent.split('\n').length
+ : undefined;
+ const mimetype = getSpecificMimeType(this.params.absolute_path);
+ recordFileOperationMetric(
+ this.config,
+ FileOperation.READ,
+ lines,
+ mimetype,
+ path.extname(this.params.absolute_path),
+ );
+
+ return {
+ llmContent: result.llmContent || '',
+ returnDisplay: result.returnDisplay || '',
+ };
+ }
+}
+
/**
* Implementation of the ReadFile tool logic
*/
-export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
+export class ReadFileTool extends BaseDeclarativeTool<
+ ReadFileToolParams,
+ ToolResult
+> {
static readonly Name: string = 'read_file';
constructor(private config: Config) {
@@ -75,7 +143,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
);
}
- validateToolParams(params: ReadFileToolParams): string | null {
+ protected validateToolParams(params: ReadFileToolParams): string | null {
const errors = SchemaValidator.validate(this.schema.parameters, params);
if (errors) {
return errors;
@@ -106,67 +174,9 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
return null;
}
- getDescription(params: ReadFileToolParams): string {
- if (
- !params ||
- typeof params.absolute_path !== 'string' ||
- params.absolute_path.trim() === ''
- ) {
- return `Path unavailable`;
- }
- const relativePath = makeRelative(
- params.absolute_path,
- this.config.getTargetDir(),
- );
- return shortenPath(relativePath);
- }
-
- toolLocations(params: ReadFileToolParams): ToolLocation[] {
- return [{ path: params.absolute_path, line: params.offset }];
- }
-
- async execute(
+ protected createInvocation(
params: ReadFileToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
- returnDisplay: validationError,
- };
- }
-
- const result = await processSingleFileContent(
- params.absolute_path,
- this.config.getTargetDir(),
- params.offset,
- params.limit,
- );
-
- if (result.error) {
- return {
- llmContent: result.error, // The detailed error for LLM
- returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error
- };
- }
-
- const lines =
- typeof result.llmContent === 'string'
- ? result.llmContent.split('\n').length
- : undefined;
- const mimetype = getSpecificMimeType(params.absolute_path);
- recordFileOperationMetric(
- this.config,
- FileOperation.READ,
- lines,
- mimetype,
- path.extname(params.absolute_path),
- );
-
- return {
- llmContent: result.llmContent || '',
- returnDisplay: result.returnDisplay || '',
- };
+ ): ToolInvocation<ReadFileToolParams, ToolResult> {
+ return new ReadFileToolInvocation(this.config, params);
}
}
diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts
index 24b6ca5f..e7c71e14 100644
--- a/packages/core/src/tools/tool-registry.test.ts
+++ b/packages/core/src/tools/tool-registry.test.ts
@@ -21,7 +21,6 @@ import {
sanitizeParameters,
} from './tool-registry.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
-import { BaseTool, Icon, ToolResult } from './tools.js';
import {
FunctionDeclaration,
CallableTool,
@@ -32,6 +31,7 @@ import {
import { spawn } from 'node:child_process';
import fs from 'node:fs';
+import { MockTool } from '../test-utils/tools.js';
vi.mock('node:fs');
@@ -107,28 +107,6 @@ const createMockCallableTool = (
callTool: vi.fn(),
});
-class MockTool extends BaseTool<{ param: string }, ToolResult> {
- constructor(
- name = 'mock-tool',
- displayName = 'A mock tool',
- description = 'A mock tool description',
- ) {
- super(name, displayName, description, Icon.Hammer, {
- type: Type.OBJECT,
- properties: {
- param: { type: Type.STRING },
- },
- required: ['param'],
- });
- }
- async execute(params: { param: string }): Promise<ToolResult> {
- return {
- llmContent: `Executed with ${params.param}`,
- returnDisplay: `Executed with ${params.param}`,
- };
- }
-}
-
const baseConfigParams: ConfigParameters = {
cwd: '/tmp',
model: 'test-model',
diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts
index e60b8f74..73b427d4 100644
--- a/packages/core/src/tools/tool-registry.ts
+++ b/packages/core/src/tools/tool-registry.ts
@@ -5,7 +5,7 @@
*/
import { FunctionDeclaration, Schema, Type } from '@google/genai';
-import { Tool, ToolResult, BaseTool, Icon } from './tools.js';
+import { AnyDeclarativeTool, Icon, ToolResult, BaseTool } from './tools.js';
import { Config } from '../config/config.js';
import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
@@ -125,7 +125,7 @@ Signal: Signal number or \`(none)\` if no signal was received.
}
export class ToolRegistry {
- private tools: Map<string, Tool> = new Map();
+ private tools: Map<string, AnyDeclarativeTool> = new Map();
private config: Config;
constructor(config: Config) {
@@ -136,7 +136,7 @@ export class ToolRegistry {
* Registers a tool definition.
* @param tool - The tool object containing schema and execution logic.
*/
- registerTool(tool: Tool): void {
+ registerTool(tool: AnyDeclarativeTool): void {
if (this.tools.has(tool.name)) {
if (tool instanceof DiscoveredMCPTool) {
tool = tool.asFullyQualifiedTool();
@@ -368,7 +368,7 @@ export class ToolRegistry {
/**
* Returns an array of all registered and discovered tool instances.
*/
- getAllTools(): Tool[] {
+ getAllTools(): AnyDeclarativeTool[] {
return Array.from(this.tools.values()).sort((a, b) =>
a.displayName.localeCompare(b.displayName),
);
@@ -377,8 +377,8 @@ export class ToolRegistry {
/**
* Returns an array of tools registered from a specific MCP server.
*/
- getToolsByServer(serverName: string): Tool[] {
- const serverTools: Tool[] = [];
+ getToolsByServer(serverName: string): AnyDeclarativeTool[] {
+ const serverTools: AnyDeclarativeTool[] = [];
for (const tool of this.tools.values()) {
if ((tool as DiscoveredMCPTool)?.serverName === serverName) {
serverTools.push(tool);
@@ -390,7 +390,7 @@ export class ToolRegistry {
/**
* Get the definition of a specific tool.
*/
- getTool(name: string): Tool | undefined {
+ getTool(name: string): AnyDeclarativeTool | undefined {
return this.tools.get(name);
}
}
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index 3404093f..79e6f010 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -9,101 +9,243 @@ import { ToolErrorType } from './tool-error.js';
import { DiffUpdateResult } from '../ide/ideContext.js';
/**
- * Interface representing the base Tool functionality
+ * Represents a validated and ready-to-execute tool call.
+ * An instance of this is created by a `ToolBuilder`.
*/
-export interface Tool<
- TParams = unknown,
- TResult extends ToolResult = ToolResult,
+export interface ToolInvocation<
+ TParams extends object,
+ TResult extends ToolResult,
> {
/**
- * The internal name of the tool (used for API calls)
+ * The validated parameters for this specific invocation.
+ */
+ params: TParams;
+
+ /**
+ * Gets a pre-execution description of the tool operation.
+ * @returns A markdown string describing what the tool will do.
+ */
+ getDescription(): string;
+
+ /**
+ * Determines what file system paths the tool will affect.
+ * @returns A list of such paths.
+ */
+ toolLocations(): ToolLocation[];
+
+ /**
+ * Determines if the tool should prompt for confirmation before execution.
+ * @returns Confirmation details or false if no confirmation is needed.
+ */
+ shouldConfirmExecute(
+ abortSignal: AbortSignal,
+ ): Promise<ToolCallConfirmationDetails | false>;
+
+ /**
+ * Executes the tool with the validated parameters.
+ * @param signal AbortSignal for tool cancellation.
+ * @param updateOutput Optional callback to stream output.
+ * @returns Result of the tool execution.
+ */
+ execute(
+ signal: AbortSignal,
+ updateOutput?: (output: string) => void,
+ ): Promise<TResult>;
+}
+
+/**
+ * A type alias for a tool invocation where the specific parameter and result types are not known.
+ */
+export type AnyToolInvocation = ToolInvocation<object, ToolResult>;
+
+/**
+ * An adapter that wraps the legacy `Tool` interface to make it compatible
+ * with the new `ToolInvocation` pattern.
+ */
+export class LegacyToolInvocation<
+ TParams extends object,
+ TResult extends ToolResult,
+> implements ToolInvocation<TParams, TResult>
+{
+ constructor(
+ private readonly legacyTool: BaseTool<TParams, TResult>,
+ readonly params: TParams,
+ ) {}
+
+ getDescription(): string {
+ return this.legacyTool.getDescription(this.params);
+ }
+
+ toolLocations(): ToolLocation[] {
+ return this.legacyTool.toolLocations(this.params);
+ }
+
+ shouldConfirmExecute(
+ abortSignal: AbortSignal,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ return this.legacyTool.shouldConfirmExecute(this.params, abortSignal);
+ }
+
+ execute(
+ signal: AbortSignal,
+ updateOutput?: (output: string) => void,
+ ): Promise<TResult> {
+ return this.legacyTool.execute(this.params, signal, updateOutput);
+ }
+}
+
+/**
+ * Interface for a tool builder that validates parameters and creates invocations.
+ */
+export interface ToolBuilder<
+ TParams extends object,
+ TResult extends ToolResult,
+> {
+ /**
+ * The internal name of the tool (used for API calls).
*/
name: string;
/**
- * The user-friendly display name of the tool
+ * The user-friendly display name of the tool.
*/
displayName: string;
/**
- * Description of what the tool does
+ * Description of what the tool does.
*/
description: string;
/**
- * The icon to display when interacting via ACP
+ * The icon to display when interacting via ACP.
*/
icon: Icon;
/**
- * Function declaration schema from @google/genai
+ * Function declaration schema from @google/genai.
*/
schema: FunctionDeclaration;
/**
- * Whether the tool's output should be rendered as markdown
+ * Whether the tool's output should be rendered as markdown.
*/
isOutputMarkdown: boolean;
/**
- * Whether the tool supports live (streaming) output
+ * Whether the tool supports live (streaming) output.
*/
canUpdateOutput: boolean;
/**
- * Validates the parameters for the tool
- * Should be called from both `shouldConfirmExecute` and `execute`
- * `shouldConfirmExecute` should return false immediately if invalid
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
+ * Validates raw parameters and builds a ready-to-execute invocation.
+ * @param params The raw, untrusted parameters from the model.
+ * @returns A valid `ToolInvocation` if successful. Throws an error if validation fails.
*/
- validateToolParams(params: TParams): string | null;
+ build(params: TParams): ToolInvocation<TParams, TResult>;
+}
- /**
- * Gets a pre-execution description of the tool operation
- * @param params Parameters for the tool execution
- * @returns A markdown string describing what the tool will do
- * Optional for backward compatibility
- */
- getDescription(params: TParams): string;
+/**
+ * New base class for tools that separates validation from execution.
+ * New tools should extend this class.
+ */
+export abstract class DeclarativeTool<
+ TParams extends object,
+ TResult extends ToolResult,
+> implements ToolBuilder<TParams, TResult>
+{
+ constructor(
+ readonly name: string,
+ readonly displayName: string,
+ readonly description: string,
+ readonly icon: Icon,
+ readonly parameterSchema: Schema,
+ readonly isOutputMarkdown: boolean = true,
+ readonly canUpdateOutput: boolean = false,
+ ) {}
+
+ get schema(): FunctionDeclaration {
+ return {
+ name: this.name,
+ description: this.description,
+ parameters: this.parameterSchema,
+ };
+ }
/**
- * Determines what file system paths the tool will affect
- * @param params Parameters for the tool execution
- * @returns A list of such paths
+ * Validates the raw tool parameters.
+ * Subclasses should override this to add custom validation logic
+ * beyond the JSON schema check.
+ * @param params The raw parameters from the model.
+ * @returns An error message string if invalid, null otherwise.
*/
- toolLocations(params: TParams): ToolLocation[];
+ protected validateToolParams(_params: TParams): string | null {
+ // Base implementation can be extended by subclasses.
+ return null;
+ }
/**
- * Determines if the tool should prompt for confirmation before execution
- * @param params Parameters for the tool execution
- * @returns Whether execute should be confirmed.
+ * The core of the new pattern. It validates parameters and, if successful,
+ * returns a `ToolInvocation` object that encapsulates the logic for the
+ * specific, validated call.
+ * @param params The raw, untrusted parameters from the model.
+ * @returns A `ToolInvocation` instance.
*/
- shouldConfirmExecute(
- params: TParams,
- abortSignal: AbortSignal,
- ): Promise<ToolCallConfirmationDetails | false>;
+ abstract build(params: TParams): ToolInvocation<TParams, TResult>;
/**
- * Executes the tool with the given parameters
- * @param params Parameters for the tool execution
- * @returns Result of the tool execution
+ * A convenience method that builds and executes the tool in one step.
+ * Throws an error if validation fails.
+ * @param params The raw, untrusted parameters from the model.
+ * @param signal AbortSignal for tool cancellation.
+ * @param updateOutput Optional callback to stream output.
+ * @returns The result of the tool execution.
*/
- execute(
+ async buildAndExecute(
params: TParams,
signal: AbortSignal,
updateOutput?: (output: string) => void,
- ): Promise<TResult>;
+ ): Promise<TResult> {
+ const invocation = this.build(params);
+ return invocation.execute(signal, updateOutput);
+ }
+}
+
+/**
+ * New base class for declarative tools that separates validation from execution.
+ * New tools should extend this class, which provides a `build` method that
+ * validates parameters before deferring to a `createInvocation` method for
+ * the final `ToolInvocation` object instantiation.
+ */
+export abstract class BaseDeclarativeTool<
+ TParams extends object,
+ TResult extends ToolResult,
+> extends DeclarativeTool<TParams, TResult> {
+ build(params: TParams): ToolInvocation<TParams, TResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+ return this.createInvocation(params);
+ }
+
+ protected abstract createInvocation(
+ params: TParams,
+ ): ToolInvocation<TParams, TResult>;
}
/**
+ * A type alias for a declarative tool where the specific parameter and result types are not known.
+ */
+export type AnyDeclarativeTool = DeclarativeTool<object, ToolResult>;
+
+/**
* Base implementation for tools with common functionality
+ * @deprecated Use `DeclarativeTool` for new tools.
*/
export abstract class BaseTool<
- TParams = unknown,
+ TParams extends object,
TResult extends ToolResult = ToolResult,
-> implements Tool<TParams, TResult>
-{
+> extends DeclarativeTool<TParams, TResult> {
/**
* Creates a new instance of BaseTool
* @param name Internal name of the tool (used for API calls)
@@ -121,17 +263,24 @@ export abstract class BaseTool<
readonly parameterSchema: Schema,
readonly isOutputMarkdown: boolean = true,
readonly canUpdateOutput: boolean = false,
- ) {}
+ ) {
+ super(
+ name,
+ displayName,
+ description,
+ icon,
+ parameterSchema,
+ isOutputMarkdown,
+ canUpdateOutput,
+ );
+ }
- /**
- * Function declaration schema computed from name, description, and parameterSchema
- */
- get schema(): FunctionDeclaration {
- return {
- name: this.name,
- description: this.description,
- parameters: this.parameterSchema,
- };
+ build(params: TParams): ToolInvocation<TParams, TResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+ return new LegacyToolInvocation(this, params);
}
/**
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
index 32ecc068..9e7e3813 100644
--- a/packages/core/src/tools/write-file.ts
+++ b/packages/core/src/tools/write-file.ts
@@ -26,7 +26,7 @@ import {
ensureCorrectFileContent,
} from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
-import { ModifiableTool, ModifyContext } from './modifiable-tool.js';
+import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import { getSpecificMimeType } from '../utils/fileUtils.js';
import {
recordFileOperationMetric,
@@ -66,7 +66,7 @@ interface GetCorrectedFileContentResult {
*/
export class WriteFileTool
extends BaseTool<WriteFileToolParams, ToolResult>
- implements ModifiableTool<WriteFileToolParams>
+ implements ModifiableDeclarativeTool<WriteFileToolParams>
{
static readonly Name: string = 'write_file';