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;
}
/**