Compare commits

...

14 Commits

Author SHA1 Message Date
Aishanee Shah 3e7dfe7fba feat(cli): add /insights slash command to analyze past sessions 2026-02-10 01:26:04 +00:00
Aishanee Shah 3f051bc58e refactor(core): centralize core tool definitions and support model-specific schemas 2026-02-09 19:16:36 +00:00
Aishanee Shah 991551b3f5 fix(core): reset lastUsedModelId in startChat to prevent stale tool cache 2026-02-09 19:16:36 +00:00
Aishanee Shah 58ecc29d5b style: move AfterModel hooks comment in GeminiChat 2026-02-09 19:16:36 +00:00
Aishanee Shah 62c46dd8db test: fix GeminiChat mocks in client.test.ts 2026-02-09 19:16:36 +00:00
Aishanee Shah ee6a0fa7d1 refactor: use parametersJsonSchema consistently across tool definitions 2026-02-09 19:16:36 +00:00
Aishanee Shah 6227ba73a7 feat: implement late-binding tool updates in GeminiChat 2026-02-09 19:16:36 +00:00
Aishanee Shah 2a44cad487 style: reorganize coreTools.ts for better readability 2026-02-09 19:16:36 +00:00
Aishanee Shah bb59ca8b24 refactor: centralize all tool definitions in coreTools.ts 2026-02-09 19:16:36 +00:00
Aishanee Shah c5cb031155 refactor: integrate resolver into read_file and shell tools 2026-02-09 19:16:36 +00:00
Aishanee Shah a4f780feb9 test: add tests for model-dependent tool definitions 2026-02-09 19:16:36 +00:00
Aishanee Shah e1c0e6d714 refactor: remove hardcoded schema details from tool files and use external definitions 2026-02-09 19:16:35 +00:00
Aishanee Shah b2d09c3248 refactor: extract tool definitions and prepare architecture for model-dependent descriptions 2026-02-09 19:16:35 +00:00
Aishanee Shah 65232686ee feat: implement model-dependent tool definitions architecture and refactor read_file and shell tools 2026-02-09 19:16:35 +00:00
26 changed files with 858 additions and 199 deletions
+24 -1
View File
@@ -2254,6 +2254,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2434,6 +2435,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2467,6 +2469,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2835,6 +2838,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2868,6 +2872,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2920,6 +2925,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4135,6 +4141,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4429,6 +4436,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5421,6 +5429,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8430,6 +8439,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8970,6 +8980,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -10571,6 +10582,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14355,6 +14367,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14365,6 +14378,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16601,6 +16615,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16824,7 +16839,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16832,6 +16848,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17004,6 +17021,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17211,6 +17229,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17324,6 +17343,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17336,6 +17356,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18040,6 +18061,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18338,6 +18360,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { insightsCommand } from '../ui/commands/insightsCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
@@ -117,6 +118,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
insightsCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { InsightsService } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
/**
* Slash command to generate usage insights based on past sessions.
*/
export const insightsCommand: SlashCommand = {
name: 'insights',
description: 'Analyze past sessions and get usage improvements and summary.',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
const config = context.services.config;
if (!config) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Config is not available.',
});
return;
}
context.ui.addItem({
type: MessageType.INFO,
text: 'Analyzing your past sessions to generate insights...',
});
try {
const insightsService = new InsightsService(config);
const baseLlmClient = config.getBaseLlmClient();
const reportMarkdown =
await insightsService.generateInsightsReport(baseLlmClient);
context.ui.addItem({
type: MessageType.GEMINI,
text: reportMarkdown,
});
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to generate insights: ${error instanceof Error ? error.message : String(error)}`,
});
}
},
};
+26
View File
@@ -291,6 +291,7 @@ describe('Gemini Client (client.ts)', () => {
it('should call chat.addHistory with the provided content', async () => {
const mockChat = {
addHistory: vi.fn(),
setTools: vi.fn(),
} as unknown as GeminiChat;
client['chat'] = mockChat;
@@ -389,6 +390,7 @@ describe('Gemini Client (client.ts)', () => {
getHistory: mockGetHistory,
addHistory: vi.fn(),
setHistory: vi.fn(),
setTools: vi.fn(),
getLastPromptTokenCount: vi.fn(),
} as unknown as GeminiChat;
});
@@ -805,6 +807,7 @@ describe('Gemini Client (client.ts)', () => {
const mockChat = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
} as unknown as GeminiChat;
@@ -868,6 +871,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -926,6 +930,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1003,6 +1008,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1119,6 +1125,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1167,6 +1174,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1232,6 +1240,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1289,6 +1298,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1349,6 +1359,7 @@ ${JSON.stringify(
const lastPromptTokenCount = 900;
const mockChat: Partial<GeminiChat> = {
getLastPromptTokenCount: vi.fn().mockReturnValue(lastPromptTokenCount),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
@@ -1409,6 +1420,7 @@ ${JSON.stringify(
const lastPromptTokenCount = 900;
const mockChat: Partial<GeminiChat> = {
getLastPromptTokenCount: vi.fn().mockReturnValue(lastPromptTokenCount),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
@@ -1467,6 +1479,7 @@ ${JSON.stringify(
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'old' }] }]),
addHistory: vi.fn(),
setTools: vi.fn(),
getChatRecordingService: vi.fn().mockReturnValue({
getConversation: vi.fn(),
getConversationFilePath: vi.fn(),
@@ -1479,6 +1492,7 @@ ${JSON.stringify(
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'old' }] }]),
addHistory: vi.fn(),
setTools: vi.fn(),
getChatRecordingService: vi.fn().mockReturnValue({
getConversation: vi.fn(),
getConversationFilePath: vi.fn(),
@@ -1616,6 +1630,7 @@ ${JSON.stringify(
const lastPromptTokenCount = 10000;
const mockChat: Partial<GeminiChat> = {
getLastPromptTokenCount: vi.fn().mockReturnValue(lastPromptTokenCount),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
@@ -1689,6 +1704,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1892,6 +1908,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1947,6 +1964,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -1984,6 +2002,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -2028,6 +2047,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setHistory: vi.fn(),
setTools: vi.fn(),
// Assume history is not empty for delta checks
getHistory: vi
.fn()
@@ -2443,6 +2463,7 @@ ${JSON.stringify(
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]), // Default empty history
setHistory: vi.fn(),
setTools: vi.fn(),
getLastPromptTokenCount: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -2783,6 +2804,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -2820,6 +2842,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -2857,6 +2880,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -3069,6 +3093,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
@@ -3103,6 +3128,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
+25 -2
View File
@@ -256,9 +256,20 @@ export class GeminiClient {
this.forceFullIdeContext = true;
}
async setTools(): Promise<void> {
private lastUsedModelId?: string;
async setTools(modelId?: string): Promise<void> {
if (!this.chat) {
return;
}
if (modelId && modelId === this.lastUsedModelId) {
return;
}
this.lastUsedModelId = modelId;
const toolRegistry = this.config.getToolRegistry();
const toolDeclarations = toolRegistry.getFunctionDeclarations();
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
this.getChat().setTools(tools);
}
@@ -321,6 +332,7 @@ export class GeminiClient {
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false;
this.lastUsedModelId = undefined;
const toolRegistry = this.config.getToolRegistry();
const toolDeclarations = toolRegistry.getFunctionDeclarations();
@@ -339,6 +351,13 @@ export class GeminiClient {
tools,
history,
resumedSessionData,
async (modelId: string) => {
this.lastUsedModelId = modelId;
const toolRegistry = this.config.getToolRegistry();
const toolDeclarations =
toolRegistry.getFunctionDeclarations(modelId);
return [{ functionDeclarations: toolDeclarations }];
},
);
} catch (error) {
await reportError(
@@ -653,6 +672,10 @@ export class GeminiClient {
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
}
this.currentSequenceModel = modelToUse;
// Update tools with the final modelId to ensure model-dependent descriptions are used.
await this.setTools(modelToUse);
const resultStream = turn.run(
modelConfigKey,
request,
+5
View File
@@ -247,6 +247,7 @@ export class GeminiChat {
private tools: Tool[] = [],
private history: Content[] = [],
resumedSessionData?: ResumedSessionData,
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
) {
validateHistory(history);
this.chatRecordingService = new ChatRecordingService(config);
@@ -580,6 +581,10 @@ export class GeminiChat {
}
}
if (this.onModelChanged) {
this.tools = await this.onModelChanged(modelToUse);
}
// Track final request parameters for AfterModel hooks
lastModelToUse = modelToUse;
lastConfig = config;
+1
View File
@@ -109,6 +109,7 @@ export * from './services/gitService.js';
export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sessionSummaryUtils.js';
export * from './services/insightsService.js';
export * from './services/contextManager.js';
export * from './skills/skillManager.js';
export * from './skills/skillLoader.js';
@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
type ConversationRecord,
SESSION_FILE_PREFIX,
} from './chatRecordingService.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import { getResponseText } from '../utils/partUtils.js';
import fs from 'node:fs/promises';
import path from 'node:path';
import { debugLogger } from '../utils/debugLogger.js';
export interface SessionMetadata {
projectPath: string;
projectName: string;
startTime: string;
summary?: string;
messageCount: number;
toolCalls: Array<{
name: string;
status: string;
}>;
errors: string[];
}
export interface InsightsReport {
summary: string;
working: string[];
notWorking: string[];
recommendations: string[];
fullMarkdown: string;
}
const INSIGHTS_PROMPT = `You are an expert AI productivity consultant for Gemini CLI (gcli) users.
Analyze the following session metadata from the user's past and present GCLI sessions.
Provide a comprehensive "Insights Report" to help the user understand their usage patterns and improve their effectiveness with the tool.
Metadata:
{metadata}
The report MUST include:
1. **Summary**: A brief (2-3 sentences) high-level overview of the user's CLI usage and general effectiveness.
2. **What is Working**: A bulleted list of successful patterns, frequently used tools that consistently work well, and types of tasks the user is successfully completing.
3. **What Isn't Working**: A bulleted list of recurring errors, failed tool calls, friction points, or inefficient patterns.
4. **Recommendations**: Specific, actionable advice on how the user can improve their usage (e.g., better prompting, trying specific slash commands, managing context better).
Output the report in Markdown format. Use a professional, encouraging, and highly technical tone.`;
export class InsightsService {
constructor(private readonly config: Config) {}
/**
* Generates an insights report based on past and present sessions.
*/
async generateInsightsReport(
baseLlmClient: BaseLlmClient,
maxSessions: number = 10,
): Promise<string> {
const metadata = await this.collectMetadata(maxSessions);
if (metadata.length === 0) {
return 'No session data found to analyze. Start using Gemini CLI to get insights!';
}
const metadataStr = JSON.stringify(metadata, null, 2);
const prompt = INSIGHTS_PROMPT.replace('{metadata}', metadataStr);
try {
const response = await baseLlmClient.generateContent({
modelConfigKey: { model: 'summarizer-default' },
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
promptId: 'insights-generation',
abortSignal: new AbortController().signal,
});
return (
getResponseText(response) ??
'Failed to generate insights report content.'
);
} catch (error) {
debugLogger.error('Error generating insights report:', error);
throw new Error('Failed to generate insights report.');
}
}
private async collectMetadata(
maxSessions: number,
): Promise<SessionMetadata[]> {
const allMetadata: SessionMetadata[] = [];
const globalGeminiDir = Storage.getGlobalGeminiDir();
const projectsFile = path.join(globalGeminiDir, 'projects.json');
let projects: Record<string, string> = {};
try {
const content = await fs.readFile(projectsFile, 'utf-8');
projects = JSON.parse(content).projects || {};
} catch (_e) {
debugLogger.debug('Failed to read projects.json:', _e);
// Fallback to current project if registry is missing
projects[this.config.getProjectRoot()] = 'current';
}
const sessionFiles: Array<{
filePath: string;
projectPath: string;
mtime: number;
}> = [];
for (const [projectPath, shortId] of Object.entries(projects)) {
const chatsDir = path.join(Storage.getGlobalTempDir(), shortId, 'chats');
try {
const files = await fs.readdir(chatsDir);
for (const file of files) {
if (file.startsWith(SESSION_FILE_PREFIX) && file.endsWith('.json')) {
const filePath = path.join(chatsDir, file);
const stats = await fs.stat(filePath);
sessionFiles.push({
filePath,
projectPath,
mtime: stats.mtime.getTime(),
});
}
}
} catch (_e) {
// Skip projects with no chats or inaccessible chats
}
}
// Sort by most recent first
sessionFiles.sort((a, b) => b.mtime - a.mtime);
// Take top N sessions
const selectedSessions = sessionFiles.slice(0, maxSessions);
for (const session of selectedSessions) {
try {
const content = await fs.readFile(session.filePath, 'utf-8');
const conversation: ConversationRecord = JSON.parse(content);
allMetadata.push(
this.extractSessionMetadata(conversation, session.projectPath),
);
} catch (_e) {
debugLogger.debug(
`Failed to parse session file ${session.filePath}:`,
_e,
);
}
}
return allMetadata;
}
private extractSessionMetadata(
conversation: ConversationRecord,
projectPath: string,
): SessionMetadata {
const toolCalls: Array<{ name: string; status: string }> = [];
const errors: string[] = [];
for (const msg of conversation.messages) {
if (msg.type === 'gemini' && msg.toolCalls) {
for (const tc of msg.toolCalls) {
toolCalls.push({
name: tc.name,
status: tc.status,
});
}
}
if (msg.type === 'error') {
const errorText =
typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
errors.push(errorText.slice(0, 200)); // Limit error length
}
}
return {
projectPath,
projectName: path.basename(projectPath),
startTime: conversation.startTime,
summary: conversation.summary,
messageCount: conversation.messages.length,
toolCalls,
errors: errors.slice(0, 5), // Limit number of errors
};
}
}
@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`;
exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`;
@@ -25,3 +25,29 @@ exports[`ShellTool > getDescription > should return the windows description when
Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available."
`;
exports[`ShellTool > getSchema > should return the base schema when no modelId is provided 1`] = `
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
The following information is returned:
Output: Combined stdout/stderr. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Exit Code: Only included if non-zero (command failed).
Error: Only included if a process-level error occurred (e.g., spawn failure).
Signal: Only included if process was terminated by a signal.
Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available."
`;
exports[`ShellTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
The following information is returned:
Output: Combined stdout/stderr. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Exit Code: Only included if non-zero (command failed).
Error: Only included if a process-level error occurred (e.g., spawn failure).
Signal: Only included if process was terminated by a signal.
Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available."
`;
@@ -0,0 +1,277 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolDefinition } from './types.js';
import * as os from 'node:os';
// Centralized tool names to avoid circular dependencies
export const GLOB_TOOL_NAME = 'glob';
export const GREP_TOOL_NAME = 'grep_search';
export const LS_TOOL_NAME = 'list_directory';
export const READ_FILE_TOOL_NAME = 'read_file';
export const SHELL_TOOL_NAME = 'run_shell_command';
export const WRITE_FILE_TOOL_NAME = 'write_file';
// ============================================================================
// READ_FILE TOOL
// ============================================================================
export const READ_FILE_DEFINITION: ToolDefinition = {
base: {
name: READ_FILE_TOOL_NAME,
description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
parametersJsonSchema: {
type: 'object',
properties: {
file_path: {
description: 'The path to the file to read.',
type: 'string',
},
offset: {
description:
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
type: 'number',
},
limit: {
description:
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
type: 'number',
},
},
required: ['file_path'],
},
},
};
// ============================================================================
// WRITE_FILE TOOL
// ============================================================================
export const WRITE_FILE_DEFINITION: ToolDefinition = {
base: {
name: WRITE_FILE_TOOL_NAME,
description: `Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
parametersJsonSchema: {
type: 'object',
properties: {
file_path: {
description: 'The path to the file to write to.',
type: 'string',
},
content: {
description: 'The content to write to the file.',
type: 'string',
},
},
required: ['file_path', 'content'],
},
},
};
// ============================================================================
// GREP TOOL
// ============================================================================
export const GREP_DEFINITION: ToolDefinition = {
base: {
name: GREP_TOOL_NAME,
description:
'Searches for a regular expression pattern within file contents. Max 100 matches.',
parametersJsonSchema: {
type: 'object',
properties: {
pattern: {
description: `The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').`,
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string',
},
include: {
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
type: 'string',
},
},
required: ['pattern'],
},
},
};
// ============================================================================
// GLOB TOOL
// ============================================================================
export const GLOB_DEFINITION: ToolDefinition = {
base: {
name: GLOB_TOOL_NAME,
description:
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
parametersJsonSchema: {
type: 'object',
properties: {
pattern: {
description:
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
type: 'string',
},
case_sensitive: {
description:
'Optional: Whether the search should be case-sensitive. Defaults to false.',
type: 'boolean',
},
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
type: 'boolean',
},
},
required: ['pattern'],
},
},
};
// ============================================================================
// LS TOOL
// ============================================================================
export const LS_DEFINITION: ToolDefinition = {
base: {
name: LS_TOOL_NAME,
description:
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
parametersJsonSchema: {
type: 'object',
properties: {
dir_path: {
description: 'The path to the directory to list',
type: 'string',
},
ignore: {
description: 'List of glob patterns to ignore',
items: {
type: 'string',
},
type: 'array',
},
file_filtering_options: {
description:
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
type: 'object',
properties: {
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: 'boolean',
},
},
},
},
required: ['dir_path'],
},
},
};
// ============================================================================
// SHELL TOOL
// ============================================================================
/**
* Generates the platform-specific description for the shell tool.
*/
export function getShellToolDescription(
enableInteractiveShell: boolean,
): string {
const returnedInfo = `
The following information is returned:
Output: Combined stdout/stderr. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Exit Code: Only included if non-zero (command failed).
Error: Only included if a process-level error occurred (e.g., spawn failure).
Signal: Only included if process was terminated by a signal.
Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available.`;
if (os.platform() === 'win32') {
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.'
: 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.';
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. ${backgroundInstructions}${returnedInfo}`;
} else {
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.'
: 'Command can start background processes using `&`.';
return `This tool executes a given shell command as \`bash -c <command>\`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`;
}
}
/**
* Returns the platform-specific description for the 'command' parameter.
*/
export function getCommandDescription(): string {
if (os.platform() === 'win32') {
return 'Exact command to execute as `powershell.exe -NoProfile -Command <command>`';
}
return 'Exact bash command to execute as `bash -c <command>`';
}
/**
* Returns the tool definition for the shell tool, customized for the platform.
*/
export function getShellDefinition(
enableInteractiveShell: boolean,
): ToolDefinition {
return {
base: {
name: SHELL_TOOL_NAME,
description: getShellToolDescription(enableInteractiveShell),
parametersJsonSchema: {
type: 'object',
properties: {
command: {
type: 'string',
description: getCommandDescription(),
},
description: {
type: 'string',
description:
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
},
dir_path: {
type: 'string',
description:
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
},
is_background: {
type: 'boolean',
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
},
required: ['command'],
},
},
};
}
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { Type } from '@google/genai';
import { resolveToolDeclaration } from './resolver.js';
import type { ToolDefinition } from './types.js';
describe('resolveToolDeclaration', () => {
const mockDefinition: ToolDefinition = {
base: {
name: 'test_tool',
description: 'A test tool description',
parameters: {
type: Type.OBJECT,
properties: {
param1: { type: Type.STRING },
},
},
},
};
it('should return the base definition when no modelId is provided', () => {
const result = resolveToolDeclaration(mockDefinition);
expect(result).toEqual(mockDefinition.base);
});
it('should return the base definition when a modelId is provided (current implementation)', () => {
const result = resolveToolDeclaration(mockDefinition, 'gemini-1.5-pro');
expect(result).toEqual(mockDefinition.base);
});
it('should return the same object reference as base (current implementation)', () => {
const result = resolveToolDeclaration(mockDefinition);
expect(result).toBe(mockDefinition.base);
});
});
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type FunctionDeclaration } from '@google/genai';
import type { ToolDefinition } from './types.js';
/**
* Resolves the declaration for a tool.
*
* @param definition The tool definition containing the base declaration.
* @param _modelId Optional model identifier (ignored in this plain refactor).
* @returns The FunctionDeclaration to be sent to the API.
*/
export function resolveToolDeclaration(
definition: ToolDefinition,
_modelId?: string,
): FunctionDeclaration {
return definition.base;
}
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type FunctionDeclaration } from '@google/genai';
/**
* Defines a tool's identity using a structured declaration.
*/
export interface ToolDefinition {
/** The base declaration for the tool. */
base: FunctionDeclaration;
}
+8 -32
View File
@@ -17,6 +17,8 @@ import { ToolErrorType } from './tool-error.js';
import { GLOB_TOOL_NAME } from './tool-names.js';
import { getErrorMessage } from '../utils/errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GLOB_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
@@ -268,39 +270,9 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
super(
GlobTool.Name,
'FindFiles',
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
GLOB_DEFINITION.base.description!,
Kind.Search,
{
properties: {
pattern: {
description:
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
type: 'string',
},
case_sensitive: {
description:
'Optional: Whether the search should be case-sensitive. Defaults to false.',
type: 'boolean',
},
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
type: 'boolean',
},
},
required: ['pattern'],
type: 'object',
},
GLOB_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
@@ -360,4 +332,8 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
_toolDisplayName,
);
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(GLOB_DEFINITION, modelId);
}
}
+8 -20
View File
@@ -25,6 +25,8 @@ import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { GREP_TOOL_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GREP_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
// --- Interfaces ---
@@ -576,27 +578,9 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
super(
GrepTool.Name,
'SearchText',
'Searches for a regular expression pattern within file contents. Max 100 matches.',
GREP_DEFINITION.base.description!,
Kind.Search,
{
properties: {
pattern: {
description: `The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').`,
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string',
},
include: {
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
type: 'string',
},
},
required: ['pattern'],
type: 'object',
},
GREP_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
@@ -659,4 +643,8 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
_toolDisplayName,
);
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(GREP_DEFINITION, modelId);
}
}
+8 -35
View File
@@ -15,6 +15,8 @@ import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
import { LS_TOOL_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { LS_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
/**
* Parameters for the LS tool
@@ -277,42 +279,9 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
super(
LSTool.Name,
'ReadFolder',
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
LS_DEFINITION.base.description!,
Kind.Search,
{
properties: {
dir_path: {
description: 'The path to the directory to list',
type: 'string',
},
ignore: {
description: 'List of glob patterns to ignore',
items: {
type: 'string',
},
type: 'array',
},
file_filtering_options: {
description:
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
type: 'object',
properties: {
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: 'boolean',
},
},
},
},
required: ['dir_path'],
type: 'object',
},
LS_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
@@ -348,4 +317,8 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
_toolDisplayName,
);
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(LS_DEFINITION, modelId);
}
}
+15
View File
@@ -563,4 +563,19 @@ describe('ReadFileTool', () => {
});
});
});
describe('getSchema', () => {
it('should return the base schema when no modelId is provided', () => {
const schema = tool.getSchema();
expect(schema.name).toBe(ReadFileTool.Name);
expect(schema.description).toMatchSnapshot();
});
it('should return the schema from the resolver when modelId is provided', () => {
const modelId = 'gemini-2.0-flash';
const schema = tool.getSchema(modelId);
expect(schema.name).toBe(ReadFileTool.Name);
expect(schema.description).toMatchSnapshot();
});
});
});
+8 -21
View File
@@ -23,6 +23,8 @@ import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { READ_FILE_TOOL_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { READ_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
/**
* Parameters for the ReadFile tool
@@ -169,28 +171,9 @@ export class ReadFileTool extends BaseDeclarativeTool<
super(
ReadFileTool.Name,
'ReadFile',
`Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
READ_FILE_DEFINITION.base.description!,
Kind.Read,
{
properties: {
file_path: {
description: 'The path to the file to read.',
type: 'string',
},
offset: {
description:
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
type: 'number',
},
limit: {
description:
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
type: 'number',
},
},
required: ['file_path'],
type: 'object',
},
READ_FILE_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
@@ -252,4 +235,8 @@ export class ReadFileTool extends BaseDeclarativeTool<
_toolDisplayName,
);
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(READ_FILE_DEFINITION, modelId);
}
}
+15
View File
@@ -815,4 +815,19 @@ describe('ShellTool', () => {
}
});
});
describe('getSchema', () => {
it('should return the base schema when no modelId is provided', () => {
const schema = shellTool.getSchema();
expect(schema.name).toBe(SHELL_TOOL_NAME);
expect(schema.description).toMatchSnapshot();
});
it('should return the schema from the resolver when modelId is provided', () => {
const modelId = 'gemini-2.0-flash';
const schema = shellTool.getSchema(modelId);
expect(schema.name).toBe(SHELL_TOOL_NAME);
expect(schema.description).toMatchSnapshot();
});
});
});
+12 -59
View File
@@ -43,6 +43,8 @@ import {
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
@@ -451,39 +453,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
function getShellToolDescription(enableInteractiveShell: boolean): string {
const returnedInfo = `
The following information is returned:
Output: Combined stdout/stderr. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Exit Code: Only included if non-zero (command failed).
Error: Only included if a process-level error occurred (e.g., spawn failure).
Signal: Only included if process was terminated by a signal.
Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available.`;
if (os.platform() === 'win32') {
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.'
: 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.';
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. ${backgroundInstructions}${returnedInfo}`;
} else {
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.'
: 'Command can start background processes using `&`.';
return `This tool executes a given shell command as \`bash -c <command>\`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`;
}
}
function getCommandDescription(): string {
if (os.platform() === 'win32') {
return 'Exact command to execute as `powershell.exe -NoProfile -Command <command>`';
} else {
return 'Exact bash command to execute as `bash -c <command>`';
}
}
export class ShellTool extends BaseDeclarativeTool<
ShellToolParams,
ToolResult
@@ -497,36 +466,13 @@ export class ShellTool extends BaseDeclarativeTool<
void initializeShellParsers().catch(() => {
// Errors are surfaced when parsing commands.
});
const definition = getShellDefinition(config.getEnableInteractiveShell());
super(
ShellTool.Name,
'Shell',
getShellToolDescription(config.getEnableInteractiveShell()),
definition.base.description!,
Kind.Execute,
{
type: 'object',
properties: {
command: {
type: 'string',
description: getCommandDescription(),
},
description: {
type: 'string',
description:
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
},
dir_path: {
type: 'string',
description:
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
},
is_background: {
type: 'boolean',
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
},
required: ['command'],
},
definition.base.parametersJsonSchema,
messageBus,
false, // output is not markdown
true, // output can be updated
@@ -564,4 +510,11 @@ export class ShellTool extends BaseDeclarativeTool<
_toolDisplayName,
);
}
override getSchema(modelId?: string) {
const definition = getShellDefinition(
this.config.getEnableInteractiveShell(),
);
return resolveToolDeclaration(definition, modelId);
}
}
+20 -6
View File
@@ -4,21 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from './definitions/coreTools.js';
// Centralized constants for tool names.
// This prevents circular dependencies that can occur when other modules (like agents)
// need to reference a tool's name without importing the tool's implementation.
export const GLOB_TOOL_NAME = 'glob';
export {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
};
export const WRITE_TODOS_TOOL_NAME = 'write_todos';
export const WRITE_FILE_TOOL_NAME = 'write_file';
export const WEB_SEARCH_TOOL_NAME = 'google_web_search';
export const WEB_FETCH_TOOL_NAME = 'web_fetch';
export const EDIT_TOOL_NAME = 'replace';
export const SHELL_TOOL_NAME = 'run_shell_command';
export const GREP_TOOL_NAME = 'grep_search';
export const READ_MANY_FILES_TOOL_NAME = 'read_many_files';
export const READ_FILE_TOOL_NAME = 'read_file';
export const LS_TOOL_NAME = 'list_directory';
export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anything used the old exported name directly
export const MEMORY_TOOL_NAME = 'save_memory';
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill';
@@ -261,6 +261,17 @@ describe('ToolRegistry', () => {
toolRegistry.registerTool(tool);
expect(toolRegistry.getTool('mock-tool')).toBe(tool);
});
it('should pass modelId to getSchema when getting function declarations', () => {
const tool = new MockTool({ name: 'mock-tool' });
const getSchemaSpy = vi.spyOn(tool, 'getSchema');
toolRegistry.registerTool(tool);
const modelId = 'test-model-id';
toolRegistry.getFunctionDeclarations(modelId);
expect(getSchemaSpy).toHaveBeenCalledWith(modelId);
});
});
describe('excluded tools', () => {
+9 -4
View File
@@ -498,12 +498,13 @@ export class ToolRegistry {
* Retrieves the list of tool schemas (FunctionDeclaration array).
* Extracts the declarations from the ToolListUnion structure.
* Includes discovered (vs registered) tools if configured.
* @param modelId Optional model identifier to get model-specific schemas.
* @returns An array of FunctionDeclarations.
*/
getFunctionDeclarations(): FunctionDeclaration[] {
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
const declarations: FunctionDeclaration[] = [];
this.getActiveTools().forEach((tool) => {
declarations.push(tool.schema);
declarations.push(tool.getSchema(modelId));
});
return declarations;
}
@@ -511,14 +512,18 @@ export class ToolRegistry {
/**
* Retrieves a filtered list of tool schemas based on a list of tool names.
* @param toolNames - An array of tool names to include.
* @param modelId Optional model identifier to get model-specific schemas.
* @returns An array of FunctionDeclarations for the specified tools.
*/
getFunctionDeclarationsFiltered(toolNames: string[]): FunctionDeclaration[] {
getFunctionDeclarationsFiltered(
toolNames: string[],
modelId?: string,
): FunctionDeclaration[] {
const declarations: FunctionDeclaration[] = [];
for (const name of toolNames) {
const tool = this.getTool(name);
if (tool) {
declarations.push(tool.schema);
declarations.push(tool.getSchema(modelId));
}
}
return declarations;
+13 -2
View File
@@ -312,8 +312,15 @@ export interface ToolBuilder<
/**
* Function declaration schema from @google/genai.
* @param modelId Optional model identifier to get a model-specific schema.
*/
schema: FunctionDeclaration;
getSchema(modelId?: string): FunctionDeclaration;
/**
* Function declaration schema for the default model.
* @deprecated Use getSchema(modelId) for model-specific schemas.
*/
readonly schema: FunctionDeclaration;
/**
* Whether the tool's output should be rendered as markdown.
@@ -355,7 +362,7 @@ export abstract class DeclarativeTool<
readonly extensionId?: string,
) {}
get schema(): FunctionDeclaration {
getSchema(_modelId?: string): FunctionDeclaration {
return {
name: this.name,
description: this.description,
@@ -363,6 +370,10 @@ export abstract class DeclarativeTool<
};
}
get schema(): FunctionDeclaration {
return this.getSchema();
}
/**
* Validates the raw tool parameters.
* Subclasses should override this to add custom validation logic
+8 -17
View File
@@ -48,6 +48,8 @@ import { getSpecificMimeType } from '../utils/fileUtils.js';
import { getLanguageFromFilePath } from '../utils/language-detection.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
/**
* Parameters for the WriteFile tool
@@ -445,24 +447,9 @@ export class WriteFileTool
super(
WriteFileTool.Name,
'WriteFile',
`Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
WRITE_FILE_DEFINITION.base.description!,
Kind.Edit,
{
properties: {
file_path: {
description: 'The path to the file to write to.',
type: 'string',
},
content: {
description: 'The content to write to the file.',
type: 'string',
},
},
required: ['file_path', 'content'],
type: 'object',
},
WRITE_FILE_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
@@ -514,6 +501,10 @@ export class WriteFileTool
);
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(WRITE_FILE_DEFINITION, modelId);
}
getModifyContext(
abortSignal: AbortSignal,
): ModifyContext<WriteFileToolParams> {