feat(core): implement tool preselection to reduce context size

- Created ToolPreselectionService using the classifier model to select only relevant tools for a given prompt.
- Integrated pre-selection into LocalAgentExecutor and GeminiClient to automatically filter the tool registry.
- Added `general.toolPreselection` toggle in configuration (enabled by default).
- Added comprehensive unit tests and an E2E scenario confirming accurate tool reduction without loss of function.
- Fixes #17113
This commit is contained in:
mkorwel
2026-02-19 13:56:49 -06:00
parent 1a8d77329e
commit 31ebfb496a
17 changed files with 509 additions and 5 deletions
@@ -50,6 +50,7 @@ export const GeneralistAgent = (
const tools = config.getToolRegistry().getAllToolNames();
return {
tools,
preselectTools: true,
};
},
get promptConfig() {
@@ -30,6 +30,7 @@ import {
} from '../core/geminiChat.js';
import {
type FunctionCall,
type FunctionDeclaration,
type Part,
type GenerateContentResponse,
type Content,
@@ -71,6 +72,7 @@ import type {
import type { AgentRegistry } from './registry.js';
import { getModelConfigAlias } from './registry.js';
import type { ModelRouterService } from '../routing/modelRouterService.js';
import { ToolPreselectionService } from '../services/toolPreselectionService.js';
const {
mockSendMessageStream,
@@ -552,6 +554,70 @@ describe('LocalAgentExecutor', () => {
});
describe('run (Execution Loop and Logic)', () => {
it('should pre-select tools when preselectTools is enabled', async () => {
const definition = createTestDefinition([
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
]);
definition.toolConfig!.preselectTools = true;
definition.promptConfig.query = '${goal}';
// Mock tool pre-selection to only keep LS
const selectToolsSpy = vi
.spyOn(ToolPreselectionService.prototype, 'selectTools')
.mockResolvedValue([LS_TOOL_NAME]);
// Mock a response to terminate the loop
mockSendMessageStream.mockImplementation(async () =>
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: createMockResponseChunk(
[],
[
{
name: TASK_COMPLETE_TOOL_NAME,
args: { result: 'done' },
id: 'call1',
},
],
),
} as StreamEvent;
})(),
);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const inputs = { goal: 'Test pre-selection' };
await executor.run(inputs, signal);
// Verify ToolPreselectionService was called
expect(selectToolsSpy).toHaveBeenCalledWith(
expect.stringContaining('Test pre-selection'),
expect.arrayContaining([
expect.objectContaining({ name: LS_TOOL_NAME }),
expect.objectContaining({ name: READ_FILE_TOOL_NAME }),
]),
expect.any(AbortSignal),
);
// Verify GeminiChat was initialized with ONLY LS and complete_task
const chatConstructorArgs = MockedGeminiChat.mock.calls[0];
const tools = chatConstructorArgs[2]![0]
.functionDeclarations as FunctionDeclaration[];
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain(LS_TOOL_NAME);
expect(toolNames).toContain(TASK_COMPLETE_TOOL_NAME);
expect(toolNames).not.toContain(READ_FILE_TOOL_NAME);
selectToolsSpy.mockRestore();
});
it('should log AgentFinish with error if run throws', async () => {
const definition = createTestDefinition();
// Make the definition invalid to cause an error during run
+32 -1
View File
@@ -16,6 +16,7 @@ import type {
Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { ToolPreselectionService } from '../services/toolPreselectionService.js';
import {
DiscoveredMCPTool,
MCP_QUALIFIED_NAME_SEPARATOR,
@@ -460,11 +461,41 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
};
tools = this.prepareToolsList();
chat = await this.createChatObject(augmentedInputs, tools);
const query = this.definition.promptConfig.query
? templateString(this.definition.promptConfig.query, augmentedInputs)
: DEFAULT_QUERY_STRING;
// Tool Pre-selection
if (
this.definition.toolConfig?.preselectTools &&
this.runtimeContext.isToolPreselectionEnabled()
) {
const preselector = new ToolPreselectionService(this.runtimeContext);
const selectedToolNames = await preselector.selectTools(
query,
tools,
combinedSignal,
);
// Filter the agent's tool registry to only include selected tools.
// We MUST always include the task completion tool.
const toolsToKeep = new Set(selectedToolNames);
toolsToKeep.add(TASK_COMPLETE_TOOL_NAME);
const allAgentTools = this.toolRegistry.getAllToolNames();
for (const toolName of allAgentTools) {
if (!toolsToKeep.has(toolName)) {
this.toolRegistry.unregisterTool(toolName);
}
}
// Re-prepare the tools list after filtering the registry.
tools = this.prepareToolsList();
}
chat = await this.createChatObject(augmentedInputs, tools);
const pendingHintsQueue: string[] = [];
const hintListener = (hint: string) => {
pendingHintsQueue.push(hint);
+7
View File
@@ -410,6 +410,13 @@ export class AgentRegistry {
);
}
if (overrides.preselectTools !== undefined) {
merged.toolConfig = {
...(definition.toolConfig ?? { tools: [] }),
preselectTools: overrides.preselectTools,
};
}
return merged;
}
+5
View File
@@ -158,6 +158,11 @@ export interface PromptConfig {
*/
export interface ToolConfig {
tools: Array<string | FunctionDeclaration | AnyDeclarativeTool>;
/**
* Whether to pre-select a subset of tools based on the user request.
* This can help reduce context size and improve performance.
*/
preselectTools?: boolean;
}
/**
+8
View File
@@ -186,6 +186,7 @@ export interface AgentRunConfig {
export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
preselectTools?: boolean;
enabled?: boolean;
}
@@ -459,6 +460,7 @@ export interface ConfigParameters {
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
enableShellOutputEfficiency?: boolean;
toolPreselection?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
recordResponses?: string;
@@ -634,6 +636,7 @@ export class Config {
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly toolPreselection: boolean;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
@@ -853,6 +856,7 @@ export class Config {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.toolPreselection = params.toolPreselection ?? true;
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -2334,6 +2338,10 @@ export class Config {
);
}
isToolPreselectionEnabled(): boolean {
return this.toolPreselection;
}
getNextCompressionTruncationId(): number {
return ++this.compressionTruncationCounter;
}
+24 -4
View File
@@ -35,6 +35,7 @@ import type {
import type { ContentGenerator } from './contentGenerator.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { ToolPreselectionService } from '../services/toolPreselectionService.js';
import { ideContextStore } from '../ide/ideContext.js';
import {
logContentRetryFailure,
@@ -259,18 +260,36 @@ export class GeminiClient {
private lastUsedModelId?: string;
async setTools(modelId?: string): Promise<void> {
async setTools(
modelId?: string,
query?: string,
signal?: AbortSignal,
): Promise<void> {
if (!this.chat) {
return;
}
if (modelId && modelId === this.lastUsedModelId) {
if (modelId && modelId === this.lastUsedModelId && !query) {
return;
}
this.lastUsedModelId = modelId;
const toolRegistry = this.config.getToolRegistry();
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
let toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
if (query && signal && this.config.isToolPreselectionEnabled()) {
const preselector = new ToolPreselectionService(this.config);
const selectedNames = await preselector.selectTools(
query,
toolDeclarations,
signal,
);
const selectedSet = new Set(selectedNames);
toolDeclarations = toolDeclarations.filter((t) =>
selectedSet.has(t.name!),
);
}
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
this.getChat().setTools(tools);
}
@@ -674,7 +693,8 @@ export class GeminiClient {
this.currentSequenceModel = modelToUse;
// Update tools with the final modelId to ensure model-dependent descriptions are used.
await this.setTools(modelToUse);
// Also perform tool pre-selection if enabled.
await this.setTools(modelToUse, partToString(request), signal);
const resultStream = turn.run(
modelConfigKey,
+1
View File
@@ -114,6 +114,7 @@ export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sessionSummaryUtils.js';
export * from './services/contextManager.js';
export * from './services/toolPreselectionService.js';
export * from './skills/skillManager.js';
export * from './skills/skillLoader.js';
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ToolPreselectionService } from './toolPreselectionService.js';
import type { Config } from '../config/config.js';
import type { FunctionDeclaration } from '@google/genai';
describe('ToolPreselectionService', () => {
let mockConfig: Config;
let mockLlmClient: Record<string, Mock>;
let service: ToolPreselectionService;
beforeEach(() => {
mockLlmClient = {
generateJson: vi.fn(),
};
mockConfig = {
getBaseLlmClient: vi.fn().mockReturnValue(mockLlmClient),
} as unknown as Config;
service = new ToolPreselectionService(mockConfig);
});
it('returns all tools if count is below threshold', async () => {
const tools: FunctionDeclaration[] = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool2', description: 'desc2' },
];
const result = await service.selectTools(
'query',
tools,
new AbortController().signal,
);
expect(result).toEqual(['tool1', 'tool2']);
expect(mockLlmClient['generateJson']).not.toHaveBeenCalled();
});
it('calls LLM for pre-selection if count is above threshold', async () => {
const tools: FunctionDeclaration[] = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool2', description: 'desc2' },
{ name: 'tool3', description: 'desc3' },
{ name: 'tool4', description: 'desc4' },
{ name: 'tool5', description: 'desc5' },
{ name: 'tool6', description: 'desc6' },
];
mockLlmClient['generateJson'].mockResolvedValue({
relevant_tools: ['tool1', 'tool3'],
});
const result = await service.selectTools(
'my query',
tools,
new AbortController().signal,
);
expect(result).toEqual(['tool1', 'tool3']);
expect(mockLlmClient['generateJson']).toHaveBeenCalledWith(
expect.objectContaining({
contents: [
{
role: 'user',
parts: [
{
text: expect.stringContaining('my query'),
},
],
},
],
}),
);
});
it('respects maxTools option', async () => {
const tools: FunctionDeclaration[] = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool2', description: 'desc2' },
{ name: 'tool3', description: 'desc3' },
];
mockLlmClient['generateJson'].mockResolvedValue({
relevant_tools: ['tool1'],
});
const result = await service.selectTools(
'query',
tools,
new AbortController().signal,
{ maxTools: 2 },
);
expect(result).toEqual(['tool1']);
expect(mockLlmClient['generateJson']).toHaveBeenCalled();
});
it('falls back to all tools if LLM call fails', async () => {
const tools: FunctionDeclaration[] = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool2', description: 'desc2' },
{ name: 'tool3', description: 'desc3' },
{ name: 'tool4', description: 'desc4' },
{ name: 'tool5', description: 'desc5' },
{ name: 'tool6', description: 'desc6' },
];
mockLlmClient['generateJson'].mockRejectedValue(new Error('LLM error'));
const result = await service.selectTools(
'query',
tools,
new AbortController().signal,
);
expect(result).toEqual([
'tool1',
'tool2',
'tool3',
'tool4',
'tool5',
'tool6',
]);
});
});
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { FunctionDeclaration } from '@google/genai';
import type { Config } from '../config/config.js';
import { LlmRole } from '../telemetry/types.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface ToolPreselectionOptions {
maxTools?: number;
modelConfigKey?: string;
}
/**
* Service to pre-select a relevant subset of tools for a given query.
* This helps reduce context size by excluding unneeded tool descriptions.
*/
export class ToolPreselectionService {
constructor(private readonly config: Config) {}
/**
* Selects relevant tools for a query.
*
* @param query The user's query or task description.
* @param tools The full list of available function declarations.
* @param signal AbortSignal for the request.
* @param options Optional configuration for pre-selection.
* @returns A list of tool names that are considered relevant.
*/
async selectTools(
query: string,
tools: FunctionDeclaration[],
signal: AbortSignal,
options: ToolPreselectionOptions = {},
): Promise<string[]> {
if (tools.length === 0) {
return [];
}
// Threshold below which we don't bother with pre-selection.
const threshold = options.maxTools ?? 5;
if (tools.length <= threshold) {
return tools.map((t) => t.name!);
}
const schema = {
type: 'object',
properties: {
relevant_tools: {
type: 'array',
items: { type: 'string' },
description:
'The names of the tools that are relevant to the user request.',
},
},
required: ['relevant_tools'],
};
const toolsList = tools
.map((_t) => `- ${_t.name}: ${_t.description}`)
.join('\n');
const prompt = `Given the following user request and a list of available tools, select only the tools that are strictly necessary to solve the request.
Return the result as a JSON array of tool names.
Request: ${query}
Available Tools:
${toolsList}`;
try {
const llmClient = this.config.getBaseLlmClient();
const modelConfigKey = options.modelConfigKey || 'classifier';
const result = await llmClient.generateJson({
modelConfigKey: { model: modelConfigKey },
contents: [{ role: 'user', parts: [{ text: prompt }] }],
schema: schema as Record<string, unknown>,
abortSignal: signal,
promptId: 'tool-preselection',
role: LlmRole.UTILITY_TOOL,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const selectedTools = result['relevant_tools'] as string[];
debugLogger.debug(
`ToolPreselectionService: Selected ${selectedTools.length} tools out of ${tools.length} for query: "${query.substring(0, 50)}..."`,
);
return selectedTools;
} catch (error) {
debugLogger.error('ToolPreselectionService failed:', error);
// Fallback: return all tools if pre-selection fails.
return tools.map((t) => t.name!);
}
}
}