feat(core): Introduce DeclarativeTool and ToolInvocation. (#5613)

This commit is contained in:
joshualitt
2025-08-06 10:50:02 -07:00
committed by GitHub
parent 882a97aff9
commit 6133bea388
24 changed files with 991 additions and 681 deletions

View File

@@ -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';

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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'),
);
});
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'));
});
it('should return . if path is the root directory', () => {
const params: ReadFileToolParams = { absolute_path: tempRootDir };
expect(tool.getDescription(params)).toBe('.');
});
});
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.',
});
});
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 };
expect(await tool.execute(params, 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 };
expect(await tool.execute(params, abortSignal)).toEqual({
llmContent: {
inlineData: {
mimeType: 'image/png',
data: pngContent.toString('base64'),
},
},
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 };
expect(await tool.execute(params, abortSignal)).toEqual({
llmContent: {
inlineData: {
mimeType: 'image/png',
data: Buffer.from(fileContent).toString('base64'),
},
},
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');
const params: ReadFileToolParams = {
absolute_path: filePath,
offset: 5, // Start from line 6
limit: 3,
};
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',
});
});
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,
expect(await invocation.execute(abortSignal)).toEqual({
llmContent: `File not found: ${filePath}`,
returnDisplay: 'File not found.',
});
});
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 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 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 };
const invocation = tool.build(params) as ToolInvocation<
ReadFileToolParams,
ToolResult
>;
expect(await invocation.execute(abortSignal)).toEqual({
llmContent: {
inlineData: {
mimeType: 'image/png',
data: pngContent.toString('base64'),
},
},
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 };
const invocation = tool.build(params) as ToolInvocation<
ReadFileToolParams,
ToolResult
>;
expect(await invocation.execute(abortSignal)).toEqual({
llmContent: {
inlineData: {
mimeType: 'image/png',
data: Buffer.from(fileContent).toString('base64'),
},
},
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');
const params: ReadFileToolParams = {
absolute_path: filePath,
offset: 5, // Start from line 6
limit: 3,
};
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 invocation = tool.build(params) as ToolInvocation<
ReadFileToolParams,
ToolResult
>;
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'),
);
});
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 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(() => 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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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);
}
}

View File

@@ -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.
*/
name: string;
params: TParams;
/**
* The user-friendly display name of the tool
* Gets a pre-execution description of the tool operation.
* @returns A markdown string describing what the tool will do.
*/
displayName: string;
getDescription(): string;
/**
* Description of what the tool does
* Determines what file system paths the tool will affect.
* @returns A list of such paths.
*/
description: string;
toolLocations(): ToolLocation[];
/**
* The icon to display when interacting via ACP
*/
icon: Icon;
/**
* Function declaration schema from @google/genai
*/
schema: FunctionDeclaration;
/**
* Whether the tool's output should be rendered as markdown
*/
isOutputMarkdown: boolean;
/**
* 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
*/
validateToolParams(params: TParams): string | null;
/**
* 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;
/**
* Determines what file system paths the tool will affect
* @param params Parameters for the tool execution
* @returns A list of such paths
*/
toolLocations(params: TParams): ToolLocation[];
/**
* Determines if the tool should prompt for confirmation before execution
* @param params Parameters for the tool execution
* @returns Whether execute should be confirmed.
* Determines if the tool should prompt for confirmation before execution.
* @returns Confirmation details or false if no confirmation is needed.
*/
shouldConfirmExecute(
params: TParams,
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false>;
/**
* Executes the tool with the given parameters
* @param params Parameters for the tool execution
* @returns Result of the tool execution
* 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(
params: TParams,
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.
*/
displayName: string;
/**
* Description of what the tool does.
*/
description: string;
/**
* The icon to display when interacting via ACP.
*/
icon: Icon;
/**
* Function declaration schema from @google/genai.
*/
schema: FunctionDeclaration;
/**
* Whether the tool's output should be rendered as markdown.
*/
isOutputMarkdown: boolean;
/**
* Whether the tool supports live (streaming) output.
*/
canUpdateOutput: boolean;
/**
* 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.
*/
build(params: TParams): ToolInvocation<TParams, TResult>;
}
/**
* 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,
};
}
/**
* 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.
*/
protected validateToolParams(_params: TParams): string | null {
// Base implementation can be extended by subclasses.
return null;
}
/**
* 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.
*/
abstract build(params: TParams): ToolInvocation<TParams, TResult>;
/**
* 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.
*/
async buildAndExecute(
params: TParams,
signal: AbortSignal,
updateOutput?: (output: string) => void,
): 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);
}
/**

View File

@@ -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';