mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94362d67f0 | |||
| af4e850aae | |||
| aa5eefa193 | |||
| 7666c07e40 | |||
| 96936a3030 | |||
| 64070914bb | |||
| be9360ee08 | |||
| 3daaec2621 | |||
| 6ce878416c | |||
| 6b81eb2964 | |||
| 7d23784631 | |||
| 11964a1b77 | |||
| 14a7be90d4 | |||
| 24bbeb11c7 | |||
| a1db5f9faf | |||
| 1d3896491f | |||
| 6dd2d219d9 | |||
| 8d041e2acd | |||
| 9ccb6ff329 | |||
| f7cba3f3a4 | |||
| 38917d8ef1 | |||
| b64bfd55e8 | |||
| 8ce3de1187 | |||
| bc8acdc309 | |||
| d61eed2880 | |||
| 2e1b556764 | |||
| 50f71a8df5 |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: experimentation
|
||||
description: Guide developers through the process of adding new remote experiments (feature flags) to the Gemini CLI codebase.
|
||||
---
|
||||
|
||||
# Experimentation Skill
|
||||
|
||||
This skill assists developers in adding net-new remote experiments (feature flags) to the Gemini CLI codebase. You MUST follow these steps sequentially to ensure consistency, type safety, and proper validation.
|
||||
|
||||
## Key Files
|
||||
- `packages/core/src/code_assist/experiments/flagNames.ts`: Definitions and metadata for all experiment flags.
|
||||
- `packages/core/src/config/config.ts`: Unified configuration object where strongly typed wrappers for experiments are added.
|
||||
- `packages/cli/src/config/settingsSchema.ts`: Schema definitions for `settings.json`.
|
||||
- `packages/core/src/config/config.experiment.test.ts`: Regression tests for experiment resolution.
|
||||
|
||||
## Core Pattern: Unified Configuration
|
||||
|
||||
Gemini CLI uses a unified `Config` object as the source of truth. Every experiment should be accessed via the `config.getExperimentValue(flagId)` method, which internally handles the priority of overrides:
|
||||
**Command Line Argument (--experiment-*) > Local Setting (experimental.*) > Remote Experiment > Default Value.**
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Adding a New Experiment
|
||||
|
||||
### 1. List Current Experiments
|
||||
- **File:** `packages/core/src/code_assist/experiments/flagNames.ts`
|
||||
- **MANDATORY:** Read this file and explicitly list EVERY existing experiment name and its numeric ID to the user in your response. This is a critical step to ensure a unique ID and name are chosen. Do NOT just read the file silently.
|
||||
- **Analyze Usage:** Search for `getExperimentValue` in `packages/core/src/config/config.ts` to see how similar experiments are wrapped and used.
|
||||
|
||||
### 2. Ask for Details (Behavior & Intent)
|
||||
- **CRITICAL:** You MUST ask the user:
|
||||
1. "What is the name of the new flag?" (e.g., `enable-new-thing`)
|
||||
2. "What is the data type?" (boolean, number, or string)
|
||||
3. "What should the behavior be when the flag is enabled vs. disabled?"
|
||||
4. "What are the architectural impacts (routing, tools, prompt construction, etc.)?"
|
||||
- Do not proceed until the behavior is clearly defined and documented.
|
||||
|
||||
### 3. Add Experiment ID and Metadata
|
||||
- **File:** `packages/core/src/code_assist/experiments/flagNames.ts`
|
||||
- Add a new constant to the `ExperimentFlags` object with a unique numeric ID.
|
||||
- Add a corresponding entry to `ExperimentMetadata`, including `description`, `type`, and `defaultValue`.
|
||||
|
||||
### 4. Add Config Entry and Schema
|
||||
- **Step A (Wrapper):** In `packages/core/src/config/config.ts`, add a strongly typed wrapper method:
|
||||
```typescript
|
||||
isNewThingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_NEW_THING) ?? false;
|
||||
}
|
||||
```
|
||||
- **Step B (Schema):** In `packages/cli/src/config/settingsSchema.ts`, add the kebab-case name to the `experimental.properties` section to enable IDE autocompletion.
|
||||
- **Step C (Regenerate):** Run `npm run schema:settings` to update `schemas/settings.schema.json`.
|
||||
|
||||
### 5. Implement Code Change with Debug Logging
|
||||
- **Usage:** Implement the logic defined in Step 2.
|
||||
- **Logging:** At the point of usage, add a debug log to confirm the flag's state:
|
||||
```typescript
|
||||
debugLogger.debug('New thing enabled:', config.isNewThingEnabled());
|
||||
```
|
||||
- **Telemetry (Crucial):** Prompt the user to think about success metrics. Ask: "How will we know if this feature is working as intended?" and help them add telemetry calls to capture these insights.
|
||||
|
||||
### 6. Update Tests
|
||||
- **File:** `packages/core/src/config/config.experiment.test.ts`
|
||||
- Add a test case to verify that the flag resolves correctly across all priority levels (CLI, Settings, Remote, Default).
|
||||
- Example: `expect(config.isNewThingEnabled()).toBe(true)` when passed via `experimentalCliArgs`.
|
||||
|
||||
### 7. Final Validation and PR Preparation
|
||||
- **Validation Action:** Run the CLI with debugging enabled and the new flag set via the command line:
|
||||
```bash
|
||||
npm run start -- --debug --experiment your-flag-name=value --prompt "test"
|
||||
```
|
||||
- **Log Verification:** Check the console for the "Experimental CLI args" normalization log and your custom debug log.
|
||||
- **Branching:** If not already on a dedicated branch, help the user create one (e.g., `git checkout -b exp/feature-name`).
|
||||
- **Pre-flight:** Remind the user to run local tests and linting (`npm run lint:all`, `npm test` or `npm run preflight`) before preparing a Pull Request.
|
||||
Binary file not shown.
@@ -106,6 +106,7 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,13 +444,22 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('experiment', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
description:
|
||||
'Override experiment flags locally (format: flag=value, comma-separated or multiple --experiment)',
|
||||
coerce: (exps: string[]) =>
|
||||
// Handle comma-separated values
|
||||
exps.flatMap((e) => e.split(',').map((s) => s.trim())),
|
||||
}),
|
||||
)
|
||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
.alias('h', 'help')
|
||||
.strict()
|
||||
.demandCommand(0, 0) // Allow base command to run with no subcommands
|
||||
.exitProcess(false);
|
||||
|
||||
@@ -873,27 +883,46 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
let clientName: string | undefined = undefined;
|
||||
if (isAcpMode) {
|
||||
const ide = detectIdeFromEnv();
|
||||
if (
|
||||
ide &&
|
||||
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
|
||||
) {
|
||||
clientName = `acp-${ide.name}`;
|
||||
const experimentalCliArgs: Record<string, unknown> = Object.create(null);
|
||||
if (argv['experiment'] && Array.isArray(argv['experiment'])) {
|
||||
for (const entry of argv['experiment']) {
|
||||
const [key, ...valueParts] = entry.split('=');
|
||||
const value = valueParts.join('=');
|
||||
if (key && value !== undefined) {
|
||||
// Simple type inference for CLI args
|
||||
if (value === 'true') experimentalCliArgs[key] = true;
|
||||
else if (value === 'false') experimentalCliArgs[key] = false;
|
||||
else if (!isNaN(Number(value)))
|
||||
experimentalCliArgs[key] = Number(value);
|
||||
else experimentalCliArgs[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
let clientName: string | undefined = undefined;
|
||||
if (isAcpMode) {
|
||||
const ide = detectIdeFromEnv();
|
||||
if (
|
||||
ide &&
|
||||
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
|
||||
) {
|
||||
clientName = `acp-${ide.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
|
||||
if (debugMode && Object.keys(experimentalCliArgs).length > 0) {
|
||||
debugLogger.debug('Experimental CLI args:', experimentalCliArgs);
|
||||
}
|
||||
return new Config({
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
@@ -985,6 +1014,8 @@ export async function loadCliConfig(
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
experimentalSettings: settings.experimental,
|
||||
experimentalCliArgs,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -2217,6 +2217,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
'enable-awesome': {
|
||||
type: 'boolean',
|
||||
label: 'Enable Awesome',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: "When enabled, the ASCII art says 'matt'.",
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { experimentCommand } from '../ui/commands/experimentCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
@@ -133,6 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
experimentCommand,
|
||||
...(this.config?.getExtensionsEnabled() === false
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -121,6 +121,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
isAwesomeEnabled: vi.fn().mockReturnValue(false),
|
||||
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Trigger CI run for latest changes.
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { experimentCommand } from './experimentCommand.js';
|
||||
import { CommandKind, type CommandContext } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
describe('experimentCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
services: {
|
||||
config: {
|
||||
getExperimentValue: vi.fn(),
|
||||
updateExperimentalSettings: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
user: {
|
||||
settings: {
|
||||
experimental: {},
|
||||
},
|
||||
},
|
||||
merged: {
|
||||
experimental: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
stats: {},
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(experimentCommand.name).toBe('experiment');
|
||||
expect(experimentCommand.description).toBe('Manage experimental features');
|
||||
expect(experimentCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
describe('list sub-command', () => {
|
||||
const listCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'list',
|
||||
);
|
||||
|
||||
it('should list experiments', async () => {
|
||||
(mockContext.services.config!.getExperimentValue as Mock).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
await listCommand?.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('enable-preview'),
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Value: true'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set sub-command', () => {
|
||||
const setCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'set',
|
||||
);
|
||||
|
||||
it('should set a boolean experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'enable-preview true');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
expect.objectContaining({
|
||||
'enable-preview': true,
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(
|
||||
'Experiment enable-preview set to true',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a number experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'classifier-threshold 0.5');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
expect.objectContaining({
|
||||
'classifier-threshold': 0.5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for unknown experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'unknown-exp true');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining('Unknown experiment: unknown-exp'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unset sub-command', () => {
|
||||
const unsetCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'unset',
|
||||
);
|
||||
|
||||
it('should unset an experiment', async () => {
|
||||
(mockContext.services.settings.user.settings as Record<string, unknown>)[
|
||||
'experimental'
|
||||
] = {
|
||||
'enable-preview': true,
|
||||
};
|
||||
await unsetCommand?.action!(mockContext, 'enable-preview');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
{},
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(
|
||||
'Local user override for experiment enable-preview removed',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if no override exists', async () => {
|
||||
await unsetCommand?.action!(mockContext, 'enable-preview');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining(
|
||||
'No local user override found for experiment: enable-preview',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagIdFromName,
|
||||
getExperimentFlagName,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
const listExperimentsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List all available experiments and their current values',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context: CommandContext) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
const entries = Object.entries(ExperimentMetadata).filter(
|
||||
([, metadata]) => !metadata.hidden,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'No experiments available.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let output = 'Available Experiments:\n\n';
|
||||
for (const [idStr, metadata] of entries) {
|
||||
const id = parseInt(idStr, 10);
|
||||
const name = getExperimentFlagName(id) || `ID: ${id}`;
|
||||
const value = config.getExperimentValue(id);
|
||||
output += `${name}\n`;
|
||||
output += ` Value: ${value}\n`;
|
||||
output += ` Description: ${metadata.description}\n`;
|
||||
output += ` Default: ${metadata.defaultValue}\n\n`;
|
||||
}
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: output.trim(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const setExperimentCommand: SlashCommand = {
|
||||
name: 'set',
|
||||
description:
|
||||
'Set a local override for an experiment. Usage: /experiment set <name> <value>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /experiment set <name> <value>',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const name = parts[0];
|
||||
const rawValue = args.trim().substring(name.length).trim();
|
||||
const id = getExperimentFlagIdFromName(name);
|
||||
|
||||
if (id === undefined) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Unknown experiment: ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = ExperimentMetadata[id];
|
||||
let value: unknown;
|
||||
|
||||
// Helper to parse strings based on Zod schema type
|
||||
const parseValue = (raw: string, schema: z.ZodTypeAny): unknown => {
|
||||
if (schema instanceof z.ZodBoolean) {
|
||||
if (raw === 'true' || raw === 'on') return true;
|
||||
if (raw === 'false' || raw === 'off') return false;
|
||||
return raw;
|
||||
}
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
return Number(raw);
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
value = parseValue(rawValue, metadata.schema);
|
||||
const result = metadata.schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Invalid value for ${name}: ${rawValue}. Error: ${result.error.errors[0].message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
value = result.data;
|
||||
|
||||
const { settings, config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
// SECURITY: Only persist user-scoped settings to prevent untrusted workspace settings
|
||||
// from being promoted to the global user configuration.
|
||||
const userExperimental = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((settings.user.settings['experimental'] as Record<string, unknown>) ||
|
||||
{}),
|
||||
};
|
||||
userExperimental[name] = value;
|
||||
|
||||
settings.setValue(SettingScope.User, 'experimental', userExperimental);
|
||||
|
||||
// Update the live config with the merged state so it takes effect immediately
|
||||
const mergedExperimental = {
|
||||
...((settings.merged.experimental as Record<string, unknown>) || {}),
|
||||
...userExperimental,
|
||||
};
|
||||
config.updateExperimentalSettings(mergedExperimental);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Experiment ${name} set to ${value} (persisted in user settings).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const unsetExperimentCommand: SlashCommand = {
|
||||
name: 'unset',
|
||||
description:
|
||||
'Remove a local override for an experiment. Usage: /experiment unset <name>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /experiment unset <name>',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { settings, config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
const userExperimental = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((settings.user.settings['experimental'] as Record<string, unknown>) ||
|
||||
{}),
|
||||
};
|
||||
|
||||
if (!(name in userExperimental)) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `No local user override found for experiment: ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
delete userExperimental[name];
|
||||
settings.setValue(SettingScope.User, 'experimental', userExperimental);
|
||||
|
||||
// Update the live config with the new merged state
|
||||
const mergedExperimental = {
|
||||
...((settings.merged.experimental as Record<string, unknown>) || {}),
|
||||
};
|
||||
delete mergedExperimental[name];
|
||||
config.updateExperimentalSettings(mergedExperimental);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Local user override for experiment ${name} removed.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const experimentCommand: SlashCommand = {
|
||||
name: 'experiment',
|
||||
description: 'Manage experimental features',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
listExperimentsCommand,
|
||||
setExperimentCommand,
|
||||
unsetExperimentCommand,
|
||||
],
|
||||
action: async (context: CommandContext, args: string) =>
|
||||
listExperimentsCommand.action!(context, args),
|
||||
};
|
||||
@@ -57,3 +57,14 @@ export const tinyAsciiLogoCompactText = `
|
||||
▝█▖ ▜█▘
|
||||
▝▀▀▀▀
|
||||
`;
|
||||
|
||||
export const mattAsciiLogo = `
|
||||
██████ ██████ ██████ ███████████ ███████████
|
||||
░░██████ ░░██████ ░░██████░█░░░███░░░█░█░░░███░░░█
|
||||
░███░███ ███░███ ░███░███░ ░███ ░ ░ ░███ ░
|
||||
░███░░███░███░███ ░███░░███ ░███ ░███
|
||||
░███ ░░███░ ░███ ░███ ░░███ ░███ ░███
|
||||
░███ ░░███ ░███ ░███ ░░███ ░███ ░███
|
||||
█████ ░░█ █████ █████ ░░█████████ █████
|
||||
░░░░░ ░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░
|
||||
`;
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
mattAsciiLogo,
|
||||
} from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useSnowfall } from '../hooks/useSnowfall.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
@@ -23,6 +29,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
version,
|
||||
nightly,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
@@ -30,6 +37,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (config.isAwesomeEnabled()) {
|
||||
displayTitle = mattAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
|
||||
@@ -46,5 +46,11 @@ export * from './src/utils/googleQuotaErrors.js';
|
||||
export type { GoogleApiError } from './src/utils/googleErrors.js';
|
||||
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
|
||||
export { getExperiments } from './src/code_assist/experiments/experiments.js';
|
||||
export { ExperimentFlags } from './src/code_assist/experiments/flagNames.js';
|
||||
export {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
type ExperimentMetadataEntry,
|
||||
getExperimentFlagName,
|
||||
getExperimentFlagIdFromName,
|
||||
} from './src/code_assist/experiments/flagNames.js';
|
||||
export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js';
|
||||
|
||||
@@ -4,22 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ExecuteOptions,
|
||||
import { BaseToolInvocation } from '../tools/tools.js';
|
||||
import type {
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
type RemoteAgentInputs,
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
import type {
|
||||
RemoteAgentInputs,
|
||||
RemoteAgentDefinition,
|
||||
AgentInputs,
|
||||
SubagentProgress,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ExperimentFlags = {
|
||||
CONTEXT_COMPRESSION_THRESHOLD: 45740197,
|
||||
USER_CACHING: 45740198,
|
||||
@@ -20,7 +22,239 @@ export const ExperimentFlags = {
|
||||
PRO_MODEL_NO_ACCESS: 45768879,
|
||||
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
|
||||
DEFAULT_REQUEST_TIMEOUT: 45773134,
|
||||
ENABLE_AWESOME: 45758820,
|
||||
|
||||
// Migrated from hardcoded experimental settings
|
||||
ENABLE_AGENTS: 45760001,
|
||||
EXTENSION_MANAGEMENT: 45760002,
|
||||
EXTENSION_CONFIG: 45760003,
|
||||
EXTENSION_REGISTRY: 45760004,
|
||||
EXTENSION_RELOADING: 45760005,
|
||||
JIT_CONTEXT: 45760006,
|
||||
USE_OSC52_PASTE: 45760007,
|
||||
USE_OSC52_COPY: 45760008,
|
||||
PLAN: 45760009,
|
||||
MODEL_STEERING: 45760010,
|
||||
DISABLE_LLM_CORRECTION: 45760011,
|
||||
ENABLE_TOOL_OUTPUT_MASKING: 45760012,
|
||||
} as const;
|
||||
|
||||
export type ExperimentFlagName =
|
||||
(typeof ExperimentFlags)[keyof typeof ExperimentFlags];
|
||||
|
||||
export interface ExperimentMetadataEntry {
|
||||
description: string;
|
||||
schema: z.ZodTypeAny;
|
||||
defaultValue: unknown;
|
||||
hidden?: boolean;
|
||||
/** The key used in settings.json (defaults to kebab-case version of flag name) */
|
||||
settingKey?: string;
|
||||
}
|
||||
|
||||
export const ExperimentMetadata: Record<number, ExperimentMetadataEntry> = {
|
||||
[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]: {
|
||||
description: 'Threshold at which context compression activates.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
},
|
||||
[ExperimentFlags.USER_CACHING]: {
|
||||
description: 'Enables caching of user contexts.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]: {
|
||||
description: 'Banner text displayed when there are no capacity issues.',
|
||||
schema: z.string(),
|
||||
defaultValue: '',
|
||||
},
|
||||
[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]: {
|
||||
description: 'Banner text displayed during capacity issues.',
|
||||
schema: z.string(),
|
||||
defaultValue: '',
|
||||
},
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: {
|
||||
description: 'Enables preview features globally.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_NUMERICAL_ROUTING]: {
|
||||
description: 'Enables numerical routing strategies for the model.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: {
|
||||
description: 'Threshold for the intent classifier.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0.5,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
description: 'Enables admin control features in the CLI.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.MASKING_PROTECTION_THRESHOLD]: {
|
||||
description: 'Threshold for masking protection logic.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
settingKey: 'toolOutputMasking.toolProtectionThreshold',
|
||||
},
|
||||
[ExperimentFlags.MASKING_PRUNABLE_THRESHOLD]: {
|
||||
description: 'Threshold for prunable masking.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
settingKey: 'toolOutputMasking.minPrunableTokensThreshold',
|
||||
},
|
||||
[ExperimentFlags.MASKING_PROTECT_LATEST_TURN]: {
|
||||
description: 'Protects the latest turn from being masked.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
settingKey: 'toolOutputMasking.protectLatestTurn',
|
||||
},
|
||||
|
||||
// Migrated settings (marked hidden to keep /experiment list focused)
|
||||
[ExperimentFlags.ENABLE_AGENTS]: {
|
||||
description: 'Enable local and remote subagents.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'enableAgents',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_MANAGEMENT]: {
|
||||
description: 'Enable extension management features.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'extensionManagement',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_CONFIG]: {
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'extensionConfig',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_REGISTRY]: {
|
||||
description: 'Enable extension registry explore UI.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'extensionRegistry',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_RELOADING]: {
|
||||
description: 'Enables extension loading/unloading within the CLI session.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'extensionReloading',
|
||||
},
|
||||
[ExperimentFlags.JIT_CONTEXT]: {
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'jitContext',
|
||||
},
|
||||
[ExperimentFlags.USE_OSC52_PASTE]: {
|
||||
description: 'Use OSC 52 for pasting.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'useOSC52Paste',
|
||||
},
|
||||
[ExperimentFlags.USE_OSC52_COPY]: {
|
||||
description: 'Use OSC 52 for copying.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'useOSC52Copy',
|
||||
},
|
||||
[ExperimentFlags.PLAN]: {
|
||||
description: 'Enable planning features (Plan Mode and tools).',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'plan',
|
||||
},
|
||||
[ExperimentFlags.MODEL_STEERING]: {
|
||||
description: 'Enable model steering (user hints).',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'modelSteering',
|
||||
},
|
||||
[ExperimentFlags.DISABLE_LLM_CORRECTION]: {
|
||||
description: 'Disable LLM-based error correction for edit tools.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'disableLLMCorrection',
|
||||
},
|
||||
[ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING]: {
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'toolOutputMasking.enabled',
|
||||
},
|
||||
[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED]: {
|
||||
description: 'Indicates if Gemini 3.1 Pro has been launched.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
|
||||
description: 'Indicates if the user has no access to Pro models.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED]: {
|
||||
description: 'Indicates if Gemini 3.1 Flash Lite has been launched.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
|
||||
description: 'The default request timeout in seconds.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_AWESOME]: {
|
||||
description: "When enabled, the ASCII art says 'matt'.",
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the name of an experiment flag from its ID.
|
||||
*/
|
||||
export function getExperimentFlagName(flagId: number): string | undefined {
|
||||
const metadata = ExperimentMetadata[flagId];
|
||||
if (metadata?.settingKey) {
|
||||
return metadata.settingKey;
|
||||
}
|
||||
|
||||
for (const [name, id] of Object.entries(ExperimentFlags)) {
|
||||
if (id === flagId) {
|
||||
return name.toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of an experiment flag from its name (supports kebab-case or camelCase).
|
||||
*/
|
||||
export function getExperimentFlagIdFromName(name: string): number | undefined {
|
||||
// Check metadata for explicit settingKey matches first
|
||||
for (const [idStr, metadata] of Object.entries(ExperimentMetadata)) {
|
||||
if (metadata.settingKey === name) {
|
||||
return parseInt(idStr, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert enableNumericalRouting or enable-numerical-routing to ENABLE_NUMERICAL_ROUTING
|
||||
const constantName = name
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2') // camelCase to snake_case
|
||||
.toUpperCase()
|
||||
.replace(/-/g, '_'); // kebab-case to snake_case
|
||||
return (ExperimentFlags as Record<string, number>)[constantName];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('Config CLI Override', () => {
|
||||
const sessionId = 'test-session';
|
||||
const targetDir = process.cwd();
|
||||
const cwd = process.cwd();
|
||||
const model = 'gemini-pro';
|
||||
|
||||
it('should prioritize CLI argument over local setting', async () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-numerical-routing': true },
|
||||
experimentalSettings: { 'enable-numerical-routing': false },
|
||||
});
|
||||
|
||||
expect(await config.getNumericalRoutingEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize CLI argument over remote experiment', async () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-numerical-routing': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_NUMERICAL_ROUTING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await config.getNumericalRoutingEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('Config getExperimentValue', () => {
|
||||
const sessionId = 'test-session';
|
||||
const targetDir = process.cwd();
|
||||
const cwd = process.cwd();
|
||||
const model = 'gemini-pro';
|
||||
|
||||
it('should prioritize CLI argument over others', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-preview': true },
|
||||
experimentalSettings: { 'enable-preview': false },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize local setting over remote experiment', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalSettings: { 'enable-preview': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use remote experiment if no local override', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default value if nothing else is set', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
});
|
||||
|
||||
// Default for ENABLE_PREVIEW is false
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle numeric values correctly', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'classifier-threshold': 0.8 },
|
||||
});
|
||||
|
||||
expect(
|
||||
config.getExperimentValue<number>(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.8);
|
||||
});
|
||||
|
||||
it('should handle string representation of numbers from remote', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: { stringValue: '0.7' },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
config.getExperimentValue<number>(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should return true for isAwesomeEnabled when flag is set', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-awesome': true },
|
||||
});
|
||||
|
||||
expect(config.isAwesomeEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -545,9 +545,9 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(await config.getUserCaching()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined if there are no experiments', async () => {
|
||||
it('should return the default value if there are no experiments', async () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(await config.getUserCaching()).toBeUndefined();
|
||||
expect(await config.getUserCaching()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import { MemoryContextManager } from '../context/memoryContextManager.js';
|
||||
import { TrackerService } from '../services/trackerService.js';
|
||||
import type { GenerateContentParameters } from '@google/genai';
|
||||
import { ExperimentManager } from './experimentManager.js';
|
||||
|
||||
// Re-export OAuth config type
|
||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||
@@ -155,14 +156,19 @@ import type {
|
||||
} from '../code_assist/types.js';
|
||||
import type { HierarchicalMemory } from './memory.js';
|
||||
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||
import { SubagentTool } from '../agents/subagent-tool.js';
|
||||
import {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagName,
|
||||
} from '../code_assist/experiments/flagNames.js';
|
||||
import {
|
||||
getExperiments,
|
||||
type Experiments,
|
||||
} from '../code_assist/experiments/experiments.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
@@ -442,7 +448,6 @@ import {
|
||||
DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD,
|
||||
DEFAULT_PROTECT_LATEST_TURN,
|
||||
} from '../context/toolOutputMaskingService.js';
|
||||
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
SimpleExtensionLoader,
|
||||
@@ -692,6 +697,8 @@ export interface ConfigParameters {
|
||||
disabledHooks?: string[];
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
enableAgents?: boolean;
|
||||
experimentalSettings?: Record<string, unknown>;
|
||||
experimentalCliArgs?: Record<string, unknown>;
|
||||
enableEventDrivenScheduler?: boolean;
|
||||
skillsSupport?: boolean;
|
||||
disabledSkills?: string[];
|
||||
@@ -817,7 +824,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly listExtensions: boolean;
|
||||
private readonly _extensionLoader: ExtensionLoader;
|
||||
private readonly _enabledExtensions: string[];
|
||||
private readonly enableExtensionReloading: boolean;
|
||||
fallbackModelHandler?: FallbackModelHandler;
|
||||
validationHandler?: ValidationHandler;
|
||||
private quotaErrorOccurred: boolean = false;
|
||||
@@ -875,6 +881,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
private compressionTruncationCounter = 0;
|
||||
private initialized = false;
|
||||
@@ -887,6 +894,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly workspacePoliciesDir: string | undefined;
|
||||
private readonly _messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
private readonly experimentManager: ExperimentManager;
|
||||
private policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
@@ -911,14 +919,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooksUI: boolean;
|
||||
private readonly enableHooks: boolean;
|
||||
|
||||
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
private projectHooks:
|
||||
| ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] })
|
||||
| undefined;
|
||||
private disabledHooks: string[];
|
||||
private experiments: Experiments | undefined;
|
||||
private experimentsPromise: Promise<Experiments | undefined> | undefined;
|
||||
private experimentsPromise: Promise<void> | undefined;
|
||||
private hookSystem?: HookSystem;
|
||||
private readonly onModelChange: ((model: string) => void) | undefined;
|
||||
private readonly onReload:
|
||||
@@ -950,6 +951,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly modelSteering: boolean;
|
||||
private memoryContextManager?: MemoryContextManager;
|
||||
private readonly contextManagement: ContextManagementConfig;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
private remoteAdminSettings: AdminControlsSettings | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
@@ -1092,15 +1094,51 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.experimentalSettings = params.experimentalSettings ?? {};
|
||||
this.experimentalCliArgs = params.experimentalCliArgs ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
this.trackerEnabled = params.tracker ?? false;
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
|
||||
// Merge legacy experimental parameters into a single object for the manager
|
||||
const experimentalSettings = {
|
||||
...(params.experimentalSettings ?? {}),
|
||||
};
|
||||
if (params.plan !== undefined) experimentalSettings['plan'] = params.plan;
|
||||
if (params.experimentalJitContext !== undefined)
|
||||
experimentalSettings['jitContext'] = params.experimentalJitContext;
|
||||
if (params.enableAgents !== undefined)
|
||||
experimentalSettings['enableAgents'] = params.enableAgents;
|
||||
if (params.modelSteering !== undefined)
|
||||
experimentalSettings['modelSteering'] = params.modelSteering;
|
||||
if (params.enableExtensionReloading !== undefined)
|
||||
experimentalSettings['extensionReloading'] =
|
||||
params.enableExtensionReloading;
|
||||
if (params.extensionManagement !== undefined)
|
||||
experimentalSettings['extensionManagement'] = params.extensionManagement;
|
||||
if (params.disableLLMCorrection !== undefined)
|
||||
experimentalSettings['disableLLMCorrection'] =
|
||||
params.disableLLMCorrection;
|
||||
if (params.toolOutputMasking?.enabled !== undefined) {
|
||||
experimentalSettings['toolOutputMasking'] = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((experimentalSettings['toolOutputMasking'] as object) ?? {}),
|
||||
enabled: params.toolOutputMasking.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
this.experimentManager = new ExperimentManager({
|
||||
experimentalSettings,
|
||||
experimentalCliArgs: params.experimentalCliArgs,
|
||||
experiments: params.experiments,
|
||||
});
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
<<<<<<< HEAD
|
||||
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
|
||||
|
||||
// HACK: The settings loading logic doesn't currently merge the default
|
||||
@@ -1344,7 +1382,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.projectHooks = params.projectHooks;
|
||||
}
|
||||
|
||||
this.experiments = params.experiments;
|
||||
this.onModelChange = params.onModelChange;
|
||||
this.onReload = params.onReload;
|
||||
|
||||
@@ -1409,7 +1446,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
if (this.isPlanEnabled()) {
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
try {
|
||||
await fs.promises.access(plansDir);
|
||||
@@ -1491,9 +1528,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
await this.hookSystem.initialize();
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if (this.experimentalJitContext) {
|
||||
this.memoryContextManager = new MemoryContextManager(this);
|
||||
await this.memoryContextManager.refresh();
|
||||
=======
|
||||
if (this.isJitContextEnabled()) {
|
||||
this.contextManager = new ContextManager(this);
|
||||
await this.contextManager.refresh();
|
||||
>>>>>>> 49ab62d87 (refactor: move experiment logic to ExperimentManager with Zod validation)
|
||||
}
|
||||
|
||||
await this._geminiClient.initialize();
|
||||
@@ -1592,9 +1635,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
const adminControlsEnabled =
|
||||
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
|
||||
false;
|
||||
// Fetch admin controls
|
||||
await this.ensureExperimentsLoaded();
|
||||
const adminControlsEnabled = !!this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_ADMIN_CONTROLS,
|
||||
);
|
||||
const adminControls = await fetchAdminControls(
|
||||
codeAssistServer,
|
||||
this.getRemoteAdminSettings(),
|
||||
@@ -1612,8 +1657,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getExperimentsAsync(): Promise<Experiments | undefined> {
|
||||
if (this.experiments) {
|
||||
return this.experiments;
|
||||
if (this.experimentManager.getExperiments()) {
|
||||
return this.experimentManager.getExperiments();
|
||||
}
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
return getExperiments(codeAssistServer);
|
||||
@@ -2320,12 +2365,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getUserMemory(): string | HierarchicalMemory {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return {
|
||||
global: this.memoryContextManager.getGlobalMemory(),
|
||||
extension: this.memoryContextManager.getExtensionMemory(),
|
||||
project: this.memoryContextManager.getEnvironmentMemory(),
|
||||
userProjectMemory: this.memoryContextManager.getUserProjectMemory(),
|
||||
global: this.contextManager.getGlobalMemory(),
|
||||
extension: this.contextManager.getExtensionMemory(),
|
||||
project: this.contextManager.getEnvironmentMemory(),
|
||||
userProjectMemory: this.contextManager.getUserProjectMemory(),
|
||||
};
|
||||
}
|
||||
return this.userMemory;
|
||||
@@ -2335,8 +2380,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* Refreshes the MCP context, including memory, tools, and system instructions.
|
||||
*/
|
||||
async refreshMcpContext(): Promise<void> {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
await this.memoryContextManager.refresh();
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
await this.contextManager.refresh();
|
||||
} else {
|
||||
const { refreshServerHierarchicalMemory } = await import(
|
||||
'../utils/memoryDiscovery.js'
|
||||
@@ -2411,7 +2456,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isJitContextEnabled(): boolean {
|
||||
return this.experimentalJitContext;
|
||||
return this.experimentManager.isJitContextEnabled();
|
||||
}
|
||||
|
||||
isContextManagementEnabled(): boolean {
|
||||
@@ -2447,49 +2492,37 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isModelSteeringEnabled(): boolean {
|
||||
return this.modelSteering;
|
||||
return this.experimentManager.isModelSteeringEnabled();
|
||||
}
|
||||
|
||||
getToolOutputMaskingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING,
|
||||
)!;
|
||||
}
|
||||
|
||||
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const remoteProtection =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PROTECTION_THRESHOLD]
|
||||
?.intValue;
|
||||
const remotePrunable =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PRUNABLE_THRESHOLD]
|
||||
?.intValue;
|
||||
const remoteProtectLatest =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PROTECT_LATEST_TURN]
|
||||
?.boolValue;
|
||||
|
||||
const parsedProtection = remoteProtection
|
||||
? parseInt(remoteProtection, 10)
|
||||
: undefined;
|
||||
const parsedPrunable = remotePrunable
|
||||
? parseInt(remotePrunable, 10)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
protectionThresholdTokens:
|
||||
parsedProtection !== undefined && !isNaN(parsedProtection)
|
||||
? parsedProtection
|
||||
: this.contextManagement.tools.outputMasking
|
||||
.protectionThresholdTokens,
|
||||
minPrunableThresholdTokens:
|
||||
parsedPrunable !== undefined && !isNaN(parsedPrunable)
|
||||
? parsedPrunable
|
||||
: this.contextManagement.tools.outputMasking
|
||||
.minPrunableThresholdTokens,
|
||||
protectLatestTurn:
|
||||
remoteProtectLatest ??
|
||||
this.contextManagement.tools.outputMasking.protectLatestTurn,
|
||||
enabled: this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING,
|
||||
)!,
|
||||
toolProtectionThreshold: this.getExperimentValue<number>(
|
||||
ExperimentFlags.MASKING_PROTECTION_THRESHOLD,
|
||||
)!,
|
||||
minPrunableTokensThreshold: this.getExperimentValue<number>(
|
||||
ExperimentFlags.MASKING_PRUNABLE_THRESHOLD,
|
||||
)!,
|
||||
protectLatestTurn: this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.MASKING_PROTECT_LATEST_TURN,
|
||||
)!,
|
||||
};
|
||||
}
|
||||
|
||||
getGeminiMdFileCount(): number {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
return this.memoryContextManager.getLoadedPaths().size;
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return this.contextManager.getLoadedPaths().size;
|
||||
}
|
||||
return this.geminiMdFileCount;
|
||||
}
|
||||
@@ -2499,8 +2532,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getGeminiMdFilePaths(): string[] {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
return Array.from(this.memoryContextManager.getLoadedPaths());
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return Array.from(this.contextManager.getLoadedPaths());
|
||||
}
|
||||
return this.geminiMdFilePaths;
|
||||
}
|
||||
@@ -2598,6 +2631,45 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes enter/exit plan mode tools based on current mode.
|
||||
*/
|
||||
syncPlanModeTools(): void {
|
||||
const registry = this.getToolRegistry();
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
const approvalMode = this.getApprovalMode();
|
||||
const isPlanMode = approvalMode === ApprovalMode.PLAN;
|
||||
const isYoloMode = approvalMode === ApprovalMode.YOLO;
|
||||
|
||||
if (isPlanMode) {
|
||||
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
if (!registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.registerTool(new ExitPlanModeTool(this, this.messageBus));
|
||||
}
|
||||
} else {
|
||||
if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
if (this.isPlanEnabled() && !isYoloMode) {
|
||||
if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.registerTool(new EnterPlanModeTool(this, this.messageBus));
|
||||
}
|
||||
} else {
|
||||
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.geminiClient?.isInitialized()) {
|
||||
this.geminiClient.setTools().catch((err) => {
|
||||
debugLogger.error('Failed to update tools', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
* Logs the duration of the current approval mode.
|
||||
*/
|
||||
logCurrentModeDuration(mode: ApprovalMode): void {
|
||||
@@ -2829,7 +2901,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getExtensionManagement(): boolean {
|
||||
return this.extensionManagement;
|
||||
return this.experimentManager.getExperimentValue<boolean>(
|
||||
ExperimentFlags.EXTENSION_MANAGEMENT,
|
||||
);
|
||||
}
|
||||
|
||||
getExtensions(): GeminiCLIExtension[] {
|
||||
@@ -2847,15 +2921,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getEnableExtensionReloading(): boolean {
|
||||
return this.enableExtensionReloading;
|
||||
return this.experimentManager.getEnableExtensionReloading();
|
||||
}
|
||||
|
||||
getDisableLLMCorrection(): boolean {
|
||||
return this.disableLLMCorrection;
|
||||
return this.experimentManager.getDisableLLMCorrection();
|
||||
}
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.planEnabled;
|
||||
return this.experimentManager.isPlanEnabled();
|
||||
}
|
||||
|
||||
isTrackerEnabled(): boolean {
|
||||
@@ -2875,7 +2949,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.enableAgents;
|
||||
return this.experimentManager.isAgentsEnabled();
|
||||
}
|
||||
|
||||
isEventDrivenSchedulerEnabled(): boolean {
|
||||
@@ -3000,9 +3074,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const remoteThreshold =
|
||||
this.experiments?.flags[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]
|
||||
?.floatValue;
|
||||
const remoteThreshold = this.getExperimentValue<number>(
|
||||
ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD,
|
||||
);
|
||||
if (remoteThreshold === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -3010,9 +3084,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getUserCaching(): Promise<boolean | undefined> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
|
||||
return this.experimentManager.getUserCaching();
|
||||
}
|
||||
|
||||
async getPlanModeRoutingEnabled(): Promise<boolean> {
|
||||
@@ -3020,11 +3092,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getNumericalRoutingEnabled(): Promise<boolean> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const flag =
|
||||
this.experiments?.flags[ExperimentFlags.ENABLE_NUMERICAL_ROUTING];
|
||||
return flag?.boolValue ?? true;
|
||||
return this.experimentManager.isNumericalRoutingEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3049,29 +3117,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getClassifierThreshold(): Promise<number | undefined> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const flag = this.experiments?.flags[ExperimentFlags.CLASSIFIER_THRESHOLD];
|
||||
if (flag?.intValue !== undefined) {
|
||||
return parseInt(flag.intValue, 10);
|
||||
}
|
||||
return flag?.floatValue;
|
||||
return this.experimentManager.getClassifierThreshold();
|
||||
}
|
||||
|
||||
async getBannerTextNoCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
return this.experimentManager.getBannerTextNoCapacityIssues();
|
||||
}
|
||||
|
||||
async getBannerTextCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
return this.experimentManager.getBannerTextCapacityIssues();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3195,6 +3249,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
}
|
||||
|
||||
isAwesomeEnabled(): boolean {
|
||||
return (
|
||||
this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_AWESOME) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureExperimentsLoaded(): Promise<void> {
|
||||
if (!this.experimentsPromise) {
|
||||
return;
|
||||
@@ -3674,14 +3734,34 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* Get experiments configuration
|
||||
*/
|
||||
getExperiments(): Experiments | undefined {
|
||||
return this.experiments;
|
||||
return this.experimentManager.getExperiments();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the value of an experiment flag based on priority:
|
||||
* 1. Command-line argument (--experiment-<flag-name>)
|
||||
* 2. Local setting (experimental.<flag-name>)
|
||||
* 3. Remote experiment
|
||||
* 4. Default value
|
||||
*/
|
||||
getExperimentValue<T extends boolean | number | string>(
|
||||
flagId: number,
|
||||
): T | undefined {
|
||||
return this.experimentManager.getExperimentValue<T>(flagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates experimental settings.
|
||||
*/
|
||||
updateExperimentalSettings(settings: Record<string, unknown>): void {
|
||||
this.experimentManager.updateExperimentalSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set experiments configuration
|
||||
*/
|
||||
setExperiments(experiments: Experiments): void {
|
||||
this.experiments = experiments;
|
||||
this.experimentManager.setExperiments(experiments);
|
||||
const flagSummaries = Object.entries(experiments.flags ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([flagId, flag]) => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExperimentManager } from './experimentManager.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('ExperimentManager', () => {
|
||||
const baseOptions = {
|
||||
experimentalSettings: {},
|
||||
experimentalCliArgs: {},
|
||||
};
|
||||
|
||||
describe('getExperimentValue', () => {
|
||||
it('should return default value when no overrides are present', () => {
|
||||
const manager = new ExperimentManager(baseOptions);
|
||||
// USER_CACHING default is false
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize CLI arguments over all else', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalCliArgs: { 'user-caching': true },
|
||||
experimentalSettings: { 'user-caching': false },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize local settings over remote experiments', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { 'user-caching': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use remote experiment if no local override', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: true },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested settings correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: {
|
||||
toolOutputMasking: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate values using Zod schema', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: {
|
||||
'classifier-threshold': 'not-a-number',
|
||||
},
|
||||
});
|
||||
// CLASSIFIER_THRESHOLD default is 0.5
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should parse numeric strings from remote flags if metadata type is number', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: { stringValue: '0.8' },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convenience getters', () => {
|
||||
it('isPlanEnabled should resolve correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { plan: true },
|
||||
});
|
||||
expect(manager.isPlanEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('isAgentsEnabled should resolve correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { enableAgents: true },
|
||||
});
|
||||
expect(manager.isAgentsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagName,
|
||||
type ExperimentMetadataEntry,
|
||||
} from '../code_assist/experiments/flagNames.js';
|
||||
import type { Experiments } from '../code_assist/experiments/experiments.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ExperimentManagerOptions {
|
||||
experimentalSettings?: Record<string, unknown>;
|
||||
experimentalCliArgs?: Record<string, unknown>;
|
||||
experiments?: Experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages resolution and validation of experimental flags.
|
||||
*/
|
||||
export class ExperimentManager {
|
||||
private experimentalSettings: Record<string, unknown>;
|
||||
private readonly experimentalCliArgs: Record<string, unknown>;
|
||||
private experiments?: Experiments;
|
||||
|
||||
constructor(options: ExperimentManagerOptions) {
|
||||
this.experimentalSettings = options.experimentalSettings ?? {};
|
||||
this.experimentalCliArgs = options.experimentalCliArgs ?? {};
|
||||
this.experiments = options.experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the value of an experiment flag, applying layering logic:
|
||||
* 1. Command-line argument
|
||||
* 2. Local setting (settings.json)
|
||||
* 3. Remote experiment (server-side)
|
||||
* 4. Default value (from metadata)
|
||||
*/
|
||||
getExperimentValue<T>(flagId: number): T {
|
||||
const metadata = ExperimentMetadata[flagId];
|
||||
if (!metadata) {
|
||||
debugLogger.warn(`Unknown experiment flag ID: ${flagId}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
const flagName = getExperimentFlagName(flagId);
|
||||
|
||||
// 1. CLI Argument
|
||||
if (flagName && this.experimentalCliArgs[flagName] !== undefined) {
|
||||
let val: unknown = this.experimentalCliArgs[flagName];
|
||||
// Type coercion for CLI args
|
||||
val = this.coerceValue(val, metadata);
|
||||
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid CLI value for ${flagName}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Local Setting (settings.json)
|
||||
const settingKey = metadata.settingKey || flagName;
|
||||
if (settingKey) {
|
||||
const val = this.getNestedValue(this.experimentalSettings, settingKey);
|
||||
if (val !== undefined) {
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid local setting for ${settingKey}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remote Experiment
|
||||
const remoteFlag = this.experiments?.flags[flagId];
|
||||
if (remoteFlag) {
|
||||
let val: unknown =
|
||||
remoteFlag.boolValue ??
|
||||
remoteFlag.floatValue ??
|
||||
(remoteFlag.intValue ? Number(remoteFlag.intValue) : undefined) ??
|
||||
remoteFlag.stringValue;
|
||||
|
||||
if (val !== undefined) {
|
||||
val = this.coerceValue(val, metadata);
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid remote value for flag ${flagId}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Default Value
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata.defaultValue as T;
|
||||
}
|
||||
|
||||
private coerceValue(
|
||||
val: unknown,
|
||||
metadata: ExperimentMetadataEntry,
|
||||
): unknown {
|
||||
if (metadata.schema instanceof z.ZodNumber && typeof val === 'string') {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
if (metadata.schema instanceof z.ZodBoolean && typeof val === 'string') {
|
||||
if (val === 'true' || val === 'on') return true;
|
||||
if (val === 'false' || val === 'off') return false;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local experimental settings.
|
||||
*/
|
||||
updateExperimentalSettings(settings: Record<string, unknown>): void {
|
||||
this.experimentalSettings = {
|
||||
...this.experimentalSettings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates remote experiments.
|
||||
*/
|
||||
setExperiments(experiments: Experiments): void {
|
||||
this.experiments = experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all experimental settings (for serialization/display).
|
||||
*/
|
||||
getExperimentalSettings(): Record<string, unknown> {
|
||||
return this.experimentalSettings;
|
||||
}
|
||||
|
||||
getExperiments(): Experiments | undefined {
|
||||
return this.experiments;
|
||||
}
|
||||
|
||||
// Convenience getters for commonly used flags
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.PLAN);
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_AGENTS);
|
||||
}
|
||||
|
||||
getEnableExtensionReloading(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.EXTENSION_RELOADING,
|
||||
);
|
||||
}
|
||||
|
||||
getDisableLLMCorrection(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.DISABLE_LLM_CORRECTION,
|
||||
);
|
||||
}
|
||||
|
||||
isModelSteeringEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.MODEL_STEERING);
|
||||
}
|
||||
|
||||
isJitContextEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.JIT_CONTEXT);
|
||||
}
|
||||
|
||||
isNumericalRoutingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_NUMERICAL_ROUTING,
|
||||
);
|
||||
}
|
||||
|
||||
getClassifierThreshold(): number | undefined {
|
||||
return this.getExperimentValue<number>(
|
||||
ExperimentFlags.CLASSIFIER_THRESHOLD,
|
||||
);
|
||||
}
|
||||
|
||||
getUserCaching(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.USER_CACHING);
|
||||
}
|
||||
|
||||
getBannerTextNoCapacityIssues(): string {
|
||||
return this.getExperimentValue<string>(
|
||||
ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES,
|
||||
);
|
||||
}
|
||||
|
||||
getBannerTextCapacityIssues(): string {
|
||||
return this.getExperimentValue<string>(
|
||||
ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -211,9 +211,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
describe('Remote Threshold Logic', () => {
|
||||
it('should use the remote CLASSIFIER_THRESHOLD if provided (int value)', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(70);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
70,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 60,
|
||||
@@ -241,9 +244,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
|
||||
it('should use the remote CLASSIFIER_THRESHOLD if provided (float value)', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(45.5);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
45.5,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 40,
|
||||
@@ -271,9 +277,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
|
||||
it('should use PRO model if score >= remote CLASSIFIER_THRESHOLD', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(30);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
30,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 35,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import 'vitest';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
|
||||
@@ -2877,6 +2877,7 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
"directWebFetch": {
|
||||
"title": "Direct Web Fetch",
|
||||
"description": "Enable web fetch behavior that bypasses LLM summarization.",
|
||||
@@ -2959,6 +2960,13 @@
|
||||
"markdownDescription": "Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable-awesome": {
|
||||
"title": "Enable Awesome",
|
||||
"description": "When enabled, the ASCII art says 'matt'.",
|
||||
"markdownDescription": "When enabled, the ASCII art says 'matt'.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user