Compare commits

...

7 Commits

16 changed files with 508 additions and 219 deletions
+2 -13
View File
@@ -49,19 +49,8 @@ Gemini CLI comes with the following built-in sub-agents:
dependencies.
- **When to use:** "How does the authentication system work?", "Map out the
dependencies of the `AgentRegistry` class."
- **Configuration:** Enabled by default. You can configure it in
`settings.json`. Example (forcing a specific model):
```json
{
"experimental": {
"codebaseInvestigatorSettings": {
"enabled": true,
"maxNumTurns": 20,
"model": "gemini-2.5-pro"
}
}
}
```
- **Configuration:** Enabled by default. You can configure it using agent
overrides in `settings.json`.
### CLI Help Agent
+24 -1
View File
@@ -2253,6 +2253,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",
@@ -2433,6 +2434,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"
}
@@ -2466,6 +2468,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"
},
@@ -2834,6 +2837,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"
@@ -2867,6 +2871,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"
@@ -2919,6 +2924,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",
@@ -4134,6 +4140,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4428,6 +4435,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",
@@ -5420,6 +5428,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"
},
@@ -8429,6 +8438,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8969,6 +8979,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",
@@ -10570,6 +10581,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",
@@ -14354,6 +14366,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14364,6 +14377,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16600,6 +16614,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16823,7 +16838,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",
@@ -16831,6 +16847,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"
@@ -17003,6 +17020,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17210,6 +17228,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",
@@ -17323,6 +17342,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17335,6 +17355,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",
@@ -18039,6 +18060,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"
}
@@ -18335,6 +18357,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+19 -6
View File
@@ -24,6 +24,7 @@ import {
type ServerGeminiErrorEvent,
type ServerGeminiStreamEvent,
type ToolCallConfirmationDetails,
type SerializableConfirmationDetails,
type Config,
type UserTierId,
type AnsiOutput,
@@ -65,7 +66,10 @@ export class Task {
scheduler: CoreToolScheduler;
config: Config;
geminiClient: GeminiClient;
pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
pendingToolConfirmationDetails: Map<
string,
ToolCallConfirmationDetails | SerializableConfirmationDetails
>;
taskState: TaskState;
eventBus?: ExecutionEventBus;
completedToolCalls: CompletedToolCall[];
@@ -378,7 +382,7 @@ export class Task {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
this.pendingToolConfirmationDetails.set(
tc.request.callId,
tc.confirmationDetails as ToolCallConfirmationDetails,
tc.confirmationDetails,
);
}
@@ -411,10 +415,12 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
if ('onConfirm' in tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
tc.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
}
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
});
@@ -803,6 +809,13 @@ export class Task {
// This will trigger the scheduler to continue or cancel the specific tool.
// The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
if (!('onConfirm' in confirmationDetails)) {
logger.error(
`[Task] Serializable confirmation details not supported yet in a2a-server for callId: ${callId}`,
);
return false;
}
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
+1
View File
@@ -804,6 +804,7 @@ export async function loadCliConfig(
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
adaptiveThinking: settings.experimental?.adaptiveThinking,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
+3 -80
View File
@@ -2154,64 +2154,9 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate experimental agent settings in system scope in memory but not save', () => {
const systemSettingsContent = {
experimental: {
codebaseInvestigatorSettings: {
enabled: true,
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
},
);
const feedbackSpy = mockCoreEvents.emitFeedback;
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify it was migrated in memory
expect(settings.system.settings.agents?.overrides).toMatchObject({
codebase_investigator: {
enabled: true,
},
});
// Verify it was NOT saved back to disk
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
getSystemSettingsPath(),
expect.anything(),
);
// Verify warnings were shown
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]',
),
);
});
it('should migrate experimental agent settings to agents overrides', () => {
it('should not throw if experimental settings are missing', () => {
const userSettingsContent = {
experimental: {
codebaseInvestigatorSettings: {
enabled: true,
maxNumTurns: 15,
maxTimeMinutes: 5,
thinkingBudget: 16384,
model: 'gemini-1.5-pro',
},
cliHelpAgentSettings: {
enabled: false,
},
},
experimental: {},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
@@ -2223,29 +2168,7 @@ describe('Settings Loading and Merging', () => {
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify migration to agents.overrides
expect(settings.user.settings.agents?.overrides).toMatchObject({
codebase_investigator: {
enabled: true,
runConfig: {
maxTurns: 15,
maxTimeMinutes: 5,
},
modelConfig: {
model: 'gemini-1.5-pro',
generateContentConfig: {
thinkingConfig: {
thinkingBudget: 16384,
},
},
},
},
cli_help: {
enabled: false,
},
});
expect(() => loadSettings(MOCK_WORKSPACE_DIR)).not.toThrow();
});
});
-117
View File
@@ -904,21 +904,6 @@ export function migrateDeprecatedSettings(
}
}
// Migrate experimental agent settings
const experimentalModified = migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
foundDeprecated,
);
if (experimentalModified) {
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (settingsFile.readOnly && foundDeprecated.length > 0) {
systemWarnings.set(scope, foundDeprecated);
}
@@ -983,105 +968,3 @@ export function saveModelChange(
);
}
}
function migrateExperimentalSettings(
settings: Settings,
loadedSettings: LoadedSettings,
scope: LoadableSettingScope,
removeDeprecated: boolean,
foundDeprecated?: string[],
): boolean {
const experimentalSettings = settings.experimental as
| Record<string, unknown>
| undefined;
if (experimentalSettings) {
const agentsSettings = {
...(settings.agents as Record<string, unknown> | undefined),
};
const agentsOverrides = {
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
};
let modified = false;
const migrateExperimental = (
oldKey: string,
migrateFn: (oldValue: Record<string, unknown>) => void,
) => {
const old = experimentalSettings[oldKey];
if (old) {
foundDeprecated?.push(`experimental.${oldKey}`);
migrateFn(old as Record<string, unknown>);
modified = true;
}
};
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
migrateExperimental('codebaseInvestigatorSettings', (old) => {
const override = {
...(agentsOverrides['codebase_investigator'] as
| Record<string, unknown>
| undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
const runConfig = {
...(override['runConfig'] as Record<string, unknown> | undefined),
};
if (old['maxNumTurns'] !== undefined)
runConfig['maxTurns'] = old['maxNumTurns'];
if (old['maxTimeMinutes'] !== undefined)
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
const modelConfig = {
...(override['modelConfig'] as Record<string, unknown> | undefined),
};
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
if (old['thinkingBudget'] !== undefined) {
const generateContentConfig = {
...(modelConfig['generateContentConfig'] as
| Record<string, unknown>
| undefined),
};
const thinkingConfig = {
...(generateContentConfig['thinkingConfig'] as
| Record<string, unknown>
| undefined),
};
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
generateContentConfig['thinkingConfig'] = thinkingConfig;
modelConfig['generateContentConfig'] = generateContentConfig;
}
override['modelConfig'] = modelConfig;
}
agentsOverrides['codebase_investigator'] = override;
});
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
migrateExperimental('cliHelpAgentSettings', (old) => {
const override = {
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
agentsOverrides['cli_help'] = override;
});
if (modified) {
agentsSettings['overrides'] = agentsOverrides;
loadedSettings.setValue(scope, 'agents', agentsSettings);
if (removeDeprecated) {
const newExperimental = { ...experimentalSettings };
delete newExperimental['codebaseInvestigatorSettings'];
delete newExperimental['cliHelpAgentSettings'];
loadedSettings.setValue(scope, 'experimental', newExperimental);
}
return true;
}
}
return false;
}
+31
View File
@@ -1552,6 +1552,37 @@ const SETTINGS_SCHEMA = {
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
adaptiveThinking: {
type: 'object',
label: 'Adaptive Thinking Settings',
category: 'Experimental',
requiresRestart: false,
default: {},
description: 'Configuration for Adaptive Thinking Budget.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Adaptive Thinking',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Enable adaptive thinking budget based on task complexity.',
showInDialog: true,
},
classifierModel: {
type: 'string',
label: 'Classifier Model',
category: 'Experimental',
requiresRestart: false,
default: 'classifier',
description:
'The model (or alias) to use for complexity classification.',
showInDialog: false,
},
},
},
},
},
+29
View File
@@ -84,6 +84,7 @@ import type {
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { ContextManager } from '../services/contextManager.js';
import { AdaptiveBudgetService } from '../services/adaptiveBudgetService.js';
import type { GenerateContentParameters } from '@google/genai';
// Re-export OAuth config type
@@ -446,6 +447,10 @@ export interface ConfigParameters {
policyEngineConfig?: PolicyEngineConfig;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
adaptiveThinking?: {
enabled?: boolean;
classifierModel?: string;
};
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
enableShellOutputEfficiency?: boolean;
@@ -592,6 +597,10 @@ export class Config {
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private readonly outputSettings: OutputSettings;
private readonly adaptiveThinking: {
enabled: boolean;
classifierModel: string;
};
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly enableShellOutputEfficiency: boolean;
@@ -634,6 +643,7 @@ export class Config {
private readonly planEnabled: boolean;
private contextManager?: ContextManager;
private terminalBackground: string | undefined = undefined;
private adaptiveBudgetService!: AdaptiveBudgetService;
private remoteAdminSettings: AdminControlsSettings | undefined;
private latestApiRequest: GenerateContentParameters | undefined;
private lastModeSwitchTime: number = Date.now();
@@ -781,6 +791,10 @@ export class Config {
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
this.adaptiveThinking = {
enabled: params.adaptiveThinking?.enabled ?? false,
classifierModel: params.adaptiveThinking?.classifierModel ?? 'classifier',
};
this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
this.enableShellOutputEfficiency =
params.enableShellOutputEfficiency ?? true;
@@ -967,6 +981,13 @@ export class Config {
await this.contextManager.refresh();
}
this.adaptiveBudgetService = new AdaptiveBudgetService(this);
if (this.adaptiveThinking.enabled) {
debugLogger.debug(
`Adaptive Thinking Budget enabled (classifier: ${this.adaptiveThinking.classifierModel})`,
);
}
await this.geminiClient.initialize();
this.syncPlanModeTools();
}
@@ -975,6 +996,10 @@ export class Config {
return this.contentGenerator;
}
getAdaptiveBudgetService(): AdaptiveBudgetService {
return this.adaptiveBudgetService;
}
async refreshAuth(authMethod: AuthType) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -2143,6 +2168,10 @@ export class Config {
return this.enableHooksUI;
}
getAdaptiveThinkingConfig(): { enabled: boolean; classifierModel: string } {
return this.adaptiveThinking;
}
async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.messageBus);
+40
View File
@@ -28,6 +28,7 @@ import { retryWithBackoff } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
import { partListUnionToString } from './geminiRequest.js';
import type {
ChatRecordingService,
ResumedSessionData,
@@ -64,6 +65,7 @@ import { resolveModel } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import type { AdaptiveBudgetResult } from '../services/adaptiveBudgetService.js';
const MAX_TURNS = 100;
@@ -88,6 +90,7 @@ export class GeminiClient {
private readonly toolOutputMaskingService: ToolOutputMaskingService;
private lastPromptId: string;
private currentSequenceModel: string | null = null;
private currentAdaptiveConfig: AdaptiveBudgetResult | null = null;
private lastSentIdeContext: IdeContext | undefined;
private forceFullIdeContext = true;
@@ -642,6 +645,42 @@ export class GeminiClient {
// availability logic
const modelConfigKey: ModelConfigKey = { model: modelToUse };
// Adaptive Thinking Budget Integration
if (
!isInvalidStreamRetry &&
this.config.getAdaptiveThinkingConfig().enabled &&
prompt_id !== 'adaptive-budget-classifier'
) {
if (this.currentAdaptiveConfig) {
modelConfigKey.thinkingBudget =
this.currentAdaptiveConfig.thinkingBudget;
modelConfigKey.thinkingLevel = this.currentAdaptiveConfig.thinkingLevel;
} else {
const userMessage = partListUnionToString(request);
if (userMessage) {
const adaptiveConfig = await this.config
.getAdaptiveBudgetService()
.determineAdaptiveConfig(
userMessage,
modelToUse,
this.getHistory(),
);
if (adaptiveConfig) {
this.currentAdaptiveConfig = adaptiveConfig;
modelConfigKey.thinkingBudget = adaptiveConfig.thinkingBudget;
modelConfigKey.thinkingLevel = adaptiveConfig.thinkingLevel;
this.getChat().recordAdaptiveThinking({
complexity: adaptiveConfig.complexity,
thinkingBudget: adaptiveConfig.thinkingBudget,
thinkingLevel: adaptiveConfig.thinkingLevel,
});
}
}
}
}
const { model: finalModel } = applyModelSelection(
this.config,
modelConfigKey,
@@ -781,6 +820,7 @@ export class GeminiClient {
this.hookStateMap.delete(this.lastPromptId);
this.lastPromptId = prompt_id;
this.currentSequenceModel = null;
this.currentAdaptiveConfig = null;
}
if (hooksEnabled && messageBus) {
+8
View File
@@ -260,6 +260,14 @@ export class GeminiChat {
this.systemInstruction = sysInstr;
}
recordAdaptiveThinking(info: {
complexity: number;
thinkingBudget?: number;
thinkingLevel?: string;
}) {
this.chatRecordingService.recordAdaptiveThinking(info);
}
/**
* Sends a message to the model and returns the response in chunks.
*
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
AdaptiveBudgetService,
ComplexityLevel,
} from './adaptiveBudgetService.js';
import type { Config } from '../config/config.js';
describe('AdaptiveBudgetService', () => {
it('should map complexity levels to correct V2 budgets', () => {
const service = new AdaptiveBudgetService({} as Config);
expect(service.getThinkingBudgetV2(ComplexityLevel.SIMPLE)).toBe(1024);
expect(service.getThinkingBudgetV2(ComplexityLevel.MODERATE)).toBe(4096);
expect(service.getThinkingBudgetV2(ComplexityLevel.HIGH)).toBe(16384);
expect(service.getThinkingBudgetV2(ComplexityLevel.EXTREME)).toBe(32768);
});
it('should map complexity levels to correct V3 levels', () => {
const service = new AdaptiveBudgetService({} as Config);
expect(service.getThinkingLevelV3(ComplexityLevel.SIMPLE)).toBe('LOW');
expect(service.getThinkingLevelV3(ComplexityLevel.MODERATE)).toBe('LOW');
expect(service.getThinkingLevelV3(ComplexityLevel.HIGH)).toBe('HIGH');
expect(service.getThinkingLevelV3(ComplexityLevel.EXTREME)).toBe('HIGH');
});
it('should determine adaptive config based on LLM response', async () => {
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: '3' }] } }],
});
const mockConfig = {
getBaseLlmClient: () => ({
generateContent: mockGenerateContent,
}),
getAdaptiveThinkingConfig: () => ({
enabled: true,
classifierModel: 'gemini-2.0-flash',
}),
} as unknown as Config;
const service = new AdaptiveBudgetService(mockConfig);
const result = await service.determineAdaptiveConfig(
'Complex task',
'gemini-2.5-pro',
[],
);
expect(result?.complexity).toBe(ComplexityLevel.HIGH);
expect(result?.thinkingBudget).toBe(16384);
expect(mockGenerateContent).toHaveBeenCalled();
});
it('should handle Gemini 3 models with thinkingLevel', async () => {
const mockConfig = {
getBaseLlmClient: () => ({
generateContent: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: '1' }] } }],
}),
}),
getAdaptiveThinkingConfig: () => ({
enabled: true,
classifierModel: 'gemini-2.0-flash',
}),
} as unknown as Config;
const service = new AdaptiveBudgetService(mockConfig);
const result = await service.determineAdaptiveConfig(
'Hi',
'gemini-3-pro-preview',
[],
);
expect(result?.complexity).toBe(ComplexityLevel.SIMPLE);
expect(result?.thinkingLevel).toBe('LOW');
expect(result?.thinkingBudget).toBeUndefined();
});
});
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import { isGemini2Model, isPreviewModel } from '../config/models.js';
import type { Content } from '@google/genai';
import {
isFunctionCall,
isFunctionResponse,
} from '../utils/messageInspectors.js';
export enum ComplexityLevel {
SIMPLE = 1,
MODERATE = 2,
HIGH = 3,
EXTREME = 4,
}
export const BUDGET_MAPPING_V2: Record<ComplexityLevel, number> = {
[ComplexityLevel.SIMPLE]: 1024,
[ComplexityLevel.MODERATE]: 4096,
[ComplexityLevel.HIGH]: 16384,
[ComplexityLevel.EXTREME]: 32768,
};
export const LEVEL_MAPPING_V3: Record<ComplexityLevel, string> = {
[ComplexityLevel.SIMPLE]: 'LOW',
[ComplexityLevel.MODERATE]: 'LOW',
[ComplexityLevel.HIGH]: 'HIGH',
[ComplexityLevel.EXTREME]: 'HIGH',
};
const HISTORY_TURNS_FOR_CONTEXT = 4;
const HISTORY_SEARCH_WINDOW = 20;
export interface AdaptiveBudgetResult {
complexity: ComplexityLevel;
thinkingBudget?: number;
thinkingLevel?: string;
strategyNote?: string;
}
export class AdaptiveBudgetService {
constructor(private config: Config) {}
/**
* Analyzes the user prompt and determines the optimal thinking configuration.
*
* Note on future scaling (per arXiv:2512.19585):
* At Complexity 4 (Extreme), we should consider:
* 1. Best-of-N: Generate multiple solutions.
* 2. LLM-as-a-Judge: Use a strong model to evaluate candidates.
* 3. Compiler Verification: Check code correctness via environment tools.
*/
async determineAdaptiveConfig(
userPrompt: string,
model: string,
history: Content[],
): Promise<AdaptiveBudgetResult | undefined> {
const { classifierModel } = this.config.getAdaptiveThinkingConfig();
try {
const llm = this.config.getBaseLlmClient();
debugLogger.debug(
`AdaptiveBudgetService: Classifying prompt complexity using ${classifierModel}...`,
);
// situational context: provide the last N turns of the history
const historySlice = history.slice(-HISTORY_SEARCH_WINDOW);
// Filter out tool-related turns to keep context focused on conversation.
const cleanHistory = historySlice.filter(
(content) => !isFunctionCall(content) && !isFunctionResponse(content),
);
// Take the last N turns from the *cleaned* history.
const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT);
const systemPrompt = `You are a complexity classifier for a coding assistant.
Analyze the user's request and determine the complexity of the task.
Output ONLY a single integer from 1 to 4 based on the following scale:
1 (Simple): Quick fixes, syntax questions, simple explanations, greetings.
2 (Moderate): Function-level logic, writing small scripts, standard debugging.
3 (High): Module-level refactoring, complex feature implementation, multi-file changes.
4 (Extreme): Architecture design, deep root-cause analysis of obscure bugs, large-scale migrations.
Request: ${userPrompt}
Complexity Level:`;
const response = await llm.generateContent({
modelConfigKey: { model: classifierModel },
contents: [
...finalHistory,
{ role: 'user', parts: [{ text: systemPrompt }] },
],
promptId: 'adaptive-budget-classifier',
abortSignal: new AbortController().signal,
});
const text = response.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
if (!text) {
debugLogger.debug(
'AdaptiveBudgetService: No response from classifier.',
);
return undefined;
}
const level = parseInt(text, 10) as ComplexityLevel;
if (isNaN(level) || level < 1 || level > 4) {
debugLogger.debug(
`AdaptiveBudgetService: Invalid complexity level returned: ${text}`,
);
return undefined;
}
const result: AdaptiveBudgetResult = { complexity: level };
// Determine mapping based on model version
// Gemini 3 uses ThinkingLevel, Gemini 2.x uses thinkingBudget
if (isPreviewModel(model)) {
result.thinkingLevel = LEVEL_MAPPING_V3[level] ?? 'HIGH';
} else if (isGemini2Model(model)) {
result.thinkingBudget = BUDGET_MAPPING_V2[level];
}
if (level === ComplexityLevel.EXTREME) {
result.strategyNote =
'EXTREME complexity detected. Future implementations should use Best-of-N + Verification.';
}
debugLogger.debug(
`AdaptiveBudgetService: Complexity ${level} -> Thinking Param: ${result.thinkingLevel || result.thinkingBudget}`,
);
return result;
} catch (error) {
debugLogger.error(
'AdaptiveBudgetService: Error classifying complexity',
error,
);
return undefined;
}
}
getThinkingBudgetV2(level: ComplexityLevel): number {
return BUDGET_MAPPING_V2[level];
}
getThinkingLevelV3(level: ComplexityLevel): string {
return LEVEL_MAPPING_V3[level] ?? 'HIGH';
}
}
@@ -83,6 +83,11 @@ export type ConversationRecordExtra =
thoughts?: Array<ThoughtSummary & { timestamp: string }>;
tokens?: TokensSummary | null;
model?: string;
adaptiveThinking?: {
complexity: number;
thinkingBudget?: number;
thinkingLevel?: string;
};
};
/**
@@ -130,6 +135,11 @@ export class ChatRecordingService {
private projectHash: string;
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
private queuedTokens: TokensSummary | null = null;
private queuedAdaptiveThinking?: {
complexity: number;
thinkingBudget?: number;
thinkingLevel?: string;
};
private config: Config;
constructor(config: Config) {
@@ -186,6 +196,7 @@ export class ChatRecordingService {
// Clear any queued data since this is a fresh start
this.queuedThoughts = [];
this.queuedTokens = null;
this.queuedAdaptiveThinking = undefined;
} catch (error) {
// Handle disk full (ENOSPC) gracefully - disable recording but allow CLI to continue
if (
@@ -230,6 +241,11 @@ export class ChatRecordingService {
type: ConversationRecordExtra['type'];
content: PartListUnion;
displayContent?: PartListUnion;
adaptiveThinking?: {
complexity: number;
thinkingBudget?: number;
thinkingLevel?: string;
};
}): void {
if (!this.conversationFile) return;
@@ -247,9 +263,12 @@ export class ChatRecordingService {
thoughts: this.queuedThoughts,
tokens: this.queuedTokens,
model: message.model,
adaptiveThinking:
message.adaptiveThinking || this.queuedAdaptiveThinking,
});
this.queuedThoughts = [];
this.queuedTokens = null;
this.queuedAdaptiveThinking = undefined;
} else {
// Or else just add it.
conversation.messages.push(msg);
@@ -261,6 +280,18 @@ export class ChatRecordingService {
}
}
/**
* Queues adaptive thinking info to be recorded with the next Gemini message.
*/
recordAdaptiveThinking(info: {
complexity: number;
thinkingBudget?: number;
thinkingLevel?: string;
}): void {
if (!this.conversationFile) return;
this.queuedAdaptiveThinking = info;
}
/**
* Records a thought from the assistant's reasoning process.
*/
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentConfig } from '@google/genai';
import type { GenerateContentConfig, ThinkingLevel } from '@google/genai';
// The primary key for the ModelConfig is the model string. However, we also
// support a secondary key to limit the override scope, typically an agent name.
@@ -26,6 +26,10 @@ export interface ModelConfigKey {
// This allows overrides to specify different settings (e.g., higher temperature)
// specifically for retry scenarios.
isRetry?: boolean;
// Dynamic thinking configuration determined at runtime (e.g. via complexity classification)
thinkingBudget?: number;
thinkingLevel?: ThinkingLevel | string;
}
export interface ModelConfig {
@@ -150,6 +154,35 @@ export class ModelConfigService {
);
}
// Apply dynamic thinking parameters from context if present
if (
context.thinkingBudget !== undefined ||
context.thinkingLevel !== undefined
) {
const thinkingConfig = {
...(currentConfig.generateContentConfig?.thinkingConfig as object),
...(context.thinkingBudget !== undefined
? { thinkingBudget: context.thinkingBudget }
: {}),
...(context.thinkingLevel !== undefined
? { thinkingLevel: context.thinkingLevel }
: {}),
};
// Ensure we don't have BOTH if one was a default from an alias
if (context.thinkingLevel) {
delete (thinkingConfig as Record<string, unknown>)['thinkingBudget'];
}
if (context.thinkingBudget) {
delete (thinkingConfig as Record<string, unknown>)['thinkingLevel'];
}
currentConfig.generateContentConfig = {
...currentConfig.generateContentConfig,
thinkingConfig: thinkingConfig as Record<string, unknown>,
};
}
return {
model: currentConfig.model,
generateContentConfig: currentConfig.generateContentConfig ?? {},
+26 -1
View File
@@ -553,7 +553,7 @@ function toGenerateContentConfigAttributes(
if (!config) {
return {};
}
return {
const attributes: LogAttributes = {
'gen_ai.request.temperature': config.temperature,
'gen_ai.request.top_p': config.topP,
'gen_ai.request.top_k': config.topK,
@@ -568,6 +568,20 @@ function toGenerateContentConfigAttributes(
toSystemInstruction(config.systemInstruction),
),
};
if (config.thinkingConfig) {
const thinkingConfig = config.thinkingConfig;
if (thinkingConfig.thinkingBudget !== undefined) {
attributes['gen_ai.request.thinking_budget'] =
thinkingConfig.thinkingBudget;
}
if (thinkingConfig.thinkingLevel !== undefined) {
attributes['gen_ai.request.thinking_level'] =
thinkingConfig.thinkingLevel;
}
}
return attributes;
}
export class ApiResponseEvent implements BaseTelemetryEvent {
@@ -632,6 +646,17 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
status_code: this.status_code,
finish_reasons: this.finish_reasons,
};
if (this.prompt.generate_content_config?.thinkingConfig) {
const thinkingConfig = this.prompt.generate_content_config.thinkingConfig;
if (thinkingConfig.thinkingBudget !== undefined) {
attributes['thinking_budget'] = thinkingConfig.thinkingBudget;
}
if (thinkingConfig.thinkingLevel !== undefined) {
attributes['thinking_level'] = thinkingConfig.thinkingLevel;
}
}
if (this.response_text) {
attributes['response_text'] = this.response_text;
}
+24
View File
@@ -1487,6 +1487,30 @@
"default": false,
"type": "boolean"
},
"adaptiveThinking": {
"title": "Adaptive Thinking Settings",
"description": "Configuration for Adaptive Thinking Budget.",
"markdownDescription": "Configuration for Adaptive Thinking Budget.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
"enabled": {
"title": "Enable Adaptive Thinking",
"description": "Enable adaptive thinking budget based on task complexity.",
"markdownDescription": "Enable adaptive thinking budget based on task complexity.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"classifierModel": {
"title": "Classifier Model",
"description": "The model (or alias) to use for complexity classification.",
"markdownDescription": "The model (or alias) to use for complexity classification.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `classifier`",
"default": "classifier",
"type": "string"
}
},
"additionalProperties": false
},
"plan": {
"title": "Plan",
"description": "Enable planning features (Plan Mode and tools).",